From dde8ea934599efc5c0a98d5bbb2d4883fdec51a0 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 15:16:52 +0200 Subject: [PATCH] feat(finalist): re-invite-safe confirmation reset helper Co-Authored-By: Claude Sonnet 4.6 --- src/server/services/finalist-enrollment.ts | 66 ++++++++++++++++++++++ tests/unit/finalist-enrollment.test.ts | 60 ++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/server/services/finalist-enrollment.ts create mode 100644 tests/unit/finalist-enrollment.test.ts diff --git a/src/server/services/finalist-enrollment.ts b/src/server/services/finalist-enrollment.ts new file mode 100644 index 0000000..8c59781 --- /dev/null +++ b/src/server/services/finalist-enrollment.ts @@ -0,0 +1,66 @@ +import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client' +import { signFinalistToken } from '@/lib/finalist-token' + +type TxClient = PrismaClient | Prisma.TransactionClient + +/** + * Re-invite-safe variant of createPendingConfirmation. If a confirmation row + * already exists for the project (projectId is @unique), reset any + * non-CONFIRMED row back to a fresh PENDING with a new token/deadline and + * clear stale attendee rows; report CONFIRMED rows as a no-op so callers can + * skip them. Returns the row id + token + deadline for the email step. + */ +export async function resetOrCreatePendingConfirmation( + prisma: TxClient, + args: { projectId: string; category: CompetitionCategory; windowHours: number }, +): Promise<{ id: string; token: string; deadline: Date; alreadyConfirmed: boolean }> { + const deadline = new Date(Date.now() + args.windowHours * 3_600_000) + const existing = await prisma.finalistConfirmation.findUnique({ + where: { projectId: args.projectId }, + select: { id: true, status: true }, + }) + + if (existing?.status === 'CONFIRMED') { + return { id: existing.id, token: '', deadline, alreadyConfirmed: true } + } + + if (existing) { + const token = signFinalistToken({ + confirmationId: existing.id, + exp: Math.floor(deadline.getTime() / 1000), + }) + // Clear any attendee rows from a prior cycle (cascade-deletes flight/visa/lunch). + await prisma.attendingMember.deleteMany({ where: { confirmationId: existing.id } }) + await prisma.finalistConfirmation.update({ + where: { id: existing.id }, + data: { + category: args.category, + status: 'PENDING', + deadline, + token, + confirmedAt: null, + declinedAt: null, + declineReason: null, + expiredAt: null, + }, + }) + return { id: existing.id, token, deadline, alreadyConfirmed: false } + } + + 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, + }, + }) + return { id, token, deadline, alreadyConfirmed: false } +} diff --git a/tests/unit/finalist-enrollment.test.ts b/tests/unit/finalist-enrollment.test.ts new file mode 100644 index 0000000..9d1fd51 --- /dev/null +++ b/tests/unit/finalist-enrollment.test.ts @@ -0,0 +1,60 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma } from '../setup' +import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers' +import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment' + +describe('resetOrCreatePendingConfirmation', () => { + const programIds: string[] = [] + afterAll(async () => { + for (const id of programIds) { + await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } }) + await cleanupTestData(id, []) + } + }) + + it('creates a fresh PENDING row when none exists', async () => { + const program = await createTestProgram({ name: `reinvite-new-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) + const res = await resetOrCreatePendingConfirmation(prisma, { + projectId: project.id, category: 'STARTUP', windowHours: 24, + }) + const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } }) + expect(row.status).toBe('PENDING') + expect(res.alreadyConfirmed).toBe(false) + }) + + it('resets a DECLINED row to a fresh PENDING (no unique-constraint crash)', async () => { + const program = await createTestProgram({ name: `reinvite-declined-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) + await prisma.finalistConfirmation.create({ + data: { projectId: project.id, category: 'STARTUP', status: 'DECLINED', + deadline: new Date(Date.now() - 1000), token: `tok_${uid()}`, declinedAt: new Date(), + declineReason: 'busy' }, + }) + const res = await resetOrCreatePendingConfirmation(prisma, { + projectId: project.id, category: 'STARTUP', windowHours: 24, + }) + const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } }) + expect(row.status).toBe('PENDING') + expect(row.declinedAt).toBeNull() + expect(row.declineReason).toBeNull() + expect(res.alreadyConfirmed).toBe(false) + }) + + it('is a no-op flagged alreadyConfirmed when row is CONFIRMED', async () => { + const program = await createTestProgram({ name: `reinvite-confirmed-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) + await prisma.finalistConfirmation.create({ + data: { projectId: project.id, category: 'STARTUP', status: 'CONFIRMED', + deadline: new Date(Date.now() + 1000), token: `tok_${uid()}`, confirmedAt: new Date() }, + }) + const res = await resetOrCreatePendingConfirmation(prisma, { + projectId: project.id, category: 'STARTUP', windowHours: 24, + }) + expect(res.alreadyConfirmed).toBe(true) + }) +})