From 14a81cd6ec2cabadc145bc3c33be6ed455de6487 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 18:00:47 +0200 Subject: [PATCH] feat: auto-cascade cron + admin waitlist management procedures - expirePendingPastDeadline service: scans PENDING confirmations past deadline, marks each EXPIRED + audit-logs, then promotes the next waitlist entry per affected category (using each program's grand-final round configJson for windowHours). - /api/cron/finalist-confirmations: hourly cron entrypoint (CRON_SECRET header gate), wraps the service. - finalist.addToWaitlist: insert at a specific rank, shifting later entries down (transactional). - finalist.reorderWaitlist: rewrite a category's rank order in one go, using a temp-rank trick to avoid unique-constraint conflicts mid-update. - finalist.manualPromote: out-of-rank-order admin promote with audit log (FINALIST_MANUAL_PROMOTE) + fresh confirmation email. 2 new tests. Suite at 14/14 for finalist-confirmation. --- .../api/cron/finalist-confirmations/route.ts | 17 ++ src/server/routers/finalist.ts | 219 ++++++++++++++++++ src/server/services/finalist-confirmation.ts | 46 ++++ tests/unit/finalist-confirmation.test.ts | 132 +++++++++++ 4 files changed, 414 insertions(+) create mode 100644 src/app/api/cron/finalist-confirmations/route.ts diff --git a/src/app/api/cron/finalist-confirmations/route.ts b/src/app/api/cron/finalist-confirmations/route.ts new file mode 100644 index 0000000..a949c19 --- /dev/null +++ b/src/app/api/cron/finalist-confirmations/route.ts @@ -0,0 +1,17 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { prisma } from '@/lib/prisma' +import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation' + +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 expirePendingPastDeadline(prisma) + return NextResponse.json({ ok: true, ...result }) + } catch (error) { + console.error('[Cron] finalist-confirmations failed:', error) + return NextResponse.json({ error: 'Internal error' }, { status: 500 }) + } +} diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 457bb52..26f3b4a 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -363,4 +363,223 @@ export const finalistRouter = router({ return { ok: true } }), + + /** + * Add a project to the waitlist at a specific rank. Existing entries at + * rank >= input.rank shift down by one to make room. + */ + addToWaitlist: adminProcedure + .input( + z.object({ + programId: z.string(), + category: z.nativeEnum(CompetitionCategory), + projectId: z.string(), + rank: z.number().int().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const project = await ctx.prisma.project.findUniqueOrThrow({ + where: { id: input.projectId }, + select: { competitionCategory: true, programId: true }, + }) + if (project.programId !== input.programId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Project does not belong to this program', + }) + } + if (project.competitionCategory !== input.category) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Project is in ${project.competitionCategory}, not ${input.category}`, + }) + } + + // Use a transaction: shift existing entries first, then insert. + const entry = await ctx.prisma.$transaction(async (tx) => { + // Shift entries at >= input.rank down by 1 in reverse rank order to + // avoid violating the unique constraint mid-update. + const toShift = await tx.waitlistEntry.findMany({ + where: { + programId: input.programId, + category: input.category, + rank: { gte: input.rank }, + }, + orderBy: { rank: 'desc' }, + select: { id: true, rank: true }, + }) + for (const e of toShift) { + await tx.waitlistEntry.update({ + where: { id: e.id }, + data: { rank: e.rank + 1 }, + }) + } + return tx.waitlistEntry.create({ + data: { + programId: input.programId, + category: input.category, + projectId: input.projectId, + rank: input.rank, + status: 'WAITING', + }, + }) + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'WAITLIST_ADD', + entityType: 'WaitlistEntry', + entityId: entry.id, + detailsJson: { + programId: input.programId, + category: input.category, + projectId: input.projectId, + rank: input.rank, + }, + }) + return entry + }), + + /** + * Replace the rank order for a category's waitlist with the given list. + * orderedProjectIds[0] becomes rank 1, etc. + */ + reorderWaitlist: adminProcedure + .input( + z.object({ + programId: z.string(), + category: z.nativeEnum(CompetitionCategory), + orderedProjectIds: z.array(z.string()), + }), + ) + .mutation(async ({ ctx, input }) => { + await ctx.prisma.$transaction(async (tx) => { + // Move each entry to a temporary very-large rank to avoid unique + // constraint conflicts during the in-place rewrite. + const TEMP_OFFSET = 100_000 + for (let i = 0; i < input.orderedProjectIds.length; i++) { + await tx.waitlistEntry.updateMany({ + where: { + programId: input.programId, + category: input.category, + projectId: input.orderedProjectIds[i], + }, + data: { rank: TEMP_OFFSET + i + 1 }, + }) + } + // Now write the final ranks + for (let i = 0; i < input.orderedProjectIds.length; i++) { + await tx.waitlistEntry.updateMany({ + where: { + programId: input.programId, + category: input.category, + projectId: input.orderedProjectIds[i], + }, + data: { rank: i + 1 }, + }) + } + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'WAITLIST_REORDER', + entityType: 'Program', + entityId: input.programId, + detailsJson: { + category: input.category, + orderedProjectIds: input.orderedProjectIds, + }, + }) + return { ok: true } + }), + + /** + * Manually promote a specific waitlist entry out of rank order. Sends a + * fresh confirmation email + audit-logs the override (separate from + * automatic cascade). + */ + manualPromote: adminProcedure + .input( + z.object({ + waitlistEntryId: z.string(), + windowHours: z.number().int().min(1).max(168).default(24), + }), + ) + .mutation(async ({ ctx, input }) => { + const entry = await ctx.prisma.waitlistEntry.findUniqueOrThrow({ + where: { id: input.waitlistEntryId }, + select: { + id: true, + projectId: true, + category: true, + status: true, + programId: true, + }, + }) + if (entry.status !== 'WAITING') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Waitlist entry is ${entry.status}, not WAITING`, + }) + } + await ctx.prisma.waitlistEntry.update({ + where: { id: entry.id }, + data: { status: 'PROMOTED' }, + }) + const { id: confirmationId, token, deadline } = await createPendingConfirmation( + ctx.prisma, + { + projectId: entry.projectId, + category: entry.category, + windowHours: input.windowHours, + promotedFromWaitlistEntryId: entry.id, + }, + ) + // Email send (best-effort) + const project = await ctx.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( + `[finalist.manualPromote] failed to send email for project ${entry.projectId}:`, + err, + ) + } + } + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_MANUAL_PROMOTE', + entityType: 'WaitlistEntry', + entityId: entry.id, + detailsJson: { + programId: entry.programId, + category: entry.category, + projectId: entry.projectId, + confirmationId, + windowHours: input.windowHours, + }, + }) + return { confirmationId } + }), }) diff --git a/src/server/services/finalist-confirmation.ts b/src/server/services/finalist-confirmation.ts index d1d7f43..65d8d60 100644 --- a/src/server/services/finalist-confirmation.ts +++ b/src/server/services/finalist-confirmation.ts @@ -1,6 +1,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' type AnyPrisma = Pick @@ -105,3 +106,48 @@ export async function promoteNextWaitlistEntry( 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 } +} diff --git a/tests/unit/finalist-confirmation.test.ts b/tests/unit/finalist-confirmation.test.ts index d663fa4..a85b68b 100644 --- a/tests/unit/finalist-confirmation.test.ts +++ b/tests/unit/finalist-confirmation.test.ts @@ -521,6 +521,138 @@ describe('finalist.confirm and decline (public)', () => { }) }) + it('expirePendingPastDeadline marks expired confirmations and promotes next waitlist entry', async () => { + const { expirePendingPastDeadline } = await import( + '../../src/server/services/finalist-confirmation' + ) + const { program, project } = await setupPendingConfirmation(`expire-${uid()}`) + // Create the original PENDING confirmation with a past deadline + const originalId = `cmfc_exp_${uid()}` + const expiredExp = Math.floor(Date.now() / 1000) - 60 + const { signFinalistToken } = await import('../../src/lib/finalist-token') + const originalToken = signFinalistToken({ confirmationId: originalId, exp: expiredExp }) + await prisma.finalistConfirmation.create({ + data: { + id: originalId, + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() - 60_000), + token: originalToken, + }, + }) + // And a waitlist entry to promote + const backupProject = await createTestProject(program.id, { + title: 'Cron Backup', + competitionCategory: 'STARTUP', + }) + const backupLead = await prisma.user.create({ + data: { + id: uid('user'), + email: `cronlead_${uid()}@test.local`, + name: 'Cron Lead', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(backupLead.id) + await prisma.teamMember.create({ + data: { projectId: backupProject.id, userId: backupLead.id, role: 'LEAD' }, + }) + const waitlistEntry = await prisma.waitlistEntry.create({ + data: { + programId: program.id, + projectId: backupProject.id, + category: 'STARTUP', + rank: 1, + status: 'WAITING', + }, + }) + + const result = await expirePendingPastDeadline(prisma) + expect(result.expired).toBeGreaterThanOrEqual(1) + expect(result.promoted).toBeGreaterThanOrEqual(1) + + const updated = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { id: originalId }, + }) + expect(updated.status).toBe('EXPIRED') + expect(updated.expiredAt).not.toBeNull() + + const promoted = await prisma.finalistConfirmation.findUnique({ + where: { projectId: backupProject.id }, + }) + expect(promoted).not.toBeNull() + expect(promoted?.status).toBe('PENDING') + + const updatedEntry = await prisma.waitlistEntry.findUniqueOrThrow({ + where: { id: waitlistEntry.id }, + }) + expect(updatedEntry.status).toBe('PROMOTED') + }) + + it('manualPromote bypasses rank order and audit-logs the override', async () => { + const { program } = await setupPendingConfirmation(`manual-${uid()}`) + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + // Three ranked waitlist entries + const entries = [] + for (let i = 0; i < 3; i++) { + const p = await createTestProject(program.id, { + title: `Rank ${i + 1}`, + competitionCategory: 'STARTUP', + }) + const lead = await prisma.user.create({ + data: { + id: uid('user'), + email: `rank${i}_${uid()}@test.local`, + name: `Rank ${i + 1} Lead`, + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(lead.id) + await prisma.teamMember.create({ + data: { projectId: p.id, userId: lead.id, role: 'LEAD' }, + }) + const entry = await prisma.waitlistEntry.create({ + data: { + programId: program.id, + projectId: p.id, + category: 'STARTUP', + rank: i + 1, + status: 'WAITING', + }, + }) + entries.push(entry) + } + + // Manually promote rank #3 (out of order) + const adminCaller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await adminCaller.manualPromote({ waitlistEntryId: entries[2].id, windowHours: 24 }) + + const promoted = await prisma.waitlistEntry.findUniqueOrThrow({ where: { id: entries[2].id } }) + expect(promoted.status).toBe('PROMOTED') + const stillWaiting = await prisma.waitlistEntry.findUniqueOrThrow({ + where: { id: entries[0].id }, + }) + expect(stillWaiting.status).toBe('WAITING') + + // Confirmation row exists + const confirmation = await prisma.finalistConfirmation.findUnique({ + where: { projectId: entries[2].projectId }, + }) + expect(confirmation).not.toBeNull() + expect(confirmation?.promotedFromWaitlistEntryId).toBe(entries[2].id) + }) + it('getByToken rejects expired tokens', async () => { const { program, project } = await setupPendingConfirmation(`confirm-expired-${uid()}`) // Manually create a confirmation with a past deadline + signed-expired token