import type { CompetitionCategory, PrismaClient } from '@prisma/client' import { signFinalistToken } from '@/lib/finalist-token' import { sendFinalistConfirmationEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' type AnyPrisma = Pick /** * Create a PENDING FinalistConfirmation row with a signed token. Caller is * responsible for sending the notification email separately. */ export async function createPendingConfirmation( prisma: Pick, args: { projectId: string category: CompetitionCategory windowHours: number promotedFromWaitlistEntryId?: string }, ): Promise<{ id: string; token: string; deadline: Date }> { const deadline = new Date(Date.now() + args.windowHours * 3_600_000) // Generate the row ID up front so we can sign it into the token before // writing the row (token is unique-indexed; embedding the ID gives the // public verify path a stable lookup key). const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}` const token = signFinalistToken({ confirmationId: id, exp: Math.floor(deadline.getTime() / 1000), }) await prisma.finalistConfirmation.create({ data: { id, projectId: args.projectId, category: args.category, status: 'PENDING', deadline, token, promotedFromWaitlistEntryId: args.promotedFromWaitlistEntryId ?? null, }, }) return { id, token, deadline } } /** * Promote the lowest-ranked WAITING waitlist entry in the given category to * PROMOTED, create a fresh PENDING confirmation for the project, and send * the notification email. No-op if no WAITING entry exists. */ export async function promoteNextWaitlistEntry( prisma: AnyPrisma, args: { programId: string; category: CompetitionCategory; windowHours: number }, ): Promise<{ promoted: boolean; entryId?: string; confirmationId?: string }> { const entry = await prisma.waitlistEntry.findFirst({ where: { programId: args.programId, category: args.category, status: 'WAITING', }, orderBy: { rank: 'asc' }, }) if (!entry) return { promoted: false } await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'PROMOTED' }, }) const { id: confirmationId, token, deadline } = await createPendingConfirmation(prisma, { projectId: entry.projectId, category: args.category, windowHours: args.windowHours, promotedFromWaitlistEntryId: entry.id, }) // Send email — log and continue on failure. const project = await prisma.project.findUnique({ where: { id: entry.projectId }, select: { title: true, teamMembers: { where: { role: 'LEAD' }, take: 1, select: { user: { select: { email: true, name: true } } }, }, }, }) const lead = project?.teamMembers[0]?.user if (lead?.email && project) { const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') const confirmUrl = `${baseUrl}/finalist/confirm/${token}` try { await sendFinalistConfirmationEmail( lead.email, lead.name ?? null, project.title, deadline, confirmUrl, ) } catch (err) { console.error( `[promoteNextWaitlistEntry] failed to send email for project ${entry.projectId}:`, err, ) } } return { promoted: true, entryId: entry.id, confirmationId } } /** * Cron entrypoint: find every PENDING confirmation past its deadline, mark * each EXPIRED, and promote the next waitlist entry per affected category. */ export async function expirePendingPastDeadline( prisma: PrismaClient, ): Promise<{ expired: number; promoted: number }> { const expired = await prisma.finalistConfirmation.findMany({ where: { status: 'PENDING', deadline: { lt: new Date() } }, include: { project: { select: { programId: true } } }, }) let promoted = 0 for (const c of expired) { await prisma.finalistConfirmation.update({ where: { id: c.id }, data: { status: 'EXPIRED', expiredAt: new Date() }, }) await logAudit({ prisma, action: 'FINALIST_EXPIRED', entityType: 'FinalistConfirmation', entityId: c.id, detailsJson: { projectId: c.projectId, category: c.category }, }) // Resolve windowHours for this program's grand-finale round const round = await prisma.round.findFirst({ where: { competition: { programId: c.project.programId }, roundType: 'LIVE_FINAL', }, orderBy: { sortOrder: 'desc' }, select: { configJson: true }, }) const cfg = (round?.configJson ?? {}) as { confirmationWindowHours?: number } const windowHours = cfg.confirmationWindowHours ?? 24 const result = await promoteNextWaitlistEntry(prisma, { programId: c.project.programId, category: c.category, windowHours, }) if (result.promoted) promoted++ } return { expired: expired.length, promoted } }