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 { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token' import { verifyFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync' import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
import {
resetOrCreatePendingConfirmation,
confirmAttendanceInTx,
} from '../services/finalist-enrollment'
export const finalistRouter = router({ export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */ /** 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 ctx.prisma.$transaction(async (tx) => {
await tx.finalistConfirmation.update({ await confirmAttendanceInTx(tx, {
where: { id: confirmation.id }, confirmationId: confirmation.id,
data: { status: 'CONFIRMED', confirmedAt: new Date() }, 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({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,
@@ -1116,4 +1095,182 @@ export const finalistRouter = router({
return { ok: true } 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 }
}),
}) })

View File

@@ -1,5 +1,6 @@
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client' import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token' import { signFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from './lunch-pick-sync'
type TxClient = PrismaClient | Prisma.TransactionClient type TxClient = PrismaClient | Prisma.TransactionClient
@@ -64,3 +65,49 @@ export async function resetOrCreatePendingConfirmation(
}) })
return { id, token, deadline, alreadyConfirmed: false } return { id, token, deadline, alreadyConfirmed: false }
} }
/**
* Shared confirm transaction: atomically writes CONFIRMED status + attendee
* rows + visa applications + lunch picks.
* Called from both `adminConfirm` and `enrollFinalists` (ADMIN_CONFIRM mode).
*
* Must be called inside a Prisma $transaction block — `tx` is the transaction
* client, NOT the top-level prisma client.
*/
export async function confirmAttendanceInTx(
tx: Prisma.TransactionClient,
args: {
confirmationId: string
attendingUserIds: string[]
visaFlags: Record<string, boolean>
},
): Promise<void> {
await tx.finalistConfirmation.update({
where: { id: args.confirmationId },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
})
await tx.attendingMember.createMany({
data: args.attendingUserIds.map((userId) => ({
confirmationId: args.confirmationId,
userId,
needsVisa: args.visaFlags[userId] ?? false,
})),
})
const visaUsers = args.attendingUserIds.filter((uid) => args.visaFlags[uid] === true)
if (visaUsers.length > 0) {
const created = await tx.attendingMember.findMany({
where: { confirmationId: args.confirmationId, 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: args.confirmationId, userId: { in: args.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
}

View File

@@ -1,7 +1,30 @@
import { afterAll, describe, expect, it } from 'vitest' import { afterAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup' import { prisma, createCaller } from '../setup'
import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers' import {
createTestUser,
createTestProgram,
createTestProject,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment' import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
import { finalistRouter } from '../../src/server/routers/finalist'
async function createApplicantUser(role: 'LEAD' | 'MEMBER' = 'MEMBER') {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Test ${role}`,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
describe('resetOrCreatePendingConfirmation', () => { describe('resetOrCreatePendingConfirmation', () => {
const programIds: string[] = [] const programIds: string[] = []
@@ -58,3 +81,239 @@ describe('resetOrCreatePendingConfirmation', () => {
expect(res.alreadyConfirmed).toBe(true) expect(res.alreadyConfirmed).toBe(true)
}) })
}) })
// ─── finalist.enrollFinalists ──────────────────────────────────────────────
describe('finalist.enrollFinalists', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const id of programIds) {
await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } })
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } })
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId: id } } } })
await cleanupTestData(id, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
async function setupEnrollFixture(programName: string) {
const program = await createTestProgram({ name: programName, defaultAttendeeCap: 3 })
const competition = await createTestCompetition(program.id)
const mentoringRound = 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: 'Enroll Test Project',
competitionCategory: 'STARTUP',
})
const lead = await createApplicantUser('LEAD')
const member = await createApplicantUser('MEMBER')
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
],
})
// Put the project in the MENTORING round (as candidates)
await prisma.projectRoundState.create({
data: { projectId: project.id, roundId: mentoringRound.id },
})
return { program, competition, mentoringRound, liveFinalRound, project, lead, member }
}
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-email-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [{ projectId: project.id, mode: 'EMAIL' }],
})
expect(result.enrolled).toBe(1)
expect(result.skipped).toHaveLength(0)
const prs = await prisma.projectRoundState.findFirst({
where: { projectId: project.id, roundId: liveFinalRound.id },
})
expect(prs).not.toBeNull()
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(conf.status).toBe('PENDING')
const attendeeCount = await prisma.attendingMember.count({
where: { confirmationId: conf.id },
})
expect(attendeeCount).toBe(0)
})
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-adminconfirm-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [
{
projectId: project.id,
mode: 'ADMIN_CONFIRM',
attendingUserIds: [lead.id, member.id],
visaFlags: { [member.id]: true },
},
],
})
expect(result.enrolled).toBe(1)
expect(result.adminConfirmed).toBe(1)
expect(result.skipped).toHaveLength(0)
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(conf.status).toBe('CONFIRMED')
const attendeeCount = await prisma.attendingMember.count({
where: { confirmationId: conf.id },
})
expect(attendeeCount).toBe(2)
const visaCount = await prisma.visaApplication.count({
where: { attendingMember: { confirmationId: conf.id } },
})
expect(visaCount).toBe(1)
})
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-reinvite-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
// Pre-create a DECLINED confirmation
await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'DECLINED',
deadline: new Date(Date.now() - 1000),
token: `tok_${uid()}`,
declinedAt: new Date(),
declineReason: 'schedule conflict',
},
})
// Pre-create a PRS row (simulating prior enrollment)
await prisma.projectRoundState.create({
data: { projectId: project.id, roundId: liveFinalRound.id },
})
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Re-enroll in EMAIL mode — should reset DECLINED to PENDING without crashing
await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [{ projectId: project.id, mode: 'EMAIL' }],
})
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(conf.status).toBe('PENDING')
expect(conf.declinedAt).toBeNull()
// Exactly one PRS row (skipDuplicates kept it idempotent)
const prsRows = await prisma.projectRoundState.findMany({
where: { projectId: project.id, roundId: liveFinalRound.id },
})
expect(prsRows).toHaveLength(1)
})
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-overcap-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
// Create 2 extra members so we can pass 4 (cap = 3)
const extra1 = await createApplicantUser('MEMBER')
const extra2 = await createApplicantUser('MEMBER')
userIds.push(extra1.id, extra2.id)
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: extra1.id, role: 'MEMBER' },
{ projectId: project.id, userId: extra2.id, role: 'MEMBER' },
],
})
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [
{
projectId: project.id,
mode: 'ADMIN_CONFIRM',
attendingUserIds: [lead.id, member.id, extra1.id, extra2.id], // 4 > cap 3
},
],
}),
).rejects.toThrow(/cap/i)
})
})