diff --git a/docs/superpowers/plans/2026-06-04-wave1-logistics-finalist-enrollment.md b/docs/superpowers/plans/2026-06-04-wave1-logistics-finalist-enrollment.md new file mode 100644 index 0000000..eafd64f --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-wave1-logistics-finalist-enrollment.md @@ -0,0 +1,467 @@ +# 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: , ... }`. 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 `` 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 `` 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).