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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user