feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)

- Add `finalist.enrollFinalists` adminProcedure: creates ProjectRoundState in
  LIVE_FINAL round (skipDuplicates) + resets/creates FinalistConfirmation in
  one step, with EMAIL and ADMIN_CONFIRM attendee modes.
- Extract `confirmAttendanceInTx` helper into finalist-enrollment.ts; reuse
  from both adminConfirm and enrollFinalists (DRY refactor, all tests green).
- Add 4 tests covering EMAIL mode, ADMIN_CONFIRM mode, re-enroll safety, and
  over-cap rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 15:20:51 +02:00
parent dde8ea9345
commit f1e62fdd3b
3 changed files with 494 additions and 31 deletions

View File

@@ -14,6 +14,10 @@ import {
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
import {
resetOrCreatePendingConfirmation,
confirmAttendanceInTx,
} from '../services/finalist-enrollment'
export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */
@@ -490,36 +494,11 @@ export const finalistRouter = router({
}
await ctx.prisma.$transaction(async (tx) => {
await tx.finalistConfirmation.update({
where: { id: confirmation.id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
await confirmAttendanceInTx(tx, {
confirmationId: confirmation.id,
attendingUserIds: input.attendingUserIds,
visaFlags: input.visaFlags,
})
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' })),
})
}
const allMembers = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
})
await logAudit({
prisma: ctx.prisma,
@@ -1116,4 +1095,182 @@ export const finalistRouter = router({
return { ok: true }
}),
/**
* Unified finalist enrollment: advances a set of projects into the LIVE_FINAL
* round (creates ProjectRoundState, skipDuplicates) AND creates/resets their
* FinalistConfirmation in one atomic step.
*
* Two attendee modes per project:
* - EMAIL: sends the self-confirm link to the team lead (never throws in loop)
* - ADMIN_CONFIRM: validates + writes attendees immediately (CONFIRMED status)
*
* Returns { enrolled, emailed, adminConfirmed, skipped }.
*/
enrollFinalists: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string(), // the LIVE_FINAL round
enrollments: z
.array(
z.object({
projectId: z.string(),
mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
attendingUserIds: z.array(z.string()).optional(),
visaFlags: z.record(z.string(), z.boolean()).optional(),
}),
)
.min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Resolve the LIVE_FINAL round + confirmationWindowHours
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, configJson: true },
})
const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24
// Validate all projects belong to this program
const projectIds = input.enrollments.map((e) => e.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds }, programId: input.programId },
select: {
id: true,
title: true,
competitionCategory: true,
program: { select: { defaultAttendeeCap: true } },
teamMembers: {
select: {
userId: true,
role: true,
user: { select: { email: true, name: true } },
},
},
},
})
if (projects.length !== projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'One or more project IDs not found in this program',
})
}
const projectMap = new Map(projects.map((p) => [p.id, p]))
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
let enrolled = 0
let emailed = 0
let adminConfirmed = 0
const skipped: Array<{ projectId: string; reason: string }> = []
for (const enrollment of input.enrollments) {
const project = projectMap.get(enrollment.projectId)!
const cap = project.program.defaultAttendeeCap
// ADMIN_CONFIRM pre-validation: validate attendingUserIds before touching DB
if (enrollment.mode === 'ADMIN_CONFIRM') {
const attendingUserIds = enrollment.attendingUserIds ?? []
if (attendingUserIds.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`,
})
}
if (attendingUserIds.length > cap) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`,
})
}
const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId))
for (const uid of attendingUserIds) {
if (!teamUserIds.has(uid)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `User ${uid} is not a team member of project ${project.id}`,
})
}
}
}
// Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
await ctx.prisma.projectRoundState.createMany({
data: [{ projectId: enrollment.projectId, roundId: input.roundId }],
skipDuplicates: true,
})
// Step 2: Create or reset the finalist confirmation
const category = project.competitionCategory as CompetitionCategory
const confirmResult = await resetOrCreatePendingConfirmation(ctx.prisma, {
projectId: enrollment.projectId,
category,
windowHours,
})
if (confirmResult.alreadyConfirmed) {
skipped.push({ projectId: enrollment.projectId, reason: 'ALREADY_CONFIRMED' })
enrolled++
continue
}
enrolled++
// Step 3: Mode-specific handling
if (enrollment.mode === 'EMAIL') {
// Send confirmation email to team lead (best-effort — never throw in loop)
const lead = project.teamMembers.find((tm) => tm.role === 'LEAD')?.user
if (lead?.email) {
const confirmUrl = `${baseUrl}/finalist/confirm/${confirmResult.token}`
try {
await sendFinalistConfirmationEmail(
lead.email,
lead.name ?? null,
project.title,
confirmResult.deadline,
confirmUrl,
)
emailed++
} catch (err) {
console.error(
`[finalist.enrollFinalists] failed to send email to ${lead.email} for project ${enrollment.projectId}:`,
err,
)
}
}
} else {
// ADMIN_CONFIRM: write attendees + visa + lunch rows immediately
const attendingUserIds = enrollment.attendingUserIds!
const visaFlags = enrollment.visaFlags ?? {}
await ctx.prisma.$transaction(async (tx) => {
await confirmAttendanceInTx(tx, {
confirmationId: confirmResult.id,
attendingUserIds,
visaFlags,
})
})
adminConfirmed++
}
// Step 4: Audit per enrollment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_ENROLL',
entityType: 'Project',
entityId: enrollment.projectId,
detailsJson: {
projectId: enrollment.projectId,
mode: enrollment.mode,
roundId: input.roundId,
programId: input.programId,
},
})
}
return { enrolled, emailed, adminConfirmed, skipped }
}),
})