From bdfd99874adb1a7f77f3ba558ecefd13d1c57ef7 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 19:31:28 +0200 Subject: [PATCH] feat: auto-create/sync VisaApplication on attendee writes confirm and adminConfirm now create REQUESTED VisaApplication rows for every attendee with needsVisa=true, in the same Prisma transaction as the AttendingMember inserts. editAttendees was extended into a fully diff-aware sync: existing attendees whose needsVisa flips on get a new VisaApp; flipping off deletes it; staying true preserves the row (and its status / notes / dates). Removed attendees cascade automatically via the FK. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/finalist.ts | 132 +++++--- tests/unit/visa-application-lifecycle.test.ts | 306 ++++++++++++++++++ 2 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 tests/unit/visa-application-lifecycle.test.ts diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index fedb8ef..27d9ef2 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -327,19 +327,31 @@ export const finalistRouter = router({ } } - await ctx.prisma.$transaction([ - ctx.prisma.finalistConfirmation.update({ + await ctx.prisma.$transaction(async (tx) => { + await tx.finalistConfirmation.update({ where: { id: confirmation.id }, data: { status: 'CONFIRMED', confirmedAt: new Date() }, - }), - ctx.prisma.attendingMember.createMany({ + }) + 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' })), + }) + } + }) await logAudit({ prisma: ctx.prisma, action: 'FINALIST_CONFIRMED', @@ -469,19 +481,31 @@ export const finalistRouter = router({ } } - await ctx.prisma.$transaction([ - ctx.prisma.finalistConfirmation.update({ + await ctx.prisma.$transaction(async (tx) => { + await tx.finalistConfirmation.update({ where: { id: confirmation.id }, data: { status: 'CONFIRMED', confirmedAt: new Date() }, - }), - ctx.prisma.attendingMember.createMany({ + }) + 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' })), + }) + } + }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, @@ -916,7 +940,14 @@ export const finalistRouter = router({ teamMembers: { select: { userId: true, role: true } }, }, }, - attendingMembers: { select: { id: true, userId: true } }, + attendingMembers: { + select: { + id: true, + userId: true, + needsVisa: true, + visaApplication: { select: { id: true } }, + }, + }, }, }) @@ -993,32 +1024,55 @@ export const finalistRouter = router({ 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 ctx.prisma.$transaction(async (tx) => { + if (toDelete.length > 0) { + // FK cascade removes any VisaApplication rows tied to deleted attendees + await tx.attendingMember.deleteMany({ + where: { id: { in: toDelete.map((m) => m.id) } }, + }) + } + + // Diff visa flips for users that stay + const visaToDelete: string[] = [] + for (const userId of toUpdate) { + const existing = existingByUser.get(userId)! + const wantsVisa = input.visaFlags[userId] === true + await tx.attendingMember.update({ + where: { id: existing.id }, + data: { needsVisa: wantsVisa }, + }) + if (existing.visaApplication && !wantsVisa) { + visaToDelete.push(existing.visaApplication.id) + } else if (!existing.visaApplication && wantsVisa) { + await tx.visaApplication.create({ + data: { attendingMemberId: existing.id, status: 'REQUESTED' }, + }) + } + } + if (visaToDelete.length > 0) { + await tx.visaApplication.deleteMany({ where: { id: { in: visaToDelete } } }) + } + + if (toCreate.length > 0) { + await tx.attendingMember.createMany({ + data: toCreate.map((userId) => ({ + confirmationId: confirmation.id, + userId, + needsVisa: input.visaFlags[userId] ?? false, + })), + }) + const newVisaUsers = toCreate.filter((id) => input.visaFlags[id] === true) + if (newVisaUsers.length > 0) { + const created = await tx.attendingMember.findMany({ + where: { confirmationId: confirmation.id, userId: { in: newVisaUsers } }, + select: { id: true }, + }) + await tx.visaApplication.createMany({ + data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })), + }) + } + } + }) await logAudit({ prisma: ctx.prisma, diff --git a/tests/unit/visa-application-lifecycle.test.ts b/tests/unit/visa-application-lifecycle.test.ts new file mode 100644 index 0000000..f012209 --- /dev/null +++ b/tests/unit/visa-application-lifecycle.test.ts @@ -0,0 +1,306 @@ +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' +import { signFinalistToken } from '../../src/lib/finalist-token' + +async function createApplicant(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', + }, + }) +} + +async function setup(opts: { + programName: string + status?: 'PENDING' | 'CONFIRMED' + windowOpenAt?: Date + attendees?: { lead?: { needsVisa: boolean }; member?: { needsVisa: boolean } } +}) { + const program = await createTestProgram({ + name: opts.programName, + defaultAttendeeCap: 3, + }) + const project = await createTestProject(program.id, { + title: 'P', + competitionCategory: 'STARTUP', + }) + const lead = await createApplicant('LEAD') + const member = await createApplicant('MEMBER') + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: lead.id, role: 'LEAD' }, + { projectId: project.id, userId: member.id, role: 'MEMBER' }, + ], + }) + const competition = await createTestCompetition(program.id) + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 99, + windowOpenAt: opts.windowOpenAt ?? new Date(Date.now() + 30 * 86_400_000), + configJson: { confirmationWindowHours: 24 }, + }) + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: opts.status ?? 'PENDING', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + confirmedAt: opts.status === 'CONFIRMED' ? new Date() : null, + }, + }) + // Seed AttendingMember rows when CONFIRMED + if (opts.status === 'CONFIRMED' && opts.attendees) { + const rows: { confirmationId: string; userId: string; needsVisa: boolean }[] = [] + if (opts.attendees.lead) { + rows.push({ + confirmationId: confirmation.id, + userId: lead.id, + needsVisa: opts.attendees.lead.needsVisa, + }) + } + if (opts.attendees.member) { + rows.push({ + confirmationId: confirmation.id, + userId: member.id, + needsVisa: opts.attendees.member.needsVisa, + }) + } + if (rows.length > 0) await prisma.attendingMember.createMany({ data: rows }) + } + return { program, project, lead, member, confirmation } +} + +describe('VisaApplication lifecycle', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.visaApplication.deleteMany({ + where: { attendingMember: { confirmation: { project: { programId } } } }, + }) + 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('public confirm creates a VisaApplication for each needsVisa=true attendee', async () => { + const { program, lead, member, confirmation } = await setup({ + programName: `visa-confirm-${uid()}`, + }) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const token = signFinalistToken({ + confirmationId: confirmation.id, + exp: Math.floor(Date.now() / 1000) + 3600, + }) + // Update the confirmation to use our signed token + await prisma.finalistConfirmation.update({ + where: { id: confirmation.id }, + data: { token }, + }) + + const caller = createCaller(finalistRouter, { id: '', email: '', role: '' } as never) + await caller.confirm({ + token, + attendingUserIds: [lead.id, member.id], + visaFlags: { [lead.id]: false, [member.id]: true }, + }) + + const apps = await prisma.visaApplication.findMany({ + where: { attendingMember: { confirmationId: confirmation.id } }, + include: { attendingMember: { select: { userId: true } } }, + }) + expect(apps).toHaveLength(1) + expect(apps[0].attendingMember.userId).toBe(member.id) + expect(apps[0].status).toBe('REQUESTED') + }) + + it('adminConfirm creates a VisaApplication for each needsVisa=true attendee', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, lead, member, confirmation } = await setup({ + programName: `visa-admin-confirm-${uid()}`, + }) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.adminConfirm({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id, member.id], + visaFlags: { [lead.id]: true, [member.id]: true }, + }) + + const apps = await prisma.visaApplication.findMany({ + where: { attendingMember: { confirmationId: confirmation.id } }, + }) + expect(apps).toHaveLength(2) + }) + + it('editAttendees creates a VisaApplication when an attendee flips to needsVisa=true', async () => { + const { program, lead, member, confirmation } = await setup({ + programName: `visa-edit-flip-on-${uid()}`, + status: 'CONFIRMED', + attendees: { lead: { needsVisa: false } }, + }) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id], + visaFlags: { [lead.id]: true }, + }) + + const apps = await prisma.visaApplication.findMany({ + where: { attendingMember: { confirmationId: confirmation.id } }, + include: { attendingMember: { select: { userId: true } } }, + }) + expect(apps).toHaveLength(1) + expect(apps[0].attendingMember.userId).toBe(lead.id) + }) + + it('editAttendees deletes the VisaApplication when an attendee flips to needsVisa=false', async () => { + const { program, lead, member, confirmation } = await setup({ + programName: `visa-edit-flip-off-${uid()}`, + status: 'CONFIRMED', + attendees: { lead: { needsVisa: true } }, + }) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const leadAttendee = await prisma.attendingMember.findFirstOrThrow({ + where: { confirmationId: confirmation.id, userId: lead.id }, + }) + await prisma.visaApplication.create({ + data: { attendingMemberId: leadAttendee.id, status: 'REQUESTED' }, + }) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id], + visaFlags: { [lead.id]: false }, + }) + + const apps = await prisma.visaApplication.findMany({ + where: { attendingMember: { confirmationId: confirmation.id } }, + }) + expect(apps).toHaveLength(0) + }) + + it('editAttendees preserves an existing VisaApplication when needsVisa stays true', async () => { + const { program, lead, member, confirmation } = await setup({ + programName: `visa-edit-preserve-${uid()}`, + status: 'CONFIRMED', + attendees: { lead: { needsVisa: true } }, + }) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const leadAttendee = await prisma.attendingMember.findFirstOrThrow({ + where: { confirmationId: confirmation.id, userId: lead.id }, + }) + const seeded = await prisma.visaApplication.create({ + data: { + attendingMemberId: leadAttendee.id, + status: 'APPOINTMENT_BOOKED', + notes: 'preserve me', + }, + }) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id], + visaFlags: { [lead.id]: true }, + }) + + const app = await prisma.visaApplication.findUniqueOrThrow({ where: { id: seeded.id } }) + expect(app.status).toBe('APPOINTMENT_BOOKED') + expect(app.notes).toBe('preserve me') + }) + + it('removing an attendee cascades the VisaApplication', async () => { + const { program, lead, member, confirmation } = await setup({ + programName: `visa-cascade-${uid()}`, + status: 'CONFIRMED', + attendees: { lead: { needsVisa: true }, member: { needsVisa: true } }, + }) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const memberAttendee = await prisma.attendingMember.findFirstOrThrow({ + where: { confirmationId: confirmation.id, userId: member.id }, + }) + const leadAttendee = await prisma.attendingMember.findFirstOrThrow({ + where: { confirmationId: confirmation.id, userId: lead.id }, + }) + await prisma.visaApplication.createMany({ + data: [ + { attendingMemberId: leadAttendee.id, status: 'REQUESTED' }, + { attendingMemberId: memberAttendee.id, status: 'REQUESTED' }, + ], + }) + + const caller = createCaller(finalistRouter, { + id: lead.id, + email: lead.email, + role: 'APPLICANT', + }) + await caller.editAttendees({ + confirmationId: confirmation.id, + attendingUserIds: [lead.id], + visaFlags: { [lead.id]: true }, + }) + + const apps = await prisma.visaApplication.findMany({ + where: { attendingMember: { confirmationId: confirmation.id } }, + include: { attendingMember: { select: { userId: true } } }, + }) + expect(apps).toHaveLength(1) + expect(apps[0].attendingMember.userId).toBe(lead.id) + }) +})