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
-
handleOpenChange(false)}>
+ handleOpenChange(false)}>
Cancel
-
+
Cancel
-
+
-
+
Back
-
+
Delete organization
- handleOpenChange(false)}
- >
+ handleOpenChange(false)}>
@@ -64,15 +60,10 @@ export default function DeleteOrganizationModal({
-
handleOpenChange(false)}
- >
+ handleOpenChange(false)}>
Cancel
-
+
Copy link
- handleOpenChange(false)}
- >
+ handleOpenChange(false)}>
@@ -167,15 +158,10 @@ export default function InviteUsersModal({
-
handleOpenChange(false)}
- >
+ handleOpenChange(false)}>
Cancel
-
+
Back
-
+
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 (
+
+ );
+}
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
- handleOpenChange(false)}
- >
+ handleOpenChange(false)}>
@@ -67,15 +63,10 @@ export default function TransferOwnershipModal({
- handleOpenChange(false)}
- >
+ handleOpenChange(false)}>
Cancel
{!selectedFile && (
{
event.stopPropagation();
@@ -187,7 +186,6 @@ export default function UploadCSVModal({
Uploading file...
-
+
Cancel
-
+
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(
@@ -122,6 +119,7 @@ export default function useCandidates(positionId: string): UseCandidatesReturn {
}
} finally {
setIsSendingAssessments(false);
+ setIsSendModalOpen(false);
}
};
@@ -134,5 +132,12 @@ export default function useCandidates(positionId: string): UseCandidatesReturn {
batchCreateCandidates,
isSendingAssessments,
handleSendAssessments,
+ 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..54c3a0c1
--- /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 { getDateToEOD, getMinPickableDate } from '@/lib/utils/date.utils';
+
+function useSendAssessmentModal({ onOpenChange, onConfirm }: SendAssessmentModalProps) {
+ const [dueDate, setDueDate] = useState('');
+
+ const handleConfirm = async () => {
+ if (!dueDate) return;
+ await onConfirm(getDateToEOD(dueDate));
+ setDueDate('');
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) setDueDate('');
+ onOpenChange(open);
+ };
+
+ const handleCancel = () => onOpenChange(false);
+
+ return {
+ dueDate,
+ setDueDate,
+ minDate: getMinPickableDate(),
+ 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..c0150c0d 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,17 @@ async function startForCandidate(assessmentId: string): Promise {
throw new BadRequestException('Assessment has already been submitted');
}
+ // NOTE(laith): the scheduler should catch this, but still a good sanity check
+ 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 +405,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 +440,20 @@ async function sendAssessmentInvitationsToPosition(
},
include: {
candidate: true,
+ assessment: { select: { id: true } },
},
});
const results = [];
for (const application of applications) {
try {
+ 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 +487,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 +513,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..da882688 100644
--- a/src/lib/types/candidate-assessment.types.ts
+++ b/src/lib/types/candidate-assessment.types.ts
@@ -26,9 +26,9 @@ export type AssessmentQuestion = {
export type CandidateAssessment = {
id: string;
- deadline: Date | null;
- assignedAt: Date;
- submittedAt: Date | null;
+ deadline: Date | string | null;
+ assignedAt: Date | string;
+ submittedAt: Date | string | null;
assessmentStatus: AssessmentStatus;
candidateName: string;
organizationName: string;
@@ -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..71198ef7 100644
--- a/src/lib/utils/date.utils.ts
+++ b/src/lib/utils/date.utils.ts
@@ -1,26 +1,29 @@
-const SHORT_MONTH_DAY_YEAR = new Intl.DateTimeFormat('en-US', {
- month: 'short',
- day: '2-digit',
- year: 'numeric',
-});
+import { DateTime } from 'luxon';
+
+const OA_TIMEZONE = 'America/New_York';
export function formatShortMonthDayYear(value: Date | number | string): string {
- const date = typeof value === 'string' || typeof value === 'number' ? new Date(value) : value;
- return SHORT_MONTH_DAY_YEAR.format(date);
+ return DateTime.fromJSDate(new Date(value)).setZone(OA_TIMEZONE).toFormat('MMM dd, yyyy');
}
-export function formatDeadline(date: Date | null): string {
+export function formatDeadline(date: Date | string | null): string {
if (!date) return 'No due date';
- const d = new Date(date);
- const month = d.toLocaleString('en-US', { month: 'long' });
- const day = d.getDate();
- const year = d.getFullYear();
- const minutes = d.getMinutes();
- const ampm = d.getHours() >= 12 ? 'pm' : 'am';
- const hour12 = d.getHours() % 12 || 12;
+
+ let dt: DateTime;
+ if (typeof date === 'string') {
+ dt = DateTime.fromISO(date, { zone: OA_TIMEZONE });
+ } else {
+ dt = DateTime.fromJSDate(date).setZone(OA_TIMEZONE);
+ }
+
+ if (!dt.isValid) return 'Invalid due date';
+
+ const day = dt.day;
const time =
- minutes === 0 ? `${hour12}${ampm}` : `${hour12}:${String(minutes).padStart(2, '0')}${ampm}`;
- return `${month} ${day}${ordinalSuffix(day)} ${year} at ${time}`;
+ dt.minute === 0 ? dt.toFormat('ha').toLowerCase() : dt.toFormat('h:mma').toLowerCase();
+ const tz = dt.offsetNameShort;
+
+ return `${dt.toFormat('MMM')} ${day}${ordinalSuffix(day)} ${dt.year} at ${time} (${tz})`;
}
function ordinalSuffix(day: number): string {
@@ -32,10 +35,23 @@ function ordinalSuffix(day: number): string {
return 'th';
}
+export function getMinPickableDate(): string {
+ return DateTime.now().setZone(OA_TIMEZONE).toISODate() ?? '';
+}
+
+export function getDateToEOD(value: string): string {
+ return DateTime.fromISO(value, { zone: OA_TIMEZONE }).endOf('day').toUTC().toISO() ?? '';
+}
+
export function formatDuration(totalSeconds: number): string {
const minutes = Math.round(totalSeconds / 60);
- if (minutes < 60) return `${minutes} minutes`;
+
+ if (minutes < 60) {
+ return `${minutes} minutes`;
+ }
+
const hours = Math.floor(minutes / 60);
const remaining = minutes % 60;
+
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours} hour${hours > 1 ? 's' : ''}`;
}
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/scheduler/Dockerfile b/src/scheduler/Dockerfile
new file mode 100644
index 00000000..63354086
--- /dev/null
+++ b/src/scheduler/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:22-slim
+
+WORKDIR /src/scheduler
+
+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/scheduler/index.js b/src/scheduler/index.js
new file mode 100644
index 00000000..e81f2662
--- /dev/null
+++ b/src/scheduler/index.js
@@ -0,0 +1,46 @@
+const INTERNAL_API_URL = process.env.INTERNAL_API_URL;
+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');
+ return;
+ }
+ 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 check 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 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
+ scheduleNextExpire();
+ }, delay);
+}
+
+scheduleNextExpire();
diff --git a/src/scheduler/package.json b/src/scheduler/package.json
new file mode 100644
index 00000000..5280991c
--- /dev/null
+++ b/src/scheduler/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "scheduler",
+ "version": "1.0.0",
+ "description": "",
+ "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/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