feat(final-docs): auto pre-deadline reminder cron

This commit is contained in:
Matt
2026-06-09 16:00:42 +02:00
parent 6e1dcc8cbf
commit e4f13aaed4
3 changed files with 96 additions and 0 deletions

View File

@@ -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<NextResponse> {
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 })
}
}

View File

@@ -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[] }