From e4f13aaed434912a3f93974d559199b3021a1428 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 9 Jun 2026 16:00:42 +0200 Subject: [PATCH] feat(final-docs): auto pre-deadline reminder cron --- .../cron/final-document-reminders/route.ts | 17 +++++++ src/server/services/final-documents.ts | 51 +++++++++++++++++++ tests/unit/final-documents.test.ts | 28 ++++++++++ 3 files changed, 96 insertions(+) create mode 100644 src/app/api/cron/final-document-reminders/route.ts diff --git a/src/app/api/cron/final-document-reminders/route.ts b/src/app/api/cron/final-document-reminders/route.ts new file mode 100644 index 0000000..14a775a --- /dev/null +++ b/src/app/api/cron/final-document-reminders/route.ts @@ -0,0 +1,17 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { prisma } from '@/lib/prisma' +import { sendDueFinalDocReminders } from '@/server/services/final-documents' + +export async function GET(request: NextRequest): Promise { + const cronSecret = request.headers.get('x-cron-secret') + if (!cronSecret || cronSecret !== process.env.CRON_SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + try { + const result = await sendDueFinalDocReminders(prisma) + return NextResponse.json({ ok: true, ...result }) + } catch (error) { + console.error('[Cron] final-document-reminders failed:', error) + return NextResponse.json({ error: 'Internal error' }, { status: 500 }) + } +} diff --git a/src/server/services/final-documents.ts b/src/server/services/final-documents.ts index 49c381a..99798bb 100644 --- a/src/server/services/final-documents.ts +++ b/src/server/services/final-documents.ts @@ -153,6 +153,57 @@ export async function sendManualFinalDocReminders( return { sent } } +/** + * Cron: remind finalist teams (enrolled in an active LIVE_FINAL round) with + * missing required documents, once, when the deadline is within the configured + * window. Stamps FinalistConfirmation.finalDocsReminderSentAt. + */ +export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{ remindersSent: number }> { + const now = new Date() + const rounds = await prisma.round.findMany({ + where: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, + select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } }, + }) + + let remindersSent = 0 + for (const round of rounds) { + if (!round.windowCloseAt) continue + const cfg = (round.configJson ?? {}) as { finalDocsReminderHoursBeforeDeadline?: number } + const windowMs = (cfg.finalDocsReminderHoursBeforeDeadline ?? 48) * 3_600_000 + const isDue = round.windowCloseAt.getTime() <= now.getTime() + windowMs && round.windowCloseAt.getTime() > now.getTime() + if (!isDue) continue + + const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } }) + for (const { projectId } of states) { + const confirmation = await prisma.finalistConfirmation.findFirst({ + where: { projectId, finalDocsReminderSentAt: null }, + select: { id: true }, + }) + if (!confirmation) continue + const status = await getFinalDocumentStatusForProject(prisma, projectId) + if (!status) continue + const missing = status.requirements.filter((r) => r.isRequired && !r.uploaded).map((r) => r.name) + if (missing.length === 0) continue + + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { title: true, teamMembers: { where: { role: 'LEAD' }, take: 1, select: { userId: true } } }, + }) + const leadUserId = project?.teamMembers[0]?.userId + if (!project || !leadUserId) continue + + try { + await remindTeam(prisma, { projectId, projectTitle: project.title, deadline: status.deadline, missing, leadUserId }) + await prisma.finalistConfirmation.update({ where: { id: confirmation.id }, data: { finalDocsReminderSentAt: new Date() } }) + remindersSent++ + } catch (e) { + console.error('[final-docs] reminder failed for', projectId, e) + } + } + } + return { remindersSent } +} + export type ReviewDocument = { requirementId: string; requirementName: string; file: { id: string; fileName: string; mimeType: string; url: string } | null } export type ReviewTeam = { projectId: string; teamName: string; category: string | null; documents: ReviewDocument[]; submitted: boolean } export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; submittedCount: number; teams: ReviewTeam[] } diff --git a/tests/unit/final-documents.test.ts b/tests/unit/final-documents.test.ts index bfbe0d0..e471ea5 100644 --- a/tests/unit/final-documents.test.ts +++ b/tests/unit/final-documents.test.ts @@ -14,6 +14,7 @@ import { import { getFinalDocumentStatusForProject, sendManualFinalDocReminders, + sendDueFinalDocReminders, } from '@/server/services/final-documents' import * as applicantRouter from '@/server/routers/applicant' import * as finalistRouter from '@/server/routers/finalist' @@ -167,6 +168,33 @@ describe('sendManualFinalDocReminders', () => { }) }) +describe('sendDueFinalDocReminders', () => { + const localPrograms: string[] = [] + const localUsers: string[] = [] + afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) }) + + it('reminds once when within the window and stamps finalDocsReminderSentAt', async () => { + const program = await createTestProgram(); localPrograms.push(program.id) + const comp = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const round = await createTestRound(comp.id, { + roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, + windowCloseAt: new Date(Date.now() + 3_600_000), // 1h out → within 48h window + configJson: { finalDocsReminderHoursBeforeDeadline: 48 }, + }) + await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + const lead = await createTestUser('APPLICANT'); localUsers.push(lead.id) + await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' } }) + await prisma.finalistConfirmation.create({ data: { id: uid('fc'), projectId: project.id, status: 'CONFIRMED', category: 'STARTUP', deadline: new Date(Date.now() + 3_600_000), token: uid('tok') } }) + + const first = await sendDueFinalDocReminders(prisma) + expect(first.remindersSent).toBe(1) + const second = await sendDueFinalDocReminders(prisma) + expect(second.remindersSent).toBe(0) // idempotent: already stamped + }) +}) + describe('finalist.listReviewDocuments', () => { const localPrograms: string[] = [] const localUsers: string[] = []