diff --git a/.env.example b/.env.example index 0337a6a8..c3ea3e8f 100644 --- a/.env.example +++ b/.env.example @@ -19,5 +19,7 @@ DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME} EMAIL_DOMAIN= -INTERNAL_API_SECRET= -INTERNAL_API_URL= +INTERNAL_API_SECRET=secret +# NOTE(laith): In local dev "web" runs on the host (next dev) while ws/scheduler run as containers +# so they reach web via host.docker.internal rather than a compose service name +INTERNAL_API_URL=http://host.docker.internal:3000 diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index f990b807..2dc80706 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -37,3 +37,12 @@ jobs: tags: | ghcr.io/${{ github.repository_owner }}/sarge-ws:latest ghcr.io/${{ github.repository_owner }}/sarge-ws:${{ github.sha }} + - name: Build and push scheduler image + uses: docker/build-push-action@v5 + with: + context: src/scheduler + file: src/scheduler/Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/sarge-scheduler:latest + ghcr.io/${{ github.repository_owner }}/sarge-scheduler:${{ github.sha }} diff --git a/docker-compose.yaml b/docker-compose.yaml index d7e7ef28..c413709a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,6 +37,16 @@ services: volumes: - ./src/ws:/src/ws - /src/ws/node_modules + scheduler: + container_name: sarge_dev_scheduler + build: + context: ./src/scheduler/ + dockerfile: Dockerfile + env_file: + - .env + volumes: + - ./src/scheduler:/src/scheduler + - /src/scheduler/node_modules volumes: postgres_data: diff --git a/package.json b/package.json index 454265eb..b04bdd5e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "cmdk": "^1.1.1", "jose": "^6.2.2", "lucide-react": "^0.548.0", + "luxon": "^3.7.2", "next": "15.5.7", "prisma": "6.14.0", "react": "19.1.2", @@ -77,6 +78,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/luxon": "^3.7.1", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c372533..15ae1dd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: lucide-react: specifier: ^0.548.0 version: 0.548.0(react@19.1.2) + luxon: + specifier: ^3.7.2 + version: 3.7.2 next: specifier: 15.5.7 version: 15.5.7(react-dom@19.1.2(react@19.1.2))(react@19.1.2) @@ -141,6 +144,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.13 + '@types/luxon': + specifier: ^3.7.1 + version: 3.7.1 '@types/node': specifier: ^22 version: 22.18.3 @@ -2158,6 +2164,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -3448,6 +3457,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} @@ -7357,6 +7370,8 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/luxon@3.7.1': {} + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -8893,6 +8908,8 @@ snapshots: dependencies: react: 19.1.2 + luxon@3.7.2: {} + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/prisma/seed-data/assessment.seed.ts b/prisma/seed-data/assessment.seed.ts index fee08219..6e67e49f 100644 --- a/prisma/seed-data/assessment.seed.ts +++ b/prisma/seed-data/assessment.seed.ts @@ -1,4 +1,5 @@ import type { AssessmentStatus } from '@/generated/prisma'; +import { getDateToEOD } from '@/lib/utils/date.utils'; export const assessmentsData: Array<{ id: string; @@ -16,7 +17,7 @@ export const assessmentsData: Array<{ assessmentTemplateId: 'assessment_template_general_001', assignedAt: new Date('2026-04-06T14:00:00Z'), submittedAt: new Date('2026-04-12T17:30:00Z'), - deadline: new Date('2026-04-13T03:59:59Z'), + deadline: new Date(getDateToEOD('2026-04-12')), reviewerIds: ['user_laith_taher_001', 'user_brad_derby_001'], applicationStatus: 'GRADED', }, @@ -26,8 +27,18 @@ export const assessmentsData: Array<{ assessmentTemplateId: 'assessment_template_general_001', assignedAt: new Date('2026-04-06T14:00:00Z'), submittedAt: null, - deadline: new Date('2026-04-13T03:59:59Z'), + deadline: new Date(getDateToEOD('2026-04-12')), reviewerIds: [], applicationStatus: 'NOT_STARTED', }, + { + id: 'assessment_anzhuo_001', + candidateId: 'cand_anzhuo_wang_001', + assessmentTemplateId: 'assessment_template_general_001', + assignedAt: new Date('2026-04-06T14:00:00Z'), + submittedAt: null, + deadline: new Date(getDateToEOD('2026-04-12')), + reviewerIds: [], + applicationStatus: 'EXPIRED', + }, ]; diff --git a/src/app/(web)/(oa)/assessment/[assessmentId]/page.tsx b/src/app/(web)/(oa)/assessment/[assessmentId]/page.tsx index 42a9efde..26bf3d83 100644 --- a/src/app/(web)/(oa)/assessment/[assessmentId]/page.tsx +++ b/src/app/(web)/(oa)/assessment/[assessmentId]/page.tsx @@ -4,6 +4,7 @@ import { use, useEffect, useState } from 'react'; import useAssessment from '@/lib/hooks/useAssessment'; import AssessmentIntro from '@/lib/components/assessment-flow/AssessmentIntro'; import AssessmentOutro from '@/lib/components/assessment-flow/AssessmentOutro'; +import AssessmentExpired from '@/lib/components/assessment-flow/AssessmentExpired'; import AssessmentSidebar from '@/lib/components/assessment-flow/AssessmentSidebar'; import AssessmentNavbar from '@/lib/components/assessment-flow/AssessmentNavbar'; import AssessmentContent from '@/lib/components/assessment-flow/AssessmentContent'; @@ -30,8 +31,7 @@ export default function AssessmentPage({ params }: { params: Promise<{ assessmen !assessment.isLoading && !assessment.error && isConnected && - assessment.phase !== 'intro' && - assessment.phase !== 'outro'; + assessment.phase === 'assessment'; useEffect(() => { if (isExamActive && isWindowUnfocused) { @@ -52,6 +52,17 @@ export default function AssessmentPage({ params }: { params: Promise<{ assessmen ); + if (assessment.phase === 'expired' && assessment.assessment) { + return ( +
+ +
+ +
+
+ ); + } + if (assessment.phase === 'intro' && assessment.assessment) { return (
diff --git a/src/app/(web)/crm/positions/[id]/page.tsx b/src/app/(web)/crm/positions/[id]/page.tsx index 52d57908..40a774c2 100644 --- a/src/app/(web)/crm/positions/[id]/page.tsx +++ b/src/app/(web)/crm/positions/[id]/page.tsx @@ -4,18 +4,17 @@ import { Button } from '@/lib/components/ui/Button'; import { CandidateTable } from '@/lib/components/core/CandidateTable'; import CreateCandidateModal from '@/lib/components/modal/CreateCandidateModal'; import UploadCSVModal from '@/lib/components/modal/UploadCSVModal'; +import SendAssessmentModal from '@/lib/components/modal/SendAssessmentModal'; import useCandidates from '@/lib/hooks/useCandidates'; import { Search } from '@/lib/components/core/Search'; import { Tabs, TabsContent, TabsList, UnderlineTabsTrigger } from '@/lib/components/ui/Tabs'; import { Plus, ArrowUpDown, SlidersHorizontal, Mail } from 'lucide-react'; -import { use, useState } from 'react'; +import { use } from 'react'; import useSearch from '@/lib/hooks/useSearch'; import Breadcrumbs from '@/lib/components/core/Breadcrumbs'; export default function CandidatesPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); - const [isModalManualOpen, setIsModalManualOpen] = useState(false); - const [isCSVModalOpen, setIsCSVModalOpen] = useState(false); const { candidates, loading, @@ -25,7 +24,15 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin batchCreateCandidates, isSendingAssessments, handleSendAssessments, + isManualModalOpen, + setIsManualModalOpen, + isCSVModalOpen, + setIsCSVModalOpen, + isSendModalOpen, + setIsSendModalOpen, + switchToCSVModal, } = useCandidates(id); + const { value: searchValue, onChange: onSearchChange } = useSearch('applications'); const displayedCandidates = searchValue.trim().length @@ -69,7 +76,7 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
{ - setIsModalManualOpen(false); - setIsCSVModalOpen(true); - }} + onSwitchModal={switchToCSVModal} /> + ); } diff --git a/src/app/api/assessments/send-invitation/route.ts b/src/app/api/assessments/send-invitation/route.ts index 6d960e93..68b8e911 100644 --- a/src/app/api/assessments/send-invitation/route.ts +++ b/src/app/api/assessments/send-invitation/route.ts @@ -3,6 +3,7 @@ import { handleError } from '@/lib/utils/errors.utils'; import { getSession } from '@/lib/utils/auth.utils'; import { assertRecruiterOrAbove } from '@/lib/utils/permissions.utils'; import AssessmentService from '@/lib/services/assessment.service'; +import { sendAssessmentInvitationSchema } from '@/lib/schemas/assessment.schema'; export async function POST(request: NextRequest) { try { @@ -10,11 +11,12 @@ export async function POST(request: NextRequest) { await assertRecruiterOrAbove(request.headers); const body = await request.json(); - const { positionId } = body as { positionId: string }; + const { positionId, deadline } = sendAssessmentInvitationSchema.parse(body); const result = await AssessmentService.sendAssessmentInvitationsToPosition( positionId, - session.activeOrganizationId + session.activeOrganizationId, + deadline ); return Response.json({ data: result }, { status: 200 }); diff --git a/src/app/api/internal/expire/route.ts b/src/app/api/internal/expire/route.ts new file mode 100644 index 00000000..c8c17327 --- /dev/null +++ b/src/app/api/internal/expire/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from 'next/server'; +import AssessmentService from '@/lib/services/assessment.service'; +import { handleError, UnauthorizedException } from '@/lib/utils/errors.utils'; + +export async function POST(request: NextRequest) { + try { + const expected = process.env.INTERNAL_API_SECRET; + const provided = request.headers.get('X-SARGE-INTERNAL-SECRET'); + if (!expected || provided !== expected) { + throw new UnauthorizedException('Invalid internal secret'); + } + + const expired = await AssessmentService.expireOverdueAssessments(); + return Response.json({ data: { expired } }, { status: 200 }); + } catch (err) { + return handleError(err); + } +} diff --git a/src/app/api/internal/snapshot/route.ts b/src/app/api/internal/snapshot/route.ts index f6fd5354..51e05663 100644 --- a/src/app/api/internal/snapshot/route.ts +++ b/src/app/api/internal/snapshot/route.ts @@ -1,22 +1,18 @@ import snapshotService from '@/lib/services/snapshot.service'; import { handleError, UnauthorizedException } from '@/lib/utils/errors.utils'; -import { z } from 'zod'; +import { CreateDisconnectSnapshotSchema } from '@/lib/schemas/snapshot.schema'; import { type NextRequest } from 'next/server'; -const BodySchema = z.object({ - candidateEmail: z.email(), -}); - export async function POST(request: NextRequest) { try { const expected = process.env.INTERNAL_API_SECRET; - const provided = request.headers.get('X-SARGE-INTERAL-SECRET'); + const provided = request.headers.get('X-SARGE-INTERNAL-SECRET'); if (!expected || provided !== expected) { throw new UnauthorizedException('Invalid internal secret'); } const body = await request.json(); - const { candidateEmail } = BodySchema.parse(body); + const { candidateEmail } = CreateDisconnectSnapshotSchema.parse(body); const snapshot = await snapshotService.createDisconnectSnapshot(candidateEmail); return Response.json({ data: snapshot }, { status: 201 }); } catch (err) { diff --git a/src/app/api/organizations/[id]/transfer-ownership/route.ts b/src/app/api/organizations/[id]/transfer-ownership/route.ts index 71ce68a7..7d9f4bf1 100644 --- a/src/app/api/organizations/[id]/transfer-ownership/route.ts +++ b/src/app/api/organizations/[id]/transfer-ownership/route.ts @@ -1,14 +1,10 @@ import { type NextRequest } from 'next/server'; -import { z } from 'zod'; import { auth } from '@/lib/auth/auth'; import { prisma } from '@/lib/prisma'; import OrganizationService from '@/lib/services/organization.service'; import { ForbiddenException, handleError } from '@/lib/utils/errors.utils'; import { assertOwner } from '@/lib/utils/permissions.utils'; - -const transferOwnershipSchema = z.object({ - targetMemberId: z.string().min(1), -}); +import { transferOwnershipSchema } from '@/lib/schemas/organization.schema'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { diff --git a/src/app/api/task-templates/[id]/[languageId]/stub/route.ts b/src/app/api/task-templates/[id]/[languageId]/stub/route.ts index f82127cc..424c5047 100644 --- a/src/app/api/task-templates/[id]/[languageId]/stub/route.ts +++ b/src/app/api/task-templates/[id]/[languageId]/stub/route.ts @@ -2,19 +2,7 @@ import { handleError } from '@/lib/utils/errors.utils'; import { generateCodeStub } from '@/lib/utils/language.utils'; import { assertRecruiterOrAbove } from '@/lib/utils/permissions.utils'; import { type NextRequest } from 'next/server'; -import { z } from 'zod'; - -const generateStubSchema = z.object({ - functionName: z.string().trim().min(1), - returnType: z.string().trim().min(1), - parameters: z.array( - z.object({ - name: z.string().trim().min(1), - type: z.string().trim().min(1), - }) - ), - language: z.string().trim().min(1), -}); +import { generateStubSchema } from '@/lib/schemas/task-template-language.schema'; export async function POST( request: NextRequest, diff --git a/src/install/internal/command/command.go b/src/install/internal/command/command.go index cf1d2a83..6eabece6 100644 --- a/src/install/internal/command/command.go +++ b/src/install/internal/command/command.go @@ -28,10 +28,11 @@ type CaddyConfig struct { } type envConfig struct { - DBPassword string - BetterAuthSecret string - Hostname string - JWTSecret string + DBPassword string + BetterAuthSecret string + Hostname string + JWTSecret string + InternalAPISecret string } var installCli string @@ -177,6 +178,10 @@ func writeEnvFile(hostname string) error { if err != nil { return fmt.Errorf("generating JWT_SECRET: %w", err) } + internalAPISecret, err := randomHex(32) + if err != nil { + return fmt.Errorf("generating INTERNAL_API_SECRET: %w", err) + } t, err := template.New("env").Parse(envTmpl) if err != nil { @@ -184,10 +189,11 @@ func writeEnvFile(hostname string) error { } var buf bytes.Buffer if err := t.Execute(&buf, envConfig{ - DBPassword: dbPassword, - BetterAuthSecret: betterAuthSecret, - Hostname: hostname, - JWTSecret: jwtSecret, + DBPassword: dbPassword, + BetterAuthSecret: betterAuthSecret, + Hostname: hostname, + JWTSecret: jwtSecret, + InternalAPISecret: internalAPISecret, }); err != nil { return err } diff --git a/src/install/internal/command/templates/docker-compose.yml b/src/install/internal/command/templates/docker-compose.yml index 3db98730..da9e3a34 100644 --- a/src/install/internal/command/templates/docker-compose.yml +++ b/src/install/internal/command/templates/docker-compose.yml @@ -41,6 +41,17 @@ services: pull_policy: always env_file: - .env + depends_on: + - web + restart: unless-stopped + + scheduler: + image: ghcr.io/sandboxnu/sarge-scheduler:latest + pull_policy: always + env_file: + - .env + depends_on: + - web restart: unless-stopped volumes: diff --git a/src/install/internal/command/templates/env.tmpl b/src/install/internal/command/templates/env.tmpl index 6828e201..0289c15e 100644 --- a/src/install/internal/command/templates/env.tmpl +++ b/src/install/internal/command/templates/env.tmpl @@ -5,6 +5,8 @@ DB_NAME=sarge BETTER_AUTH_SECRET={{ .BetterAuthSecret }} BETTER_AUTH_URL=https://{{ .Hostname }} JWT_SECRET={{ .JWTSecret }} +INTERNAL_API_SECRET={{ .InternalAPISecret }} +INTERNAL_API_URL=http://web:3000 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/src/lib/api/assessments.ts b/src/lib/api/assessments.ts index 58d6dab2..11883f76 100644 --- a/src/lib/api/assessments.ts +++ b/src/lib/api/assessments.ts @@ -69,17 +69,17 @@ export async function updateAssessmentStatus( /** * POST /api/assessments/send-invitation - * Sends assessment invitation emails to all NOT_SENT candidates of a position */ export async function sendAssessmentInvitation( - positionId: string + positionId: string, + deadline: string ): Promise { const res = await fetch('/api/assessments/send-invitation', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ positionId }), + body: JSON.stringify({ positionId, deadline }), }); const json = await res.json(); diff --git a/src/lib/components/assessment-flow/AssessmentExpired.tsx b/src/lib/components/assessment-flow/AssessmentExpired.tsx new file mode 100644 index 00000000..e25106cb --- /dev/null +++ b/src/lib/components/assessment-flow/AssessmentExpired.tsx @@ -0,0 +1,50 @@ +import { Button } from '@/lib/components/ui/Button'; +import type { CandidateAssessment } from '@/lib/types/candidate-assessment.types'; +import { formatDeadline } from '@/lib/utils/date.utils'; + +type AssessmentExpiredProps = { + assessment: CandidateAssessment; +}; + +export default function AssessmentExpired({ assessment }: AssessmentExpiredProps) { + const handleCloseTab = () => { + // same behvaior as closing the tab in the assessmentoutro + window.close(); + alert('Your browser likely blocked the tab from closing. Please close this tab manually.'); + }; + + return ( +
+
+
+

+ {assessment.assessmentTemplate.title} +

+
+

+ This assessment has expired. The due date, + {assessment.deadline + ? ` ${formatDeadline(assessment.deadline)},` + : ''}{' '} + has passed, so it can no longer be started or submitted. +

+

+ If you believe this is a mistake, please reach out to the{' '} + {assessment.organizationName} team. +

+
+
+ +
+ +
+

+ For any questions, please reach out to the exam administrator at [email]. +

+
+
+
+ ); +} diff --git a/src/lib/components/modal/AddTaskModal.tsx b/src/lib/components/modal/AddTaskModal.tsx index 4a12de22..b59a6d2c 100644 --- a/src/lib/components/modal/AddTaskModal.tsx +++ b/src/lib/components/modal/AddTaskModal.tsx @@ -200,11 +200,10 @@ export default function AddTaskModal({
- +
@@ -64,15 +60,10 @@ export default function DeleteOrganizationModal({
- -
@@ -167,15 +158,10 @@ export default function InviteUsersModal({
- +
+ +

+ OAs not completed by 11:59 PM Eastern on this day will expire. +

+ +
+ + Due date + + setDueDate(e.target.value)} + className="h-11 w-full" + /> +
+ +
+ + +
+ + + + ); +} diff --git a/src/lib/components/modal/TransferOwnershipModal.tsx b/src/lib/components/modal/TransferOwnershipModal.tsx index 738d04e9..6c7d8d7d 100644 --- a/src/lib/components/modal/TransferOwnershipModal.tsx +++ b/src/lib/components/modal/TransferOwnershipModal.tsx @@ -38,11 +38,7 @@ export default function TransferOwnershipModal({
Transfer ownership -
@@ -67,15 +63,10 @@ export default function TransferOwnershipModal({
- +