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

28 KiB
Raw Blame History

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:42listDishes: adminProcedureprotectedProcedure.
  • 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.

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 failsnpx 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: Commitgit 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):

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 failsnpx 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:

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 AttendingMemberFlightDetail/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: Commitgit 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:

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:
// 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 failnpx 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: Commitgit 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 passnpx vitest run tests/unit/finalist-unenroll.test.ts.
  • Step 5: Commitgit 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:

{
  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: Commitgit 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: Typechecknpm run typecheck. Expected: clean.
  • Step 5: Commitgit 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: Typechecknpm run typecheck. Expected: clean.

  • Step 5: Commitgit 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 suitenpx 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 + buildnpm 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).