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

@@ -1,5 +1,6 @@
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from './lunch-pick-sync'
type TxClient = PrismaClient | Prisma.TransactionClient
@@ -64,3 +65,49 @@ export async function resetOrCreatePendingConfirmation(
})
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)
}
}