From f1e62fdd3b11a3d17034ab141a8c70b8623e89d7 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 15:20:51 +0200 Subject: [PATCH] feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm) - Add `finalist.enrollFinalists` adminProcedure: creates ProjectRoundState in LIVE_FINAL round (skipDuplicates) + resets/creates FinalistConfirmation in one step, with EMAIL and ADMIN_CONFIRM attendee modes. - Extract `confirmAttendanceInTx` helper into finalist-enrollment.ts; reuse from both adminConfirm and enrollFinalists (DRY refactor, all tests green). - Add 4 tests covering EMAIL mode, ADMIN_CONFIRM mode, re-enroll safety, and over-cap rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server/routers/finalist.ts | 215 ++++++++++++++--- src/server/services/finalist-enrollment.ts | 47 ++++ tests/unit/finalist-enrollment.test.ts | 263 ++++++++++++++++++++- 3 files changed, 494 insertions(+), 31 deletions(-) diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 685fc3c..314f13a 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -14,6 +14,10 @@ import { import { sendFinalistConfirmationEmail } from '@/lib/email' import { verifyFinalistToken } from '@/lib/finalist-token' import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync' +import { + resetOrCreatePendingConfirmation, + confirmAttendanceInTx, +} from '../services/finalist-enrollment' export const finalistRouter = router({ /** List all per-category finalist slot quotas for a program. */ @@ -490,36 +494,11 @@ export const finalistRouter = router({ } await ctx.prisma.$transaction(async (tx) => { - await tx.finalistConfirmation.update({ - where: { id: confirmation.id }, - data: { status: 'CONFIRMED', confirmedAt: new Date() }, + await confirmAttendanceInTx(tx, { + confirmationId: confirmation.id, + attendingUserIds: input.attendingUserIds, + visaFlags: input.visaFlags, }) - await tx.attendingMember.createMany({ - data: input.attendingUserIds.map((userId) => ({ - confirmationId: confirmation.id, - userId, - needsVisa: input.visaFlags[userId] ?? false, - })), - }) - const visaUsers = input.attendingUserIds.filter( - (uid) => input.visaFlags[uid] === true, - ) - if (visaUsers.length > 0) { - const created = await tx.attendingMember.findMany({ - where: { confirmationId: confirmation.id, userId: { in: visaUsers } }, - select: { id: true }, - }) - await tx.visaApplication.createMany({ - data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })), - }) - } - const allMembers = await tx.attendingMember.findMany({ - where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } }, - select: { id: true }, - }) - for (const m of allMembers) { - await ensureLunchPickForAttendingMember(tx, m.id) - } }) await logAudit({ prisma: ctx.prisma, @@ -1116,4 +1095,182 @@ export const finalistRouter = router({ return { ok: true } }), + + /** + * Unified finalist enrollment: advances a set of projects into the LIVE_FINAL + * round (creates ProjectRoundState, skipDuplicates) AND creates/resets their + * FinalistConfirmation in one atomic step. + * + * Two attendee modes per project: + * - EMAIL: sends the self-confirm link to the team lead (never throws in loop) + * - ADMIN_CONFIRM: validates + writes attendees immediately (CONFIRMED status) + * + * Returns { enrolled, emailed, adminConfirmed, skipped }. + */ + enrollFinalists: adminProcedure + .input( + z.object({ + programId: z.string(), + roundId: z.string(), // the LIVE_FINAL round + enrollments: z + .array( + z.object({ + projectId: z.string(), + mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']), + attendingUserIds: z.array(z.string()).optional(), + visaFlags: z.record(z.string(), z.boolean()).optional(), + }), + ) + .min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + // Resolve the LIVE_FINAL round + confirmationWindowHours + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { id: true, configJson: true }, + }) + const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number } + const windowHours = cfg.confirmationWindowHours ?? 24 + + // Validate all projects belong to this program + const projectIds = input.enrollments.map((e) => e.projectId) + const projects = await ctx.prisma.project.findMany({ + where: { id: { in: projectIds }, programId: input.programId }, + select: { + id: true, + title: true, + competitionCategory: true, + program: { select: { defaultAttendeeCap: true } }, + teamMembers: { + select: { + userId: true, + role: true, + user: { select: { email: true, name: true } }, + }, + }, + }, + }) + if (projects.length !== projectIds.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'One or more project IDs not found in this program', + }) + } + + const projectMap = new Map(projects.map((p) => [p.id, p])) + const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') + + let enrolled = 0 + let emailed = 0 + let adminConfirmed = 0 + const skipped: Array<{ projectId: string; reason: string }> = [] + + for (const enrollment of input.enrollments) { + const project = projectMap.get(enrollment.projectId)! + const cap = project.program.defaultAttendeeCap + + // ADMIN_CONFIRM pre-validation: validate attendingUserIds before touching DB + if (enrollment.mode === 'ADMIN_CONFIRM') { + const attendingUserIds = enrollment.attendingUserIds ?? [] + if (attendingUserIds.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`, + }) + } + if (attendingUserIds.length > cap) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`, + }) + } + const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId)) + for (const uid of attendingUserIds) { + if (!teamUserIds.has(uid)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `User ${uid} is not a team member of project ${project.id}`, + }) + } + } + } + + // Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent) + await ctx.prisma.projectRoundState.createMany({ + data: [{ projectId: enrollment.projectId, roundId: input.roundId }], + skipDuplicates: true, + }) + + // Step 2: Create or reset the finalist confirmation + const category = project.competitionCategory as CompetitionCategory + const confirmResult = await resetOrCreatePendingConfirmation(ctx.prisma, { + projectId: enrollment.projectId, + category, + windowHours, + }) + + if (confirmResult.alreadyConfirmed) { + skipped.push({ projectId: enrollment.projectId, reason: 'ALREADY_CONFIRMED' }) + enrolled++ + continue + } + + enrolled++ + + // Step 3: Mode-specific handling + if (enrollment.mode === 'EMAIL') { + // Send confirmation email to team lead (best-effort — never throw in loop) + const lead = project.teamMembers.find((tm) => tm.role === 'LEAD')?.user + if (lead?.email) { + const confirmUrl = `${baseUrl}/finalist/confirm/${confirmResult.token}` + try { + await sendFinalistConfirmationEmail( + lead.email, + lead.name ?? null, + project.title, + confirmResult.deadline, + confirmUrl, + ) + emailed++ + } catch (err) { + console.error( + `[finalist.enrollFinalists] failed to send email to ${lead.email} for project ${enrollment.projectId}:`, + err, + ) + } + } + } else { + // ADMIN_CONFIRM: write attendees + visa + lunch rows immediately + const attendingUserIds = enrollment.attendingUserIds! + const visaFlags = enrollment.visaFlags ?? {} + + await ctx.prisma.$transaction(async (tx) => { + await confirmAttendanceInTx(tx, { + confirmationId: confirmResult.id, + attendingUserIds, + visaFlags, + }) + }) + adminConfirmed++ + } + + // Step 4: Audit per enrollment + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_ENROLL', + entityType: 'Project', + entityId: enrollment.projectId, + detailsJson: { + projectId: enrollment.projectId, + mode: enrollment.mode, + roundId: input.roundId, + programId: input.programId, + }, + }) + } + + return { enrolled, emailed, adminConfirmed, skipped } + }), }) diff --git a/src/server/services/finalist-enrollment.ts b/src/server/services/finalist-enrollment.ts index 8c59781..2659388 100644 --- a/src/server/services/finalist-enrollment.ts +++ b/src/server/services/finalist-enrollment.ts @@ -1,5 +1,6 @@ import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client' import { signFinalistToken } from '@/lib/finalist-token' +import { ensureLunchPickForAttendingMember } from './lunch-pick-sync' type TxClient = PrismaClient | Prisma.TransactionClient @@ -64,3 +65,49 @@ export async function resetOrCreatePendingConfirmation( }) return { id, token, deadline, alreadyConfirmed: false } } + +/** + * Shared confirm transaction: atomically writes CONFIRMED status + attendee + * rows + visa applications + lunch picks. + * Called from both `adminConfirm` and `enrollFinalists` (ADMIN_CONFIRM mode). + * + * Must be called inside a Prisma $transaction block — `tx` is the transaction + * client, NOT the top-level prisma client. + */ +export async function confirmAttendanceInTx( + tx: Prisma.TransactionClient, + args: { + confirmationId: string + attendingUserIds: string[] + visaFlags: Record + }, +): Promise { + await tx.finalistConfirmation.update({ + where: { id: args.confirmationId }, + data: { status: 'CONFIRMED', confirmedAt: new Date() }, + }) + await tx.attendingMember.createMany({ + data: args.attendingUserIds.map((userId) => ({ + confirmationId: args.confirmationId, + userId, + needsVisa: args.visaFlags[userId] ?? false, + })), + }) + const visaUsers = args.attendingUserIds.filter((uid) => args.visaFlags[uid] === true) + if (visaUsers.length > 0) { + const created = await tx.attendingMember.findMany({ + where: { confirmationId: args.confirmationId, userId: { in: visaUsers } }, + select: { id: true }, + }) + await tx.visaApplication.createMany({ + data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })), + }) + } + const allMembers = await tx.attendingMember.findMany({ + where: { confirmationId: args.confirmationId, userId: { in: args.attendingUserIds } }, + select: { id: true }, + }) + for (const m of allMembers) { + await ensureLunchPickForAttendingMember(tx, m.id) + } +} diff --git a/tests/unit/finalist-enrollment.test.ts b/tests/unit/finalist-enrollment.test.ts index 9d1fd51..8e20a7a 100644 --- a/tests/unit/finalist-enrollment.test.ts +++ b/tests/unit/finalist-enrollment.test.ts @@ -1,7 +1,30 @@ import { afterAll, describe, expect, it } from 'vitest' -import { prisma } from '../setup' -import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + createTestCompetition, + createTestRound, + cleanupTestData, + uid, +} from '../helpers' import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment' +import { finalistRouter } from '../../src/server/routers/finalist' + +async function createApplicantUser(role: 'LEAD' | 'MEMBER' = 'MEMBER') { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Test ${role}`, + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) +} describe('resetOrCreatePendingConfirmation', () => { const programIds: string[] = [] @@ -58,3 +81,239 @@ describe('resetOrCreatePendingConfirmation', () => { expect(res.alreadyConfirmed).toBe(true) }) }) + +// ─── finalist.enrollFinalists ────────────────────────────────────────────── + +describe('finalist.enrollFinalists', () => { + const programIds: string[] = [] + const userIds: 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 prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId: id } } } }) + await cleanupTestData(id, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + async function setupEnrollFixture(programName: string) { + const program = await createTestProgram({ name: programName, defaultAttendeeCap: 3 }) + const competition = await createTestCompetition(program.id) + const mentoringRound = await createTestRound(competition.id, { + roundType: 'MENTORING', + sortOrder: 60, + }) + const liveFinalRound = await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 70, + configJson: { confirmationWindowHours: 24 }, + }) + const project = await createTestProject(program.id, { + title: 'Enroll Test Project', + competitionCategory: 'STARTUP', + }) + const lead = await createApplicantUser('LEAD') + const member = await createApplicantUser('MEMBER') + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: lead.id, role: 'LEAD' }, + { projectId: project.id, userId: member.id, role: 'MEMBER' }, + ], + }) + // Put the project in the MENTORING round (as candidates) + await prisma.projectRoundState.create({ + data: { projectId: project.id, roundId: mentoringRound.id }, + }) + return { program, competition, mentoringRound, liveFinalRound, project, lead, member } + } + + it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture( + `enroll-email-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const result = await caller.enrollFinalists({ + programId: program.id, + roundId: liveFinalRound.id, + enrollments: [{ projectId: project.id, mode: 'EMAIL' }], + }) + + expect(result.enrolled).toBe(1) + expect(result.skipped).toHaveLength(0) + + const prs = await prisma.projectRoundState.findFirst({ + where: { projectId: project.id, roundId: liveFinalRound.id }, + }) + expect(prs).not.toBeNull() + + const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { projectId: project.id }, + }) + expect(conf.status).toBe('PENDING') + + const attendeeCount = await prisma.attendingMember.count({ + where: { confirmationId: conf.id }, + }) + expect(attendeeCount).toBe(0) + }) + + it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture( + `enroll-adminconfirm-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const result = await caller.enrollFinalists({ + programId: program.id, + roundId: liveFinalRound.id, + enrollments: [ + { + projectId: project.id, + mode: 'ADMIN_CONFIRM', + attendingUserIds: [lead.id, member.id], + visaFlags: { [member.id]: true }, + }, + ], + }) + + expect(result.enrolled).toBe(1) + expect(result.adminConfirmed).toBe(1) + expect(result.skipped).toHaveLength(0) + + const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { projectId: project.id }, + }) + expect(conf.status).toBe('CONFIRMED') + + const attendeeCount = await prisma.attendingMember.count({ + where: { confirmationId: conf.id }, + }) + expect(attendeeCount).toBe(2) + + const visaCount = await prisma.visaApplication.count({ + where: { attendingMember: { confirmationId: conf.id } }, + }) + expect(visaCount).toBe(1) + }) + + it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture( + `enroll-reinvite-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + // Pre-create a DECLINED confirmation + 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: 'schedule conflict', + }, + }) + + // Pre-create a PRS row (simulating prior enrollment) + await prisma.projectRoundState.create({ + data: { projectId: project.id, roundId: liveFinalRound.id }, + }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // Re-enroll in EMAIL mode — should reset DECLINED to PENDING without crashing + await caller.enrollFinalists({ + programId: program.id, + roundId: liveFinalRound.id, + enrollments: [{ projectId: project.id, mode: 'EMAIL' }], + }) + + const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { projectId: project.id }, + }) + expect(conf.status).toBe('PENDING') + expect(conf.declinedAt).toBeNull() + + // Exactly one PRS row (skipDuplicates kept it idempotent) + const prsRows = await prisma.projectRoundState.findMany({ + where: { projectId: project.id, roundId: liveFinalRound.id }, + }) + expect(prsRows).toHaveLength(1) + }) + + it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture( + `enroll-overcap-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + // Create 2 extra members so we can pass 4 (cap = 3) + const extra1 = await createApplicantUser('MEMBER') + const extra2 = await createApplicantUser('MEMBER') + userIds.push(extra1.id, extra2.id) + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: extra1.id, role: 'MEMBER' }, + { projectId: project.id, userId: extra2.id, role: 'MEMBER' }, + ], + }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + await expect( + caller.enrollFinalists({ + programId: program.id, + roundId: liveFinalRound.id, + enrollments: [ + { + projectId: project.id, + mode: 'ADMIN_CONFIRM', + attendingUserIds: [lead.id, member.id, extra1.id, extra2.id], // 4 > cap 3 + }, + ], + }), + ).rejects.toThrow(/cap/i) + }) +})