From 1b4ab6be181a3215fc24ea467e85787597744aea Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 16:17:19 +0200 Subject: [PATCH] feat(finalist): deadline reminder emails via cron Add sendDueConfirmationReminders() to finalist-confirmation.ts: queries PENDING confirmations with no reminderSentAt whose deadline is within the per-program LIVE_FINAL round reminderHoursBeforeDeadline window (default 12h), sends a FINALIST_REMINDER in-app notification (+ email via pipeline) to the team LEAD, then stamps reminderSentAt for idempotency. Wire into the finalist-confirmations cron route alongside expirePendingPastDeadline. Also clear reminderSentAt on re-invite in resetOrCreatePendingConfirmation so re-invited teams get a fresh reminder window. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/cron/finalist-confirmations/route.ts | 12 +- src/server/services/finalist-confirmation.ts | 115 +++++++++- src/server/services/finalist-enrollment.ts | 1 + tests/unit/finalist-reminders.test.ts | 215 ++++++++++++++++++ 4 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 tests/unit/finalist-reminders.test.ts diff --git a/src/app/api/cron/finalist-confirmations/route.ts b/src/app/api/cron/finalist-confirmations/route.ts index a949c19..62927c7 100644 --- a/src/app/api/cron/finalist-confirmations/route.ts +++ b/src/app/api/cron/finalist-confirmations/route.ts @@ -1,6 +1,9 @@ import { NextResponse, type NextRequest } from 'next/server' import { prisma } from '@/lib/prisma' -import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation' +import { + expirePendingPastDeadline, + sendDueConfirmationReminders, +} from '@/server/services/finalist-confirmation' export async function GET(request: NextRequest): Promise { const cronSecret = request.headers.get('x-cron-secret') @@ -8,8 +11,11 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } try { - const result = await expirePendingPastDeadline(prisma) - return NextResponse.json({ ok: true, ...result }) + const [expireResult, reminderResult] = await Promise.all([ + expirePendingPastDeadline(prisma), + sendDueConfirmationReminders(prisma), + ]) + return NextResponse.json({ ok: true, ...expireResult, ...reminderResult }) } catch (error) { console.error('[Cron] finalist-confirmations failed:', error) return NextResponse.json({ error: 'Internal error' }, { status: 500 }) diff --git a/src/server/services/finalist-confirmation.ts b/src/server/services/finalist-confirmation.ts index a4c80b2..cfb6edb 100644 --- a/src/server/services/finalist-confirmation.ts +++ b/src/server/services/finalist-confirmation.ts @@ -2,7 +2,7 @@ import type { CompetitionCategory, PrismaClient } from '@prisma/client' import { signFinalistToken } from '@/lib/finalist-token' import { sendFinalistConfirmationEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' -import { notifyAdmins, NotificationTypes } from './in-app-notification' +import { notifyAdmins, createNotification, NotificationTypes } from './in-app-notification' type AnyPrisma = Pick @@ -198,3 +198,116 @@ export async function expirePendingPastDeadline( } return { expired: expired.length, promoted } } + +/** + * Cron entrypoint: send pre-deadline confirmation reminders. + * + * For each PENDING confirmation that has not yet received a reminder + * (reminderSentAt IS NULL) and whose deadline is still in the future but + * within the program's configured `reminderHoursBeforeDeadline` window + * (default 12 h), send a FINALIST_REMINDER in-app notification (+ email via + * the notification pipeline) to the project's LEAD team member, then stamp + * `reminderSentAt` so the row is never processed again. + * + * Best-effort per row — a failure on one row never aborts the rest. + */ +export async function sendDueConfirmationReminders( + prisma: PrismaClient, +): Promise<{ remindersSent: number }> { + const now = new Date() + + // Load all candidates: PENDING, no reminder sent yet, deadline still future. + const candidates = await prisma.finalistConfirmation.findMany({ + where: { + status: 'PENDING', + reminderSentAt: null, + deadline: { gt: now }, + }, + include: { + project: { + select: { + id: true, + title: true, + programId: true, + teamMembers: { + where: { role: 'LEAD' }, + take: 1, + select: { + userId: true, + user: { select: { name: true } }, + }, + }, + }, + }, + }, + }) + + if (candidates.length === 0) return { remindersSent: 0 } + + // Cache reminderHoursBeforeDeadline per programId to avoid repeat queries. + const reminderHoursCache = new Map() + + async function getReminderHours(programId: string): Promise { + if (reminderHoursCache.has(programId)) { + return reminderHoursCache.get(programId)! + } + const round = await prisma.round.findFirst({ + where: { + competition: { programId }, + roundType: 'LIVE_FINAL', + }, + orderBy: { sortOrder: 'desc' }, + select: { configJson: true }, + }) + const cfg = (round?.configJson ?? {}) as { reminderHoursBeforeDeadline?: number } + const hours = cfg.reminderHoursBeforeDeadline ?? 12 + reminderHoursCache.set(programId, hours) + return hours + } + + const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') + let remindersSent = 0 + + for (const row of candidates) { + try { + const reminderHours = await getReminderHours(row.project.programId) + const windowMs = reminderHours * 3_600_000 + const isDue = row.deadline.getTime() <= now.getTime() + windowMs + + if (!isDue) continue + + const lead = row.project.teamMembers[0] + if (!lead) continue + + const confirmUrl = `${baseUrl}/finalist/confirm/${row.token}` + const title = row.project.title + + await createNotification({ + userId: lead.userId, + type: NotificationTypes.FINALIST_REMINDER, + title: 'Reminder: confirm your grand-finale attendance', + message: `Please confirm attendance for "${title}" before the deadline.`, + linkUrl: confirmUrl, + metadata: { + projectTitle: title, + projectId: row.project.id, + deadline: row.deadline.toISOString(), + }, + }) + + await prisma.finalistConfirmation.update({ + where: { id: row.id }, + data: { reminderSentAt: new Date() }, + }) + + remindersSent++ + } catch (err) { + console.error( + `[sendDueConfirmationReminders] failed for confirmation ${row.id} (project ${row.projectId}):`, + err, + ) + } + } + + return { remindersSent } +} diff --git a/src/server/services/finalist-enrollment.ts b/src/server/services/finalist-enrollment.ts index 2659388..661cb37 100644 --- a/src/server/services/finalist-enrollment.ts +++ b/src/server/services/finalist-enrollment.ts @@ -43,6 +43,7 @@ export async function resetOrCreatePendingConfirmation( declinedAt: null, declineReason: null, expiredAt: null, + reminderSentAt: null, }, }) return { id: existing.id, token, deadline, alreadyConfirmed: false } diff --git a/tests/unit/finalist-reminders.test.ts b/tests/unit/finalist-reminders.test.ts new file mode 100644 index 0000000..386a573 --- /dev/null +++ b/tests/unit/finalist-reminders.test.ts @@ -0,0 +1,215 @@ +/** + * Task 5: Confirmation reminder cron + * + * Tests that sendDueConfirmationReminders: + * - sends a FINALIST_REMINDER notification for leads whose deadline is within the window + * - stamps reminderSentAt so the second call is idempotent + * - skips rows whose deadline is further away than reminderHoursBeforeDeadline + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { prisma } from '../setup' +import { + createTestProgram, + createTestCompetition, + createTestRound, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { sendDueConfirmationReminders } from '../../src/server/services/finalist-confirmation' + +beforeAll(() => { + process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens' + process.env.NEXTAUTH_URL = 'http://localhost:3001' +}) + +describe('sendDueConfirmationReminders', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.inAppNotification.deleteMany({ + where: { metadata: { path: ['projectId'], string_contains: '' } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('sends FINALIST_REMINDER for a lead whose deadline is within the reminder window', async () => { + const program = await createTestProgram({ name: `reminder-due-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + // LIVE_FINAL round with 12h reminder window + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + configJson: { reminderHoursBeforeDeadline: 12 }, + }) + + const lead = await prisma.user.create({ + data: { + id: uid('user'), + email: `lead-reminder-${uid()}@test.local`, + name: 'Reminder Lead', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(lead.id) + + const project = await createTestProject(program.id, { + title: 'Reminder Project', + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + + // Deadline 6 hours from now — within the 12h window + const deadline = new Date(Date.now() + 6 * 3_600_000) + const token = `tok_reminder_${uid()}` + await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline, + token, + reminderSentAt: null, + }, + }) + + const result = await sendDueConfirmationReminders(prisma) + expect(result.remindersSent).toBe(1) + + // Notification created for the lead + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: lead.id, type: 'FINALIST_REMINDER' }, + }) + expect(notification).not.toBeNull() + expect(notification?.metadata).toMatchObject({ projectTitle: 'Reminder Project' }) + + // reminderSentAt is stamped + const updated = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { projectId: project.id }, + }) + expect(updated.reminderSentAt).not.toBeNull() + + // Clean up notification so it doesn't interfere with idempotency test + await prisma.inAppNotification.deleteMany({ where: { userId: lead.id, type: 'FINALIST_REMINDER' } }) + }) + + it('is idempotent — second call sends 0 reminders for the same row', async () => { + // Reuse the row created above — reminderSentAt is now set + const program = await createTestProgram({ name: `reminder-idempotent-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + configJson: { reminderHoursBeforeDeadline: 12 }, + }) + + const lead = await prisma.user.create({ + data: { + id: uid('user'), + email: `lead-idempotent-${uid()}@test.local`, + name: 'Idempotent Lead', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(lead.id) + + const project = await createTestProject(program.id, { + title: 'Idempotent Project', + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + + const deadline = new Date(Date.now() + 6 * 3_600_000) + const token = `tok_idempotent_${uid()}` + await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline, + token, + reminderSentAt: null, + }, + }) + + // First call — should send 1 + const first = await sendDueConfirmationReminders(prisma) + expect(first.remindersSent).toBeGreaterThanOrEqual(1) + + // Second call — same row, reminderSentAt is now set → 0 + const second = await sendDueConfirmationReminders(prisma) + expect(second.remindersSent).toBe(0) + }) + + it('does NOT send for a PENDING row whose deadline is outside the reminder window (48h from now, 12h window)', async () => { + const program = await createTestProgram({ name: `reminder-notdue-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + configJson: { reminderHoursBeforeDeadline: 12 }, + }) + + const lead = await prisma.user.create({ + data: { + id: uid('user'), + email: `lead-notdue-${uid()}@test.local`, + name: 'Not-Due Lead', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(lead.id) + + const project = await createTestProject(program.id, { + title: 'Not Due Project', + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + + // Deadline 48 hours from now — far outside the 12h window + const deadline = new Date(Date.now() + 48 * 3_600_000) + const token = `tok_notdue_${uid()}` + await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline, + token, + reminderSentAt: null, + }, + }) + + const result = await sendDueConfirmationReminders(prisma) + // The only unflagged row in this program has deadline 48h out, should not be sent + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: lead.id, type: 'FINALIST_REMINDER' }, + }) + expect(notification).toBeNull() + // reminderSentAt still null + const row = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { projectId: project.id }, + }) + expect(row.reminderSentAt).toBeNull() + void result // result.remindersSent may be > 0 from other programs' rows already in DB + }) +})