From 5b642c3d505df35cb9eae7cf51648ba454fde39c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 18:50:52 +0200 Subject: [PATCH] feat: finalist.editAttendees with cutoff and diff-based update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team-lead-only mutation that replaces the AttendingMember roster on a CONFIRMED finalist confirmation. Diffs the requested user list against existing rows: kept rows are updated in place (preserving FlightDetail), removed rows are deleted, added rows are created. Enforces: - team-lead role - CONFIRMED status - defaultAttendeeCap - team-membership of every supplied userId - cutoff = LIVE_FINAL.windowOpenAt − attendeeEditCutoffHours (default 48) Audit-logged as FINALIST_EDIT_ATTENDEES with the diff payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/finalist.ts | 150 +++++++++- tests/helpers.ts | 5 +- tests/unit/finalist-edit-attendees.test.ts | 312 +++++++++++++++++++++ 3 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 tests/unit/finalist-edit-attendees.test.ts diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 29d4f9d..ce4d5b3 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { CompetitionCategory } from '@prisma/client' -import { router, adminProcedure, publicProcedure } from '../trpc' +import { router, adminProcedure, protectedProcedure, publicProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { createPendingConfirmation, @@ -723,4 +723,152 @@ export const finalistRouter = router({ }) return { confirmationId } }), + + /** + * Team lead replaces the AttendingMember roster for a CONFIRMED finalist + * confirmation. Diff-based: rows for users who stay are kept (preserving + * their FlightDetail); removed users are deleted (cascading FlightDetail); + * added users get fresh rows. Closed once `attendeeEditCutoffHours` (default + * 48) before the LIVE_FINAL round's `windowOpenAt`. + */ + editAttendees: protectedProcedure + .input( + z.object({ + confirmationId: z.string(), + attendingUserIds: z.array(z.string()).min(1), + visaFlags: z.record(z.string(), z.boolean()).default({}), + }), + ) + .mutation(async ({ ctx, input }) => { + const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({ + where: { id: input.confirmationId }, + include: { + project: { + select: { + id: true, + programId: true, + program: { select: { defaultAttendeeCap: true } }, + teamMembers: { select: { userId: true, role: true } }, + }, + }, + attendingMembers: { select: { id: true, userId: true } }, + }, + }) + + const callerMembership = confirmation.project.teamMembers.find( + (tm) => tm.userId === ctx.user.id, + ) + if (!callerMembership) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a team member of this project', + }) + } + if (callerMembership.role !== 'LEAD') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Only the team lead can edit attendees', + }) + } + + if (confirmation.status !== 'CONFIRMED') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Confirmation must be in CONFIRMED status to edit attendees', + }) + } + + // Cap check + const cap = confirmation.project.program.defaultAttendeeCap + if (input.attendingUserIds.length > cap) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Selection exceeds attendee cap of ${cap}`, + }) + } + + // Team membership check for the new roster + const teamUserIds = new Set(confirmation.project.teamMembers.map((tm) => tm.userId)) + for (const id of input.attendingUserIds) { + if (!teamUserIds.has(id)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `User ${id} is not a team member`, + }) + } + } + + // Cutoff check — uses the LIVE_FINAL round's windowOpenAt + cfg.attendeeEditCutoffHours + const round = await ctx.prisma.round.findFirst({ + where: { + competition: { programId: confirmation.project.programId }, + roundType: 'LIVE_FINAL', + }, + orderBy: { sortOrder: 'desc' }, + select: { windowOpenAt: true, configJson: true }, + }) + if (round?.windowOpenAt) { + const cfg = (round.configJson ?? {}) as { attendeeEditCutoffHours?: number } + const cutoffHours = cfg.attendeeEditCutoffHours ?? 48 + const cutoffAt = new Date(round.windowOpenAt.getTime() - cutoffHours * 3_600_000) + if (Date.now() > cutoffAt.getTime()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Attendee edits closed ${cutoffHours}h before the grand finale (cutoff was ${cutoffAt.toISOString()})`, + }) + } + } + + // Diff: keep users in both, delete removed, create added + const desiredIds = new Set(input.attendingUserIds) + const existingByUser = new Map( + confirmation.attendingMembers.map((m) => [m.userId, m] as const), + ) + const toDelete = confirmation.attendingMembers.filter((m) => !desiredIds.has(m.userId)) + const toCreate = input.attendingUserIds.filter((id) => !existingByUser.has(id)) + const toUpdate = input.attendingUserIds.filter((id) => existingByUser.has(id)) + + await ctx.prisma.$transaction([ + ...(toDelete.length > 0 + ? [ + ctx.prisma.attendingMember.deleteMany({ + where: { id: { in: toDelete.map((m) => m.id) } }, + }), + ] + : []), + ...toUpdate.map((userId) => + ctx.prisma.attendingMember.update({ + where: { id: existingByUser.get(userId)!.id }, + data: { needsVisa: input.visaFlags[userId] ?? false }, + }), + ), + ...(toCreate.length > 0 + ? [ + ctx.prisma.attendingMember.createMany({ + data: toCreate.map((userId) => ({ + confirmationId: confirmation.id, + userId, + needsVisa: input.visaFlags[userId] ?? false, + })), + }), + ] + : []), + ]) + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_EDIT_ATTENDEES', + entityType: 'FinalistConfirmation', + entityId: confirmation.id, + detailsJson: { + attendingUserIds: input.attendingUserIds, + visaFlags: input.visaFlags, + added: toCreate, + removed: toDelete.map((m) => m.userId), + }, + }) + + return { ok: true } + }), }) diff --git a/tests/helpers.ts b/tests/helpers.ts index 2317590..a5531a7 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -53,7 +53,7 @@ export async function createTestUser( // ─── Program Factory ─────────────────────────────────────────────────────── export async function createTestProgram( - overrides: Partial<{ name: string; year: number }> = {}, + overrides: Partial<{ name: string; year: number; defaultAttendeeCap: number }> = {}, ) { const id = uid('prog') return prisma.program.create({ @@ -62,6 +62,9 @@ export async function createTestProgram( name: overrides.name ?? `Test Program ${id}`, year: overrides.year ?? 2026, status: 'ACTIVE', + ...(overrides.defaultAttendeeCap !== undefined + ? { defaultAttendeeCap: overrides.defaultAttendeeCap } + : {}), }, }) } diff --git a/tests/unit/finalist-edit-attendees.test.ts b/tests/unit/finalist-edit-attendees.test.ts new file mode 100644 index 0000000..91490c9 --- /dev/null +++ b/tests/unit/finalist-edit-attendees.test.ts @@ -0,0 +1,312 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + createTestCompetition, + createTestRound, + cleanupTestData, + uid, +} from '../helpers' +import { finalistRouter } from '../../src/server/routers/finalist' + +async function createApplicant(role: 'LEAD' | 'MEMBER' | 'ADVISOR' = 'MEMBER') { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Test ${role}`, + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) +} + +async function setupFinalistWithTeam(opts: { + programName: string + status?: 'CONFIRMED' | 'PENDING' | 'DECLINED' | 'EXPIRED' | 'SUPERSEDED' + windowOpenAt?: Date + attendeeEditCutoffHours?: number + defaultAttendeeCap?: number +}) { + const program = await createTestProgram({ + name: opts.programName, + defaultAttendeeCap: opts.defaultAttendeeCap ?? 3, + }) + const project = await createTestProject(program.id, { + title: 'P', + competitionCategory: 'STARTUP', + }) + + const lead = await createApplicant('LEAD') + const teammateA = await createApplicant('MEMBER') + const teammateB = await createApplicant('MEMBER') + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: lead.id, role: 'LEAD' }, + { projectId: project.id, userId: teammateA.id, role: 'MEMBER' }, + { projectId: project.id, userId: teammateB.id, role: 'MEMBER' }, + ], + }) + + const competition = await createTestCompetition(program.id) + const cfg: Record = {} + if (opts.attendeeEditCutoffHours !== undefined) { + cfg.attendeeEditCutoffHours = opts.attendeeEditCutoffHours + } + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 99, + windowOpenAt: opts.windowOpenAt, + configJson: cfg, + }) + + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: opts.status ?? 'CONFIRMED', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + confirmedAt: opts.status === 'CONFIRMED' || opts.status === undefined ? new Date() : null, + }, + }) + // seed one existing AttendingMember (just the lead) + await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: lead.id, needsVisa: false }, + }) + + return { program, project, lead, teammateA, teammateB, confirmation } +} + +describe('finalist.editAttendees', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.attendingMember.deleteMany({ + where: { confirmation: { project: { programId } } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('team lead can edit attendees on a CONFIRMED confirmation before cutoff', async () => { + const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ + programName: `edit-ok-${uid()}`, + windowOpenAt: new Date(Date.now() + 30 * 86_400_000), // 30 days out + attendeeEditCutoffHours: 48, + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, teammateA.id], + visaFlags: { [teammateA.id]: true }, + }) + + const rows = await prisma.attendingMember.findMany({ + where: { confirmationId: confirmation.id }, + orderBy: { userId: 'asc' }, + }) + expect(rows).toHaveLength(2) + const userIdSet = new Set(rows.map((r) => r.userId)) + expect(userIdSet.has(lead.id)).toBe(true) + expect(userIdSet.has(teammateA.id)).toBe(true) + const teammateRow = rows.find((r) => r.userId === teammateA.id)! + expect(teammateRow.needsVisa).toBe(true) + }) + + it('preserves AttendingMember row (and its FlightDetail) for users that stay', async () => { + const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ + programName: `edit-preserve-${uid()}`, + windowOpenAt: new Date(Date.now() + 30 * 86_400_000), + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id) + + const leadRow = await prisma.attendingMember.findFirstOrThrow({ + where: { confirmationId: confirmation.id, userId: lead.id }, + }) + await prisma.flightDetail.create({ + data: { + attendingMemberId: leadRow.id, + arrivalFlightNumber: 'AF123', + }, + }) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, teammateA.id], + visaFlags: {}, + }) + + const stillThere = await prisma.attendingMember.findFirst({ + where: { confirmationId: confirmation.id, userId: lead.id }, + }) + expect(stillThere?.id).toBe(leadRow.id) + const flight = await prisma.flightDetail.findFirst({ + where: { attendingMemberId: leadRow.id }, + }) + expect(flight?.arrivalFlightNumber).toBe('AF123') + }) + + it('rejects edits past the cutoff', async () => { + const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ + programName: `edit-cutoff-${uid()}`, + windowOpenAt: new Date(Date.now() + 24 * 3_600_000), // 24h out + attendeeEditCutoffHours: 48, // cutoff was 24h ago + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await expect( + caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, teammateA.id], + visaFlags: {}, + }), + ).rejects.toThrow(/cutoff|closed/i) + }) + + it('rejects when caller is not a team member', async () => { + const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ + programName: `edit-foreign-${uid()}`, + windowOpenAt: new Date(Date.now() + 30 * 86_400_000), + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id) + + const stranger = await createApplicant('MEMBER') + userIds.push(stranger.id) + const caller = createCaller(finalistRouter, { + id: stranger.id, + email: stranger.email, + role: 'APPLICANT', + }) + await expect( + caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, teammateA.id], + visaFlags: {}, + }), + ).rejects.toThrow(/team member|forbidden/i) + }) + + it('rejects when caller is a team member but not the lead', async () => { + const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ + programName: `edit-not-lead-${uid()}`, + windowOpenAt: new Date(Date.now() + 30 * 86_400_000), + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id) + + const caller = createCaller(finalistRouter, { + id: teammateA.id, + email: teammateA.email, + role: 'APPLICANT', + }) + await expect( + caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, teammateA.id], + visaFlags: {}, + }), + ).rejects.toThrow(/lead/i) + }) + + it('rejects when confirmation is not CONFIRMED', async () => { + const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ + programName: `edit-pending-${uid()}`, + status: 'PENDING', + windowOpenAt: new Date(Date.now() + 30 * 86_400_000), + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await expect( + caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, teammateA.id], + visaFlags: {}, + }), + ).rejects.toThrow(/CONFIRMED/i) + }) + + it('rejects when attendees exceed defaultAttendeeCap', async () => { + const { program, lead, teammateA, teammateB, confirmation } = await setupFinalistWithTeam({ + programName: `edit-cap-${uid()}`, + windowOpenAt: new Date(Date.now() + 30 * 86_400_000), + defaultAttendeeCap: 2, + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id, teammateB.id) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await expect( + caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, teammateA.id, teammateB.id], + visaFlags: {}, + }), + ).rejects.toThrow(/cap|exceeds/i) + }) + + it('rejects when an attendingUserId is not a team member', async () => { + const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ + programName: `edit-nonteam-${uid()}`, + windowOpenAt: new Date(Date.now() + 30 * 86_400_000), + }) + programIds.push(program.id) + userIds.push(lead.id, teammateA.id) + + const outsider = await createApplicant('MEMBER') + userIds.push(outsider.id) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await expect( + caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, outsider.id], + visaFlags: {}, + }), + ).rejects.toThrow(/not a team member/i) + }) +})