diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 314f13a..0cbaac9 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -1273,4 +1273,49 @@ export const finalistRouter = router({ return { enrolled, emailed, adminConfirmed, skipped } }), + + /** + * Reverse enrollment: removes a project from the LIVE_FINAL round and + * deletes its FinalistConfirmation (cascade removes AttendingMember, + * FlightDetail, VisaApplication, and MemberLunchPick rows). + * + * Mentor assignments (tied to the MENTORING round) are intentionally + * left untouched. Safe to call even if the project was never enrolled + * (deleteMany is a no-op when no rows match). + */ + unenroll: adminProcedure + .input( + z.object({ + projectId: z.string(), + roundId: z.string(), // the LIVE_FINAL round + }), + ) + .mutation(async ({ ctx, input }) => { + // Step 1: Delete the FinalistConfirmation (cascade removes AttendingMember + // / FlightDetail / VisaApplication / MemberLunchPick). + // deleteMany is no-op-safe when no row exists. + await ctx.prisma.finalistConfirmation.deleteMany({ + where: { projectId: input.projectId }, + }) + + // Step 2: Delete the LIVE_FINAL ProjectRoundState. + await ctx.prisma.projectRoundState.deleteMany({ + where: { projectId: input.projectId, roundId: input.roundId }, + }) + + // Step 3: Audit log + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_UNENROLL', + entityType: 'Project', + entityId: input.projectId, + detailsJson: { + projectId: input.projectId, + roundId: input.roundId, + }, + }) + + return { ok: true } + }), }) diff --git a/tests/unit/finalist-unenroll.test.ts b/tests/unit/finalist-unenroll.test.ts new file mode 100644 index 0000000..020bfb3 --- /dev/null +++ b/tests/unit/finalist-unenroll.test.ts @@ -0,0 +1,207 @@ +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 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('finalist.unenroll', () => { + 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 setupUnenrollFixture(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: 'Unenroll 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('removes FinalistConfirmation, AttendingMember rows, and LIVE_FINAL PRS; logs FINALIST_UNENROLL audit', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead, member } = await setupUnenrollFixture( + `unenroll-main-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // First enroll via ADMIN_CONFIRM so we have a full set of rows to verify removal + 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 }, + }, + ], + }) + + // Verify pre-conditions: confirmation + attendees + PRS exist + const confBefore = await prisma.finalistConfirmation.findUnique({ + where: { projectId: project.id }, + }) + expect(confBefore).not.toBeNull() + expect(confBefore!.status).toBe('CONFIRMED') + + const attendeesBefore = await prisma.attendingMember.count({ + where: { confirmationId: confBefore!.id }, + }) + expect(attendeesBefore).toBe(2) + + const prsBefore = await prisma.projectRoundState.findFirst({ + where: { projectId: project.id, roundId: liveFinalRound.id }, + }) + expect(prsBefore).not.toBeNull() + + // Now unenroll + const result = await caller.unenroll({ + projectId: project.id, + roundId: liveFinalRound.id, + }) + + expect(result).toEqual({ ok: true }) + + // FinalistConfirmation is gone + const confAfter = await prisma.finalistConfirmation.findUnique({ + where: { projectId: project.id }, + }) + expect(confAfter).toBeNull() + + // AttendingMember rows are gone (cascade from FinalistConfirmation deletion) + const attendeesAfter = await prisma.attendingMember.count({ + where: { confirmationId: confBefore!.id }, + }) + expect(attendeesAfter).toBe(0) + + // LIVE_FINAL ProjectRoundState is gone + const prsAfter = await prisma.projectRoundState.findFirst({ + where: { projectId: project.id, roundId: liveFinalRound.id }, + }) + expect(prsAfter).toBeNull() + + // MENTORING PRS is NOT touched + const mentoringPrs = await prisma.projectRoundState.findFirst({ + where: { projectId: project.id, roundId: { not: liveFinalRound.id } }, + }) + expect(mentoringPrs).not.toBeNull() + + // Audit row exists + const auditRow = await prisma.auditLog.findFirst({ + where: { + action: 'FINALIST_UNENROLL', + entityType: 'Project', + entityId: project.id, + }, + orderBy: { timestamp: 'desc' }, + }) + expect(auditRow).not.toBeNull() + expect((auditRow!.detailsJson as Record).projectId).toBe(project.id) + expect((auditRow!.detailsJson as Record).roundId).toBe(liveFinalRound.id) + }) + + it('is a no-op when the project was never enrolled (no rows to delete)', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead, member } = await setupUnenrollFixture( + `unenroll-noop-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id, member.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // No prior enrollment — should not throw + const result = await caller.unenroll({ + projectId: project.id, + roundId: liveFinalRound.id, + }) + + expect(result).toEqual({ ok: true }) + + // Nothing exists after + const confAfter = await prisma.finalistConfirmation.findUnique({ + where: { projectId: project.id }, + }) + expect(confAfter).toBeNull() + + const prsAfter = await prisma.projectRoundState.findFirst({ + where: { projectId: project.id, roundId: liveFinalRound.id }, + }) + expect(prsAfter).toBeNull() + }) +})