# 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 ~1528–1531, 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).
