feat(finalist): unenroll reverses round membership + confirmation

Adds finalist.unenroll(projectId, roundId) which deletes the
FinalistConfirmation (cascading AttendingMember/FlightDetail/
VisaApplication/MemberLunchPick) and the LIVE_FINAL ProjectRoundState,
then logs a FINALIST_UNENROLL audit entry. Safe no-op when no rows exist.
Tests cover ADMIN_CONFIRM enrolled teardown and the no-rows path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 15:23:50 +02:00
parent f1e62fdd3b
commit 375aeb08af
2 changed files with 252 additions and 0 deletions

View File

@@ -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 }
}),
})