From 585e9d52b72b068501edc19ad0631095cb3eb3c9 Mon Sep 17 00:00:00 2001 From: Laith Taher Date: Tue, 16 Jun 2026 16:03:27 -0400 Subject: [PATCH 01/14] first pass through of expiring OAs --- .github/workflows/publish-images.yml | 9 +++ docker-compose.yaml | 12 +++ .../(oa)/assessment/[assessmentId]/page.tsx | 15 +++- src/app/(web)/crm/positions/[id]/page.tsx | 38 ++++++---- .../api/assessments/send-invitation/route.ts | 6 +- src/app/api/internal/expire/route.ts | 18 +++++ src/app/api/internal/snapshot/route.ts | 10 +-- .../[id]/transfer-ownership/route.ts | 6 +- .../[id]/[languageId]/stub/route.ts | 14 +--- src/cron/Dockerfile | 11 +++ src/cron/index.js | 44 +++++++++++ src/cron/package.json | 18 +++++ src/install/internal/command/command.go | 22 ++++-- .../command/templates/docker-compose.yml | 9 +++ .../internal/command/templates/env.tmpl | 1 + src/lib/api/assessments.ts | 6 +- .../assessment-flow/AssessmentExpired.tsx | 36 +++++++++ .../components/modal/SendAssessmentModal.tsx | 74 +++++++++++++++++++ src/lib/hooks/useAssessment.ts | 11 +++ src/lib/hooks/useCandidates.ts | 39 ++++++---- src/lib/hooks/useSendAssessmentModal.ts | 31 ++++++++ src/lib/schemas/assessment.schema.ts | 6 ++ src/lib/schemas/organization.schema.ts | 5 ++ src/lib/schemas/snapshot.schema.ts | 5 ++ .../schemas/task-template-language.schema.ts | 13 ++++ src/lib/services/assessment.service.ts | 51 ++++++++++++- src/lib/services/email.service.ts | 4 +- src/lib/types/candidate-assessment.types.ts | 2 +- src/lib/utils/date.utils.ts | 13 ++++ 29 files changed, 456 insertions(+), 73 deletions(-) create mode 100644 src/app/api/internal/expire/route.ts create mode 100644 src/cron/Dockerfile create mode 100644 src/cron/index.js create mode 100644 src/cron/package.json create mode 100644 src/lib/components/assessment-flow/AssessmentExpired.tsx create mode 100644 src/lib/components/modal/SendAssessmentModal.tsx create mode 100644 src/lib/hooks/useSendAssessmentModal.ts diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index f990b807..22220908 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 cron image + uses: docker/build-push-action@v5 + with: + context: src/cron + file: src/cron/Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/sarge-cron:latest + ghcr.io/${{ github.repository_owner }}/sarge-cron:${{ github.sha }} diff --git a/docker-compose.yaml b/docker-compose.yaml index d7e7ef28..09df3b77 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,6 +37,18 @@ services: volumes: - ./src/ws:/src/ws - /src/ws/node_modules + cron: + container_name: sarge_dev_cron + build: + context: ./src/cron/ + dockerfile: Dockerfile + env_file: + - .env + depends_on: + - web + volumes: + - ./src/cron:/src/cron + - /src/cron/node_modules volumes: postgres_data: 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..07cd19e1 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, @@ -24,14 +23,22 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin createCandidate, batchCreateCandidates, isSendingAssessments, - handleSendAssessments, + confirmSendAssessments, + isManualModalOpen, + setIsManualModalOpen, + isCSVModalOpen, + setIsCSVModalOpen, + isSendModalOpen, + setIsSendModalOpen, + switchToCSVModal, } = useCandidates(id); + const { value: searchValue, onChange: onSearchChange } = useSearch('applications'); const displayedCandidates = searchValue.trim().length ? candidates.filter((c) => - c.candidate.name.toLowerCase().includes(searchValue.trim().toLowerCase()) - ) + c.candidate.name.toLowerCase().includes(searchValue.trim().toLowerCase()) + ) : candidates; return ( @@ -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/cron/Dockerfile b/src/cron/Dockerfile new file mode 100644 index 00000000..4aa2f77f --- /dev/null +++ b/src/cron/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-slim + +WORKDIR /src/cron + +RUN corepack enable && corepack prepare pnpm@9.12.2 --activate + +COPY package.json index.js pnpm-lock.yaml* ./ + +RUN pnpm install + +CMD ["node", "index.js"] diff --git a/src/cron/index.js b/src/cron/index.js new file mode 100644 index 00000000..fbc43c27 --- /dev/null +++ b/src/cron/index.js @@ -0,0 +1,44 @@ +const INTERNAL_API_URL = process.env.INTERNAL_API_URL ?? 'http://localhost:3000'; +const INTERNAL_API_SECRET = process.env.INTERNAL_API_SECRET; + +if (!INTERNAL_API_SECRET) { + console.warn('INTERNAL_API_SECRET is missing, this means expiring OAs will be skipped'); +} + +async function runExpire() { + try { + const res = await fetch(`${INTERNAL_API_URL}/api/internal/expire`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-SARGE-INTERNAL-SECRET': INTERNAL_API_SECRET, + }, + }); + + if (!res.ok) { + console.error(`Expiry check failed (${res.status})`); + return; + } + } catch (err) { + console.error('Expiry sweep error:', err.message); + } +} + +function msUntilNextMidnight() { + const now = new Date(); + const nextMidnight = new Date(now); + nextMidnight.setHours(24, 0, 0, 0); + return nextMidnight.getTime() - now.getTime(); +} + +function scheduleNextExpire() { + const delay = msUntilNextMidnight(); + console.log(`Sarge cron: next expiry check in ${Math.round(delay / 1000)} seconds (midnight)`); + setTimeout(() => { + runExpire(); + // NOTE(laith): recursive call which calculcates the delay to avoid the few times of year where daylight savings occurs and we don't accidently expire OAs an hour before or after midnight + scheduleNextExpire(); + }, delay); +} + +scheduleNextExpire(); diff --git a/src/cron/package.json b/src/cron/package.json new file mode 100644 index 00000000..3d445970 --- /dev/null +++ b/src/cron/package.json @@ -0,0 +1,18 @@ +{ + "name": "cron", + "version": "1.0.0", + "description": "Periodic background sweeps for the Sarge web app (e.g. expiring overdue assessments)", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node --env-file ../../.env index.js", + "dev": "nodemon --exec 'node --env-file ../../.env' index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": {}, + "devDependencies": { + "nodemon": "^3.1.14" + } +} 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..5fc01d33 100644 --- a/src/install/internal/command/templates/docker-compose.yml +++ b/src/install/internal/command/templates/docker-compose.yml @@ -43,6 +43,15 @@ services: - .env restart: unless-stopped + cron: + image: ghcr.io/sandboxnu/sarge-cron:latest + pull_policy: always + env_file: + - .env + depends_on: + - web + restart: unless-stopped + volumes: postgres_data: caddy_data: diff --git a/src/install/internal/command/templates/env.tmpl b/src/install/internal/command/templates/env.tmpl index 6828e201..9c755f90 100644 --- a/src/install/internal/command/templates/env.tmpl +++ b/src/install/internal/command/templates/env.tmpl @@ -5,6 +5,7 @@ DB_NAME=sarge BETTER_AUTH_SECRET={{ .BetterAuthSecret }} BETTER_AUTH_URL=https://{{ .Hostname }} JWT_SECRET={{ .JWTSecret }} +INTERNAL_API_SECRET={{ .InternalAPISecret }} 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..14ea364f --- /dev/null +++ b/src/lib/components/assessment-flow/AssessmentExpired.tsx @@ -0,0 +1,36 @@ +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) { + 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/SendAssessmentModal.tsx b/src/lib/components/modal/SendAssessmentModal.tsx new file mode 100644 index 00000000..ddcc739e --- /dev/null +++ b/src/lib/components/modal/SendAssessmentModal.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { Dialog } from '@radix-ui/react-dialog'; +import { DialogContent, DialogTitle } from '@/lib/components/ui/Modal'; +import { FieldLabel } from '@/lib/components/ui/Field'; +import { Input } from '@/lib/components/ui/Input'; +import { Button } from '@/lib/components/ui/Button'; +import { X } from 'lucide-react'; +import useSendAssessmentModal from '@/lib/hooks/useSendAssessmentModal'; + +export type SendAssessmentModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (deadlineIso: string) => Promise | void; + isSending: boolean; +}; + +export default function SendAssessmentModal({ + open, + onOpenChange, + onConfirm, + isSending, +}: SendAssessmentModalProps) { + const { dueDate, setDueDate, minDate, handleConfirm, handleOpenChange, handleCancel } = + useSendAssessmentModal({ open, onOpenChange, onConfirm, isSending }); + + return ( + + +
+
+ + Send assessments + + +
+ +

+ OAs not completed by the end of this day (11:59 PM) will expire. +

+ +
+ + Due date + + setDueDate(e.target.value)} + className="h-11 w-full" + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/hooks/useAssessment.ts b/src/lib/hooks/useAssessment.ts index 2e1f0622..c23ccde6 100644 --- a/src/lib/hooks/useAssessment.ts +++ b/src/lib/hooks/useAssessment.ts @@ -149,6 +149,17 @@ export default function useAssessment(assessmentId: string) { setAssessment(data); setToken(token); setSections(buildInitialSections(data.assessmentTemplate.tasks)); + + // Gate expired assessments at load: if the due date has passed (or the server + // already marked it EXPIRED) and the candidate hasn't already finished, show the + // expired screen instead of the intro. + const alreadyFinished = + data.assessmentStatus === 'SUBMITTED' || data.assessmentStatus === 'GRADED'; + const deadlinePassed = + data.deadline !== null && new Date(data.deadline).getTime() < Date.now(); + if (!alreadyFinished && (data.assessmentStatus === 'EXPIRED' || deadlinePassed)) { + setPhase('expired'); + } } catch (err) { setError(err as Error); } finally { diff --git a/src/lib/hooks/useCandidates.ts b/src/lib/hooks/useCandidates.ts index fc2675e8..6af5555a 100644 --- a/src/lib/hooks/useCandidates.ts +++ b/src/lib/hooks/useCandidates.ts @@ -10,23 +10,20 @@ import { } from '@/lib/api/positions'; import { sendAssessmentInvitation } from '@/lib/api/assessments'; -interface UseCandidatesReturn { - candidates: ApplicationDisplayInfo[]; - loading: boolean; - error: string | null; - positionTitle: string | null; - createCandidate: (candidate: AddApplicationWithCandidateDataDTO) => Promise; - batchCreateCandidates: (candidates: AddApplicationWithCandidateDataDTO[]) => Promise; - isSendingAssessments: boolean; - handleSendAssessments: () => Promise; -} - -export default function useCandidates(positionId: string): UseCandidatesReturn { +export default function useCandidates(positionId: string) { const [candidates, setCandidates] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [positionTitle, setPositionTitle] = useState(null); const [isSendingAssessments, setIsSendingAssessments] = useState(false); + const [isManualModalOpen, setIsManualModalOpen] = useState(false); + const [isCSVModalOpen, setIsCSVModalOpen] = useState(false); + const [isSendModalOpen, setIsSendModalOpen] = useState(false); + + const switchToCSVModal = () => { + setIsManualModalOpen(false); + setIsCSVModalOpen(true); + }; useEffect(() => { if (!positionId) return; @@ -93,10 +90,10 @@ export default function useCandidates(positionId: string): UseCandidatesReturn { } }; - const handleSendAssessments = async () => { + const handleSendAssessments = async (deadlineIso: string) => { try { setIsSendingAssessments(true); - const result = await sendAssessmentInvitation(positionId); + const result = await sendAssessmentInvitation(positionId, deadlineIso); if (result.totalSent > 0) { toast.success( @@ -125,6 +122,11 @@ export default function useCandidates(positionId: string): UseCandidatesReturn { } }; + const confirmSendAssessments = async (deadlineIso: string) => { + await handleSendAssessments(deadlineIso); + setIsSendModalOpen(false); + }; + return { candidates, loading, @@ -133,6 +135,13 @@ export default function useCandidates(positionId: string): UseCandidatesReturn { createCandidate, batchCreateCandidates, isSendingAssessments, - handleSendAssessments, + confirmSendAssessments, + isManualModalOpen, + setIsManualModalOpen, + isCSVModalOpen, + setIsCSVModalOpen, + isSendModalOpen, + setIsSendModalOpen, + switchToCSVModal, }; } diff --git a/src/lib/hooks/useSendAssessmentModal.ts b/src/lib/hooks/useSendAssessmentModal.ts new file mode 100644 index 00000000..906b933d --- /dev/null +++ b/src/lib/hooks/useSendAssessmentModal.ts @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { type SendAssessmentModalProps } from '@/lib/components/modal/SendAssessmentModal'; +import { endOfDayISO, todayLocalISODate } from '@/lib/utils/date.utils'; + +function useSendAssessmentModal({ onOpenChange, onConfirm }: SendAssessmentModalProps) { + const [dueDate, setDueDate] = useState(''); + + const handleConfirm = async () => { + if (!dueDate) return; + await onConfirm(endOfDayISO(dueDate)); + setDueDate(''); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) setDueDate(''); + onOpenChange(open); + }; + + const handleCancel = () => onOpenChange(false); + + return { + dueDate, + setDueDate, + minDate: todayLocalISODate(), + handleConfirm, + handleOpenChange, + handleCancel, + }; +} + +export default useSendAssessmentModal; diff --git a/src/lib/schemas/assessment.schema.ts b/src/lib/schemas/assessment.schema.ts index 60baf2d0..940a28be 100644 --- a/src/lib/schemas/assessment.schema.ts +++ b/src/lib/schemas/assessment.schema.ts @@ -23,8 +23,14 @@ export const deleteAssessmentSchema = z.object({ id: z.string(), }); +export const sendAssessmentInvitationSchema = z.object({ + positionId: z.string(), + deadline: z.coerce.date(), +}); + export type GetAssessmentDTO = z.infer; export type UpdateAssessmentDTO = z.infer; export type DeleteAssessmentDTO = z.infer; export type Assessment = z.infer; export type CreateAssessmentDTO = z.infer; +export type SendAssessmentInvitationDTO = z.infer; diff --git a/src/lib/schemas/organization.schema.ts b/src/lib/schemas/organization.schema.ts index 69281dce..d720434b 100644 --- a/src/lib/schemas/organization.schema.ts +++ b/src/lib/schemas/organization.schema.ts @@ -24,6 +24,11 @@ export const updateOrganizationSchema = z }) .partial(); +export const transferOwnershipSchema = z.object({ + targetMemberId: z.string().min(1), +}); + export type CreateOrganizationDTO = z.infer; export type UpdateOrganizationDTO = z.infer; export type GetOrganizationDTO = z.infer; +export type TransferOwnershipDTO = z.infer; diff --git a/src/lib/schemas/snapshot.schema.ts b/src/lib/schemas/snapshot.schema.ts index 4affc563..ca287602 100644 --- a/src/lib/schemas/snapshot.schema.ts +++ b/src/lib/schemas/snapshot.schema.ts @@ -14,5 +14,10 @@ export const CreateSnapshotForCandidateSchema = z.object({ content: z.string().optional(), }); +export const CreateDisconnectSnapshotSchema = z.object({ + candidateEmail: z.email(), +}); + export type SnapshotDTO = z.infer; export type CreateSnapshotForCandidateDTO = z.infer; +export type CreateDisconnectSnapshotDTO = z.infer; diff --git a/src/lib/schemas/task-template-language.schema.ts b/src/lib/schemas/task-template-language.schema.ts index 499c1dc7..5d31b5b1 100644 --- a/src/lib/schemas/task-template-language.schema.ts +++ b/src/lib/schemas/task-template-language.schema.ts @@ -9,4 +9,17 @@ export const TaskTemplateLanguageSchema = z.object({ language: z.enum(ProgrammingLanguage), }); +export 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), +}); + export type TaskTemplateLanguageDTO = z.infer; +export type GenerateStubDTO = z.infer; diff --git a/src/lib/services/assessment.service.ts b/src/lib/services/assessment.service.ts index e046390e..f5bfacac 100644 --- a/src/lib/services/assessment.service.ts +++ b/src/lib/services/assessment.service.ts @@ -324,6 +324,7 @@ async function startForCandidate(assessmentId: string): Promise { select: { id: true, submittedAt: true, + deadline: true, application: { select: { id: true, assessmentStatus: true } }, }, }); @@ -336,6 +337,18 @@ async function startForCandidate(assessmentId: string): Promise { throw new BadRequestException('Assessment has already been submitted'); } + // Refuse to start an assessment whose due date has passed (e.g. from a stale tab), + // and mark it expired so the CRM reflects it without waiting for the background sweep. + if (assessment.deadline && assessment.deadline.getTime() < Date.now()) { + if (assessment.application.assessmentStatus !== AssessmentStatus.EXPIRED) { + await prisma.application.update({ + where: { id: assessment.application.id }, + data: { assessmentStatus: AssessmentStatus.EXPIRED }, + }); + } + throw new BadRequestException('This assessment has expired'); + } + if (assessment.application.assessmentStatus === AssessmentStatus.IN_PROGRESS) { return; } @@ -393,8 +406,17 @@ async function submitAssessmentForCandidate(assessmentId: string): Promise async function sendAssessmentInvitationsToPosition( positionId: string, - orgId: string + orgId: string, + deadline: Date ): Promise { + if (Number.isNaN(deadline.getTime())) { + throw new BadRequestException('A valid due date is required to send assessments'); + } + + if (deadline.getTime() <= Date.now()) { + throw new BadRequestException('Due date must be in the future'); + } + const position = await prisma.position.findUnique({ where: { id: positionId }, select: { assessmentId: true, orgId: true }, @@ -419,12 +441,22 @@ async function sendAssessmentInvitationsToPosition( }, include: { candidate: true, + assessment: { select: { id: true } }, }, }); const results = []; for (const application of applications) { try { + // Persist the deadline before emailing so the invitation email reflects the real + // due date (email.service reads assessment.deadline). + if (application.assessment) { + await prisma.assessment.update({ + where: { id: application.assessment.id }, + data: { deadline }, + }); + } + const result = await emailService.sendAssessmentInvitationEmail( application.candidate.id, orgId @@ -458,6 +490,22 @@ async function sendAssessmentInvitationsToPosition( }; } +async function expireOverdueAssessments(): Promise { + const result = await prisma.application.updateMany({ + where: { + assessmentStatus: { + in: [AssessmentStatus.NOT_STARTED, AssessmentStatus.IN_PROGRESS], + }, + assessment: { + deadline: { lt: new Date() }, + }, + }, + data: { assessmentStatus: AssessmentStatus.EXPIRED }, + }); + + return result.count; +} + const AssessmentService = { getAssessmentWithRelations, getAssessmentForCandidate, @@ -468,6 +516,7 @@ const AssessmentService = { deleteAssessment, updateAssessment, sendAssessmentInvitationsToPosition, + expireOverdueAssessments, }; export default AssessmentService; diff --git a/src/lib/services/email.service.ts b/src/lib/services/email.service.ts index 5ee63485..a68b8e12 100644 --- a/src/lib/services/email.service.ts +++ b/src/lib/services/email.service.ts @@ -1,6 +1,7 @@ import { prisma } from '@/lib/prisma'; import sesConnector from '@/lib/connectors/ses.connector'; import { generateAssessmentInvitationHTML } from '@/lib/templates/invitation'; +import { formatDeadline } from '@/lib/utils/date.utils'; interface SendAssessmentInvitationResult { success: boolean; @@ -64,12 +65,11 @@ export async function sendAssessmentInvitationEmail( const assessmentUrl = `${baseUrl}/assessment/${assessment.id}`; const logoUrl = `${baseUrl}/Sarge_logo.svg`; - //placeholder duration and expiration const durationMinutes = assessment.assessmentTemplate.tasks.reduce( (sum, task) => sum + task.taskTemplate.estimatedTime, 0 ); - const expirationDate = 'April 12, 2026 11:59PM EST'; + const expirationDate = formatDeadline(assessment.deadline); const htmlContent = generateAssessmentInvitationHTML({ candidateName: candidate.name, positionTitle: position.title, diff --git a/src/lib/types/candidate-assessment.types.ts b/src/lib/types/candidate-assessment.types.ts index ce593b54..c5d17ce8 100644 --- a/src/lib/types/candidate-assessment.types.ts +++ b/src/lib/types/candidate-assessment.types.ts @@ -39,7 +39,7 @@ export type CandidateAssessment = { }; }; -export type AssessmentPhase = 'intro' | 'assessment' | 'outro'; +export type AssessmentPhase = 'intro' | 'assessment' | 'outro' | 'expired'; export type OutroReason = 'submitted' | 'expired'; export type SectionStatus = 'locked' | 'current' | 'completed'; export type TestCaseResultStatus = diff --git a/src/lib/utils/date.utils.ts b/src/lib/utils/date.utils.ts index 8f6cb964..39d2aa5c 100644 --- a/src/lib/utils/date.utils.ts +++ b/src/lib/utils/date.utils.ts @@ -32,6 +32,19 @@ function ordinalSuffix(day: number): string { return 'th'; } +export function todayLocalISODate(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function endOfDayISO(value: string): string { + const [year, month, day] = value.split('-').map(Number); + return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString(); +} + export function formatDuration(totalSeconds: number): string { const minutes = Math.round(totalSeconds / 60); if (minutes < 60) return `${minutes} minutes`; From 289af03827a32753bb1a7ff6ece5948d2e0f144d Mon Sep 17 00:00:00 2001 From: Laith Taher Date: Tue, 16 Jun 2026 16:05:07 -0400 Subject: [PATCH 02/14] prettier --- src/app/(web)/crm/positions/[id]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(web)/crm/positions/[id]/page.tsx b/src/app/(web)/crm/positions/[id]/page.tsx index 07cd19e1..85f32656 100644 --- a/src/app/(web)/crm/positions/[id]/page.tsx +++ b/src/app/(web)/crm/positions/[id]/page.tsx @@ -37,8 +37,8 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin const displayedCandidates = searchValue.trim().length ? candidates.filter((c) => - c.candidate.name.toLowerCase().includes(searchValue.trim().toLowerCase()) - ) + c.candidate.name.toLowerCase().includes(searchValue.trim().toLowerCase()) + ) : candidates; return ( From 49cf3c83113249292a496547cdeb06e87650d53b Mon Sep 17 00:00:00 2001 From: Laith Taher Date: Fri, 19 Jun 2026 14:07:51 +0300 Subject: [PATCH 03/14] rename cron to scheduler and make both extra containers work locally --- .env.example | 6 ++++-- .github/workflows/publish-images.yml | 10 +++++----- docker-compose.yaml | 12 +++++------- .../command/templates/docker-compose.yml | 4 ++-- src/install/internal/command/templates/env.tmpl | 1 + src/lib/hooks/useAssessment.ts | 3 --- src/scheduler/.env.example | 2 ++ src/{cron => scheduler}/Dockerfile | 2 +- src/{cron => scheduler}/index.js | 16 ++++++++++------ src/{cron => scheduler}/package.json | 4 ++-- src/ws/.env.example | 4 ++-- src/ws/index.js | 13 +++++++------ start.sh | 2 +- 13 files changed, 42 insertions(+), 37 deletions(-) create mode 100644 src/scheduler/.env.example rename src/{cron => scheduler}/Dockerfile (88%) rename src/{cron => scheduler}/index.js (75%) rename src/{cron => scheduler}/package.json (74%) diff --git a/.env.example b/.env.example index 0337a6a8..ef0ce587 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 +# 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 22220908..2dc80706 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -37,12 +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 cron image + - name: Build and push scheduler image uses: docker/build-push-action@v5 with: - context: src/cron - file: src/cron/Dockerfile + context: src/scheduler + file: src/scheduler/Dockerfile push: true tags: | - ghcr.io/${{ github.repository_owner }}/sarge-cron:latest - ghcr.io/${{ github.repository_owner }}/sarge-cron:${{ github.sha }} + 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 09df3b77..c413709a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,18 +37,16 @@ services: volumes: - ./src/ws:/src/ws - /src/ws/node_modules - cron: - container_name: sarge_dev_cron + scheduler: + container_name: sarge_dev_scheduler build: - context: ./src/cron/ + context: ./src/scheduler/ dockerfile: Dockerfile env_file: - .env - depends_on: - - web volumes: - - ./src/cron:/src/cron - - /src/cron/node_modules + - ./src/scheduler:/src/scheduler + - /src/scheduler/node_modules volumes: postgres_data: diff --git a/src/install/internal/command/templates/docker-compose.yml b/src/install/internal/command/templates/docker-compose.yml index 5fc01d33..21b4917d 100644 --- a/src/install/internal/command/templates/docker-compose.yml +++ b/src/install/internal/command/templates/docker-compose.yml @@ -43,8 +43,8 @@ services: - .env restart: unless-stopped - cron: - image: ghcr.io/sandboxnu/sarge-cron:latest + scheduler: + image: ghcr.io/sandboxnu/sarge-scheduler:latest pull_policy: always env_file: - .env diff --git a/src/install/internal/command/templates/env.tmpl b/src/install/internal/command/templates/env.tmpl index 9c755f90..0289c15e 100644 --- a/src/install/internal/command/templates/env.tmpl +++ b/src/install/internal/command/templates/env.tmpl @@ -6,6 +6,7 @@ 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/hooks/useAssessment.ts b/src/lib/hooks/useAssessment.ts index c23ccde6..4831c5c3 100644 --- a/src/lib/hooks/useAssessment.ts +++ b/src/lib/hooks/useAssessment.ts @@ -150,9 +150,6 @@ export default function useAssessment(assessmentId: string) { setToken(token); setSections(buildInitialSections(data.assessmentTemplate.tasks)); - // Gate expired assessments at load: if the due date has passed (or the server - // already marked it EXPIRED) and the candidate hasn't already finished, show the - // expired screen instead of the intro. const alreadyFinished = data.assessmentStatus === 'SUBMITTED' || data.assessmentStatus === 'GRADED'; const deadlinePassed = diff --git a/src/scheduler/.env.example b/src/scheduler/.env.example new file mode 100644 index 00000000..423e122a --- /dev/null +++ b/src/scheduler/.env.example @@ -0,0 +1,2 @@ +INTERNAL_API_SECRET=secret +INTERNAL_API_URL=http://host.docker.internal:3000 diff --git a/src/cron/Dockerfile b/src/scheduler/Dockerfile similarity index 88% rename from src/cron/Dockerfile rename to src/scheduler/Dockerfile index 4aa2f77f..63354086 100644 --- a/src/cron/Dockerfile +++ b/src/scheduler/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-slim -WORKDIR /src/cron +WORKDIR /src/scheduler RUN corepack enable && corepack prepare pnpm@9.12.2 --activate diff --git a/src/cron/index.js b/src/scheduler/index.js similarity index 75% rename from src/cron/index.js rename to src/scheduler/index.js index fbc43c27..a9859f51 100644 --- a/src/cron/index.js +++ b/src/scheduler/index.js @@ -1,11 +1,13 @@ -const INTERNAL_API_URL = process.env.INTERNAL_API_URL ?? 'http://localhost:3000'; +const INTERNAL_API_URL = process.env.INTERNAL_API_URL; const INTERNAL_API_SECRET = process.env.INTERNAL_API_SECRET; -if (!INTERNAL_API_SECRET) { - console.warn('INTERNAL_API_SECRET is missing, this means expiring OAs will be skipped'); -} - async function runExpire() { + if (!INTERNAL_API_SECRET || !INTERNAL_API_URL) { + console.warn( + 'INTERNAL_API_SECRET or INTERNAL_API_URL is missing, skipping expiry check' + ); + return; + } try { const res = await fetch(`${INTERNAL_API_URL}/api/internal/expire`, { method: 'POST', @@ -33,7 +35,9 @@ function msUntilNextMidnight() { function scheduleNextExpire() { const delay = msUntilNextMidnight(); - console.log(`Sarge cron: next expiry check in ${Math.round(delay / 1000)} seconds (midnight)`); + console.log( + `Sarge scheduler: next expiry check in ${Math.round(delay / 1000)} seconds (midnight)` + ); setTimeout(() => { runExpire(); // NOTE(laith): recursive call which calculcates the delay to avoid the few times of year where daylight savings occurs and we don't accidently expire OAs an hour before or after midnight diff --git a/src/cron/package.json b/src/scheduler/package.json similarity index 74% rename from src/cron/package.json rename to src/scheduler/package.json index 3d445970..5280991c 100644 --- a/src/cron/package.json +++ b/src/scheduler/package.json @@ -1,7 +1,7 @@ { - "name": "cron", + "name": "scheduler", "version": "1.0.0", - "description": "Periodic background sweeps for the Sarge web app (e.g. expiring overdue assessments)", + "description": "", "main": "index.js", "type": "module", "scripts": { diff --git a/src/ws/.env.example b/src/ws/.env.example index 78dccda4..df53d616 100644 --- a/src/ws/.env.example +++ b/src/ws/.env.example @@ -1,4 +1,4 @@ JWT_SECRET= -INTERNAL_API_SECRET= -INTERNAL_API_URL= +INTERNAL_API_SECRET=secret +INTERNAL_API_URL=http://host.docker.internal:3000 diff --git a/src/ws/index.js b/src/ws/index.js index c6417c82..949944fe 100644 --- a/src/ws/index.js +++ b/src/ws/index.js @@ -2,7 +2,7 @@ import { WebSocketServer } from 'ws'; import { jwtVerify } from 'jose'; const HEARTBEAT_INTERVAL = 5000; -const INTERNAL_API_URL = process.env.INTERNAL_API_URL ?? 'http://localhost:3000'; +const INTERNAL_API_URL = process.env.INTERNAL_API_URL; const INTERNAL_API_SECRET = process.env.INTERNAL_API_SECRET; const ws = new WebSocketServer({ port: 8080 }); @@ -11,14 +11,15 @@ const secret = new TextEncoder().encode(process.env.JWT_SECRET); // const clients = new Map(); -if (!INTERNAL_API_SECRET) { - console.warn('INTERNAL_API_SECRET is missing, this means disconnect snapshots will be skipped'); -} - console.log('Sarge WS server listening on 8080'); async function postDisconnectSnapshot(candidateEmail) { - if (!INTERNAL_API_SECRET) return; + if (!INTERNAL_API_SECRET || !INTERNAL_API_URL) { + console.warn( + 'INTERNAL_API_SECRET or INTERNAL_API_URL is missing, skipping disconnect snapshot' + ); + return; + } try { const res = await fetch(`${INTERNAL_API_URL}/api/internal/snapshot`, { method: 'POST', diff --git a/start.sh b/start.sh index 1447825a..585b7df3 100755 --- a/start.sh +++ b/start.sh @@ -8,4 +8,4 @@ cleanup() { trap cleanup SIGINT SIGTERM -docker compose up -d db ws && pnpm install && pnpm run dev +docker compose up -d db ws scheduler && pnpm install && pnpm run dev From fbdca9020215779429e697cc370a0c31665dcdd9 Mon Sep 17 00:00:00 2001 From: Laith Taher Date: Fri, 19 Jun 2026 14:39:15 +0300 Subject: [PATCH 04/14] fix expired seed and view --- prisma/seed-data/assessment.seed.ts | 15 +++++++++++++-- .../assessment-flow/AssessmentExpired.tsx | 14 ++++++++++++++ src/lib/hooks/useAssessment.ts | 5 ++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/prisma/seed-data/assessment.seed.ts b/prisma/seed-data/assessment.seed.ts index fee08219..94492782 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 { endOfDayISO } 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(endOfDayISO('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(endOfDayISO('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(endOfDayISO('2026-04-12')), + reviewerIds: [], + applicationStatus: 'EXPIRED', + }, ]; diff --git a/src/lib/components/assessment-flow/AssessmentExpired.tsx b/src/lib/components/assessment-flow/AssessmentExpired.tsx index 14ea364f..ec5c5cb9 100644 --- a/src/lib/components/assessment-flow/AssessmentExpired.tsx +++ b/src/lib/components/assessment-flow/AssessmentExpired.tsx @@ -1,3 +1,4 @@ +import { Button } from '@/lib/components/ui/Button'; import type { CandidateAssessment } from '@/lib/types/candidate-assessment.types'; import { formatDeadline } from '@/lib/utils/date.utils'; @@ -6,6 +7,12 @@ type AssessmentExpiredProps = { }; 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 (
@@ -27,9 +34,16 @@ export default function AssessmentExpired({ assessment }: AssessmentExpiredProps

+ +
+ +

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

+
); diff --git a/src/lib/hooks/useAssessment.ts b/src/lib/hooks/useAssessment.ts index 4831c5c3..12851813 100644 --- a/src/lib/hooks/useAssessment.ts +++ b/src/lib/hooks/useAssessment.ts @@ -154,7 +154,10 @@ export default function useAssessment(assessmentId: string) { data.assessmentStatus === 'SUBMITTED' || data.assessmentStatus === 'GRADED'; const deadlinePassed = data.deadline !== null && new Date(data.deadline).getTime() < Date.now(); - if (!alreadyFinished && (data.assessmentStatus === 'EXPIRED' || deadlinePassed)) { + if (alreadyFinished) { + setOutroReason('submitted'); + setPhase('outro'); + } else if (data.assessmentStatus === 'EXPIRED' || deadlinePassed) { setPhase('expired'); } } catch (err) { From 9e1508248d56122598f8e4b5aaea66328c2d3c6d Mon Sep 17 00:00:00 2001 From: Laith Taher Date: Fri, 19 Jun 2026 14:40:05 +0300 Subject: [PATCH 05/14] prettier --- src/scheduler/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/scheduler/index.js b/src/scheduler/index.js index a9859f51..a5d68c22 100644 --- a/src/scheduler/index.js +++ b/src/scheduler/index.js @@ -3,9 +3,7 @@ const INTERNAL_API_SECRET = process.env.INTERNAL_API_SECRET; async function runExpire() { if (!INTERNAL_API_SECRET || !INTERNAL_API_URL) { - console.warn( - 'INTERNAL_API_SECRET or INTERNAL_API_URL is missing, skipping expiry check' - ); + console.warn('INTERNAL_API_SECRET or INTERNAL_API_URL is missing, skipping expiry check'); return; } try { From 14bc2633371723014b8727e642c36223dfcfa2f6 Mon Sep 17 00:00:00 2001 From: Laith Taher Date: Fri, 19 Jun 2026 14:56:45 +0300 Subject: [PATCH 06/14] Update modals to use a consistent link button --- src/lib/components/modal/AddTaskModal.tsx | 2 +- src/lib/components/modal/CreateCandidateModal.tsx | 8 ++------ src/lib/components/modal/CreatePositionModal.tsx | 7 ++----- src/lib/components/modal/DeleteOrganizationModal.tsx | 6 +----- src/lib/components/modal/InviteUsersModal.tsx | 6 +----- src/lib/components/modal/OnboardingModal.tsx | 7 ++----- src/lib/components/modal/TransferOwnershipModal.tsx | 6 +----- src/lib/components/modal/UploadCSVModal.tsx | 7 ++----- 8 files changed, 12 insertions(+), 37 deletions(-) diff --git a/src/lib/components/modal/AddTaskModal.tsx b/src/lib/components/modal/AddTaskModal.tsx index 4a12de22..31e5cfe6 100644 --- a/src/lib/components/modal/AddTaskModal.tsx +++ b/src/lib/components/modal/AddTaskModal.tsx @@ -200,7 +200,7 @@ export default function AddTaskModal({
- + +
@@ -68,7 +64,6 @@ export default function DeleteOrganizationModal({ Cancel - @@ -171,7 +162,6 @@ export default function InviteUsersModal({ Cancel @@ -71,7 +67,6 @@ export default function TransferOwnershipModal({ Cancel