# 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).