diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 02aeae6..29d4f9d 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -7,6 +7,10 @@ import { createPendingConfirmation, promoteNextWaitlistEntry, } from '../services/finalist-confirmation' +import { + createNotification, + NotificationTypes, +} from '../services/in-app-notification' import { sendFinalistConfirmationEmail } from '@/lib/email' import { verifyFinalistToken } from '@/lib/finalist-token' @@ -543,6 +547,93 @@ export const finalistRouter = router({ return { ok: true } }), + /** + * Admin un-confirm: flips a CONFIRMED finalist back to SUPERSEDED. Cascades + * to drop the active mentor assignment (if any), notifies the mentor, and + * audit-logs the override. Used to allow a category quota decrease when + * the new quota would otherwise be below the confirmed count. + */ + unconfirm: adminProcedure + .input( + z.object({ + confirmationId: z.string(), + reason: z.string().min(5).max(500), + }), + ) + .mutation(async ({ ctx, input }) => { + const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({ + where: { id: input.confirmationId }, + include: { + project: { + select: { + id: true, + title: true, + mentorAssignment: { + select: { + id: true, + completionStatus: true, + droppedAt: true, + mentorId: true, + }, + }, + }, + }, + }, + }) + if (confirmation.status !== 'CONFIRMED') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Confirmation is not in CONFIRMED status (current: ${confirmation.status})`, + }) + } + + await ctx.prisma.finalistConfirmation.update({ + where: { id: confirmation.id }, + data: { status: 'SUPERSEDED' }, + }) + + // Cascade: drop active mentor assignment (skip if completed or already dropped) + const ma = confirmation.project.mentorAssignment + let cascadedMentorAssignment = false + if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') { + await ctx.prisma.mentorAssignment.update({ + where: { id: ma.id }, + data: { + droppedAt: new Date(), + droppedReason: `Finalist un-confirmed: ${input.reason}`, + droppedBy: 'finalist_unconfirmed', + }, + }) + cascadedMentorAssignment = true + // Notify mentor — best-effort + try { + await createNotification({ + userId: ma.mentorId, + type: NotificationTypes.MENTEE_DROPPED, + title: 'Mentee finalist slot withdrawn', + message: `Your mentee "${confirmation.project.title}" is no longer a confirmed finalist. Your assignment has ended.`, + priority: 'high', + }) + } catch (err) { + console.error('[finalist.unconfirm] notify mentor failed:', err) + } + } + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_UNCONFIRM', + entityType: 'FinalistConfirmation', + entityId: confirmation.id, + detailsJson: { + reason: input.reason, + projectId: confirmation.projectId, + cascadedMentorAssignment, + }, + }) + return { ok: true, cascadedMentorAssignment } + }), + /** * Manually promote a specific waitlist entry out of rank order. Sends a * fresh confirmation email + audit-logs the override (separate from diff --git a/tests/unit/finalist-unconfirm.test.ts b/tests/unit/finalist-unconfirm.test.ts new file mode 100644 index 0000000..32f8a6e --- /dev/null +++ b/tests/unit/finalist-unconfirm.test.ts @@ -0,0 +1,186 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { finalistRouter } from '../../src/server/routers/finalist' + +describe('finalist.unconfirm', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { 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 } } }) + } + }) + + async function setupConfirmedFinalist(programName: string) { + const program = await createTestProgram({ name: programName }) + const project = await createTestProject(program.id, { + title: 'Test Finalist', + competitionCategory: 'STARTUP', + }) + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86400000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + return { program, project, confirmation } + } + + it('flips CONFIRMED → SUPERSEDED with audit log', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, confirmation } = await setupConfirmedFinalist(`unconfirm-basic-${uid()}`) + programIds.push(program.id) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.unconfirm({ confirmationId: confirmation.id, reason: 'Quota change required' }) + + const updated = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { id: confirmation.id }, + }) + expect(updated.status).toBe('SUPERSEDED') + + // Audit log + const audit = await prisma.auditLog.findFirst({ + where: { action: 'FINALIST_UNCONFIRM', entityId: confirmation.id }, + }) + expect(audit).not.toBeNull() + }) + + it('cascades to drop the active mentor assignment', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const mentor = await prisma.user.create({ + data: { + id: uid('user'), + email: `m_${uid()}@test.local`, + name: 'Mentor', + role: 'MENTOR', + roles: ['MENTOR'], + status: 'ACTIVE', + }, + }) + userIds.push(mentor.id) + + const { program, project, confirmation } = await setupConfirmedFinalist( + `unconfirm-cascade-${uid()}`, + ) + programIds.push(program.id) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + workspaceEnabled: true, + }, + }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.unconfirm({ confirmationId: confirmation.id, reason: 'No longer attending' }) + + const droppedMa = await prisma.mentorAssignment.findUniqueOrThrow({ where: { id: ma.id } }) + expect(droppedMa.droppedAt).not.toBeNull() + expect(droppedMa.droppedBy).toBe('finalist_unconfirmed') + expect(droppedMa.droppedReason).toContain('No longer attending') + }) + + it('does not touch already-completed mentor assignments', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const mentor = await prisma.user.create({ + data: { + id: uid('user'), + email: `mc_${uid()}@test.local`, + name: 'M', + role: 'MENTOR', + roles: ['MENTOR'], + status: 'ACTIVE', + }, + }) + userIds.push(mentor.id) + + const { program, project, confirmation } = await setupConfirmedFinalist( + `unconfirm-completed-${uid()}`, + ) + programIds.push(program.id) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + completionStatus: 'completed', + }, + }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.unconfirm({ confirmationId: confirmation.id, reason: 'Quota shrink' }) + + const stillCompleted = await prisma.mentorAssignment.findUniqueOrThrow({ + where: { id: ma.id }, + }) + expect(stillCompleted.droppedAt).toBeNull() + expect(stillCompleted.completionStatus).toBe('completed') + }) + + it('rejects when confirmation is not in CONFIRMED status', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `unconfirm-pending-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { + title: 'Pending', + competitionCategory: 'STARTUP', + }) + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() + 86400000), + token: `tok_${uid()}`, + }, + }) + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await expect( + caller.unconfirm({ confirmationId: confirmation.id, reason: 'Reason text here' }), + ).rejects.toThrow(/CONFIRMED status/i) + }) +})