feat(final-docs): auto pre-deadline reminder cron
This commit is contained in:
17
src/app/api/cron/final-document-reminders/route.ts
Normal file
17
src/app/api/cron/final-document-reminders/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
Reference in New Issue
Block a user