28 KiB
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) orADMIN_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) andconfirmAttendanceInTx()(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— addlistEnrollmentCandidates,enrollFinalists,unenroll; refactoradminConfirm/selectFinaliststo reuse the new helpers.src/server/services/finalist-confirmation.ts— export the reset-safe helper or re-export from the new module (keepcreatePendingConfirmationintact for waitlist promotion).src/server/routers/lunch.ts:42—listDishes:adminProcedure→protectedProcedure.src/app/(admin)/admin/rounds/[roundId]/page.tsx— renderFinalistEnrollmentCardin the LIVE_FINAL grand-finale block (currently lines ~1528–1531, aboveFinalistSlotsCard).src/components/admin/grand-finale/waitlist-card.tsx— add an "Add to waitlist" control (wires the existingfinalist.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 existingunconfirm+ new re-invite viaenrollFinalists).
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 aFinalistConfirmation.selectFinalistsonly ever created the latter and had zero UI callers;addToWaitlistalso 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
ProjectRoundStatein the MENTORING round. FinalistConfirmation.projectIdis@unique(prisma/schema.prisma:2755).createPendingConfirmationdoes a naive.create()→ a second invite for a previously DECLINED/EXPIRED project throws Prisma P2002. The newresetOrCreatePendingConfirmation()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-42andsrc/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).
- PRS creation in target round:
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
Dishfield names (lunchEventId,name,sortOrder) againstprisma/schema.prismabefore 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 anUNAUTHORIZEDTRPCError (APPLICANT blocked byadminProcedure). -
Step 3: Change the procedure — in
src/server/routers/lunch.ts:42, changelistDishes: adminProceduretolistDishes: protectedProcedure. EnsureprotectedProcedureis 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):
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:
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/MemberLunchPickareonDelete: Cascade(they are, per schema 2789+); thedeleteManythen cleans dependents. IfsignFinalistToken's import path differs, matchsrc/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:
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:
- Validate the project is in
programIdand read itscompetitionCategory,defaultAttendeeCap, team members + LEAD email. - Round membership:
projectRoundState.createMany({ data: [{ projectId, roundId }], skipDuplicates: true })(mirrorround.ts:545). - Confirmation:
resetOrCreatePendingConfirmation(). IfalreadyConfirmed, recordskipped: 'ALREADY_CONFIRMED'and continue. - If
mode === 'EMAIL': sendsendFinalistConfirmationEmail(lead.email, lead.name, project.title, deadline, confirmUrl)inside a try/catch (never throw in the loop — mirrorfinalist.ts:213). - If
mode === 'ADMIN_CONFIRM': validateattendingUserIds(non-empty, ≤ cap, all team members) then run the confirm transaction (attendees + visa rows + lunch picks) exactly asfinalist.ts:492-523. No email. - Audit
FINALIST_ENROLLwith{ projectId, mode }. - Return
{ enrolled, emailed, adminConfirmed, skipped: [...] }.
- Step 1: Write failing tests (extend
finalist-enrollment.test.ts). Build an admin caller viacreateCaller(finalistRouter, {…SUPER_ADMIN}), a program (defaultAttendeeCap: 3), a competition with aLIVE_FINALround (createTestRound(comp.id, { roundType: 'LIVE_FINAL', sortOrder: 99, configJson: { confirmationWindowHours: 24 } })) and a MENTORING round with aProjectRoundStatefor 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 fail —
npx vitest run tests/unit/finalist-enrollment.test.ts. Expected: FAIL (enrollFinalistsnot a function). -
Step 3: Implement the procedure in
finalist.ts. Reuse: importresetOrCreatePendingConfirmationfrom../services/finalist-enrollment; reusesendFinalistConfirmationEmail,ensureLunchPickForAttendingMember,logAudit,signFinalistTokenalready imported. For the ADMIN_CONFIRM transaction body, copy the exact$transactionblock fromadminConfirm(finalist.ts:492-523). ResolvewindowHoursfrom the LIVE_FINAL round'sconfigJson.confirmationWindowHours ?? 24(mirrorfinalist.ts:145). BuildconfirmUrlfromNEXTAUTH_URL(mirrorfinalist.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 })infinalist-enrollment.tsand call it from bothadminConfirmandenrollFinalists. Re-runnpx 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 }) →
- Delete the
FinalistConfirmationfor the project (cascade removes AttendingMember/FlightDetail/VisaApplication/lunch picks). - Delete the LIVE_FINAL
ProjectRoundState(deleteMany({ where: { projectId, roundId } })). - Audit
FINALIST_UNENROLL. - 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:
{
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, aboveFinalistSlotsCard)
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 chosenattendingUserIds/visaFlagson that row. - Footer: Enroll selected and Enroll all eligible (eligible = not already CONFIRMED). Calls
trpc.finalist.enrollFinalists.useMutation, thenutils.finalist.listEnrollmentCandidates.invalidate()+utils.logistics.listConfirmations.invalidate(). Toast the{ enrolled, emailed, adminConfirmed, skipped }summary. - Un-enroll buttons call
trpc.finalist.unenrollbehind anAlertDialogconfirm ("This removes them from the Grand Final round and deletes their attendance record. Continue?").
Use shadcn
AlertDialogfor confirms (no nativeconfirm()), 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.tsxper 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 existingtrpc.finalist.addToWaitlistmutation (which had no UI). InvalidatelistWaitliston success. ConfirmaddToWaitlist's exact input shape atfinalist.ts:629before 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(behindAlertDialog). - DECLINED / EXPIRED rows → Re-invite button calling
trpc.finalist.enrollFinalistswith{ projectId, mode: 'EMAIL', roundId: liveFinalRoundId }(re-invite-safe via Task 2). Needs the LIVE_FINAL roundId — fetch vialistEnrollmentCandidatesor add it tolistConfirmations' payload.
- CONFIRMED rows → Un-confirm button calling existing
-
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:
- Mentoring round (R6) → ensure a project has a
ProjectRoundState(advance one in if needed). - Grand Final round (R7) Overview → the new Enrollment card lists it; enroll it in EMAIL mode.
/admin/logistics→ Confirmations tab now shows a PENDING row (no longer the dead-end empty state).- 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.
- Confirm the LIVE_FINAL round's Projects tab now shows the enrolled teams (jury can see them).
- Mentoring round (R6) → ensure a project has a
- 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).