Files
MOPC-Portal/docs/superpowers/plans/2026-06-04-wave1-logistics-finalist-enrollment.md
2026-06-04 15:13:01 +02:00

468 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Wave 1 — Make Logistics Operable: Finalist Enrollment + BLOCKER fixes
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give the grand-finale logistics flow its missing entry points so it is operable end-to-end: a unified "Enroll finalists" action that advances mentoring-round teams into the Grand Final round *and* creates their attendance confirmation in one step, a way to populate the waitlist, safe re-invitation, un-enroll, and the lunch-picker permission fix.
**Architecture:** Three new `finalist` tRPC procedures (`listEnrollmentCandidates`, `enrollFinalists`, `unenroll`) plus one shared service helper. The enroll mutation composes three existing mechanisms that are currently disconnected: (a) round membership (`ProjectRoundState` in the LIVE_FINAL round — same pattern as `round.advanceProjects`), (b) finalist confirmation (`createPendingConfirmation` service), and (c) optional immediate admin confirmation (same transaction as `finalist.adminConfirm`). A new admin card on the LIVE_FINAL round Overview drives it. One one-line permission fix unblocks the applicant lunch picker.
**Tech Stack:** Next.js 15 App Router, tRPC 11 (Zod), Prisma 6, shadcn/ui, Vitest 4. No schema migration required (all models already exist).
**Decisions locked (with Matt, 2026-06-04):**
- Enroll is **one unified step**: adds the team to the R7 LIVE_FINAL round (so the Finals Jury sees them) AND creates the finalist confirmation. Un-enroll reverses both.
- **Offer both attendee modes per team** at enroll time: `EMAIL` (send the self-confirm link; team lead picks ≤cap attendees) or `ADMIN_CONFIRM` (admin picks attendees now; status goes straight to CONFIRMED, no email).
---
## File structure
**Create:**
- `src/server/services/finalist-enrollment.ts` — shared helpers: `resetOrCreatePendingConfirmation()` (re-invite-safe) and `confirmAttendanceInTx()` (extracted from adminConfirm, reused by enroll's ADMIN_CONFIRM mode).
- `src/components/admin/grand-finale/finalist-enrollment-card.tsx` — the enrollment UI card on the LIVE_FINAL round Overview.
- `src/components/admin/grand-finale/enroll-attendees-dialog.tsx` — per-team attendee picker for ADMIN_CONFIRM mode.
- `tests/unit/finalist-enrollment.test.ts` — enrollFinalists (both modes), re-invite safety, unified PRS creation.
- `tests/unit/finalist-unenroll.test.ts` — unenroll reverses membership + confirmation.
- `tests/unit/lunch-list-dishes-perm.test.ts` — non-admin can read dishes.
**Modify:**
- `src/server/routers/finalist.ts` — add `listEnrollmentCandidates`, `enrollFinalists`, `unenroll`; refactor `adminConfirm`/`selectFinalists` to reuse the new helpers.
- `src/server/services/finalist-confirmation.ts` — export the reset-safe helper or re-export from the new module (keep `createPendingConfirmation` intact for waitlist promotion).
- `src/server/routers/lunch.ts:42``listDishes`: `adminProcedure``protectedProcedure`.
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` — render `FinalistEnrollmentCard` in the LIVE_FINAL grand-finale block (currently lines ~15281531, above `FinalistSlotsCard`).
- `src/components/admin/grand-finale/waitlist-card.tsx` — add an "Add to waitlist" control (wires the existing `finalist.addToWaitlist`, which has no UI today).
- `src/components/admin/logistics/confirmations-tab.tsx` — fix the dead-end empty-state copy; add **Un-confirm** and **Re-invite** row actions (surface existing `unconfirm` + new re-invite via `enrollFinalists`).
---
## Background facts the executor needs (verified 2026-06-04)
- **Two disconnected systems.** A project is "in" the Grand Final round iff it has a `ProjectRoundState{ roundId: <LIVE_FINAL>, ... }`. A project is a "confirmed finalist (logistics)" iff it has a `FinalistConfirmation`. `selectFinalists` only ever created the latter and **had zero UI callers**; `addToWaitlist` also had zero UI callers. This wave joins them.
- **Rounds for the live edition:** R5 EVALUATION → **R6 MENTORING (active)****R7 LIVE_FINAL** → R8 DELIBERATION. Candidates to enroll = projects with a `ProjectRoundState` in the MENTORING round.
- **`FinalistConfirmation.projectId` is `@unique`** (`prisma/schema.prisma:2755`). `createPendingConfirmation` does a naive `.create()` → a second invite for a previously DECLINED/EXPIRED project throws Prisma P2002. The new `resetOrCreatePendingConfirmation()` fixes this.
- **Cap:** `Program.defaultAttendeeCap` (live value: 3) bounds attendees per team.
- **Reference code to mirror:**
- PRS creation in target round: `src/server/routers/round.ts:545-551` (`createMany … skipDuplicates`).
- Admin confirm transaction (attendees + visa rows + lunch picks): `src/server/routers/finalist.ts:492-523`.
- Pending-confirmation + token + email: `src/server/services/finalist-confirmation.ts:12-42` and `src/server/routers/finalist.ts:191-219`.
- Candidate data source: `src/server/routers/round.ts:323` (`listMentoringProjects`).
- Test harness: `tests/unit/finalist-admin-confirm.test.ts` (factories, `createCaller`, cleanup pattern).
---
## Task 1: Unblock the applicant lunch picker (BLOCKER)
**Files:**
- Modify: `src/server/routers/lunch.ts:42`
- Test: `tests/unit/lunch-list-dishes-perm.test.ts`
- [ ] **Step 1: Write the failing test** — a non-admin (APPLICANT) can call `listDishes`.
```ts
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
import { lunchRouter } from '../../src/server/routers/lunch'
describe('lunch.listDishes permission', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const id of programIds) {
await prisma.dish.deleteMany({ where: { lunchEvent: { programId: id } } })
await prisma.lunchEvent.deleteMany({ where: { programId: id } })
await cleanupTestData(id, [])
}
if (userIds.length) await prisma.user.deleteMany({ where: { id: { in: userIds } } })
})
it('lets a non-admin (APPLICANT) read the dish list', async () => {
const program = await createTestProgram({ name: `dish-perm-${uid()}` })
programIds.push(program.id)
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
await prisma.dish.create({ data: { lunchEventId: event.id, name: 'Sea bass', sortOrder: 0 } })
const applicant = await createTestUser('APPLICANT')
userIds.push(applicant.id)
const caller = createCaller(lunchRouter, {
id: applicant.id, email: applicant.email, role: 'APPLICANT',
})
const dishes = await caller.listDishes({ lunchEventId: event.id })
expect(dishes).toHaveLength(1)
expect(dishes[0].name).toBe('Sea bass')
})
})
```
> Note: confirm the exact `Dish` field names (`lunchEventId`, `name`, `sortOrder`) against `prisma/schema.prisma` before running; adjust the factory data if they differ.
- [ ] **Step 2: Run it, verify it fails**`npx vitest run tests/unit/lunch-list-dishes-perm.test.ts`. Expected: FAIL with an `UNAUTHORIZED` TRPCError (APPLICANT blocked by `adminProcedure`).
- [ ] **Step 3: Change the procedure** — in `src/server/routers/lunch.ts:42`, change `listDishes: adminProcedure` to `listDishes: protectedProcedure`. Ensure `protectedProcedure` is imported in that file (it is used elsewhere in the router; if not, add it to the import from `../trpc`).
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS.
- [ ] **Step 5: Commit**`git add -A && git commit -m "fix(lunch): allow non-admins to read dish list (unblocks applicant picker)"`
---
## Task 2: Re-invite-safe confirmation helper (fixes the P2002 dead-end)
**Files:**
- Create: `src/server/services/finalist-enrollment.ts`
- Test: covered indirectly in Task 3; add a focused unit here.
- [ ] **Step 1: Write the failing test** (add to `tests/unit/finalist-enrollment.test.ts`, created here):
```ts
import { afterAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup'
import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers'
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
describe('resetOrCreatePendingConfirmation', () => {
const programIds: 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 cleanupTestData(id, [])
}
})
it('creates a fresh PENDING row when none exists', async () => {
const program = await createTestProgram({ name: `reinvite-new-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
const res = await resetOrCreatePendingConfirmation(prisma, {
projectId: project.id, category: 'STARTUP', windowHours: 24,
})
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
expect(row.status).toBe('PENDING')
expect(res.alreadyConfirmed).toBe(false)
})
it('resets a DECLINED row to a fresh PENDING (no unique-constraint crash)', async () => {
const program = await createTestProgram({ name: `reinvite-declined-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
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: 'busy' },
})
const res = await resetOrCreatePendingConfirmation(prisma, {
projectId: project.id, category: 'STARTUP', windowHours: 24,
})
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
expect(row.status).toBe('PENDING')
expect(row.declinedAt).toBeNull()
expect(row.declineReason).toBeNull()
expect(res.alreadyConfirmed).toBe(false)
})
it('is a no-op flagged alreadyConfirmed when row is CONFIRMED', async () => {
const program = await createTestProgram({ name: `reinvite-confirmed-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
await prisma.finalistConfirmation.create({
data: { projectId: project.id, category: 'STARTUP', status: 'CONFIRMED',
deadline: new Date(Date.now() + 1000), token: `tok_${uid()}`, confirmedAt: new Date() },
})
const res = await resetOrCreatePendingConfirmation(prisma, {
projectId: project.id, category: 'STARTUP', windowHours: 24,
})
expect(res.alreadyConfirmed).toBe(true)
})
})
```
- [ ] **Step 2: Run it, verify it fails**`npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (module/function not found).
- [ ] **Step 3: Implement the helper** — create `src/server/services/finalist-enrollment.ts`:
```ts
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
type TxClient = PrismaClient | Prisma.TransactionClient
/**
* Re-invite-safe variant of createPendingConfirmation. If a confirmation row
* already exists for the project (projectId is @unique), reset any
* non-CONFIRMED row back to a fresh PENDING with a new token/deadline and
* clear stale attendee rows; report CONFIRMED rows as a no-op so callers can
* skip them. Returns the row id + token + deadline for the email step.
*/
export async function resetOrCreatePendingConfirmation(
prisma: TxClient,
args: { projectId: string; category: CompetitionCategory; windowHours: number },
): Promise<{ id: string; token: string; deadline: Date; alreadyConfirmed: boolean }> {
const deadline = new Date(Date.now() + args.windowHours * 3_600_000)
const existing = await prisma.finalistConfirmation.findUnique({
where: { projectId: args.projectId },
select: { id: true, status: true },
})
if (existing?.status === 'CONFIRMED') {
return { id: existing.id, token: '', deadline, alreadyConfirmed: true }
}
if (existing) {
const token = signFinalistToken({
confirmationId: existing.id,
exp: Math.floor(deadline.getTime() / 1000),
})
// Clear any attendee rows from a prior cycle (cascade-deletes flight/visa/lunch).
await prisma.attendingMember.deleteMany({ where: { confirmationId: existing.id } })
await prisma.finalistConfirmation.update({
where: { id: existing.id },
data: {
category: args.category,
status: 'PENDING',
deadline,
token,
confirmedAt: null,
declinedAt: null,
declineReason: null,
expiredAt: null,
},
})
return { id: existing.id, token, deadline, alreadyConfirmed: false }
}
const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
const token = signFinalistToken({
confirmationId: id,
exp: Math.floor(deadline.getTime() / 1000),
})
await prisma.finalistConfirmation.create({
data: {
id,
projectId: args.projectId,
category: args.category,
status: 'PENDING',
deadline,
token,
},
})
return { id, token, deadline, alreadyConfirmed: false }
}
```
> Confirm `AttendingMember`→`FlightDetail`/`VisaApplication`/`MemberLunchPick` are `onDelete: Cascade` (they are, per schema 2789+); the `deleteMany` then cleans dependents. If `signFinalistToken`'s import path differs, match `src/server/services/finalist-confirmation.ts:2`.
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS (3 tests).
- [ ] **Step 5: Commit**`git add -A && git commit -m "feat(finalist): re-invite-safe confirmation reset helper"`
---
## Task 3: `finalist.enrollFinalists` — the unified entry point
**Files:**
- Modify: `src/server/routers/finalist.ts` (add procedure; import the helper)
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
**Procedure contract:**
```ts
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(), // required for ADMIN_CONFIRM
visaFlags: z.record(z.string(), z.boolean()).optional(),
})).min(1),
}))
.mutation(async ({ ctx, input }) => { /* see steps */ })
```
Behavior per enrollment:
1. Validate the project is in `programId` and read its `competitionCategory`, `defaultAttendeeCap`, team members + LEAD email.
2. **Round membership:** `projectRoundState.createMany({ data: [{ projectId, roundId }], skipDuplicates: true })` (mirror `round.ts:545`).
3. **Confirmation:** `resetOrCreatePendingConfirmation()`. If `alreadyConfirmed`, record `skipped: 'ALREADY_CONFIRMED'` and continue.
4. If `mode === 'EMAIL'`: send `sendFinalistConfirmationEmail(lead.email, lead.name, project.title, deadline, confirmUrl)` inside a try/catch (never throw in the loop — mirror `finalist.ts:213`).
5. If `mode === 'ADMIN_CONFIRM'`: validate `attendingUserIds` (non-empty, ≤ cap, all team members) then run the confirm transaction (attendees + visa rows + lunch picks) exactly as `finalist.ts:492-523`. No email.
6. Audit `FINALIST_ENROLL` with `{ projectId, mode }`.
7. Return `{ enrolled, emailed, adminConfirmed, skipped: [...] }`.
- [ ] **Step 1: Write failing tests** (extend `finalist-enrollment.test.ts`). Build an admin caller via `createCaller(finalistRouter, {…SUPER_ADMIN})`, a program (`defaultAttendeeCap: 3`), a competition with a `LIVE_FINAL` round (`createTestRound(comp.id, { roundType: 'LIVE_FINAL', sortOrder: 99, configJson: { confirmationWindowHours: 24 } })`) and a MENTORING round with a `ProjectRoundState` for the project. Assert:
```ts
// EMAIL mode
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
// ... call enrollFinalists with one { projectId, mode: 'EMAIL' }
const prs = await prisma.projectRoundState.findFirst({ where: { projectId, roundId: liveFinalId } })
expect(prs).not.toBeNull()
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
expect(conf.status).toBe('PENDING')
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(0)
})
// ADMIN_CONFIRM mode
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
// ... call with { projectId, mode: 'ADMIN_CONFIRM', attendingUserIds: [lead.id, member.id], visaFlags: { [member.id]: true } }
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
expect(conf.status).toBe('CONFIRMED')
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(2)
expect(await prisma.visaApplication.count({ where: { attendingMember: { confirmationId: conf.id } } })).toBe(1)
})
// idempotent membership + re-invite
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
// pre-create DECLINED confirmation + a PRS; enroll EMAIL again
// expect status PENDING and exactly one PRS row for (projectId, liveFinalId)
})
// ADMIN_CONFIRM over cap rejects
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
await expect(/* 4 attendees with cap 3 */).rejects.toThrow(/cap/i)
})
```
- [ ] **Step 2: Run, verify fail**`npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (`enrollFinalists` not a function).
- [ ] **Step 3: Implement** the procedure in `finalist.ts`. Reuse: import `resetOrCreatePendingConfirmation` from `../services/finalist-enrollment`; reuse `sendFinalistConfirmationEmail`, `ensureLunchPickForAttendingMember`, `logAudit`, `signFinalistToken` already imported. For the ADMIN_CONFIRM transaction body, copy the exact `$transaction` block from `adminConfirm` (`finalist.ts:492-523`). Resolve `windowHours` from the LIVE_FINAL round's `configJson.confirmationWindowHours ?? 24` (mirror `finalist.ts:145`). Build `confirmUrl` from `NEXTAUTH_URL` (mirror `finalist.ts:191-204`).
- [ ] **Step 4: Run, verify pass** — same command. Expected: PASS.
- [ ] **Step 5: Refactor for DRY (optional within budget)** — extract the shared confirm transaction into `confirmAttendanceInTx(tx, { confirmationId, attendingUserIds, visaFlags })` in `finalist-enrollment.ts` and call it from both `adminConfirm` and `enrollFinalists`. Re-run `npx vitest run tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-enrollment.test.ts`. Expected: PASS both.
- [ ] **Step 6: Commit**`git commit -am "feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)"`
---
## Task 4: `finalist.unenroll` — reverse membership + confirmation
**Files:**
- Modify: `src/server/routers/finalist.ts`
- Test: `tests/unit/finalist-unenroll.test.ts`
**Contract:** `unenroll({ projectId, roundId })`
1. Delete the `FinalistConfirmation` for the project (cascade removes AttendingMember/FlightDetail/VisaApplication/lunch picks).
2. Delete the LIVE_FINAL `ProjectRoundState` (`deleteMany({ where: { projectId, roundId } })`).
3. Audit `FINALIST_UNENROLL`.
4. Return `{ ok: true }`. (Mentor assignments are tied to the MENTORING round and are intentionally left untouched.)
- [ ] **Step 1: Failing test** — enroll (ADMIN_CONFIRM) a project, then `unenroll`; assert no confirmation, no attendees, no LIVE_FINAL PRS, and an audit row exists.
- [ ] **Step 2: Run, verify fail.**
- [ ] **Step 3: Implement** the procedure.
- [ ] **Step 4: Run, verify pass**`npx vitest run tests/unit/finalist-unenroll.test.ts`.
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): unenroll reverses round membership + confirmation"`
---
## Task 5: `finalist.listEnrollmentCandidates` — data for the UI
**Files:**
- Modify: `src/server/routers/finalist.ts`
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
**Contract:** `listEnrollmentCandidates({ programId })` resolves the program's competition, finds the MENTORING round and the LIVE_FINAL round, and returns:
```ts
{
liveFinalRoundId: string | null,
attendeeCap: number,
categories: Array<{
category: CompetitionCategory,
quota: number | null,
confirmedCount: number,
pendingCount: number,
candidates: Array<{
projectId: string,
title: string,
teamName: string | null,
country: string | null,
inLiveFinal: boolean, // has a LIVE_FINAL ProjectRoundState
confirmationStatus: FinalistConfirmationStatus | null,
teamMembers: Array<{ userId: string, name: string | null, role: 'LEAD' | 'MEMBER', email: string }>,
}>,
}>,
}
```
Source candidates from `ProjectRoundState` in the MENTORING round (mirror `round.ts:335`), join `project.finalistConfirmation.status`, a `ProjectRoundState` existence check against the LIVE_FINAL round for `inLiveFinal`, and `project.teamMembers` (for the ADMIN_CONFIRM picker). Group by `competitionCategory`; merge per-category `FinalistSlotQuota` + confirmed/pending counts (reuse the count logic from `finalist.listCategoryCounts`).
- [ ] **Step 1: Failing test** — set up a MENTORING round with one STARTUP project (PRS), assert the project appears under the STARTUP category with `inLiveFinal: false`, `confirmationStatus: null`, and its team members listed.
- [ ] **Step 2: Run, verify fail.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run, verify pass.**
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): listEnrollmentCandidates query for enrollment UI"`
---
## Task 6: Enrollment UI card on the LIVE_FINAL round page
**Files:**
- Create: `src/components/admin/grand-finale/finalist-enrollment-card.tsx`
- Create: `src/components/admin/grand-finale/enroll-attendees-dialog.tsx`
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (render the card in the grand-finale block, ~line 1528, above `FinalistSlotsCard`)
**Card UX (follow the visual pattern of `finalist-slots-card.tsx` / `waitlist-card.tsx`):**
- `trpc.finalist.listEnrollmentCandidates.useQuery({ programId })`. Loading → Skeleton; empty → "No mentoring-round teams to enroll yet."
- Group candidates by category with a header showing `confirmed/quota` (e.g. "Startup — 2/8 confirmed, 1 pending").
- Each candidate row: checkbox, title + teamName + country, and a status badge (`Not enrolled` / `In round` / `Pending` / `Confirmed` / `Declined`). Confirmed/declined rows show an **Un-enroll** button instead of a checkbox.
- Per selected row, a small mode toggle: **Email team** (default) or **Set attendees now**. Choosing "Set attendees now" opens `EnrollAttendeesDialog` (a ≤cap member multi-select with per-member "needs visa" checkbox) and stashes the chosen `attendingUserIds`/`visaFlags` on that row.
- Footer: **Enroll selected** and **Enroll all eligible** (eligible = not already CONFIRMED). Calls `trpc.finalist.enrollFinalists.useMutation`, then `utils.finalist.listEnrollmentCandidates.invalidate()` + `utils.logistics.listConfirmations.invalidate()`. Toast the `{ enrolled, emailed, adminConfirmed, skipped }` summary.
- Un-enroll buttons call `trpc.finalist.unenroll` behind an `AlertDialog` confirm ("This removes them from the Grand Final round and deletes their attendance record. Continue?").
> Use shadcn `AlertDialog` for confirms (no native `confirm()`), per house style and the no-keyboard-shortcuts preference (visible affordances only).
- [ ] **Step 1:** Build `enroll-attendees-dialog.tsx` (props: `members`, `cap`, `onConfirm(attendingUserIds, visaFlags)`).
- [ ] **Step 2:** Build `finalist-enrollment-card.tsx` per the UX above.
- [ ] **Step 3:** Render `<FinalistEnrollmentCard programId={programId} roundId={round.id} />` in the LIVE_FINAL grand-finale block in the round page.
- [ ] **Step 4: Typecheck**`npm run typecheck`. Expected: clean.
- [ ] **Step 5: Commit**`git commit -am "feat(grand-finale): finalist enrollment card on LIVE_FINAL round page"`
---
## Task 7: Waitlist populate UI + confirmations-tab dead-end fixes
**Files:**
- Modify: `src/components/admin/grand-finale/waitlist-card.tsx`
- Modify: `src/components/admin/logistics/confirmations-tab.tsx`
- [ ] **Step 1: Add-to-waitlist control** in `waitlist-card.tsx` — a category + project picker (project options = enrollment candidates not already confirmed/waitlisted) that calls the existing `trpc.finalist.addToWaitlist` mutation (which had no UI). Invalidate `listWaitlist` on success. Confirm `addToWaitlist`'s exact input shape at `finalist.ts:629` before wiring.
- [ ] **Step 2: Fix the dead-end copy** in `confirmations-tab.tsx:127` — replace "Use the grand-finale round page to send confirmations." with "Enroll finalists from the Grand Final round's Overview tab to start confirmations." (Or, if scope allows, render an inline `<Link>` to the round page.)
- [ ] **Step 3: Add row actions** to `confirmations-tab.tsx` (currently only PENDING rows show Confirm/Decline; all others show "—"):
- CONFIRMED rows → **Un-confirm** button calling existing `trpc.finalist.unconfirm` (behind `AlertDialog`).
- DECLINED / EXPIRED rows → **Re-invite** button calling `trpc.finalist.enrollFinalists` with `{ projectId, mode: 'EMAIL', roundId: liveFinalRoundId }` (re-invite-safe via Task 2). Needs the LIVE_FINAL roundId — fetch via `listEnrollmentCandidates` or add it to `listConfirmations`' payload.
- [ ] **Step 4: Typecheck**`npm run typecheck`. Expected: clean.
- [ ] **Step 5: Commit**`git commit -am "feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions"`
---
## Task 8: Full verification
- [ ] **Step 1: Run the whole finalist/lunch/logistics suite**`npx vitest run tests/unit/finalist-enrollment.test.ts tests/unit/finalist-unenroll.test.ts tests/unit/lunch-list-dishes-perm.test.ts tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-quotas.test.ts tests/unit/finalist-confirmation.test.ts`. Expected: all PASS (regression check on the refactor).
- [ ] **Step 2: Typecheck + build**`npm run typecheck && npm run build`. Expected: clean (build is required before any push, per CLAUDE.md).
- [ ] **Step 3: Dev smoke (Playwright, server already on :3001 as super-admin)** — verify the end-to-end the audit proved was impossible:
1. Mentoring round (R6) → ensure a project has a `ProjectRoundState` (advance one in if needed).
2. Grand Final round (R7) Overview → the new Enrollment card lists it; enroll it in EMAIL mode.
3. `/admin/logistics` → Confirmations tab now shows a PENDING row (no longer the dead-end empty state).
4. Enroll a second team in ADMIN_CONFIRM mode with 2 attendees → Confirmations shows CONFIRMED with attendee count 2; Travel/Visas tabs now list those attendees.
5. Confirm the LIVE_FINAL round's Projects tab now shows the enrolled teams (jury can see them).
- [ ] **Step 4: Final commit / branch** — ensure work is on a feature branch (not `main`); summarize for review.
---
## Self-review notes (coverage vs. the 4 BLOCKERs)
- BLOCKER 1 (no select-finalists UI) → Tasks 3 + 6 (`enrollFinalists` + enrollment card).
- BLOCKER 2 (no waitlist populate UI) → Task 7 step 1.
- BLOCKER 3 (lunch picker broken) → Task 1.
- BLOCKER 4 (re-invite crash) → Task 2 (`resetOrCreatePendingConfirmation`), surfaced via Task 7 step 3 (Re-invite action).
- Unified enroll + both attendee modes (locked decisions) → Task 3.
- Un-confirm dead-end (HIGH) → Task 7 step 3.
**Out of scope for Wave 1 (deferred to later waves):** all the new transactional emails/notifications and reminders (Wave 2), the Email Templates tab (Wave 3), team-facing "My Logistics" + timezone/validation/export UX fixes (Wave 4). The only email this wave sends is the existing `sendFinalistConfirmationEmail` (now reachable via EMAIL-mode enroll and Re-invite).