diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 598589f..be3dfb9 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -559,7 +559,19 @@ export const finalistRouter = router({ .mutation(async ({ ctx, input }) => { const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: input.confirmationId }, - include: { project: { select: { programId: true, title: true } } }, + include: { + project: { + select: { + programId: true, + title: true, + teamMembers: { + where: { role: 'LEAD' }, + take: 1, + select: { userId: true }, + }, + }, + }, + }, }) if (confirmation.status !== 'PENDING') { throw new TRPCError({ @@ -603,6 +615,25 @@ export const finalistRouter = router({ console.error('[finalist.adminDecline] failed to send admin notification:', err) } + // Withdrawal notification to team lead — best-effort, never throws + try { + const lead = confirmation.project.teamMembers[0] + if (lead) { + const projectTitle = confirmation.project.title + const reason = input.reason + await createNotification({ + userId: lead.userId, + type: NotificationTypes.FINALIST_WITHDRAWN, + title: 'Grand finale slot withdrawn', + message: `Your team "${projectTitle}" is no longer a confirmed finalist.${reason ? ' Reason: ' + reason : ''}`, + linkUrl: '/applicant', + metadata: { projectTitle, reason }, + }) + } + } catch (err) { + console.error('[finalist.adminDecline] failed to send withdrawal notification to team:', err) + } + const round = await ctx.prisma.round.findFirst({ where: { competition: { programId: confirmation.project.programId }, @@ -810,6 +841,11 @@ export const finalistRouter = router({ mentorId: true, }, }, + teamMembers: { + where: { role: 'LEAD' }, + take: 1, + select: { userId: true }, + }, }, }, }, @@ -868,6 +904,25 @@ export const finalistRouter = router({ cascadedAssignmentCount: activeAssignments.length, }, }) + + // Withdrawal notification to team lead — best-effort, never throws + try { + const lead = confirmation.project.teamMembers[0] + if (lead) { + const projectTitle = confirmation.project.title + await createNotification({ + userId: lead.userId, + type: NotificationTypes.FINALIST_WITHDRAWN, + title: 'Grand finale slot withdrawn', + message: `Your team "${projectTitle}" is no longer a confirmed finalist.`, + linkUrl: '/applicant', + metadata: { projectTitle }, + }) + } + } catch (err) { + console.error('[finalist.unconfirm] failed to send withdrawal notification to team:', err) + } + return { ok: true, cascadedMentorAssignment } }), @@ -1538,19 +1593,38 @@ export const finalistRouter = router({ }) } - // Step 1: Delete the FinalistConfirmation (cascade removes AttendingMember + // Step 1: Capture the CONFIRMED confirmation (if any) BEFORE deleting, + // so we can notify the team lead. We only notify when the team had + // already confirmed (CONFIRMED status) — not for PENDING or absent rows. + const confirmedConfirmation = await ctx.prisma.finalistConfirmation.findFirst({ + where: { projectId: input.projectId, status: 'CONFIRMED' }, + select: { + project: { + select: { + title: true, + teamMembers: { + where: { role: 'LEAD' }, + take: 1, + select: { userId: true }, + }, + }, + }, + }, + }) + + // Step 2: 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. + // Step 3: Delete the LIVE_FINAL ProjectRoundState. await ctx.prisma.projectRoundState.deleteMany({ where: { projectId: input.projectId, roundId: input.roundId }, }) - // Step 3: Audit log + // Step 4: Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, @@ -1563,6 +1637,27 @@ export const finalistRouter = router({ }, }) + // Step 5: Withdrawal notification — only when a CONFIRMED row existed. + // Best-effort, never throws. + if (confirmedConfirmation) { + try { + const lead = confirmedConfirmation.project.teamMembers[0] + if (lead) { + const projectTitle = confirmedConfirmation.project.title + await createNotification({ + userId: lead.userId, + type: NotificationTypes.FINALIST_WITHDRAWN, + title: 'Grand finale slot withdrawn', + message: `Your team "${projectTitle}" is no longer a confirmed finalist.`, + linkUrl: '/applicant', + metadata: { projectTitle }, + }) + } + } catch (err) { + console.error('[finalist.unenroll] failed to send withdrawal notification to team:', err) + } + } + return { ok: true } }), }) diff --git a/tests/unit/finalist-withdrawal.test.ts b/tests/unit/finalist-withdrawal.test.ts new file mode 100644 index 0000000..9412ea3 --- /dev/null +++ b/tests/unit/finalist-withdrawal.test.ts @@ -0,0 +1,265 @@ +/** + * Task 4: Withdrawal emails to teams + * + * Verifies that FINALIST_WITHDRAWN in-app notifications are sent to the team + * lead when an admin withdraws a team's grand-finale slot via: + * - adminDecline (PENDING → DECLINED) + * - unconfirm (CONFIRMED → SUPERSEDED) + * - unenroll (only when a CONFIRMED confirmation existed; NOT when never enrolled) + */ +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' + +beforeAll(() => { + process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens' + process.env.NEXTAUTH_URL = 'http://localhost:3001' +}) + +import { beforeAll } from 'vitest' + +// ── helpers ──────────────────────────────────────────────────────────────── + +async function createApplicant(label = 'user') { + const id = uid(label) + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Test ${label}`, + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) +} + +/** Creates a program + competition + round (LIVE_FINAL) + project + team lead, + * returns all. Callers can then create a FinalistConfirmation themselves so they + * control the initial status. */ +async function setupBase(label: string) { + const program = await createTestProgram({ name: `withdrawal-${label}-${uid()}`, defaultAttendeeCap: 3 }) + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + 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: `Withdrawal Test ${label}`, + competitionCategory: 'STARTUP', + }) + const lead = await createApplicant('lead') + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + // Put project in MENTORING round (enrollment candidate) + await prisma.projectRoundState.create({ + data: { + projectId: project.id, + roundId: ( + await prisma.round.findFirstOrThrow({ + where: { competition: { programId: program.id }, roundType: 'MENTORING' }, + }) + ).id, + }, + }) + return { program, liveFinalRound, project, lead } +} + +// ── suite ────────────────────────────────────────────────────────────────── + +describe('finalist withdrawal notifications (Task 4)', () => { + const programIds: string[] = [] + const userIds: string[] = [] + const notificationIds: string[] = [] + + afterAll(async () => { + if (notificationIds.length > 0) { + await prisma.inAppNotification.deleteMany({ where: { id: { in: notificationIds } } }) + } + for (const programId of programIds) { + await prisma.inAppNotification.deleteMany({ + where: { metadata: { path: ['projectTitle'], string_contains: 'Withdrawal Test' } }, + }) + await prisma.attendingMember.deleteMany({ + where: { confirmation: { project: { programId } } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await prisma.waitlistEntry.deleteMany({ where: { programId } }) + await prisma.projectRoundState.deleteMany({ + where: { round: { competition: { programId } } }, + }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('Test 1: adminDecline on PENDING sends FINALIST_WITHDRAWN to team lead', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, project, lead } = await setupBase('adminDecline') + programIds.push(program.id) + userIds.push(lead.id) + + // Create a PENDING FinalistConfirmation manually + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + }, + }) + + // Clear any prior notifications for lead + await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + await caller.adminDecline({ + confirmationId: confirmation.id, + reason: 'No longer eligible', + }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, + }) + expect(notification, 'lead should have FINALIST_WITHDRAWN notification').not.toBeNull() + expect(notification?.type).toBe('FINALIST_WITHDRAWN') + if (notification) notificationIds.push(notification.id) + }) + + it('Test 2: unconfirm (CONFIRMED→SUPERSEDED) sends FINALIST_WITHDRAWN to team lead', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, project, lead } = await setupBase('unconfirm') + programIds.push(program.id) + userIds.push(lead.id) + + // Create a CONFIRMED FinalistConfirmation manually + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + + await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + await caller.unconfirm({ + confirmationId: confirmation.id, + reason: 'Quota change required — test', + }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, + }) + expect(notification, 'lead should have FINALIST_WITHDRAWN notification after unconfirm').not.toBeNull() + expect(notification?.type).toBe('FINALIST_WITHDRAWN') + if (notification) notificationIds.push(notification.id) + }) + + it('Test 3: unenroll of CONFIRMED team sends FINALIST_WITHDRAWN to team lead', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead } = await setupBase('unenroll-confirmed') + programIds.push(program.id) + userIds.push(lead.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // Enroll via ADMIN_CONFIRM → status = CONFIRMED + await caller.enrollFinalists({ + programId: program.id, + roundId: liveFinalRound.id, + enrollments: [ + { + projectId: project.id, + mode: 'ADMIN_CONFIRM', + attendingUserIds: [lead.id], + visaFlags: {}, + }, + ], + }) + + // Verify the confirmation is indeed CONFIRMED before we unenroll + const confBefore = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { projectId: project.id }, + }) + expect(confBefore.status).toBe('CONFIRMED') + + await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) + + await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, + }) + expect(notification, 'lead should have FINALIST_WITHDRAWN after unenrolling a CONFIRMED team').not.toBeNull() + expect(notification?.type).toBe('FINALIST_WITHDRAWN') + if (notification) notificationIds.push(notification.id) + }) + + it('Test 4: unenroll of never-enrolled project does NOT send FINALIST_WITHDRAWN', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const { program, liveFinalRound, project, lead } = await setupBase('unenroll-none') + programIds.push(program.id) + userIds.push(lead.id) + + await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // No prior enrollment — should be a no-op + await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, + }) + expect(notification, 'no FINALIST_WITHDRAWN when project was never enrolled').toBeNull() + }) +})