468 lines
28 KiB
Markdown
468 lines
28 KiB
Markdown
|
|
# Wave 1 — Make Logistics Operable: Finalist Enrollment + BLOCKER fixes
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** Give the grand-finale logistics flow its missing entry points so it is operable end-to-end: a unified "Enroll finalists" action that advances mentoring-round teams into the Grand Final round *and* creates their attendance confirmation in one step, a way to populate the waitlist, safe re-invitation, un-enroll, and the lunch-picker permission fix.
|
|||
|
|
|
|||
|
|
**Architecture:** Three new `finalist` tRPC procedures (`listEnrollmentCandidates`, `enrollFinalists`, `unenroll`) plus one shared service helper. The enroll mutation composes three existing mechanisms that are currently disconnected: (a) round membership (`ProjectRoundState` in the LIVE_FINAL round — same pattern as `round.advanceProjects`), (b) finalist confirmation (`createPendingConfirmation` service), and (c) optional immediate admin confirmation (same transaction as `finalist.adminConfirm`). A new admin card on the LIVE_FINAL round Overview drives it. One one-line permission fix unblocks the applicant lunch picker.
|
|||
|
|
|
|||
|
|
**Tech Stack:** Next.js 15 App Router, tRPC 11 (Zod), Prisma 6, shadcn/ui, Vitest 4. No schema migration required (all models already exist).
|
|||
|
|
|
|||
|
|
**Decisions locked (with Matt, 2026-06-04):**
|
|||
|
|
- Enroll is **one unified step**: adds the team to the R7 LIVE_FINAL round (so the Finals Jury sees them) AND creates the finalist confirmation. Un-enroll reverses both.
|
|||
|
|
- **Offer both attendee modes per team** at enroll time: `EMAIL` (send the self-confirm link; team lead picks ≤cap attendees) or `ADMIN_CONFIRM` (admin picks attendees now; status goes straight to CONFIRMED, no email).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## File structure
|
|||
|
|
|
|||
|
|
**Create:**
|
|||
|
|
- `src/server/services/finalist-enrollment.ts` — shared helpers: `resetOrCreatePendingConfirmation()` (re-invite-safe) and `confirmAttendanceInTx()` (extracted from adminConfirm, reused by enroll's ADMIN_CONFIRM mode).
|
|||
|
|
- `src/components/admin/grand-finale/finalist-enrollment-card.tsx` — the enrollment UI card on the LIVE_FINAL round Overview.
|
|||
|
|
- `src/components/admin/grand-finale/enroll-attendees-dialog.tsx` — per-team attendee picker for ADMIN_CONFIRM mode.
|
|||
|
|
- `tests/unit/finalist-enrollment.test.ts` — enrollFinalists (both modes), re-invite safety, unified PRS creation.
|
|||
|
|
- `tests/unit/finalist-unenroll.test.ts` — unenroll reverses membership + confirmation.
|
|||
|
|
- `tests/unit/lunch-list-dishes-perm.test.ts` — non-admin can read dishes.
|
|||
|
|
|
|||
|
|
**Modify:**
|
|||
|
|
- `src/server/routers/finalist.ts` — add `listEnrollmentCandidates`, `enrollFinalists`, `unenroll`; refactor `adminConfirm`/`selectFinalists` to reuse the new helpers.
|
|||
|
|
- `src/server/services/finalist-confirmation.ts` — export the reset-safe helper or re-export from the new module (keep `createPendingConfirmation` intact for waitlist promotion).
|
|||
|
|
- `src/server/routers/lunch.ts:42` — `listDishes`: `adminProcedure` → `protectedProcedure`.
|
|||
|
|
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` — render `FinalistEnrollmentCard` in the LIVE_FINAL grand-finale block (currently lines ~1528–1531, above `FinalistSlotsCard`).
|
|||
|
|
- `src/components/admin/grand-finale/waitlist-card.tsx` — add an "Add to waitlist" control (wires the existing `finalist.addToWaitlist`, which has no UI today).
|
|||
|
|
- `src/components/admin/logistics/confirmations-tab.tsx` — fix the dead-end empty-state copy; add **Un-confirm** and **Re-invite** row actions (surface existing `unconfirm` + new re-invite via `enrollFinalists`).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Background facts the executor needs (verified 2026-06-04)
|
|||
|
|
|
|||
|
|
- **Two disconnected systems.** A project is "in" the Grand Final round iff it has a `ProjectRoundState{ roundId: <LIVE_FINAL>, ... }`. A project is a "confirmed finalist (logistics)" iff it has a `FinalistConfirmation`. `selectFinalists` only ever created the latter and **had zero UI callers**; `addToWaitlist` also had zero UI callers. This wave joins them.
|
|||
|
|
- **Rounds for the live edition:** R5 EVALUATION → **R6 MENTORING (active)** → **R7 LIVE_FINAL** → R8 DELIBERATION. Candidates to enroll = projects with a `ProjectRoundState` in the MENTORING round.
|
|||
|
|
- **`FinalistConfirmation.projectId` is `@unique`** (`prisma/schema.prisma:2755`). `createPendingConfirmation` does a naive `.create()` → a second invite for a previously DECLINED/EXPIRED project throws Prisma P2002. The new `resetOrCreatePendingConfirmation()` fixes this.
|
|||
|
|
- **Cap:** `Program.defaultAttendeeCap` (live value: 3) bounds attendees per team.
|
|||
|
|
- **Reference code to mirror:**
|
|||
|
|
- PRS creation in target round: `src/server/routers/round.ts:545-551` (`createMany … skipDuplicates`).
|
|||
|
|
- Admin confirm transaction (attendees + visa rows + lunch picks): `src/server/routers/finalist.ts:492-523`.
|
|||
|
|
- Pending-confirmation + token + email: `src/server/services/finalist-confirmation.ts:12-42` and `src/server/routers/finalist.ts:191-219`.
|
|||
|
|
- Candidate data source: `src/server/routers/round.ts:323` (`listMentoringProjects`).
|
|||
|
|
- Test harness: `tests/unit/finalist-admin-confirm.test.ts` (factories, `createCaller`, cleanup pattern).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: Unblock the applicant lunch picker (BLOCKER)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/server/routers/lunch.ts:42`
|
|||
|
|
- Test: `tests/unit/lunch-list-dishes-perm.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the failing test** — a non-admin (APPLICANT) can call `listDishes`.
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { afterAll, describe, expect, it } from 'vitest'
|
|||
|
|
import { prisma, createCaller } from '../setup'
|
|||
|
|
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
|
|||
|
|
import { lunchRouter } from '../../src/server/routers/lunch'
|
|||
|
|
|
|||
|
|
describe('lunch.listDishes permission', () => {
|
|||
|
|
const programIds: string[] = []
|
|||
|
|
const userIds: string[] = []
|
|||
|
|
afterAll(async () => {
|
|||
|
|
for (const id of programIds) {
|
|||
|
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId: id } } })
|
|||
|
|
await prisma.lunchEvent.deleteMany({ where: { programId: id } })
|
|||
|
|
await cleanupTestData(id, [])
|
|||
|
|
}
|
|||
|
|
if (userIds.length) await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('lets a non-admin (APPLICANT) read the dish list', async () => {
|
|||
|
|
const program = await createTestProgram({ name: `dish-perm-${uid()}` })
|
|||
|
|
programIds.push(program.id)
|
|||
|
|
const event = await prisma.lunchEvent.create({
|
|||
|
|
data: { programId: program.id, enabled: true },
|
|||
|
|
})
|
|||
|
|
await prisma.dish.create({ data: { lunchEventId: event.id, name: 'Sea bass', sortOrder: 0 } })
|
|||
|
|
|
|||
|
|
const applicant = await createTestUser('APPLICANT')
|
|||
|
|
userIds.push(applicant.id)
|
|||
|
|
const caller = createCaller(lunchRouter, {
|
|||
|
|
id: applicant.id, email: applicant.email, role: 'APPLICANT',
|
|||
|
|
})
|
|||
|
|
const dishes = await caller.listDishes({ lunchEventId: event.id })
|
|||
|
|
expect(dishes).toHaveLength(1)
|
|||
|
|
expect(dishes[0].name).toBe('Sea bass')
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> Note: confirm the exact `Dish` field names (`lunchEventId`, `name`, `sortOrder`) against `prisma/schema.prisma` before running; adjust the factory data if they differ.
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run it, verify it fails** — `npx vitest run tests/unit/lunch-list-dishes-perm.test.ts`. Expected: FAIL with an `UNAUTHORIZED` TRPCError (APPLICANT blocked by `adminProcedure`).
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Change the procedure** — in `src/server/routers/lunch.ts:42`, change `listDishes: adminProcedure` to `listDishes: protectedProcedure`. Ensure `protectedProcedure` is imported in that file (it is used elsewhere in the router; if not, add it to the import from `../trpc`).
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit** — `git add -A && git commit -m "fix(lunch): allow non-admins to read dish list (unblocks applicant picker)"`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: Re-invite-safe confirmation helper (fixes the P2002 dead-end)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/server/services/finalist-enrollment.ts`
|
|||
|
|
- Test: covered indirectly in Task 3; add a focused unit here.
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the failing test** (add to `tests/unit/finalist-enrollment.test.ts`, created here):
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { afterAll, describe, expect, it } from 'vitest'
|
|||
|
|
import { prisma } from '../setup'
|
|||
|
|
import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers'
|
|||
|
|
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
|
|||
|
|
|
|||
|
|
describe('resetOrCreatePendingConfirmation', () => {
|
|||
|
|
const programIds: string[] = []
|
|||
|
|
afterAll(async () => {
|
|||
|
|
for (const id of programIds) {
|
|||
|
|
await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } })
|
|||
|
|
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } })
|
|||
|
|
await cleanupTestData(id, [])
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('creates a fresh PENDING row when none exists', async () => {
|
|||
|
|
const program = await createTestProgram({ name: `reinvite-new-${uid()}` })
|
|||
|
|
programIds.push(program.id)
|
|||
|
|
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
|||
|
|
const res = await resetOrCreatePendingConfirmation(prisma, {
|
|||
|
|
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
|||
|
|
})
|
|||
|
|
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
|
|||
|
|
expect(row.status).toBe('PENDING')
|
|||
|
|
expect(res.alreadyConfirmed).toBe(false)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('resets a DECLINED row to a fresh PENDING (no unique-constraint crash)', async () => {
|
|||
|
|
const program = await createTestProgram({ name: `reinvite-declined-${uid()}` })
|
|||
|
|
programIds.push(program.id)
|
|||
|
|
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
|||
|
|
await prisma.finalistConfirmation.create({
|
|||
|
|
data: { projectId: project.id, category: 'STARTUP', status: 'DECLINED',
|
|||
|
|
deadline: new Date(Date.now() - 1000), token: `tok_${uid()}`, declinedAt: new Date(),
|
|||
|
|
declineReason: 'busy' },
|
|||
|
|
})
|
|||
|
|
const res = await resetOrCreatePendingConfirmation(prisma, {
|
|||
|
|
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
|||
|
|
})
|
|||
|
|
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
|
|||
|
|
expect(row.status).toBe('PENDING')
|
|||
|
|
expect(row.declinedAt).toBeNull()
|
|||
|
|
expect(row.declineReason).toBeNull()
|
|||
|
|
expect(res.alreadyConfirmed).toBe(false)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('is a no-op flagged alreadyConfirmed when row is CONFIRMED', async () => {
|
|||
|
|
const program = await createTestProgram({ name: `reinvite-confirmed-${uid()}` })
|
|||
|
|
programIds.push(program.id)
|
|||
|
|
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
|||
|
|
await prisma.finalistConfirmation.create({
|
|||
|
|
data: { projectId: project.id, category: 'STARTUP', status: 'CONFIRMED',
|
|||
|
|
deadline: new Date(Date.now() + 1000), token: `tok_${uid()}`, confirmedAt: new Date() },
|
|||
|
|
})
|
|||
|
|
const res = await resetOrCreatePendingConfirmation(prisma, {
|
|||
|
|
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
|||
|
|
})
|
|||
|
|
expect(res.alreadyConfirmed).toBe(true)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run it, verify it fails** — `npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (module/function not found).
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Implement the helper** — create `src/server/services/finalist-enrollment.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
|
|||
|
|
import { signFinalistToken } from '@/lib/finalist-token'
|
|||
|
|
|
|||
|
|
type TxClient = PrismaClient | Prisma.TransactionClient
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Re-invite-safe variant of createPendingConfirmation. If a confirmation row
|
|||
|
|
* already exists for the project (projectId is @unique), reset any
|
|||
|
|
* non-CONFIRMED row back to a fresh PENDING with a new token/deadline and
|
|||
|
|
* clear stale attendee rows; report CONFIRMED rows as a no-op so callers can
|
|||
|
|
* skip them. Returns the row id + token + deadline for the email step.
|
|||
|
|
*/
|
|||
|
|
export async function resetOrCreatePendingConfirmation(
|
|||
|
|
prisma: TxClient,
|
|||
|
|
args: { projectId: string; category: CompetitionCategory; windowHours: number },
|
|||
|
|
): Promise<{ id: string; token: string; deadline: Date; alreadyConfirmed: boolean }> {
|
|||
|
|
const deadline = new Date(Date.now() + args.windowHours * 3_600_000)
|
|||
|
|
const existing = await prisma.finalistConfirmation.findUnique({
|
|||
|
|
where: { projectId: args.projectId },
|
|||
|
|
select: { id: true, status: true },
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (existing?.status === 'CONFIRMED') {
|
|||
|
|
return { id: existing.id, token: '', deadline, alreadyConfirmed: true }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (existing) {
|
|||
|
|
const token = signFinalistToken({
|
|||
|
|
confirmationId: existing.id,
|
|||
|
|
exp: Math.floor(deadline.getTime() / 1000),
|
|||
|
|
})
|
|||
|
|
// Clear any attendee rows from a prior cycle (cascade-deletes flight/visa/lunch).
|
|||
|
|
await prisma.attendingMember.deleteMany({ where: { confirmationId: existing.id } })
|
|||
|
|
await prisma.finalistConfirmation.update({
|
|||
|
|
where: { id: existing.id },
|
|||
|
|
data: {
|
|||
|
|
category: args.category,
|
|||
|
|
status: 'PENDING',
|
|||
|
|
deadline,
|
|||
|
|
token,
|
|||
|
|
confirmedAt: null,
|
|||
|
|
declinedAt: null,
|
|||
|
|
declineReason: null,
|
|||
|
|
expiredAt: null,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
return { id: existing.id, token, deadline, alreadyConfirmed: false }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
|||
|
|
const token = signFinalistToken({
|
|||
|
|
confirmationId: id,
|
|||
|
|
exp: Math.floor(deadline.getTime() / 1000),
|
|||
|
|
})
|
|||
|
|
await prisma.finalistConfirmation.create({
|
|||
|
|
data: {
|
|||
|
|
id,
|
|||
|
|
projectId: args.projectId,
|
|||
|
|
category: args.category,
|
|||
|
|
status: 'PENDING',
|
|||
|
|
deadline,
|
|||
|
|
token,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
return { id, token, deadline, alreadyConfirmed: false }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> Confirm `AttendingMember`→`FlightDetail`/`VisaApplication`/`MemberLunchPick` are `onDelete: Cascade` (they are, per schema 2789+); the `deleteMany` then cleans dependents. If `signFinalistToken`'s import path differs, match `src/server/services/finalist-confirmation.ts:2`.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS (3 tests).
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(finalist): re-invite-safe confirmation reset helper"`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: `finalist.enrollFinalists` — the unified entry point
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/server/routers/finalist.ts` (add procedure; import the helper)
|
|||
|
|
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
|
|||
|
|
|
|||
|
|
**Procedure contract:**
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
enrollFinalists: adminProcedure
|
|||
|
|
.input(z.object({
|
|||
|
|
programId: z.string(),
|
|||
|
|
roundId: z.string(), // the LIVE_FINAL round
|
|||
|
|
enrollments: z.array(z.object({
|
|||
|
|
projectId: z.string(),
|
|||
|
|
mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
|
|||
|
|
attendingUserIds: z.array(z.string()).optional(), // required for ADMIN_CONFIRM
|
|||
|
|
visaFlags: z.record(z.string(), z.boolean()).optional(),
|
|||
|
|
})).min(1),
|
|||
|
|
}))
|
|||
|
|
.mutation(async ({ ctx, input }) => { /* see steps */ })
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Behavior per enrollment:
|
|||
|
|
1. Validate the project is in `programId` and read its `competitionCategory`, `defaultAttendeeCap`, team members + LEAD email.
|
|||
|
|
2. **Round membership:** `projectRoundState.createMany({ data: [{ projectId, roundId }], skipDuplicates: true })` (mirror `round.ts:545`).
|
|||
|
|
3. **Confirmation:** `resetOrCreatePendingConfirmation()`. If `alreadyConfirmed`, record `skipped: 'ALREADY_CONFIRMED'` and continue.
|
|||
|
|
4. If `mode === 'EMAIL'`: send `sendFinalistConfirmationEmail(lead.email, lead.name, project.title, deadline, confirmUrl)` inside a try/catch (never throw in the loop — mirror `finalist.ts:213`).
|
|||
|
|
5. If `mode === 'ADMIN_CONFIRM'`: validate `attendingUserIds` (non-empty, ≤ cap, all team members) then run the confirm transaction (attendees + visa rows + lunch picks) exactly as `finalist.ts:492-523`. No email.
|
|||
|
|
6. Audit `FINALIST_ENROLL` with `{ projectId, mode }`.
|
|||
|
|
7. Return `{ enrolled, emailed, adminConfirmed, skipped: [...] }`.
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing tests** (extend `finalist-enrollment.test.ts`). Build an admin caller via `createCaller(finalistRouter, {…SUPER_ADMIN})`, a program (`defaultAttendeeCap: 3`), a competition with a `LIVE_FINAL` round (`createTestRound(comp.id, { roundType: 'LIVE_FINAL', sortOrder: 99, configJson: { confirmationWindowHours: 24 } })`) and a MENTORING round with a `ProjectRoundState` for the project. Assert:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// EMAIL mode
|
|||
|
|
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
|
|||
|
|
// ... call enrollFinalists with one { projectId, mode: 'EMAIL' }
|
|||
|
|
const prs = await prisma.projectRoundState.findFirst({ where: { projectId, roundId: liveFinalId } })
|
|||
|
|
expect(prs).not.toBeNull()
|
|||
|
|
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
|
|||
|
|
expect(conf.status).toBe('PENDING')
|
|||
|
|
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(0)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ADMIN_CONFIRM mode
|
|||
|
|
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
|
|||
|
|
// ... call with { projectId, mode: 'ADMIN_CONFIRM', attendingUserIds: [lead.id, member.id], visaFlags: { [member.id]: true } }
|
|||
|
|
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
|
|||
|
|
expect(conf.status).toBe('CONFIRMED')
|
|||
|
|
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(2)
|
|||
|
|
expect(await prisma.visaApplication.count({ where: { attendingMember: { confirmationId: conf.id } } })).toBe(1)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// idempotent membership + re-invite
|
|||
|
|
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
|
|||
|
|
// pre-create DECLINED confirmation + a PRS; enroll EMAIL again
|
|||
|
|
// expect status PENDING and exactly one PRS row for (projectId, liveFinalId)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ADMIN_CONFIRM over cap rejects
|
|||
|
|
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
|
|||
|
|
await expect(/* 4 attendees with cap 3 */).rejects.toThrow(/cap/i)
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run, verify fail** — `npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (`enrollFinalists` not a function).
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Implement** the procedure in `finalist.ts`. Reuse: import `resetOrCreatePendingConfirmation` from `../services/finalist-enrollment`; reuse `sendFinalistConfirmationEmail`, `ensureLunchPickForAttendingMember`, `logAudit`, `signFinalistToken` already imported. For the ADMIN_CONFIRM transaction body, copy the exact `$transaction` block from `adminConfirm` (`finalist.ts:492-523`). Resolve `windowHours` from the LIVE_FINAL round's `configJson.confirmationWindowHours ?? 24` (mirror `finalist.ts:145`). Build `confirmUrl` from `NEXTAUTH_URL` (mirror `finalist.ts:191-204`).
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run, verify pass** — same command. Expected: PASS.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Refactor for DRY (optional within budget)** — extract the shared confirm transaction into `confirmAttendanceInTx(tx, { confirmationId, attendingUserIds, visaFlags })` in `finalist-enrollment.ts` and call it from both `adminConfirm` and `enrollFinalists`. Re-run `npx vitest run tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-enrollment.test.ts`. Expected: PASS both.
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Commit** — `git commit -am "feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)"`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: `finalist.unenroll` — reverse membership + confirmation
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/server/routers/finalist.ts`
|
|||
|
|
- Test: `tests/unit/finalist-unenroll.test.ts`
|
|||
|
|
|
|||
|
|
**Contract:** `unenroll({ projectId, roundId })` →
|
|||
|
|
1. Delete the `FinalistConfirmation` for the project (cascade removes AttendingMember/FlightDetail/VisaApplication/lunch picks).
|
|||
|
|
2. Delete the LIVE_FINAL `ProjectRoundState` (`deleteMany({ where: { projectId, roundId } })`).
|
|||
|
|
3. Audit `FINALIST_UNENROLL`.
|
|||
|
|
4. Return `{ ok: true }`. (Mentor assignments are tied to the MENTORING round and are intentionally left untouched.)
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Failing test** — enroll (ADMIN_CONFIRM) a project, then `unenroll`; assert no confirmation, no attendees, no LIVE_FINAL PRS, and an audit row exists.
|
|||
|
|
- [ ] **Step 2: Run, verify fail.**
|
|||
|
|
- [ ] **Step 3: Implement** the procedure.
|
|||
|
|
- [ ] **Step 4: Run, verify pass** — `npx vitest run tests/unit/finalist-unenroll.test.ts`.
|
|||
|
|
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): unenroll reverses round membership + confirmation"`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: `finalist.listEnrollmentCandidates` — data for the UI
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/server/routers/finalist.ts`
|
|||
|
|
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
|
|||
|
|
|
|||
|
|
**Contract:** `listEnrollmentCandidates({ programId })` resolves the program's competition, finds the MENTORING round and the LIVE_FINAL round, and returns:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
{
|
|||
|
|
liveFinalRoundId: string | null,
|
|||
|
|
attendeeCap: number,
|
|||
|
|
categories: Array<{
|
|||
|
|
category: CompetitionCategory,
|
|||
|
|
quota: number | null,
|
|||
|
|
confirmedCount: number,
|
|||
|
|
pendingCount: number,
|
|||
|
|
candidates: Array<{
|
|||
|
|
projectId: string,
|
|||
|
|
title: string,
|
|||
|
|
teamName: string | null,
|
|||
|
|
country: string | null,
|
|||
|
|
inLiveFinal: boolean, // has a LIVE_FINAL ProjectRoundState
|
|||
|
|
confirmationStatus: FinalistConfirmationStatus | null,
|
|||
|
|
teamMembers: Array<{ userId: string, name: string | null, role: 'LEAD' | 'MEMBER', email: string }>,
|
|||
|
|
}>,
|
|||
|
|
}>,
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Source candidates from `ProjectRoundState` in the MENTORING round (mirror `round.ts:335`), join `project.finalistConfirmation.status`, a `ProjectRoundState` existence check against the LIVE_FINAL round for `inLiveFinal`, and `project.teamMembers` (for the ADMIN_CONFIRM picker). Group by `competitionCategory`; merge per-category `FinalistSlotQuota` + confirmed/pending counts (reuse the count logic from `finalist.listCategoryCounts`).
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Failing test** — set up a MENTORING round with one STARTUP project (PRS), assert the project appears under the STARTUP category with `inLiveFinal: false`, `confirmationStatus: null`, and its team members listed.
|
|||
|
|
- [ ] **Step 2: Run, verify fail.**
|
|||
|
|
- [ ] **Step 3: Implement.**
|
|||
|
|
- [ ] **Step 4: Run, verify pass.**
|
|||
|
|
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): listEnrollmentCandidates query for enrollment UI"`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: Enrollment UI card on the LIVE_FINAL round page
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/components/admin/grand-finale/finalist-enrollment-card.tsx`
|
|||
|
|
- Create: `src/components/admin/grand-finale/enroll-attendees-dialog.tsx`
|
|||
|
|
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (render the card in the grand-finale block, ~line 1528, above `FinalistSlotsCard`)
|
|||
|
|
|
|||
|
|
**Card UX (follow the visual pattern of `finalist-slots-card.tsx` / `waitlist-card.tsx`):**
|
|||
|
|
- `trpc.finalist.listEnrollmentCandidates.useQuery({ programId })`. Loading → Skeleton; empty → "No mentoring-round teams to enroll yet."
|
|||
|
|
- Group candidates by category with a header showing `confirmed/quota` (e.g. "Startup — 2/8 confirmed, 1 pending").
|
|||
|
|
- Each candidate row: checkbox, title + teamName + country, and a status badge (`Not enrolled` / `In round` / `Pending` / `Confirmed` / `Declined`). Confirmed/declined rows show an **Un-enroll** button instead of a checkbox.
|
|||
|
|
- Per selected row, a small mode toggle: **Email team** (default) or **Set attendees now**. Choosing "Set attendees now" opens `EnrollAttendeesDialog` (a ≤cap member multi-select with per-member "needs visa" checkbox) and stashes the chosen `attendingUserIds`/`visaFlags` on that row.
|
|||
|
|
- Footer: **Enroll selected** and **Enroll all eligible** (eligible = not already CONFIRMED). Calls `trpc.finalist.enrollFinalists.useMutation`, then `utils.finalist.listEnrollmentCandidates.invalidate()` + `utils.logistics.listConfirmations.invalidate()`. Toast the `{ enrolled, emailed, adminConfirmed, skipped }` summary.
|
|||
|
|
- Un-enroll buttons call `trpc.finalist.unenroll` behind an `AlertDialog` confirm ("This removes them from the Grand Final round and deletes their attendance record. Continue?").
|
|||
|
|
|
|||
|
|
> Use shadcn `AlertDialog` for confirms (no native `confirm()`), per house style and the no-keyboard-shortcuts preference (visible affordances only).
|
|||
|
|
|
|||
|
|
- [ ] **Step 1:** Build `enroll-attendees-dialog.tsx` (props: `members`, `cap`, `onConfirm(attendingUserIds, visaFlags)`).
|
|||
|
|
- [ ] **Step 2:** Build `finalist-enrollment-card.tsx` per the UX above.
|
|||
|
|
- [ ] **Step 3:** Render `<FinalistEnrollmentCard programId={programId} roundId={round.id} />` in the LIVE_FINAL grand-finale block in the round page.
|
|||
|
|
- [ ] **Step 4: Typecheck** — `npm run typecheck`. Expected: clean.
|
|||
|
|
- [ ] **Step 5: Commit** — `git commit -am "feat(grand-finale): finalist enrollment card on LIVE_FINAL round page"`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: Waitlist populate UI + confirmations-tab dead-end fixes
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/components/admin/grand-finale/waitlist-card.tsx`
|
|||
|
|
- Modify: `src/components/admin/logistics/confirmations-tab.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add-to-waitlist control** in `waitlist-card.tsx` — a category + project picker (project options = enrollment candidates not already confirmed/waitlisted) that calls the existing `trpc.finalist.addToWaitlist` mutation (which had no UI). Invalidate `listWaitlist` on success. Confirm `addToWaitlist`'s exact input shape at `finalist.ts:629` before wiring.
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Fix the dead-end copy** in `confirmations-tab.tsx:127` — replace "Use the grand-finale round page to send confirmations." with "Enroll finalists from the Grand Final round's Overview tab to start confirmations." (Or, if scope allows, render an inline `<Link>` to the round page.)
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Add row actions** to `confirmations-tab.tsx` (currently only PENDING rows show Confirm/Decline; all others show "—"):
|
|||
|
|
- CONFIRMED rows → **Un-confirm** button calling existing `trpc.finalist.unconfirm` (behind `AlertDialog`).
|
|||
|
|
- DECLINED / EXPIRED rows → **Re-invite** button calling `trpc.finalist.enrollFinalists` with `{ projectId, mode: 'EMAIL', roundId: liveFinalRoundId }` (re-invite-safe via Task 2). Needs the LIVE_FINAL roundId — fetch via `listEnrollmentCandidates` or add it to `listConfirmations`' payload.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Typecheck** — `npm run typecheck`. Expected: clean.
|
|||
|
|
- [ ] **Step 5: Commit** — `git commit -am "feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions"`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: Full verification
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Run the whole finalist/lunch/logistics suite** — `npx vitest run tests/unit/finalist-enrollment.test.ts tests/unit/finalist-unenroll.test.ts tests/unit/lunch-list-dishes-perm.test.ts tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-quotas.test.ts tests/unit/finalist-confirmation.test.ts`. Expected: all PASS (regression check on the refactor).
|
|||
|
|
- [ ] **Step 2: Typecheck + build** — `npm run typecheck && npm run build`. Expected: clean (build is required before any push, per CLAUDE.md).
|
|||
|
|
- [ ] **Step 3: Dev smoke (Playwright, server already on :3001 as super-admin)** — verify the end-to-end the audit proved was impossible:
|
|||
|
|
1. Mentoring round (R6) → ensure a project has a `ProjectRoundState` (advance one in if needed).
|
|||
|
|
2. Grand Final round (R7) Overview → the new Enrollment card lists it; enroll it in EMAIL mode.
|
|||
|
|
3. `/admin/logistics` → Confirmations tab now shows a PENDING row (no longer the dead-end empty state).
|
|||
|
|
4. Enroll a second team in ADMIN_CONFIRM mode with 2 attendees → Confirmations shows CONFIRMED with attendee count 2; Travel/Visas tabs now list those attendees.
|
|||
|
|
5. Confirm the LIVE_FINAL round's Projects tab now shows the enrolled teams (jury can see them).
|
|||
|
|
- [ ] **Step 4: Final commit / branch** — ensure work is on a feature branch (not `main`); summarize for review.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Self-review notes (coverage vs. the 4 BLOCKERs)
|
|||
|
|
|
|||
|
|
- BLOCKER 1 (no select-finalists UI) → Tasks 3 + 6 (`enrollFinalists` + enrollment card).
|
|||
|
|
- BLOCKER 2 (no waitlist populate UI) → Task 7 step 1.
|
|||
|
|
- BLOCKER 3 (lunch picker broken) → Task 1.
|
|||
|
|
- BLOCKER 4 (re-invite crash) → Task 2 (`resetOrCreatePendingConfirmation`), surfaced via Task 7 step 3 (Re-invite action).
|
|||
|
|
- Unified enroll + both attendee modes (locked decisions) → Task 3.
|
|||
|
|
- Un-confirm dead-end (HIGH) → Task 7 step 3.
|
|||
|
|
|
|||
|
|
**Out of scope for Wave 1 (deferred to later waves):** all the new transactional emails/notifications and reminders (Wave 2), the Email Templates tab (Wave 3), team-facing "My Logistics" + timezone/validation/export UX fixes (Wave 4). The only email this wave sends is the existing `sendFinalistConfirmationEmail` (now reachable via EMAIL-mode enroll and Re-invite).
|