Compare commits

..

26 Commits

Author SHA1 Message Date
Matt
97951deb68 feat(logistics): departure-after-arrival validation + travel/visa CSV export
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s
- upsertFlightDetail throws BAD_REQUEST when departureAt < arrivalAt
- Travel tab: Download CSV button (project/attendee/email/flight fields/status/visa)
- Visas tab: Download CSV button (project/attendee/nationality/status/dates/notes)
- TDD: 2 new tests (rejects invalid, accepts valid); all 6 flight tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:56:22 +02:00
Matt
53b623fb20 feat(applicant): My Logistics card (hotel/flights/visa+nationality) + confirm-page dashboard link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:53:21 +02:00
Matt
74cd111e3a feat(applicant): self-service visa nationality entry
Add updateMyVisaNationality mutation: finds the caller's AttendingMember where the program has visaStatusVisibleToMembers=true and a VisaApplication exists, updates VisaApplication.nationality, and emits a VISA_NATIONALITY_SELF_SET audit log. Throws NOT_FOUND when no eligible application exists. Tests: persists update; rejects caller without a visible visa app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:51:01 +02:00
Matt
d03c705642 feat(applicant): getMyLogistics (hotel+flight+visa) + submitter-match fix
- Fix getMyFinalistConfirmation to resolve project via OR [submittedByUserId, teamMembers] so a lead who submitted but has no TeamMember row can see their card.
- Add getMyLogistics query: returns projectTitle, confirmationStatus, hotel (program 1:1), myFlight (caller's AttendingMember.flightDetail), visaVisible flag, and myVisa when visible. Returns null for non-confirmed or unrelated callers.
- Tests: confirmed finalist sees hotel/flight/visa; non-finalist gets null; PENDING confirmation gets null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:50:14 +02:00
Matt
ed426a6fb4 docs(logistics): Wave 4 plan — team-facing My Logistics + travel/visa UX
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:46:12 +02:00
Matt
ee8d65a300 feat(logistics): enable Email Templates tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:41:39 +02:00
Matt
0058b2b73b feat(logistics): Email Templates tab (toggle/subject/preview/test) + logistics in global settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:41:33 +02:00
Matt
e5788b3e9d feat(notifications): renderNotificationEmail + previewEmailTemplate + logistics sample data
- Export `renderNotificationEmail` from email.ts (pure template resolver, no send)
- Refactor `sendStyledNotificationEmail` to delegate to `renderNotificationEmail`
- Hoist sampleData to module-level `NOTIFICATION_SAMPLE_DATA` in notification router
- Add 8 logistics sample entries (FINALIST_*/TRAVEL_CONFIRMED/VISA_STATUS_UPDATE)
- Add `notification.previewEmailTemplate` adminProcedure query (returns subject/html/hasStyledTemplate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:39:29 +02:00
Matt
27bdf8cdef docs(logistics): Wave 3 plan — enable Email Templates tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:36:05 +02:00
Matt
3f25ba112b fix(lunch): reminder filter, recap failure surfacing, manual send-reminders
- Extract selectUnpickedAttendees helper with OR filter (is null OR pickedAt null)
  to fix cron missing attendees with no MemberLunchPick row at all
- Update cron route to use the helper
- sendRecap now throws TRPCError on email failure instead of silently stamping success
- Add lunch.sendReminders adminProcedure for manual on-demand reminder sends
- Add "Send reminders now" AlertDialog button to LunchRecapActions
- Tests: lunch-reminder-filter.test.ts (2 new), all 5 lunch test files pass (40 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:24:01 +02:00
Matt
884c96c710 feat(logistics): travel-confirmed + visa-status emails to attendees
When a flight is set to CONFIRMED, fire a TRAVEL_CONFIRMED in-app
notification (+ email via the existing NotificationEmailSetting pipeline)
to the attending member. When a visa status changes to one of
INVITATION_SENT|APPOINTMENT_BOOKED|GRANTED|DENIED, fire a
VISA_STATUS_UPDATE notification. Both are best-effort (try/catch, never
throw inside the mutation). Admin notes are not forwarded to metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:20:21 +02:00
Matt
1b4ab6be18 feat(finalist): deadline reminder emails via cron
Add sendDueConfirmationReminders() to finalist-confirmation.ts: queries
PENDING confirmations with no reminderSentAt whose deadline is within the
per-program LIVE_FINAL round reminderHoursBeforeDeadline window (default 12h),
sends a FINALIST_REMINDER in-app notification (+ email via pipeline) to the
team LEAD, then stamps reminderSentAt for idempotency.

Wire into the finalist-confirmations cron route alongside expirePendingPastDeadline.
Also clear reminderSentAt on re-invite in resetOrCreatePendingConfirmation so
re-invited teams get a fresh reminder window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:17:19 +02:00
Matt
d501624c56 feat(finalist): withdrawal notification to team on decline/unconfirm/unenroll
Notifies the team LEAD with FINALIST_WITHDRAWN (in-app + email) when an admin
withdraws a grand-finale slot via adminDecline, unconfirm, or unenroll.
For unenroll, only notifies when a CONFIRMED confirmation existed before deletion.
Adds finalist-withdrawal.test.ts (4 tests) covering all three paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:14:26 +02:00
Matt
b2826d595f feat(finalist): admin alerts on confirm/decline/expire/promote
Wire notifyAdmins() into finalist lifecycle events so admins receive
in-app (+ email when enabled) notifications on each key transition:
- finalist.confirm → FINALIST_CONFIRMED
- finalist.decline / adminDecline → FINALIST_DECLINED
- expirePendingPastDeadline (per row) → FINALIST_EXPIRED
- promoteNextWaitlistEntry + manualPromote → FINALIST_WAITLIST_PROMOTED

All calls are wrapped in try/catch — comms never throw inside a mutation
or cron (CLAUDE.md: "round notifications never throw"). Added project
title to the project select where it was missing.

Tests: tests/unit/finalist-comms.test.ts — 3 tests passing (TDD).
Regression: finalist-confirmation, finalist-admin-confirm,
finalist-enrollment — 30 tests all passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:11:17 +02:00
Matt
f529e79cd7 feat(comms): logistics notification types, templates, and email settings
- Add 8 constants to NotificationTypes (FINALIST_CONFIRMED/DECLINED/EXPIRED/
  WAITLIST_PROMOTED/REMINDER/WITHDRAWN, TRAVEL_CONFIRMED, VISA_STATUS_UPDATE)
  with matching icons and priorities in NotificationIcons/NotificationPriorities
- Add 4 branded email templates: getFinalistReminderTemplate,
  getFinalistWithdrawnTemplate, getTravelConfirmedTemplate,
  getVisaStatusTemplate — registered in NOTIFICATION_EMAIL_TEMPLATES
  (admin-alert types use generic fallback)
- Add 8 logistics seed rows to seed-notification-settings.ts; upserted to
  dev DB (idempotent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:06:10 +02:00
Matt
0ea949309a feat(finalist): add reminderSentAt for confirmation reminders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:02:57 +02:00
Matt
8afef1ecba docs(logistics): Wave 2 plan — close the email/notification void
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:02:12 +02:00
Matt
34bb2bad57 fix(finalist): program-scope guards on enroll/unenroll (code review)
- enrollFinalists: reject a roundId whose competition belongs to a
  different program than input.programId.
- unenroll: reject a project/round pair from different programs before
  any delete.
- Hoist ADMIN_CONFIRM attendee validation to a pre-pass so a bad entry
  in a multi-team batch fails before any project is partially written.
- Add regression tests for both cross-program guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:49:13 +02:00
Matt
8ee517f6ca feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions
- waitlist-card: AddToWaitlistForm sub-component wires finalist.addToWaitlist (category + project pickers sourced from listEnrollmentCandidates, filtered to exclude confirmed/waitlisted projects; appends at next rank)
- confirmations-tab: fix dead-end empty-state copy to reference Grand Final round Overview tab
- confirmations-tab: CONFIRMED rows → Un-confirm button (AlertDialog with required reason, calls finalist.unconfirm)
- confirmations-tab: DECLINED/EXPIRED rows → Re-invite button (calls enrollFinalists EMAIL mode via liveFinalRoundId from listEnrollmentCandidates)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:36:15 +02:00
Matt
2a98f0cacf feat(grand-finale): finalist enrollment card on LIVE_FINAL round page
Adds EnrollAttendeesDialog and FinalistEnrollmentCard components and
wires the card above FinalistSlotsCard on the LIVE_FINAL round Overview,
giving admins the missing UI entry point to enroll mentoring-round teams
into the Grand Final via EMAIL or ADMIN_CONFIRM mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:32:40 +02:00
Matt
e80710487c feat(finalist): listEnrollmentCandidates query for enrollment UI
Returns mentoring-round candidates grouped by category with status,
team members, quota and confirmed/pending counts; inLiveFinal flag and
attendeeCap for the enrollment UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:29:26 +02:00
Matt
375aeb08af feat(finalist): unenroll reverses round membership + confirmation
Adds finalist.unenroll(projectId, roundId) which deletes the
FinalistConfirmation (cascading AttendingMember/FlightDetail/
VisaApplication/MemberLunchPick) and the LIVE_FINAL ProjectRoundState,
then logs a FINALIST_UNENROLL audit entry. Safe no-op when no rows exist.
Tests cover ADMIN_CONFIRM enrolled teardown and the no-rows path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:23:50 +02:00
Matt
f1e62fdd3b feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)
- Add `finalist.enrollFinalists` adminProcedure: creates ProjectRoundState in
  LIVE_FINAL round (skipDuplicates) + resets/creates FinalistConfirmation in
  one step, with EMAIL and ADMIN_CONFIRM attendee modes.
- Extract `confirmAttendanceInTx` helper into finalist-enrollment.ts; reuse
  from both adminConfirm and enrollFinalists (DRY refactor, all tests green).
- Add 4 tests covering EMAIL mode, ADMIN_CONFIRM mode, re-enroll safety, and
  over-cap rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:20:51 +02:00
Matt
dde8ea9345 feat(finalist): re-invite-safe confirmation reset helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:16:52 +02:00
Matt
ca9edcd038 fix(lunch): allow non-admins to read dish list (unblocks applicant picker) 2026-06-04 15:15:14 +02:00
Matt
647e7f09a7 docs(logistics): Wave 1 plan — finalist enrollment + BLOCKER fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:13:01 +02:00
45 changed files with 6532 additions and 194 deletions

View File

@@ -0,0 +1,467 @@
# Wave 1 — Make Logistics Operable: Finalist Enrollment + BLOCKER fixes
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give the grand-finale logistics flow its missing entry points so it is operable end-to-end: a unified "Enroll finalists" action that advances mentoring-round teams into the Grand Final round *and* creates their attendance confirmation in one step, a way to populate the waitlist, safe re-invitation, un-enroll, and the lunch-picker permission fix.
**Architecture:** Three new `finalist` tRPC procedures (`listEnrollmentCandidates`, `enrollFinalists`, `unenroll`) plus one shared service helper. The enroll mutation composes three existing mechanisms that are currently disconnected: (a) round membership (`ProjectRoundState` in the LIVE_FINAL round — same pattern as `round.advanceProjects`), (b) finalist confirmation (`createPendingConfirmation` service), and (c) optional immediate admin confirmation (same transaction as `finalist.adminConfirm`). A new admin card on the LIVE_FINAL round Overview drives it. One one-line permission fix unblocks the applicant lunch picker.
**Tech Stack:** Next.js 15 App Router, tRPC 11 (Zod), Prisma 6, shadcn/ui, Vitest 4. No schema migration required (all models already exist).
**Decisions locked (with Matt, 2026-06-04):**
- Enroll is **one unified step**: adds the team to the R7 LIVE_FINAL round (so the Finals Jury sees them) AND creates the finalist confirmation. Un-enroll reverses both.
- **Offer both attendee modes per team** at enroll time: `EMAIL` (send the self-confirm link; team lead picks ≤cap attendees) or `ADMIN_CONFIRM` (admin picks attendees now; status goes straight to CONFIRMED, no email).
---
## File structure
**Create:**
- `src/server/services/finalist-enrollment.ts` — shared helpers: `resetOrCreatePendingConfirmation()` (re-invite-safe) and `confirmAttendanceInTx()` (extracted from adminConfirm, reused by enroll's ADMIN_CONFIRM mode).
- `src/components/admin/grand-finale/finalist-enrollment-card.tsx` — the enrollment UI card on the LIVE_FINAL round Overview.
- `src/components/admin/grand-finale/enroll-attendees-dialog.tsx` — per-team attendee picker for ADMIN_CONFIRM mode.
- `tests/unit/finalist-enrollment.test.ts` — enrollFinalists (both modes), re-invite safety, unified PRS creation.
- `tests/unit/finalist-unenroll.test.ts` — unenroll reverses membership + confirmation.
- `tests/unit/lunch-list-dishes-perm.test.ts` — non-admin can read dishes.
**Modify:**
- `src/server/routers/finalist.ts` — add `listEnrollmentCandidates`, `enrollFinalists`, `unenroll`; refactor `adminConfirm`/`selectFinalists` to reuse the new helpers.
- `src/server/services/finalist-confirmation.ts` — export the reset-safe helper or re-export from the new module (keep `createPendingConfirmation` intact for waitlist promotion).
- `src/server/routers/lunch.ts:42``listDishes`: `adminProcedure``protectedProcedure`.
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` — render `FinalistEnrollmentCard` in the LIVE_FINAL grand-finale block (currently lines ~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`.
```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).

View File

@@ -0,0 +1,186 @@
# Wave 2 — Close the Logistics Email/Notification Void
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Make logistics actually *communicate*. Today logistics fires exactly one automatic email (`sendFinalistConfirmationEmail`) and creates zero in-app notifications. This wave adds confirmation reminders, admin alerts, withdrawal emails, attendee travel/visa emails, and fixes the lunch reminder/recap comms — all routed through the existing notification pipeline so admins keep on/off control.
**Architecture:** Reuse the established comms pipeline (decision: reuse, not a new system). `createNotification({ userId, type, title, message, linkUrl, metadata })` writes an in-app row AND conditionally sends a branded email when a `NotificationEmailSetting` row exists for that type with `sendEmail=true` and the user's `notificationPreference` allows email. New notification types get registered in `NotificationTypes`, optionally given a custom branded template in `NOTIFICATION_EMAIL_TEMPLATES` (team/attendee-facing) or left to fall back to the generic branded template (admin alerts), and seeded in `prisma/seed-notification-settings.ts` (which runs on every deploy + dev).
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, Vitest 4. One small schema migration (`reminderSentAt`).
**Key infra facts (verified 2026-06-04):**
- `createNotification(params)``src/server/services/in-app-notification.ts:185`. Email leg gated by `NotificationEmailSetting` (no row OR `sendEmail=false` → no email) + user `notificationPreference ∈ {EMAIL, BOTH}`.
- Helpers: `notifyAdmins({type,title,message,linkUrl,metadata})` (`:324`), `notifyProjectTeam({projectId,...})` (`:374`), `createBulkNotifications` (`:263`).
- Email body for a type comes from `NOTIFICATION_EMAIL_TEMPLATES[type]` (`src/lib/email.ts:2196`); missing entry → generic branded fallback `getNotificationEmailTemplate` (`:2635`). `sendStyledNotificationEmail` (`:2400`) is the sender.
- Settings seeded idempotently in `prisma/seed-notification-settings.ts` (row shape `{ notificationType, category, label, description, sendEmail }`), run on every container start via `docker/docker-entrypoint.sh:72`.
- Dead stub types already in registry (do NOT reuse; add explicit new ones): `MENTEE_FINALIST`, `EVENT_INVITATION`, `FINALISTS_ANNOUNCED`.
- Recipients: admins via `roles: { has: 'SUPER_ADMIN' }` / `'PROGRAM_ADMIN'` + `status:'ACTIVE'`; team lead via `teamMembers where role:'LEAD'`; attendee email via `AttendingMember.user`.
- `FinalistConfirmation` has NO `reminderSentAt` yet (Task 1 adds it). Lunch cron attendee-filter bug confirmed (Task 7).
**New notification type constants (add all to `NotificationTypes`):**
| Constant | Audience | Email template | Seed `sendEmail` |
|---|---|---|---|
| `FINALIST_CONFIRMED` | admins | fallback | true |
| `FINALIST_DECLINED` | admins | fallback | true |
| `FINALIST_EXPIRED` | admins | fallback | true |
| `FINALIST_WAITLIST_PROMOTED` | admins | fallback | true |
| `FINALIST_REMINDER` | team lead | custom | true |
| `FINALIST_WITHDRAWN` | team | custom | true |
| `TRAVEL_CONFIRMED` | attendee | custom | true |
| `VISA_STATUS_UPDATE` | attendee | custom | true |
---
## File structure
**Create:**
- `prisma/migrations/<ts>_add_finalist_reminder_sent_at/migration.sql`
- `tests/unit/finalist-comms.test.ts` — admin alerts + withdrawal notifications fire
- `tests/unit/finalist-reminders.test.ts` — reminder cron sends + stamps + idempotent
- `tests/unit/logistics-comms.test.ts` — flight-confirmed + visa-status emails fire
- `tests/unit/lunch-reminder-filter.test.ts` — cron picks up attendees with no pick row
**Modify:**
- `prisma/schema.prisma``FinalistConfirmation.reminderSentAt DateTime?`
- `src/server/services/in-app-notification.ts` — add 8 `NotificationTypes` constants (+ icons/priorities optional)
- `src/lib/email.ts` — 4 custom templates + register in `NOTIFICATION_EMAIL_TEMPLATES`
- `prisma/seed-notification-settings.ts` — 8 new setting rows (category `logistics`)
- `src/server/routers/finalist.ts` — admin alerts in `confirm`/`decline`/`adminDecline`/`manualPromote`; withdrawal in `adminDecline`/`unconfirm`/`unenroll`
- `src/server/services/finalist-confirmation.ts` — admin alert in `expirePendingPastDeadline` + `promoteNextWaitlistEntry`; new `sendDueConfirmationReminders`
- `src/app/api/cron/finalist-confirmations/route.ts` — call `sendDueConfirmationReminders`
- `src/server/routers/logistics.ts` — emails in `setFlightStatus`(→CONFIRMED) + `updateVisaApplication`(status transitions)
- `src/app/api/cron/lunch-reminders/route.ts` — fix attendee OR-filter
- `src/server/routers/lunch.ts` — surface recap send failure; add `sendReminders` mutation
- `src/components/admin/logistics/lunch-recap-actions.tsx` — "Send reminders now" button
---
## Task 1: Schema — `reminderSentAt`
**Files:** `prisma/schema.prisma`, migration.
- [ ] **Step 1:** Add to `FinalistConfirmation`: `reminderSentAt DateTime?` (near `confirmedAt`/`expiredAt`).
- [ ] **Step 2:** `npx prisma migrate dev --name add_finalist_reminder_sent_at`. Expected: migration created + applied, client regenerated.
- [ ] **Step 3:** `npm run typecheck` — clean.
- [ ] **Step 4: Commit**`git add prisma/ && git commit -m "feat(finalist): add reminderSentAt for confirmation reminders"`
---
## Task 2: Notification types, templates, and settings
**Files:** `src/server/services/in-app-notification.ts`, `src/lib/email.ts`, `prisma/seed-notification-settings.ts`.
- [ ] **Step 1:** Add the 8 constants to the `NotificationTypes` object (`in-app-notification.ts:15`), grouped under a `// Logistics` comment, e.g. `FINALIST_CONFIRMED: 'FINALIST_CONFIRMED',` … through `VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',`. Optionally add icons/priorities (e.g. `FINALIST_EXPIRED: 'urgent'`, `VISA_STATUS_UPDATE: 'high'`).
- [ ] **Step 2:** Add 4 custom branded templates in `src/lib/email.ts` (mirror an existing private template that uses `getEmailWrapper` + `sectionTitle`/`paragraph`/`ctaButton`/`infoBox`). Each returns `{ subject, html, text }`:
- `getFinalistReminderTemplate(name, projectTitle, deadline, confirmUrl)` — "Reminder: confirm your grand-finale attendance by <formatted deadline>". Format the deadline human-readably (`toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle:'full', timeStyle:'short' })`).
- `getFinalistWithdrawnTemplate(name, projectTitle, reason?)` — "Your grand-finale slot has been withdrawn".
- `getTravelConfirmedTemplate(name, projectTitle, flight, hotel?)` — itinerary (arrival/departure flight no + airport + formatted times) and, if `hotel` provided, hotel name/address/link.
- `getVisaStatusTemplate(name, projectTitle, status, note?)` — status-specific copy for `INVITATION_SENT` / `APPOINTMENT_BOOKED` / `GRANTED` / `DENIED`.
Then register them in `NOTIFICATION_EMAIL_TEMPLATES` (`:2196`) keyed by the type string, reading fields from `ctx.metadata` (e.g. `FINALIST_REMINDER: (ctx) => getFinalistReminderTemplate(ctx.name||'', ctx.metadata?.projectTitle as string, new Date(ctx.metadata?.deadline as string), ctx.linkUrl||'')`). Admin-alert types are intentionally NOT registered (they use the generic fallback).
- [ ] **Step 3:** Add 8 rows to `NOTIFICATION_EMAIL_SETTINGS` in `prisma/seed-notification-settings.ts` (category `'logistics'`), all `sendEmail: true`, with clear `label`/`description`.
- [ ] **Step 4:** Apply to the dev DB: `npx tsx prisma/seed-notification-settings.ts`. Expected: upserts succeed (idempotent).
- [ ] **Step 5:** `npm run typecheck` — clean.
- [ ] **Step 6: Commit**`git commit -am "feat(comms): logistics notification types, templates, and email settings"`
---
## Task 3: Admin alerts on confirmation lifecycle
**Files:** `src/server/routers/finalist.ts`, `src/server/services/finalist-confirmation.ts`; test `tests/unit/finalist-comms.test.ts`.
For each event, call `notifyAdmins({ type, title, message, linkUrl: '/admin/logistics', metadata: { projectId, projectTitle, category } })`. Wrap in try/catch — comms must never throw inside the mutation (mirror the round-notification rule in CLAUDE.md).
- Team confirms (`finalist.confirm`, after the transaction) → `FINALIST_CONFIRMED`.
- Team declines (`finalist.decline`) and admin declines (`finalist.adminDecline`) → `FINALIST_DECLINED`.
- Cron expiry (`expirePendingPastDeadline`, per expired row) → `FINALIST_EXPIRED`.
- Waitlist promotion (`promoteNextWaitlistEntry` and `manualPromote`) → `FINALIST_WAITLIST_PROMOTED`.
- [ ] **Step 1: Failing tests** — for `confirm`, `decline`, and `expirePendingPastDeadline`, assert an `InAppNotification` row with the right `type` is created for an admin user after the action (set up a `SUPER_ADMIN` with `status:'ACTIVE'`). Use the existing finalist test setup patterns.
- [ ] **Step 2:** Run → fail.
- [ ] **Step 3:** Implement the `notifyAdmins` calls. Import from `../services/in-app-notification` (note: `finalist-confirmation.ts` is a service — import directly).
- [ ] **Step 4:** Run → pass; re-run `tests/unit/finalist-confirmation.test.ts` for no regressions.
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): admin alerts on confirm/decline/expire/promote"`
---
## Task 4: Withdrawal emails to teams
**Files:** `src/server/routers/finalist.ts`; test `tests/unit/finalist-comms.test.ts`.
When a team's slot is withdrawn by an admin, notify the team lead with `FINALIST_WITHDRAWN` (in-app + email). Events: `adminDecline`, `unconfirm` (CONFIRMED→SUPERSEDED), and `unenroll` when a CONFIRMED confirmation existed.
- [ ] **Step 1: Failing test** — after `adminDecline`, assert a `FINALIST_WITHDRAWN` `InAppNotification` exists for the team lead's userId.
- [ ] **Step 2:** Run → fail.
- [ ] **Step 3:** Implement: resolve the lead (`teamMembers where role:'LEAD'`), `createNotification({ userId: lead.userId, type: NotificationTypes.FINALIST_WITHDRAWN, title:'Grand finale slot withdrawn', message:`Your team "${title}" is no longer a confirmed finalist.${reason? ' Reason: '+reason : ''}`, linkUrl:'/applicant', metadata:{ projectTitle: title, reason } })` in try/catch. In `unenroll`, capture whether a CONFIRMED row existed BEFORE the delete, and only notify then.
- [ ] **Step 4:** Run → pass; re-run `finalist-unconfirm`, `finalist-unenroll`, `finalist-admin-confirm` suites.
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): withdrawal notification to team on decline/unconfirm/unenroll"`
---
## Task 5: Confirmation reminder cron
**Files:** `src/server/services/finalist-confirmation.ts`, `src/app/api/cron/finalist-confirmations/route.ts`; test `tests/unit/finalist-reminders.test.ts`.
Add `sendDueConfirmationReminders(prisma): Promise<{ remindersSent: number }>`:
- Resolve a reminder lead time: read each program's LIVE_FINAL round `configJson.reminderHoursBeforeDeadline` (default 12).
- Query `FinalistConfirmation` where `status:'PENDING' AND reminderSentAt IS NULL AND deadline > now AND deadline <= now + reminderHours`. (Simplest: load all PENDING with `reminderSentAt:null AND deadline>now`, then filter by each program's lead time.)
- For each: send via `createNotification({ userId: lead.userId, type: FINALIST_REMINDER, title, message, linkUrl: confirmUrl (the public token URL — build like selectFinalists), metadata:{ projectTitle, deadline } })`, then `update reminderSentAt = now`. Best-effort per row (try/catch).
- Reset `reminderSentAt` is NOT needed (deadlines don't move here; if re-invited, `resetOrCreatePendingConfirmation` should also clear `reminderSentAt` — ADD that field reset in the Wave 1 helper).
- [ ] **Step 1:** In `resetOrCreatePendingConfirmation` (`src/server/services/finalist-enrollment.ts`), add `reminderSentAt: null` to the reset `update` data (so re-invited teams get a fresh reminder window).
- [ ] **Step 2: Failing test** — create a PENDING confirmation with `deadline = now + 6h` and `reminderSentAt:null`, a LIVE_FINAL round with `reminderHoursBeforeDeadline: 12`, a lead user; call `sendDueConfirmationReminders`; assert `remindersSent===1`, a `FINALIST_REMINDER` notification exists for the lead, and `reminderSentAt` is now set. Second call → `remindersSent===0` (idempotent).
- [ ] **Step 3:** Run → fail.
- [ ] **Step 4:** Implement `sendDueConfirmationReminders` and call it from the cron route (before or after `expirePendingPastDeadline`).
- [ ] **Step 5:** Run → pass.
- [ ] **Step 6: Commit**`git commit -am "feat(finalist): deadline reminder emails via cron"`
---
## Task 6: Travel + visa attendee emails
**Files:** `src/server/routers/logistics.ts`; test `tests/unit/logistics-comms.test.ts`.
- `setFlightStatus` → when set to `CONFIRMED`: load the attendee's user + the program hotel; `createNotification({ userId: attendee.userId, type: TRAVEL_CONFIRMED, ..., metadata:{ projectTitle, arrival/departure fields, hotel } })`. (No email when set back to PENDING.)
- `updateVisaApplication` → when `input.status` changes to one of `INVITATION_SENT|APPOINTMENT_BOOKED|GRANTED|DENIED` (and differs from `existing.status`): `createNotification({ userId: attendee.userId, type: VISA_STATUS_UPDATE, ..., metadata:{ projectTitle, status, note } })`. Gate on nothing (visa outcomes are always relevant); include the admin `notes` only if appropriate — default: don't leak internal notes, send status-only copy.
- [ ] **Step 1: Failing tests** — set a flight to CONFIRMED → assert `TRAVEL_CONFIRMED` notification for the attendee; update a visa to `GRANTED` → assert `VISA_STATUS_UPDATE` notification. (Reuse `logistics-flight.test.ts` / `visa-admin.test.ts` setup patterns.)
- [ ] **Step 2:** Run → fail.
- [ ] **Step 3:** Implement. For `setFlightStatus`, the procedure currently only has `flightDetailId`; join to `attendingMember.user` + program hotel. For `updateVisaApplication`, the existing row read already gives `attendingMember` — extend the select to include `user` + project title.
- [ ] **Step 4:** Run → pass; re-run `logistics-flight`, `visa-admin`, `visa-application-lifecycle`.
- [ ] **Step 5: Commit**`git commit -am "feat(logistics): travel-confirmed + visa-status emails to attendees"`
---
## Task 7: Lunch reminder/recap fixes
**Files:** `src/app/api/cron/lunch-reminders/route.ts`, `src/server/routers/lunch.ts`, `src/components/admin/logistics/lunch-recap-actions.tsx`; test `tests/unit/lunch-reminder-filter.test.ts`.
- [ ] **Step 1: Failing test** — a CONFIRMED attendee with NO `MemberLunchPick` row should be counted as needing a reminder. Assert the cron's selection query (extract it into a small exported helper `selectUnpickedAttendees(prisma, event)` for testability) returns that attendee.
- [ ] **Step 2:** Run → fail (current `is` filter misses null-relation rows).
- [ ] **Step 3:** Fix the filter to `OR: [{ lunchPick: { is: null } }, { lunchPick: { is: { pickedAt: null } } }]` (cron `route.ts:44`).
- [ ] **Step 4:** Recap failure surfacing (`lunch.ts:345`): only stamp `recapSentAt` + audit `LUNCH_RECAP_SENT` if `sendLunchRecapEmail` resolved; on failure, re-throw (or return `{ ok:false, error }`) so the admin sees a failure toast. Update `lunch-recap-actions.tsx` to show the error.
- [ ] **Step 5:** Add `lunch.sendReminders` mutation (admin) that runs the same selection + `sendLunchReminderEmail` loop as the cron for a given `lunchEventId`, returns `{ sent }`; add a "Send reminders now" button to `lunch-recap-actions.tsx` (behind a confirm).
- [ ] **Step 6:** Run tests → pass; `npm run typecheck` — clean.
- [ ] **Step 7: Commit**`git commit -am "fix(lunch): reminder filter, recap failure surfacing, manual send-reminders"`
---
## Task 8: Full verification
- [ ] **Step 1:** `npx vitest run` — full suite green (target: prior 256 + new tests).
- [ ] **Step 2:** `npm run typecheck` — clean.
- [ ] **Step 3:** Stop the dev server, `rm -rf .next`, `npm run build` — clean (don't build while dev server runs).
- [ ] **Step 4:** Restart dev on :3001; `npx tsx prisma/seed-notification-settings.ts` to ensure settings exist. Dev smoke: enroll a team (ADMIN_CONFIRM) → set their flight CONFIRMED in Travel tab → set a visa GRANTED in Visas tab → confirm an `InAppNotification` row exists for the attendee (query DB) for `TRAVEL_CONFIRMED` and `VISA_STATUS_UPDATE`. Decline a PENDING team → confirm admin `FINALIST_DECLINED` + team `FINALIST_WITHDRAWN`. Clean up (unenroll).
- [ ] **Step 5:** Summarize for review.
---
## Notes
- All comms calls are best-effort (try/catch, never throw inside a mutation/cron) — consistent with CLAUDE.md "round notifications never throw".
- Email sending in dev uses `SMTP_HOST=localhost` (`.env.local`) → sends fail silently and are swallowed; tests assert on the `InAppNotification` row, not on actual delivery.
- Prod gets the new `NotificationEmailSetting` rows automatically via `docker-entrypoint.sh` running `seed-notification-settings.ts` on deploy.
- Deferred to later waves: Email Templates admin tab (Wave 3), team-facing "My Logistics" (Wave 4).

View File

@@ -0,0 +1,113 @@
# Wave 3 — Enable the "Email Templates" tab (logistics hub)
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Turn the disabled "Email Templates (soon)" tab in `/admin/logistics` into a working surface where admins can, for every logistics email, toggle it on/off, customize the subject, **preview** the rendered email, and send a test to themselves — reusing the existing notification-settings infra.
**Architecture:** Reuse `notification.getEmailSettings` / `updateEmailSetting` / `sendTestEmail` (already built) scoped to the `logistics` category (the 8 types seeded in Wave 2). The only new server capability is **preview without sending**: extract a `renderNotificationEmail(...)` from `sendStyledNotificationEmail` and expose a `notification.previewEmailTemplate({ notificationType })` query returning `{ subject, html }`. UI reuses the existing `EmailPreviewDialog` (with `previewOnly`).
**Tech Stack:** Next.js 15, tRPC 11, shadcn/ui. No schema change.
**Key facts (verified 2026-06-04):**
- `notification.getEmailSettings` (`src/server/routers/notification.ts:140`) returns all `NotificationEmailSetting` rows (incl. our 8 `category:'logistics'` rows).
- `notification.updateEmailSetting` (`:152`) accepts `{ notificationType, sendEmail, emailSubject?, emailTemplate? }`.
- `notification.sendTestEmail` (`:243`) renders via `sendStyledNotificationEmail` using a per-type `sampleData` map (which currently has NO logistics entries → previews/tests render with template fallbacks).
- `sendStyledNotificationEmail` (`src/lib/email.ts:2400`) looks up `NOTIFICATION_EMAIL_TEMPLATES[type]`, else falls back to `getNotificationEmailTemplate`. Our 4 custom templates are registered; the 4 admin-alert types use the fallback.
- `EmailPreviewDialog` (`src/components/admin/round/email-preview-dialog.tsx`) props: `{ open, onOpenChange, title, description, recipientCount, previewHtml, isPreviewLoading, onSend, isSending, showCustomMessage?, onRefreshPreview?, previewOnly? }`.
- The existing global form `src/components/settings/notification-settings-form.tsx` renders categories team/jury/mentor/observer/admin — NOT `logistics`.
---
## Task 1: Server — render-without-send + preview query + logistics sample data
**Files:** `src/lib/email.ts`, `src/server/routers/notification.ts`; test `tests/unit/notification-preview.test.ts`.
- [ ] **Step 1:** In `src/lib/email.ts`, extract the template-resolution logic of `sendStyledNotificationEmail` into an exported pure function:
```ts
export function renderNotificationEmail(
name: string,
type: string,
context: NotificationEmailContext,
subjectOverride?: string,
): EmailTemplate {
const generator = NOTIFICATION_EMAIL_TEMPLATES[type]
const template = generator
? generator({ ...context, name })
: getNotificationEmailTemplate(name, subjectOverride || context.title, context.message, ensureAbsoluteUrl(context.linkUrl))
return subjectOverride ? { ...template, subject: subjectOverride } : template
}
```
Then refactor `sendStyledNotificationEmail` to call `renderNotificationEmail(...)` and `sendEmail(...)` the result (keep its existing signature/behavior identical — verify by re-running any notification email tests).
- [ ] **Step 2:** In `src/server/routers/notification.ts`, hoist the `sampleData` map (currently inside `sendTestEmail`) to a module-level `const NOTIFICATION_SAMPLE_DATA` and ADD logistics entries so previews/tests are realistic:
```ts
FINALIST_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_DECLINED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_WAITLIST_PROMOTED:{ projectTitle: 'Reef Guardians', category: 'STARTUP' },
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now()+86_400_000).toISOString() },
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
TRAVEL_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', arrivalAt: new Date(Date.now()+5*86_400_000).toISOString(), arrivalFlightNumber: 'AF1234', arrivalAirport: 'NCE', departureAt: new Date(Date.now()+7*86_400_000).toISOString(), departureFlightNumber: 'AF1235', departureAirport: 'NCE', hotel: { name: 'Hotel de Paris', address: 'Place du Casino, Monaco', link: 'https://example.com' } },
VISA_STATUS_UPDATE: { projectTitle: 'Ocean Cleanup Initiative', status: 'GRANTED' },
```
Have `sendTestEmail` use `NOTIFICATION_SAMPLE_DATA` (behavior unchanged otherwise).
- [ ] **Step 3:** Add a `previewEmailTemplate` adminProcedure query:
```ts
previewEmailTemplate: adminProcedure
.input(z.object({ notificationType: z.string() }))
.query(async ({ ctx, input }) => {
const setting = await ctx.prisma.notificationEmailSetting.findUnique({ where: { notificationType: input.notificationType } })
const label = setting?.label || input.notificationType
const metadata = NOTIFICATION_SAMPLE_DATA[input.notificationType] || {}
const rendered = renderNotificationEmail(ctx.user.name || 'Admin', input.notificationType, {
title: label,
message: `Preview of the "${label}" email.`,
linkUrl: `${process.env.NEXTAUTH_URL || ''}/applicant`,
linkLabel: 'Open',
metadata,
}, setting?.emailSubject || undefined)
return { subject: rendered.subject, html: rendered.html, hasStyledTemplate: input.notificationType in NOTIFICATION_EMAIL_TEMPLATES }
}),
```
Import `renderNotificationEmail` from `@/lib/email`.
- [ ] **Step 4: Test** (`tests/unit/notification-preview.test.ts`): call `notification.previewEmailTemplate({ notificationType: 'VISA_STATUS_UPDATE' })` via an admin caller; assert `html` contains a recognizable string (e.g. 'visa' or 'Grand Finale') and `subject` is non-empty. Also test a fallback type (`FINALIST_EXPIRED`) returns non-empty `html`. (Pattern: `createCaller(notificationRouter, {SUPER_ADMIN})`.)
- [ ] **Step 5:** `npx vitest run tests/unit/notification-preview.test.ts` → pass. `npm run typecheck` → clean.
- [ ] **Step 6: Commit**`git commit -am "feat(notifications): renderNotificationEmail + previewEmailTemplate + logistics sample data"`
---
## Task 2: UI — logistics Email Templates tab + show logistics in global settings
**Files:** create `src/components/admin/logistics/email-templates-tab.tsx`; modify `src/components/settings/notification-settings-form.tsx`.
- [ ] **Step 1:** Add `logistics: { label: 'Logistics', icon: Plane }` to the `CATEGORIES` map in `notification-settings-form.tsx` (import `Plane` from lucide-react) so logistics settings are also manageable on the global settings page.
- [ ] **Step 2:** Build `EmailTemplatesTab` (`src/components/admin/logistics/email-templates-tab.tsx`), `'use client'`, mirroring `NotificationSettingsForm`'s structure but: (a) filter `trpc.notification.getEmailSettings` to `category === 'logistics'`; (b) per row show the toggle (`updateEmailSetting`), a **subject** input (debounced `onBlur``updateEmailSetting({ notificationType, sendEmail, emailSubject })`), a **Test** button (`sendTestEmail`), and a **Preview** button; (c) Preview opens `EmailPreviewDialog` with `previewOnly`, fetching `trpc.notification.previewEmailTemplate({ notificationType })` (lazy, `enabled: !!previewType`) and passing its `html` to `previewHtml`. Loading → Skeleton; empty → "No logistics email types found — run the notification settings seed." Use sonner toasts + `trpc.useUtils()` invalidation.
- [ ] **Step 3:** `npm run typecheck` → clean.
- [ ] **Step 4: Commit**`git commit -am "feat(logistics): Email Templates tab (toggle/subject/preview/test) + logistics in global settings"`
---
## Task 3: Enable the tab in the logistics page
**Files:** `src/app/(admin)/admin/logistics/page.tsx`.
- [ ] **Step 1:** Remove `disabled` + the "(soon)" span from the `email-templates` `TabsTrigger`; import and add `<TabsContent value="email-templates"><EmailTemplatesTab programId={programId} /></TabsContent>` (the tab doesn't strictly need programId — settings are global — but pass it for consistency / future scoping; if unused, omit the prop).
- [ ] **Step 2:** `npm run typecheck` → clean.
- [ ] **Step 3: Commit**`git commit -am "feat(logistics): enable Email Templates tab"`
---
## Task 4: Verify
- [ ] **Step 1:** `npx vitest run` — full suite green.
- [ ] **Step 2:** `npm run typecheck` — clean. Stop dev server, `rm -rf .next`, `npm run build` — clean.
- [ ] **Step 3:** Restart dev on :3001; dev smoke: `/admin/logistics` → Email Templates tab renders the 8 logistics types; toggle one off/on (persists); click Preview on `VISA_STATUS_UPDATE` → dialog shows the rendered branded email; click Test → success toast (email swallowed by localhost SMTP in dev — fine). Screenshot.
- [ ] **Step 4:** Summarize.
## Notes
- No new email is sent automatically by this wave — it only adds admin visibility/control over the Wave-2 emails.
- Deferred to Wave 4: team-facing "My Logistics" + travel/visa UX fixes.

View File

@@ -0,0 +1,101 @@
# Wave 4 — Team-facing "My Logistics" + travel/visa UX fixes
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Close the loop for finalist teams. Today a confirmed team can see only their attendee roster + a visa badge; they have no view of their flights or hotel, can't self-enter passport nationality, and the submitter (non-TeamMember lead) can't even see the card. This wave adds a team-facing "My Logistics" view (flights + hotel + visa + nationality self-entry), fixes the submitter gap, links the confirm-success page to the dashboard, and adds the admin-side travel/visa correctness fixes (departure-after-arrival validation, CSV export).
**Architecture:** New read procedures on the applicant router (`getMyLogistics`) reusing the same caller→project resolution as the existing finalist procedures, plus a self-service `updateMyVisaNationality`. A new applicant dashboard card. Admin-side: input validation on `logistics.upsertFlightDetail` + CSV export buttons mirroring the lunch manifest.
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, shadcn/ui. No schema change.
**Key facts (verified 2026-06-04):**
- `applicant.getMyFinalistConfirmation` (`src/server/routers/applicant.ts:2748`) resolves the project via `teamMembers: { some: { userId } }` — MISSES a lead who submitted but has no TeamMember row, and has no role guard.
- `applicant.getMyVisaApplications` (`:2819`) returns visa rows when `program.visaStatusVisibleToMembers`, else null. Reuse its visibility logic.
- Admin-only: `logistics.getHotel`, `logistics.listFlightDetails` (`src/server/routers/logistics.ts`). No team-facing equivalents → teams never see hotel/flights.
- Applicant dashboard composes cards in `src/app/(applicant)/applicant/page.tsx` (~line 409): `LunchBanner`, `ExternalAttendeesStrip`, `AttendingMembersCard`. Add the new card here.
- Confirm-success copy at `src/app/(public)/finalist/confirm/[token]/page.tsx:183` promises "your project page" with no link.
- `logistics.upsertFlightDetail` input (`:145`) has no departure-after-arrival check. Lunch CSV export pattern: `src/components/admin/logistics/lunch-manifest.tsx:28-57`.
- Models: `Hotel{ name, address?, link?, notes? }` (programId 1:1); `FlightDetail{ arrival/departure At/FlightNumber/Airport, status, adminNotes }` (per AttendingMember); `VisaApplication{ status, nationality?, ... }`.
---
## Task 1: Team-facing read endpoint + submitter fix
**Files:** `src/server/routers/applicant.ts`; test `tests/unit/applicant-my-logistics.test.ts`.
- [ ] **Step 1:** Fix `getMyFinalistConfirmation` resolution: change the `where` to
`{ OR: [{ submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }], finalistConfirmation: { isNot: null } }`
(verify `Project.submittedByUserId` is the correct field name in schema). Keep the rest. (No role guard added — APPLICANT/lead may not have an explicit role; the project-scoping is the guard. If other procedures in this router use a role check, match it; otherwise leave project-scoping as the guard and note it.)
- [ ] **Step 2:** Add `getMyLogistics: protectedProcedure.query`:
- Resolve the caller's confirmed-finalist project (same OR-resolution as above; return `null` if none or confirmation not CONFIRMED).
- Return:
```ts
{
projectTitle: string,
confirmationStatus: FinalistConfirmationStatus,
hotel: { name, address, link, notes } | null, // program Hotel (1:1)
myFlight: { arrivalAt, arrivalFlightNumber, arrivalAirport, departureAt, departureFlightNumber, departureAirport, status } | null, // the caller's own AttendingMember.flightDetail
visaVisible: boolean, // program.visaStatusVisibleToMembers
myVisa: { status, nationality } | null, // caller's own VisaApplication, only when visaVisible
}
```
- `myFlight`: find the caller's `AttendingMember` for this confirmation (`where confirmationId + userId`), include `flightDetail`. `hotel`: `prisma.hotel.findUnique({ where: { programId } })`. `myVisa`: only when `visaVisible` and the caller's AttendingMember has a `visaApplication`.
- [ ] **Step 3: Test** — set up a CONFIRMED finalist with the caller as an AttendingMember, a Hotel, a FlightDetail (CONFIRMED), `visaStatusVisibleToMembers:true`, a VisaApplication GRANTED. Call `getMyLogistics` as that user → assert `hotel.name`, `myFlight.arrivalFlightNumber`, `visaVisible:true`, `myVisa.status==='GRANTED'`. Also: a non-finalist user → `null`. (Caller via `createCaller(applicantRouter, { id, email, role:'APPLICANT' })`.)
- [ ] **Step 4:** `npx vitest run tests/unit/applicant-my-logistics.test.ts` → pass; re-run any applicant tests touching these procedures. `npm run typecheck` → clean.
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): getMyLogistics (hotel+flight+visa) + submitter-match fix"`
---
## Task 2: Self-service nationality entry
**Files:** `src/server/routers/applicant.ts`; test in the same file.
- [ ] **Step 1:** Add `updateMyVisaNationality: protectedProcedure.input(z.object({ nationality: z.string().max(100) })).mutation`:
- Find the caller's `AttendingMember` whose program has `visaStatusVisibleToMembers:true` and which has a `VisaApplication`; if none → `TRPCError NOT_FOUND` ("No visa application to update").
- Update that `VisaApplication.nationality`. Audit `VISA_NATIONALITY_SELF_SET`. Return `{ ok: true }`.
- (Optional nicety: also copy to `User.nationality` if empty.)
- [ ] **Step 2: Test** — caller with a visible VisaApplication sets nationality → assert it persisted. Caller without one → rejects.
- [ ] **Step 3:** Run → pass. `npm run typecheck` → clean.
- [ ] **Step 4: Commit** — `git commit -am "feat(applicant): self-service visa nationality entry"`
---
## Task 3: "My Logistics" card + confirm-page link
**Files:** create `src/components/applicant/my-logistics-card.tsx`; modify `src/app/(applicant)/applicant/page.tsx`, `src/app/(public)/finalist/confirm/[token]/page.tsx`.
- [ ] **Step 1:** Build `MyLogisticsCard` (`'use client'`): `trpc.applicant.getMyLogistics.useQuery()`. If `null` or loading → render nothing / Skeleton. Otherwise a Card "Your grand-finale logistics" with sections:
- **Hotel** — name + address + link (if present), else "Hotel details coming soon."
- **Flights** — the caller's arrival/departure (flight no, airport, formatted date/time in Europe/Paris) + a status badge, else "Your flight details will appear here once arranged."
- **Visa** (only if `visaVisible`) — status badge + a nationality field: shows current `myVisa.nationality` or an inline editable input → `trpc.applicant.updateMyVisaNationality` (sonner toast + invalidate).
Follow the visual pattern of `attending-members-card.tsx`. Visible affordances only.
- [ ] **Step 2:** Render `<MyLogisticsCard />` in `src/app/(applicant)/applicant/page.tsx` near `AttendingMembersCard` (~line 415).
- [ ] **Step 3:** In the confirm-success block (`finalist/confirm/[token]/page.tsx:168-185`), replace the dead "your project page" sentence with a real link/button to `/applicant` ("Go to my dashboard"). (The confirm page is public; the link will hit auth and land them on their dashboard after login.)
- [ ] **Step 4:** `npm run typecheck` → clean.
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): My Logistics card (hotel/flights/visa+nationality) + confirm-page dashboard link"`
---
## Task 4: Admin travel/visa UX — validation + CSV export
**Files:** `src/server/routers/logistics.ts`, `src/components/admin/logistics/travel-tab.tsx`, `src/components/admin/logistics/visas-tab.tsx`; test `tests/unit/logistics-flight.test.ts` (extend).
- [ ] **Step 1:** Server validation in `logistics.upsertFlightDetail`: after building `data`, if both `arrivalAt` and `departureAt` are present and `departureAt < arrivalAt`, throw `TRPCError BAD_REQUEST` ("Departure must be after arrival"). Add a test asserting the rejection.
- [ ] **Step 2:** CSV export — add a "Download CSV" button to `travel-tab.tsx` (columns: Project, Attendee, Email, Arrival date/time, Arrival flight, Arrival airport, Departure date/time, Departure flight, Departure airport, Status, Needs visa) and to `visas-tab.tsx` (columns: Project, Attendee, Email, Nationality, Status, Invitation sent, Appointment, Decision, Notes). MIRROR the CSV builder in `src/components/admin/logistics/lunch-manifest.tsx:28-57` (Blob + anchor download; escape commas/quotes). Build from the data already loaded by each tab's query.
- [ ] **Step 3:** `npx vitest run tests/unit/logistics-flight.test.ts` → pass. `npm run typecheck` → clean.
- [ ] **Step 4: Commit** — `git commit -am "feat(logistics): departure-after-arrival validation + travel/visa CSV export"`
---
## Task 5: Verify
- [ ] **Step 1:** `npx vitest run` — full suite green.
- [ ] **Step 2:** `npm run typecheck` — clean. Stop dev, `rm -rf .next`, `npm run build` — clean.
- [ ] **Step 3:** Restart dev on :3001. Dev smoke: as admin, enroll a team (ADMIN_CONFIRM) and add flight + hotel + set a visa; then log in as that team's lead (seed user `matt@letsbe.solutions` is a MEMBER of "Revamp Flips" — or use the lead) and confirm the "My Logistics" card shows the hotel + flight + visa + nationality field. Verify the CSV export downloads. Clean up.
- [ ] **Step 4:** Summarize.
## Notes
- Deep timezone overhaul of the admin flight `datetime-local` inputs (storing explicit Europe/Paris) is a separate design decision — NOT in this wave; the validation + Europe/Paris display labels are the pragmatic improvement. Flag as remaining polish.
- This completes the 4-wave logistics overhaul.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "FinalistConfirmation" ADD COLUMN "reminderSentAt" TIMESTAMP(3);

View File

@@ -2761,6 +2761,7 @@ model FinalistConfirmation {
declinedAt DateTime? declinedAt DateTime?
declineReason String? // optional free-text on decline declineReason String? // optional free-text on decline
expiredAt DateTime? expiredAt DateTime?
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
promotedFromWaitlistEntryId String? @unique // null for original finalists promotedFromWaitlistEntryId String? @unique // null for original finalists
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -214,6 +214,64 @@ const NOTIFICATION_EMAIL_SETTINGS = [
sendEmail: true, sendEmail: true,
}, },
// Logistics notifications
{
notificationType: 'FINALIST_CONFIRMED',
category: 'logistics',
label: 'Finalist Confirmed',
description: 'Admin alert when a team confirms their grand-finale attendance',
sendEmail: true,
},
{
notificationType: 'FINALIST_DECLINED',
category: 'logistics',
label: 'Finalist Declined',
description: 'Admin alert when a team declines or an admin declines their finalist slot',
sendEmail: true,
},
{
notificationType: 'FINALIST_EXPIRED',
category: 'logistics',
label: 'Finalist Confirmation Expired',
description: 'Admin alert when a pending confirmation passes its deadline without a response',
sendEmail: true,
},
{
notificationType: 'FINALIST_WAITLIST_PROMOTED',
category: 'logistics',
label: 'Waitlist Promoted',
description: 'Admin alert when a waitlisted team is promoted to a confirmed finalist slot',
sendEmail: true,
},
{
notificationType: 'FINALIST_REMINDER',
category: 'logistics',
label: 'Confirmation Reminder',
description: 'Reminder email to the team lead when the confirmation deadline is approaching',
sendEmail: true,
},
{
notificationType: 'FINALIST_WITHDRAWN',
category: 'logistics',
label: 'Finalist Slot Withdrawn',
description: 'Notification to the team when their confirmed grand-finale slot is withdrawn by an admin',
sendEmail: true,
},
{
notificationType: 'TRAVEL_CONFIRMED',
category: 'logistics',
label: 'Travel Confirmed',
description: 'Email to the attendee when their flight and travel details are confirmed',
sendEmail: true,
},
{
notificationType: 'VISA_STATUS_UPDATE',
category: 'logistics',
label: 'Visa Status Update',
description: 'Email to the attendee when their visa application status changes',
sendEmail: true,
},
// Admin notifications (in-app only by default) // Admin notifications (in-app only by default)
{ {
notificationType: 'FILTERING_COMPLETE', notificationType: 'FILTERING_COMPLETE',

View File

@@ -16,6 +16,7 @@ import { TravelTab } from '@/components/admin/logistics/travel-tab'
import { HotelsTab } from '@/components/admin/logistics/hotels-tab' import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
import { VisasTab } from '@/components/admin/logistics/visas-tab' import { VisasTab } from '@/components/admin/logistics/visas-tab'
import { LunchTab } from '@/components/admin/logistics/lunch-tab' import { LunchTab } from '@/components/admin/logistics/lunch-tab'
import { EmailTemplatesTab } from '@/components/admin/logistics/email-templates-tab'
export default function LogisticsPage() { export default function LogisticsPage() {
const { currentEdition } = useEdition() const { currentEdition } = useEdition()
@@ -56,9 +57,8 @@ export default function LogisticsPage() {
<TabsTrigger value="lunch"> <TabsTrigger value="lunch">
<Salad className="mr-2 h-4 w-4" /> Lunch <Salad className="mr-2 h-4 w-4" /> Lunch
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="email-templates" disabled> <TabsTrigger value="email-templates">
<ScrollText className="mr-2 h-4 w-4" /> Email Templates <ScrollText className="mr-2 h-4 w-4" /> Email Templates
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -77,6 +77,9 @@ export default function LogisticsPage() {
<TabsContent value="lunch"> <TabsContent value="lunch">
<LunchTab programId={programId} /> <LunchTab programId={programId} />
</TabsContent> </TabsContent>
<TabsContent value="email-templates">
<EmailTemplatesTab programId={programId} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
) )

View File

@@ -95,6 +95,7 @@ import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table' import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard' import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report' import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
@@ -1526,10 +1527,13 @@ export default function RoundDetailPage() {
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */} {/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
{isGrandFinale && programId && ( {isGrandFinale && programId && (
<>
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} /> <FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} /> <WaitlistCard programId={programId} />
</div> </div>
</>
)} )}
{/* Round Info + Project Breakdown */} {/* Round Info + Project Breakdown */}

View File

@@ -19,6 +19,7 @@ import { CompetitionTimelineSidebar } from '@/components/applicant/competition-t
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card' import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
import { AttendingMembersCard } from '@/components/applicant/attending-members-card' import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
import { MyLogisticsCard } from '@/components/applicant/my-logistics-card'
import { LunchBanner } from '@/components/applicant/lunch-banner' import { LunchBanner } from '@/components/applicant/lunch-banner'
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip' import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
@@ -414,6 +415,9 @@ export default function ApplicantDashboardPage() {
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */} {/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
<AttendingMembersCard /> <AttendingMembersCard />
{/* Grand-finale logistics: hotel, flight, visa (auto-hides when not a confirmed finalist) */}
<MyLogisticsCard />
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */} {/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
<MentorConversationCard projectId={project.id} /> <MentorConversationCard projectId={project.id} />

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { Suspense, use, useEffect, useMemo, useState } from 'react' import { Suspense, use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -180,8 +181,11 @@ function FinalistConfirmContent({ token }: { token: string }) {
</p> </p>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
We&apos;ll be in touch shortly with travel and lunch logistics. You can edit your team We&apos;ll be in touch shortly with travel and lunch logistics. You can edit your team
selection from your project page closer to the event. selection and view hotel, flight, and visa details from your dashboard.
</p> </p>
<Button asChild className="mt-4">
<Link href="/applicant">Go to my dashboard</Link>
</Button>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,6 +1,9 @@
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation' import {
expirePendingPastDeadline,
sendDueConfirmationReminders,
} from '@/server/services/finalist-confirmation'
export async function GET(request: NextRequest): Promise<NextResponse> { export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret') const cronSecret = request.headers.get('x-cron-secret')
@@ -8,8 +11,11 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
try { try {
const result = await expirePendingPastDeadline(prisma) const [expireResult, reminderResult] = await Promise.all([
return NextResponse.json({ ok: true, ...result }) expirePendingPastDeadline(prisma),
sendDueConfirmationReminders(prisma),
])
return NextResponse.json({ ok: true, ...expireResult, ...reminderResult })
} catch (error) { } catch (error) {
console.error('[Cron] finalist-confirmations failed:', error) console.error('[Cron] finalist-confirmations failed:', error)
return NextResponse.json({ error: 'Internal error' }, { status: 500 }) return NextResponse.json({ error: 'Internal error' }, { status: 500 })

View File

@@ -1,6 +1,7 @@
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { sendLunchReminderEmail } from '@/lib/email' import { sendLunchReminderEmail } from '@/lib/email'
import { selectUnpickedAttendees } from '@/server/services/lunch-reminders'
/** /**
* Cron: send a single reminder email per attending member who hasn't picked * Cron: send a single reminder email per attending member who hasn't picked
@@ -35,16 +36,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
) )
if (now < reminderAt || now >= deadline) continue if (now < reminderAt || now >= deadline) continue
const ams = await prisma.attendingMember.findMany({ const ams = await selectUnpickedAttendees(prisma, event)
where: {
confirmation: {
project: { programId: event.programId },
status: 'CONFIRMED',
},
lunchPick: { is: { pickedAt: null } },
},
include: { user: { select: { name: true, email: true } } },
})
for (const am of ams) { for (const am of ams) {
if (!am.user.email) continue if (!am.user.email) continue
try { try {

View File

@@ -0,0 +1,159 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
export type AttendeeSelection = {
attendingUserIds: string[]
visaFlags: Record<string, boolean>
}
type Member = {
userId: string
name: string | null
role: string
email: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
members: Member[]
cap: number
onConfirm: (attendingUserIds: string[], visaFlags: Record<string, boolean>) => void
initial?: AttendeeSelection
isPending?: boolean
}
export function EnrollAttendeesDialog({
open,
onOpenChange,
members,
cap,
onConfirm,
initial,
isPending = false,
}: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
// Seed from initial or default to first member (the lead)
useEffect(() => {
if (!open) return
if (initial) {
setSelected(new Set(initial.attendingUserIds))
setVisa(initial.visaFlags)
} else {
const defaultSelected = members.slice(0, 1).map((m) => m.userId)
setSelected(new Set(defaultSelected))
setVisa({})
}
}, [open, initial, members])
const overCap = selected.size > cap
const noneSelected = selected.size === 0
const toggleMember = (userId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(userId)
else next.delete(userId)
return next
})
}
const handleConfirm = () => {
const ids = Array.from(selected)
onConfirm(
ids,
Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
)
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Select attendees</DialogTitle>
<DialogDescription>
Choose up to {cap} team member{cap === 1 ? '' : 's'} who will attend. Toggle
&quot;Visa?&quot; for anyone who needs a visa letter.
</DialogDescription>
</DialogHeader>
<ul className="max-h-[50vh] space-y-2 overflow-y-auto pr-1">
{members.map((m) => {
const checked = selected.has(m.userId)
const atCap = !checked && selected.size >= cap
return (
<li
key={m.userId}
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
>
<label className="flex flex-1 cursor-pointer items-start gap-3">
<Checkbox
checked={checked}
disabled={atCap}
onCheckedChange={(c) => toggleMember(m.userId, c === true)}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">{m.name ?? m.email}</div>
<div className="text-muted-foreground text-xs">
{m.email}
{m.role && m.role !== 'MEMBER' ? ` · ${m.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Visa?</span>
<Switch
checked={!!visa[m.userId]}
onCheckedChange={(c) => setVisa((prev) => ({ ...prev, [m.userId]: c }))}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={overCap || noneSelected || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm attendees
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,456 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, UserCheck } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import {
EnrollAttendeesDialog,
type AttendeeSelection,
} from './enroll-attendees-dialog'
interface Props {
programId: string
roundId: string
}
type EnrollMode = 'EMAIL' | 'ADMIN_CONFIRM'
type RowState = {
mode: EnrollMode
attendees?: AttendeeSelection
}
type Candidate = {
projectId: string
title: string
teamName: string | null
country: string | null
inLiveFinal: boolean
confirmationStatus: string | null
teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }>
}
const STATUS_CONFIG: Record<
string,
{ label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }
> = {
PENDING: { label: 'Pending', variant: 'secondary' },
CONFIRMED: { label: 'Confirmed', variant: 'default' },
DECLINED: { label: 'Declined', variant: 'destructive' },
EXPIRED: { label: 'Expired', variant: 'outline' },
}
function deriveStatus(candidate: Candidate): string {
if (candidate.confirmationStatus) return candidate.confirmationStatus
if (candidate.inLiveFinal) return 'IN_ROUND'
return 'NOT_ENROLLED'
}
function StatusBadge({ candidate }: { candidate: Candidate }) {
const status = deriveStatus(candidate)
if (status === 'NOT_ENROLLED') {
return (
<Badge variant="outline" className="text-xs">
Not enrolled
</Badge>
)
}
if (status === 'IN_ROUND') {
return (
<Badge variant="secondary" className="text-xs">
In round
</Badge>
)
}
const cfg = STATUS_CONFIG[status] ?? { label: status, variant: 'outline' as const }
return (
<Badge variant={cfg.variant} className="text-xs">
{cfg.label}
</Badge>
)
}
export function FinalistEnrollmentCard({ programId, roundId }: Props) {
const utils = trpc.useUtils()
const { data, isLoading } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
// Per-row selection + mode state
const [selected, setSelected] = useState<Set<string>>(new Set())
const [rowState, setRowState] = useState<Record<string, RowState>>({})
// Dialog state for "Set attendees now" picker
const [attendeesDialog, setAttendeesDialog] = useState<{
open: boolean
projectId: string
members: Candidate['teamMembers']
} | null>(null)
// Un-enroll state
const [unenrolling, setUnenrolling] = useState<string | null>(null)
const invalidateQueries = () => {
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
utils.logistics.listConfirmations.invalidate({ programId })
}
const enrollMutation = trpc.finalist.enrollFinalists.useMutation({
onSuccess: (result) => {
const parts: string[] = []
if (result.enrolled > 0) parts.push(`${result.enrolled} enrolled`)
if (result.emailed > 0) parts.push(`${result.emailed} emailed`)
if (result.adminConfirmed > 0) parts.push(`${result.adminConfirmed} admin-confirmed`)
if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`)
toast.success(parts.join(' · ') || 'Done')
setSelected(new Set())
setRowState({})
invalidateQueries()
},
onError: (err) => toast.error(err.message),
})
const unenrollMutation = trpc.finalist.unenroll.useMutation({
onSuccess: () => {
toast.success('Team removed from the Grand Final round')
setUnenrolling(null)
invalidateQueries()
},
onError: (err) => {
toast.error(err.message)
setUnenrolling(null)
},
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const toggleRow = (projectId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
setRowState((prev) => {
if (prev[projectId]) return prev
return { ...prev, [projectId]: { mode: 'EMAIL' } }
})
}
const setMode = (projectId: string, mode: EnrollMode, candidate: Candidate) => {
if (mode === 'ADMIN_CONFIRM') {
setAttendeesDialog({
open: true,
projectId,
members: candidate.teamMembers,
})
} else {
setRowState((prev) => ({
...prev,
[projectId]: { mode: 'EMAIL' },
}))
}
}
const handleAttendeesConfirm = (
projectId: string,
attendingUserIds: string[],
visaFlags: Record<string, boolean>,
) => {
setRowState((prev) => ({
...prev,
[projectId]: {
mode: 'ADMIN_CONFIRM',
attendees: { attendingUserIds, visaFlags },
},
}))
setAttendeesDialog(null)
}
const buildEnrollments = (projectIds: string[]) => {
return projectIds.map((projectId) => {
const rs = rowState[projectId] ?? { mode: 'EMAIL' as EnrollMode }
if (rs.mode === 'ADMIN_CONFIRM' && rs.attendees) {
return {
projectId,
mode: 'ADMIN_CONFIRM' as const,
attendingUserIds: rs.attendees.attendingUserIds,
visaFlags: rs.attendees.visaFlags,
}
}
return { projectId, mode: 'EMAIL' as const }
})
}
const handleEnrollSelected = () => {
if (!data?.liveFinalRoundId) {
toast.error('No LIVE_FINAL round found for this program')
return
}
const ids = Array.from(selected)
if (ids.length === 0) return
enrollMutation.mutate({
programId,
roundId: data.liveFinalRoundId,
enrollments: buildEnrollments(ids),
})
}
const handleEnrollAllEligible = () => {
if (!data?.liveFinalRoundId) {
toast.error('No LIVE_FINAL round found for this program')
return
}
const allCandidates = data.categories.flatMap((c) => c.candidates)
const eligible = allCandidates.filter((c) => c.confirmationStatus !== 'CONFIRMED')
if (eligible.length === 0) {
toast.info('No eligible teams to enroll')
return
}
enrollMutation.mutate({
programId,
roundId: data.liveFinalRoundId,
enrollments: eligible.map((c) => ({
projectId: c.projectId,
mode: 'EMAIL' as const,
})),
})
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (isLoading) {
return <Skeleton className="h-56 w-full rounded-md" />
}
const noMentoringTeams = !data || data.categories.length === 0
return (
<>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<UserCheck className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Enroll finalists</CardTitle>
</div>
<CardDescription>
Select mentoring-round teams to advance into the Grand Final. Each enrolled team
immediately appears on the Finals jury&#39;s project list and receives an attendance
confirmation request (or can be admin-confirmed on the spot).
</CardDescription>
</CardHeader>
<CardContent>
{noMentoringTeams ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No mentoring-round teams to enroll yet.
</p>
) : (
<div className="space-y-6">
{data.categories.map((cat) => (
<div key={cat.category}>
{/* Category header */}
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide">
<span>{formatEnumLabel(cat.category)}</span>
<span className="font-normal">
{cat.confirmedCount}/{cat.quota ?? '?'} confirmed
{cat.pendingCount > 0 ? `, ${cat.pendingCount} pending` : ''}
</span>
</div>
<div className="space-y-2">
{cat.candidates.map((candidate) => {
const status = deriveStatus(candidate)
const isEnrolled =
status === 'CONFIRMED' || status === 'DECLINED' || status === 'EXPIRED'
const isChecked = selected.has(candidate.projectId)
const rs = rowState[candidate.projectId]
const isUnenrolling =
unenrollMutation.isPending && unenrolling === candidate.projectId
return (
<div
key={candidate.projectId}
className="flex flex-wrap items-start gap-3 rounded-md border p-3"
>
{/* Left: checkbox (or spacer for enrolled rows) */}
<div className="mt-0.5 flex-shrink-0">
{isEnrolled ? (
<div className="h-4 w-4" />
) : (
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleRow(candidate.projectId)}
/>
)}
</div>
{/* Middle: project info */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{candidate.title}</span>
<StatusBadge candidate={candidate} />
</div>
<div className="text-muted-foreground mt-0.5 text-xs">
{[candidate.teamName, candidate.country]
.filter(Boolean)
.join(' · ') || '—'}
</div>
{/* Mode toggle for selected rows */}
{isChecked && !isEnrolled && (
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
variant={rs?.mode === 'EMAIL' ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
onClick={() => setMode(candidate.projectId, 'EMAIL', candidate)}
>
Email team
</Button>
<Button
size="sm"
variant={rs?.mode === 'ADMIN_CONFIRM' ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
onClick={() =>
setMode(candidate.projectId, 'ADMIN_CONFIRM', candidate)
}
>
Set attendees now
{rs?.mode === 'ADMIN_CONFIRM' && rs.attendees && (
<span className="ml-1 opacity-70">
({rs.attendees.attendingUserIds.length})
</span>
)}
</Button>
</div>
)}
</div>
{/* Right: un-enroll button for CONFIRMED/DECLINED rows */}
{(status === 'CONFIRMED' || status === 'DECLINED') && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={isUnenrolling}
onClick={() => setUnenrolling(candidate.projectId)}
>
{isUnenrolling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Un-enroll'
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove from Grand Final?</AlertDialogTitle>
<AlertDialogDescription>
This removes <strong>{candidate.title}</strong> from the Grand
Final round and deletes their attendance record. Continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setUnenrolling(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
unenrollMutation.mutate({
projectId: candidate.projectId,
roundId,
})
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
</div>
</div>
))}
{/* Footer actions */}
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
<Button
size="sm"
disabled={selected.size === 0 || enrollMutation.isPending}
onClick={handleEnrollSelected}
>
{enrollMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Enroll selected ({selected.size})
</Button>
<Button
size="sm"
variant="outline"
disabled={enrollMutation.isPending}
onClick={handleEnrollAllEligible}
>
Enroll all eligible
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Attendees picker dialog */}
{attendeesDialog && (
<EnrollAttendeesDialog
open={attendeesDialog.open}
onOpenChange={(open) => {
if (!open) {
// If user closes without confirming, revert mode to EMAIL
setRowState((prev) => ({
...prev,
[attendeesDialog.projectId]: {
mode: 'EMAIL',
},
}))
setAttendeesDialog(null)
}
}}
members={attendeesDialog.members}
cap={data?.attendeeCap ?? 3}
initial={rowState[attendeesDialog.projectId]?.attendees}
onConfirm={(ids, flags) =>
handleAttendeesConfirm(attendeesDialog.projectId, ids, flags)
}
/>
)}
</>
)
}

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -17,7 +18,14 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { ListOrdered, Loader2 } from 'lucide-react' import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ListOrdered, Loader2, PlusCircle } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils' import { formatEnumLabel } from '@/lib/utils'
import type { CompetitionCategory } from '@prisma/client' import type { CompetitionCategory } from '@prisma/client'
@@ -25,6 +33,145 @@ interface Props {
programId: string programId: string
} }
function AddToWaitlistForm({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const [category, setCategory] = useState<string>('')
const [projectId, setProjectId] = useState<string>('')
const { data: candidatesData, isLoading: loadingCandidates } =
trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
const { data: waitlistData } = trpc.finalist.listWaitlist.useQuery({ programId })
const addMutation = trpc.finalist.addToWaitlist.useMutation({
onSuccess: () => {
toast.success('Project added to waitlist')
utils.finalist.listWaitlist.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
setProjectId('')
},
onError: (err) => toast.error(err.message),
})
// Build set of project IDs already on the waitlist
const waitlistedProjectIds = new Set(
(waitlistData ?? [])
.filter((e) => e.status === 'WAITING' || e.status === 'PROMOTED')
.map((e) => e.projectId),
)
// Candidates per selected category — exclude confirmed/waitlisted
const categoryData = candidatesData?.categories.find((c) => c.category === category)
const availableCandidates = (categoryData?.candidates ?? []).filter(
(c) =>
!waitlistedProjectIds.has(c.projectId) &&
c.confirmationStatus !== 'CONFIRMED',
)
// Category options (only categories that have candidates)
const categoryOptions = (candidatesData?.categories ?? []).filter(
(c) =>
c.candidates.some(
(p) =>
!waitlistedProjectIds.has(p.projectId) &&
p.confirmationStatus !== 'CONFIRMED',
),
)
// Derive the next rank for the selected category
const currentMaxRank = Math.max(
0,
...(waitlistData ?? [])
.filter((e) => e.category === category)
.map((e) => e.rank),
)
const nextRank = currentMaxRank + 1
const canSubmit = !!category && !!projectId && !addMutation.isPending
if (loadingCandidates) return <Skeleton className="h-10 w-full" />
return (
<div className="border-t pt-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<PlusCircle className="text-muted-foreground h-4 w-4" />
Add to waitlist
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[160px]">
<Select
value={category}
onValueChange={(v) => {
setCategory(v)
setProjectId('')
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categoryOptions.length === 0 ? (
<SelectItem value="__none__" disabled>
No eligible categories
</SelectItem>
) : (
categoryOptions.map((c) => (
<SelectItem key={c.category} value={c.category}>
{formatEnumLabel(c.category)}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="min-w-[220px] flex-1">
<Select
value={projectId}
onValueChange={setProjectId}
disabled={!category || availableCandidates.length === 0}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={category ? 'Select project' : 'Choose category first'} />
</SelectTrigger>
<SelectContent>
{availableCandidates.length === 0 ? (
<SelectItem value="__none__" disabled>
No eligible projects
</SelectItem>
) : (
availableCandidates.map((c) => (
<SelectItem key={c.projectId} value={c.projectId}>
{c.title}
{c.country ? ` · ${c.country}` : ''}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<Button
size="sm"
disabled={!canSubmit}
onClick={() =>
addMutation.mutate({
programId,
category: category as CompetitionCategory,
projectId,
rank: nextRank,
})
}
>
{addMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Add at end'
)}
</Button>
</div>
</div>
)
}
const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = { const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
WAITING: { label: 'Waiting', variant: 'outline' }, WAITING: { label: 'Waiting', variant: 'outline' },
PROMOTED: { label: 'Promoted', variant: 'default' }, PROMOTED: { label: 'Promoted', variant: 'default' },
@@ -65,10 +212,11 @@ export function WaitlistCard({ programId }: Props) {
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires. Per-category ranked waitlist. Auto-cascades when a finalist declines or expires.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<p className="text-muted-foreground py-6 text-center text-sm"> <p className="text-muted-foreground py-4 text-center text-sm">
No waitlist entries yet. No waitlist entries yet.
</p> </p>
<AddToWaitlistForm programId={programId} />
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -161,6 +309,7 @@ export function WaitlistCard({ programId }: Props) {
</div> </div>
</div> </div>
))} ))}
<AddToWaitlistForm programId={programId} />
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -2,6 +2,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -14,6 +15,19 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Textarea } from '@/components/ui/textarea'
import { Loader2 } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils' import { formatEnumLabel } from '@/lib/utils'
import type { FinalistConfirmationStatus } from '@prisma/client' import type { FinalistConfirmationStatus } from '@prisma/client'
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog' import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
@@ -52,17 +66,52 @@ function relativeFromNow(d: Date): string {
} }
export function ConfirmationsTab({ programId }: Props) { export function ConfirmationsTab({ programId }: Props) {
const utils = trpc.useUtils()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all') const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [dialogState, setDialogState] = useState<{ const [dialogState, setDialogState] = useState<{
open: boolean open: boolean
mode: AttendanceMode mode: AttendanceMode
confirmationId: string | null confirmationId: string | null
}>({ open: false, mode: 'confirm', confirmationId: null }) }>({ open: false, mode: 'confirm', confirmationId: null })
const [unconfirmState, setUnconfirmState] = useState<{
open: boolean
confirmationId: string | null
projectTitle: string
reason: string
}>({ open: false, confirmationId: null, projectTitle: '', reason: '' })
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery( const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
{ programId }, { programId },
{ refetchInterval: 60_000 }, { refetchInterval: 60_000 },
) )
// Get liveFinalRoundId for re-invite action
const { data: candidatesData } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
const liveFinalRoundId = candidatesData?.liveFinalRoundId ?? null
const unconfirmMutation = trpc.finalist.unconfirm.useMutation({
onSuccess: () => {
toast.success('Finalist un-confirmed')
utils.logistics.listConfirmations.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
setUnconfirmState((prev) => ({ ...prev, open: false, confirmationId: null, reason: '' }))
},
onError: (err) => toast.error(err.message),
})
const reinviteMutation = trpc.finalist.enrollFinalists.useMutation({
onSuccess: (result) => {
if (result.skipped.length > 0) {
toast.info('Re-invite skipped — team is already confirmed')
} else {
toast.success('Re-invite sent')
}
utils.logistics.listConfirmations.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!data) return [] if (!data) return []
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter) return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
@@ -124,7 +173,7 @@ export function ConfirmationsTab({ programId }: Props) {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm"> <p className="text-muted-foreground py-12 text-center text-sm">
{statusFilter === 'all' {statusFilter === 'all'
? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.' ? 'No finalists have been selected yet. Enroll finalists from the Grand Final round\'s Overview tab to start confirmations.'
: 'No confirmations match this filter.'} : 'No confirmations match this filter.'}
</p> </p>
) : ( ) : (
@@ -218,6 +267,43 @@ export function ConfirmationsTab({ programId }: Props) {
Decline Decline
</Button> </Button>
</div> </div>
) : r.status === 'CONFIRMED' ? (
<Button
size="sm"
variant="outline"
onClick={() =>
setUnconfirmState({
open: true,
confirmationId: r.id,
projectTitle: r.project.title,
reason: '',
})
}
>
Un-confirm
</Button>
) : r.status === 'DECLINED' || r.status === 'EXPIRED' ? (
<Button
size="sm"
variant="outline"
disabled={!liveFinalRoundId || reinviteMutation.isPending}
onClick={() => {
if (!liveFinalRoundId) return
reinviteMutation.mutate({
programId,
roundId: liveFinalRoundId,
enrollments: [{ projectId: r.project.id, mode: 'EMAIL' }],
})
}}
>
{reinviteMutation.isPending &&
reinviteMutation.variables?.enrollments?.[0]?.projectId ===
r.project.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Re-invite'
)}
</Button>
) : ( ) : (
<span className="text-muted-foreground text-xs"></span> <span className="text-muted-foreground text-xs"></span>
)} )}
@@ -241,6 +327,60 @@ export function ConfirmationsTab({ programId }: Props) {
setDialogState((prev) => ({ ...prev, open: next })) setDialogState((prev) => ({ ...prev, open: next }))
} }
/> />
{/* Un-confirm AlertDialog (needs a reason — min 5 chars per the server) */}
<AlertDialog
open={unconfirmState.open}
onOpenChange={(next) => {
if (!unconfirmMutation.isPending)
setUnconfirmState((prev) => ({ ...prev, open: next }))
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Un-confirm this finalist?</AlertDialogTitle>
<AlertDialogDescription>
{unconfirmState.projectTitle} will be moved back to Superseded. Any active mentor
assignment will be dropped and the mentor notified. This action is audit-logged.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="px-1 pb-2">
<label
htmlFor="unconfirm-reason"
className="text-muted-foreground mb-1 block text-sm"
>
Reason <span className="text-destructive">*</span>
</label>
<Textarea
id="unconfirm-reason"
value={unconfirmState.reason}
onChange={(e) =>
setUnconfirmState((prev) => ({ ...prev, reason: e.target.value }))
}
placeholder="e.g. team withdrew, scheduling conflict, administrative correction"
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={unconfirmMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={unconfirmState.reason.trim().length < 5 || unconfirmMutation.isPending}
onClick={() => {
if (!unconfirmState.confirmationId) return
unconfirmMutation.mutate({
confirmationId: unconfirmState.confirmationId,
reason: unconfirmState.reason.trim(),
})
}}
>
{unconfirmMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Un-confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }

View File

@@ -0,0 +1,222 @@
'use client'
import { useState, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
import { Plane, Mail, Eye, Loader2 } from 'lucide-react'
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
export function EmailTemplatesTab({ programId }: { programId?: string }) {
const [previewType, setPreviewType] = useState<string | null>(null)
const [testingType, setTestingType] = useState<string | null>(null)
const utils = trpc.useUtils()
const { data: allSettings, isLoading } = trpc.notification.getEmailSettings.useQuery()
const settings = (allSettings ?? []).filter((s) => s.category === 'logistics')
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
onSuccess: () => {
toast.success('Setting updated')
void utils.notification.getEmailSettings.invalidate()
},
onError: (error) => {
toast.error(`Failed to update: ${error.message}`)
},
})
const testMutation = trpc.notification.sendTestEmail.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success(data.message, {
description: data.hasStyledTemplate ? 'Using styled template' : 'Using generic template',
})
} else {
toast.error('Failed to send test email', { description: data.message })
}
setTestingType(null)
},
onError: (error) => {
toast.error(`Failed to send: ${error.message}`)
setTestingType(null)
},
})
const preview = trpc.notification.previewEmailTemplate.useQuery(
{ notificationType: previewType! },
{ enabled: !!previewType },
)
const previewSetting = settings.find((s) => s.notificationType === previewType)
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)
}
if (settings.length === 0) {
return (
<p className="text-sm text-muted-foreground py-8 text-center">
No logistics email types found run the notification settings seed.
</p>
)
}
return (
<>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-base">
<Plane className="h-5 w-5 text-muted-foreground" />
Logistics Emails
<span className="ml-auto text-xs font-normal text-muted-foreground">
{settings.filter((s) => s.sendEmail).length}/{settings.length} enabled
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{settings.map((setting) => (
<EmailTemplateRow
key={setting.id}
setting={setting}
isTesting={testingType === setting.notificationType}
isUpdating={updateMutation.isPending}
onTest={() => {
setTestingType(setting.notificationType)
testMutation.mutate({ notificationType: setting.notificationType })
}}
onPreview={() => setPreviewType(setting.notificationType)}
onToggle={(checked) =>
updateMutation.mutate({
notificationType: setting.notificationType,
sendEmail: checked,
emailSubject: setting.emailSubject ?? undefined,
})
}
onSubjectBlur={(subject) => {
if (subject !== (setting.emailSubject ?? '')) {
updateMutation.mutate({
notificationType: setting.notificationType,
sendEmail: setting.sendEmail,
emailSubject: subject || undefined,
})
}
}}
/>
))}
</CardContent>
</Card>
<EmailPreviewDialog
open={!!previewType}
onOpenChange={(o) => { if (!o) setPreviewType(null) }}
title={previewSetting?.label ?? 'Email Preview'}
description={previewSetting?.description ?? ''}
recipientCount={0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={() => {}}
isSending={false}
previewOnly
showCustomMessage={false}
/>
</>
)
}
type RowSetting = {
id: string
notificationType: string
category: string
label: string
description: string | null
sendEmail: boolean
emailSubject: string | null
}
function EmailTemplateRow({
setting,
isTesting,
isUpdating,
onTest,
onPreview,
onToggle,
onSubjectBlur,
}: {
setting: RowSetting
isTesting: boolean
isUpdating: boolean
onTest: () => void
onPreview: () => void
onToggle: (checked: boolean) => void
onSubjectBlur: (value: string) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5 flex-1 min-w-0">
<Label className="text-sm font-medium">{setting.label}</Label>
{setting.description && (
<p className="text-xs text-muted-foreground">{setting.description}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-foreground"
onClick={onPreview}
title="Preview email"
>
<Eye className="h-4 w-4" />
<span className="ml-1.5 text-xs">Preview</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-foreground"
onClick={onTest}
disabled={isTesting}
title="Send test email to yourself"
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
<span className="ml-1.5 text-xs">Test</span>
</Button>
<Switch
checked={setting.sendEmail}
onCheckedChange={onToggle}
disabled={isUpdating}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground w-16 shrink-0">Subject</Label>
<Input
ref={inputRef}
className="h-7 text-xs"
defaultValue={setting.emailSubject ?? ''}
placeholder="(default subject)"
onBlur={(e) => onSubjectBlur(e.target.value)}
/>
</div>
</div>
)
}

View File

@@ -11,15 +11,28 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Send, Eye } from 'lucide-react' import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Send, Eye, Bell } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
export function LunchRecapActions({ export function LunchRecapActions({
programId, programId,
lunchEventId,
recapSentAt, recapSentAt,
extraRecipientCount, extraRecipientCount,
}: { }: {
programId: string programId: string
lunchEventId: string
recapSentAt: Date | null recapSentAt: Date | null
extraRecipientCount: number extraRecipientCount: number
}) { }) {
@@ -46,6 +59,15 @@ export function LunchRecapActions({
}, },
}) })
const sendReminders = trpc.lunch.sendReminders.useMutation({
onSuccess: (data) => {
toast.success(`Reminders sent to ${data.sent} attendee${data.sent === 1 ? '' : 's'}`)
},
onError: (e) => {
toast.error(`Failed to send reminders: ${e.message}`)
},
})
const { data: preview, isLoading: loadingPreview } = const { data: preview, isLoading: loadingPreview } =
trpc.lunch.getRecapPreview.useQuery( trpc.lunch.getRecapPreview.useQuery(
{ programId }, { programId },
@@ -68,6 +90,31 @@ export function LunchRecapActions({
> >
<Send className="mr-2 h-4 w-4" /> Send recap now <Send className="mr-2 h-4 w-4" /> Send recap now
</Button> </Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={sendReminders.isPending}>
<Bell className="mr-2 h-4 w-4" /> Send reminders now
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send lunch pick reminders?</AlertDialogTitle>
<AlertDialogDescription>
This will send a reminder email to all confirmed attendees who
haven&apos;t picked a lunch dish yet. You can do this multiple
times it won&apos;t affect the automatic reminder window.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => sendReminders.mutate({ lunchEventId })}
>
Send reminders
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{recapSentAt {recapSentAt

View File

@@ -32,6 +32,7 @@ export function LunchTab({ programId }: { programId: string }) {
/> />
<LunchRecapActions <LunchRecapActions
programId={programId} programId={programId}
lunchEventId={event.id}
recapSentAt={event.recapSentAt} recapSentAt={event.recapSentAt}
extraRecipientCount={event.extraRecipients.length} extraRecipientCount={event.extraRecipients.length}
/> />

View File

@@ -26,7 +26,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Loader2, Plane } from 'lucide-react' import { Download, Loader2, Plane } from 'lucide-react'
import type { FlightDetailStatus } from '@prisma/client' import type { FlightDetailStatus } from '@prisma/client'
interface Props { interface Props {
@@ -240,6 +240,47 @@ function FlightEditorSheet({
) )
} }
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function buildTravelCsv(rows: AttendeeRow[]): string {
const header = [
'Project',
'Attendee',
'Email',
'Arrival date/time',
'Arrival flight',
'Arrival airport',
'Departure date/time',
'Departure flight',
'Departure airport',
'Status',
'Needs visa',
].join(',')
const lines = rows.map((r) => {
const fd = r.flightDetail
return [
csvEscape(r.confirmation.project.title),
csvEscape(r.user.name ?? r.user.email),
csvEscape(r.user.email),
csvEscape(fd?.arrivalAt ? new Date(fd.arrivalAt).toLocaleString() : ''),
csvEscape(fd?.arrivalFlightNumber),
csvEscape(fd?.arrivalAirport),
csvEscape(fd?.departureAt ? new Date(fd.departureAt).toLocaleString() : ''),
csvEscape(fd?.departureFlightNumber),
csvEscape(fd?.departureAirport),
csvEscape(fd?.status ?? ''),
r.needsVisa ? 'Yes' : 'No',
].join(',')
})
return [header, ...lines].join('\r\n')
}
export function TravelTab({ programId }: Props) { export function TravelTab({ programId }: Props) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all') const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
@@ -306,11 +347,31 @@ export function TravelTab({ programId }: Props) {
<Plane className="text-muted-foreground h-4 w-4" /> <Plane className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle> <CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<StatusPill value="all" label="All" count={totals.all} /> <StatusPill value="all" label="All" count={totals.all} />
<StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} /> <StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} />
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} /> <StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} /> <StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
<Button
variant="outline"
size="sm"
disabled={!data || data.length === 0}
onClick={() => {
if (!data) return
const csv = buildTravelCsv(data as AttendeeRow[])
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'travel-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -15,7 +15,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Settings as SettingsIcon, ShieldOff } from 'lucide-react' import { Download, Settings as SettingsIcon, ShieldOff } from 'lucide-react'
import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog' import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog'
import type { VisaStatus } from '@prisma/client' import type { VisaStatus } from '@prisma/client'
@@ -56,6 +56,53 @@ function nextDate(row: {
return { label: '—', date: null } return { label: '—', date: null }
} }
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
type VisaRow = {
status: VisaStatus
nationality: string | null
invitationSentAt: Date | null
appointmentAt: Date | null
decisionAt: Date | null
notes: string | null
project: { id: string; title: string }
attendee: { id: string; user: { id: string; name: string | null; email: string } }
}
function buildVisaCsv(rows: VisaRow[]): string {
const header = [
'Project',
'Attendee',
'Email',
'Nationality',
'Status',
'Invitation sent',
'Appointment',
'Decision',
'Notes',
].join(',')
const lines = rows.map((r) => {
return [
csvEscape(r.project.title),
csvEscape(r.attendee.user.name ?? r.attendee.user.email),
csvEscape(r.attendee.user.email),
csvEscape(r.nationality),
csvEscape(r.status),
csvEscape(r.invitationSentAt ? new Date(r.invitationSentAt).toLocaleDateString() : ''),
csvEscape(r.appointmentAt ? new Date(r.appointmentAt).toLocaleDateString() : ''),
csvEscape(r.decisionAt ? new Date(r.decisionAt).toLocaleDateString() : ''),
csvEscape(r.notes),
].join(',')
})
return [header, ...lines].join('\r\n')
}
export function VisasTab({ programId }: Props) { export function VisasTab({ programId }: Props) {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all') const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null) const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
@@ -117,6 +164,27 @@ export function VisasTab({ programId }: Props) {
continue to flow over email and are never stored on this platform. continue to flow over email and are never stored on this platform.
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!data || data.length === 0}
onClick={() => {
if (!data) return
const csv = buildVisaCsv(data)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'visa-applications.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link href="/admin/settings?tab=edition"> <Link href="/admin/settings?tab=edition">
<SettingsIcon className="mr-2 h-3.5 w-3.5" /> <SettingsIcon className="mr-2 h-3.5 w-3.5" />
@@ -124,6 +192,7 @@ export function VisasTab({ programId }: Props) {
</Link> </Link>
</Button> </Button>
</div> </div>
</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<StatusPill value="all" label="All" count={(data ?? []).length} /> <StatusPill value="all" label="All" count={(data ?? []).length} />
<StatusPill value="REQUESTED" label="Requested" count={totals.REQUESTED} /> <StatusPill value="REQUESTED" label="Requested" count={totals.REQUESTED} />

View File

@@ -0,0 +1,291 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Hotel, PlaneLanding, PlaneTakeoff, ShieldCheck, Pencil, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import type { FlightDetailStatus, VisaStatus } from '@prisma/client'
const FLIGHT_BADGE: Record<
FlightDetailStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
PENDING: { label: 'Pending confirmation', variant: 'secondary' },
CONFIRMED: { label: 'Flight confirmed', variant: 'default' },
}
const VISA_BADGE: Record<
VisaStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
NOT_NEEDED: { label: 'Visa not needed', variant: 'outline' },
REQUESTED: { label: 'Visa requested', variant: 'secondary' },
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
GRANTED: { label: 'Visa granted', variant: 'default' },
DENIED: { label: 'Visa denied', variant: 'destructive' },
}
function formatMonacoTime(d: Date | string | null | undefined): string {
if (!d) return '—'
return new Date(d).toLocaleString('en-GB', {
timeZone: 'Europe/Paris',
dateStyle: 'medium',
timeStyle: 'short',
})
}
function FlightRow({
label,
icon,
flightNumber,
airport,
at,
}: {
label: string
icon: React.ReactNode
flightNumber: string | null | undefined
airport: string | null | undefined
at: Date | string | null | undefined
}) {
const hasData = flightNumber || airport || at
if (!hasData) return null
return (
<div className="space-y-0.5">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide">
{icon}
{label}
</div>
<div className="text-sm">
{[flightNumber, airport].filter(Boolean).join(' · ')}
{at && (
<span className="text-muted-foreground ml-1">
{formatMonacoTime(at)}{' '}
<span className="text-xs">(Monaco time)</span>
</span>
)}
</div>
</div>
)
}
function NationalityField({
currentNationality,
onSaved,
}: {
currentNationality: string | null | undefined
onSaved: () => void
}) {
const [editing, setEditing] = useState(!currentNationality)
const [value, setValue] = useState(currentNationality ?? '')
const utils = trpc.useUtils()
const mutation = trpc.applicant.updateMyVisaNationality.useMutation({
onSuccess: () => {
toast.success('Nationality saved.')
utils.applicant.getMyLogistics.invalidate()
setEditing(false)
onSaved()
},
onError: (err) => {
toast.error(err.message ?? 'Could not save nationality.')
},
})
if (!editing && currentNationality) {
return (
<div className="flex items-center gap-2">
<span className="text-sm">{currentNationality}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setValue(currentNationality)
setEditing(true)
}}
aria-label="Edit nationality"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="e.g. French"
className="h-8 max-w-[200px] text-sm"
disabled={mutation.isPending}
/>
<Button
size="sm"
className="h-8"
disabled={!value.trim() || mutation.isPending}
onClick={() => mutation.mutate({ nationality: value.trim() })}
>
{mutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
{currentNationality && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setValue(currentNationality)
setEditing(false)
}}
disabled={mutation.isPending}
>
Cancel
</Button>
)}
</div>
)
}
export function MyLogisticsCard() {
const { data, isLoading } = trpc.applicant.getMyLogistics.useQuery()
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-56" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
if (!data) return null
const { hotel, myFlight, visaVisible, myVisa } = data
const hasFlightData =
myFlight &&
(myFlight.arrivalAt ||
myFlight.arrivalFlightNumber ||
myFlight.arrivalAirport ||
myFlight.departureAt ||
myFlight.departureFlightNumber ||
myFlight.departureAirport)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-teal-500/10 p-1.5">
<Hotel className="h-4 w-4 text-teal-600" />
</div>
Your grand-finale logistics
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Hotel */}
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Hotel
</p>
{hotel ? (
<div className="space-y-0.5">
<p className="text-sm font-medium">
{hotel.link ? (
<a
href={hotel.link}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
{hotel.name}
</a>
) : (
hotel.name
)}
</p>
{hotel.address && (
<p className="text-muted-foreground text-sm">{hotel.address}</p>
)}
</div>
) : (
<p className="text-muted-foreground text-sm">Hotel details coming soon.</p>
)}
</div>
{/* Flights */}
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Flights
</p>
{hasFlightData ? (
<div className="space-y-3">
<FlightRow
label="Arrival"
icon={<PlaneLanding className="h-3 w-3" />}
flightNumber={myFlight.arrivalFlightNumber}
airport={myFlight.arrivalAirport}
at={myFlight.arrivalAt}
/>
<FlightRow
label="Departure"
icon={<PlaneTakeoff className="h-3 w-3" />}
flightNumber={myFlight.departureFlightNumber}
airport={myFlight.departureAirport}
at={myFlight.departureAt}
/>
<Badge variant={FLIGHT_BADGE[myFlight.status].variant}>
{FLIGHT_BADGE[myFlight.status].label}
</Badge>
</div>
) : (
<p className="text-muted-foreground text-sm">
Your flight details will appear here once arranged.
</p>
)}
</div>
{/* Visa — only when visaVisible */}
{visaVisible && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Visa
</p>
<div className="space-y-2">
<Badge
variant={myVisa ? VISA_BADGE[myVisa.status].variant : 'outline'}
className="gap-1"
>
<ShieldCheck className="h-3 w-3" />
{myVisa ? VISA_BADGE[myVisa.status].label : 'Not started'}
</Badge>
{myVisa && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Passport nationality</p>
<NationalityField
currentNationality={myVisa.nationality}
onSaved={() => {}}
/>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Users, Scale, GraduationCap, Eye, Shield, Mail, Loader2 } from 'lucide-react' import { Users, Scale, GraduationCap, Eye, Shield, Mail, Loader2, Plane } from 'lucide-react'
// Category icons and labels // Category icons and labels
const CATEGORIES = { const CATEGORIES = {
@@ -17,6 +17,7 @@ const CATEGORIES = {
mentor: { label: 'Mentors', icon: GraduationCap }, mentor: { label: 'Mentors', icon: GraduationCap },
observer: { label: 'Observers', icon: Eye }, observer: { label: 'Observers', icon: Eye },
admin: { label: 'Administrators', icon: Shield }, admin: { label: 'Administrators', icon: Shield },
logistics: { label: 'Logistics', icon: Plane },
} }
type NotificationSetting = { type NotificationSetting = {

View File

@@ -336,7 +336,7 @@ function statCard(label: string, value: string | number): string {
// Email Templates // Email Templates
// ============================================================================= // =============================================================================
interface EmailTemplate { export interface EmailTemplate {
subject: string subject: string
html: string html: string
text: string text: string
@@ -2188,6 +2188,265 @@ export function getAccountReminderTemplate(
} }
} }
// =============================================================================
// Logistics email templates
// =============================================================================
/**
* Reminder email sent to a team lead when the confirmation deadline is approaching.
*/
function getFinalistReminderTemplate(
name: string,
projectTitle: string,
deadline: Date,
confirmUrl: string,
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const formattedDeadline = deadline.toLocaleString('en-GB', {
timeZone: 'Europe/Paris',
dateStyle: 'full',
timeStyle: 'short',
})
const content = `
${sectionTitle('Reminder: Grand-Finale Attendance')}
${paragraph(greeting)}
${paragraph(`Your project <strong>${escapeHtml(projectTitle)}</strong> has been selected as a finalist for the Monaco Ocean Protection Challenge Grand Finale. Your confirmation is still pending.`)}
${infoBox(`Please confirm your attendance <strong>by ${escapeHtml(formattedDeadline)} (Paris time)</strong>. If no response is received by the deadline, your slot may be reallocated.`, 'warning')}
${ctaButton(confirmUrl, 'Confirm attendance')}
${paragraph('If you have any questions, please reach out to the MOPC team.')}
`
const text = [
greeting,
'',
`Your project "${projectTitle}" has been selected as a finalist for the Monaco Ocean Protection Challenge Grand Finale. Your confirmation is still pending.`,
'',
`Please confirm your attendance by ${formattedDeadline} (Paris time). If no response is received by the deadline, your slot may be reallocated.`,
'',
`Confirm attendance: ${confirmUrl}`,
'',
'If you have any questions, please reach out to the MOPC team.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Reminder: confirm your grand-finale attendance',
html: getEmailWrapper(content),
text,
}
}
/**
* Notification email sent to a team when their confirmed finalist slot is withdrawn by an admin.
*/
function getFinalistWithdrawnTemplate(
name: string,
projectTitle: string,
reason?: string,
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const reasonHtml = reason
? paragraph(`<strong>Reason:</strong> ${escapeHtml(reason)}`)
: ''
const reasonText = reason ? `Reason: ${reason}\n\n` : ''
const content = `
${sectionTitle('Grand-Finale Slot Withdrawn')}
${paragraph(greeting)}
${infoBox(`We regret to inform you that your team's confirmed spot at the Grand Finale has been withdrawn for the project <strong>${escapeHtml(projectTitle)}</strong>.`, 'warning')}
${reasonHtml}
${paragraph('If you believe this is an error or have questions, please contact the MOPC team as soon as possible.')}
`
const text = [
greeting,
'',
`We regret to inform you that your team's confirmed spot at the Grand Finale has been withdrawn for the project "${projectTitle}".`,
'',
reasonText + 'If you believe this is an error or have questions, please contact the MOPC team as soon as possible.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Your grand-finale slot has been withdrawn',
html: getEmailWrapper(content),
text,
}
}
/**
* Travel confirmation email for a grand-finale attendee, including flight and hotel details.
*/
function getTravelConfirmedTemplate(
name: string,
projectTitle: string,
flight: {
arrivalAt?: Date | string | null
arrivalFlightNumber?: string | null
arrivalAirport?: string | null
departureAt?: Date | string | null
departureFlightNumber?: string | null
departureAirport?: string | null
},
hotel?: {
name: string
address?: string | null
link?: string | null
},
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const fmtDate = (d: Date | string | null | undefined) => {
if (!d) return null
const dt = d instanceof Date ? d : new Date(d)
return dt.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'full', timeStyle: 'short' })
}
const arrivalLines: string[] = []
const departureLines: string[] = []
if (flight.arrivalAt) arrivalLines.push(`Date: ${fmtDate(flight.arrivalAt)} (Paris time)`)
if (flight.arrivalFlightNumber) arrivalLines.push(`Flight: ${flight.arrivalFlightNumber}`)
if (flight.arrivalAirport) arrivalLines.push(`Airport: ${flight.arrivalAirport}`)
if (flight.departureAt) departureLines.push(`Date: ${fmtDate(flight.departureAt)} (Paris time)`)
if (flight.departureFlightNumber) departureLines.push(`Flight: ${flight.departureFlightNumber}`)
if (flight.departureAirport) departureLines.push(`Airport: ${flight.departureAirport}`)
const arrivalHtml =
arrivalLines.length > 0
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Arrival</h3>` +
`<ul style="margin:0 0 16px;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
arrivalLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const departureHtml =
departureLines.length > 0
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Departure</h3>` +
`<ul style="margin:0 0 16px;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
departureLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const hotelHtml = hotel
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` +
infoBox(
`<strong>${escapeHtml(hotel.name)}</strong>` +
(hotel.address ? `<br>${escapeHtml(hotel.address)}` : '') +
(hotel.link ? `<br><a href="${hotel.link}" style="color:${BRAND.darkBlue};">View hotel</a>` : ''),
'info',
)
: ''
const hotelText = hotel
? ['\nHotel:', ` ${hotel.name}`, ...(hotel.address ? [` ${hotel.address}`] : []), ...(hotel.link ? [` ${hotel.link}`] : [])].join('\n')
: ''
const content = `
${sectionTitle('Your travel is confirmed!')}
${paragraph(greeting)}
${paragraph(`Great news — your travel arrangements for the Grand Finale (<strong>${escapeHtml(projectTitle)}</strong>) have been confirmed. Here are your details:`)}
${arrivalHtml}
${departureHtml}
${hotelHtml}
${paragraph('If anything looks incorrect, please contact the MOPC team immediately.')}
`
const arrivalText = arrivalLines.length > 0 ? '\nArrival:\n' + arrivalLines.map((l) => ` ${l}`).join('\n') : ''
const departureText = departureLines.length > 0 ? '\nDeparture:\n' + departureLines.map((l) => ` ${l}`).join('\n') : ''
const text = [
greeting,
'',
`Great news — your travel arrangements for the Grand Finale ("${projectTitle}") have been confirmed. Here are your details:`,
arrivalText,
departureText,
hotelText,
'',
'If anything looks incorrect, please contact the MOPC team immediately.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Your grand-finale travel is confirmed',
html: getEmailWrapper(content),
text,
}
}
/**
* Visa status update email for a grand-finale attendee.
*/
function getVisaStatusTemplate(
name: string,
projectTitle: string,
status: 'INVITATION_SENT' | 'APPOINTMENT_BOOKED' | 'GRANTED' | 'DENIED',
note?: string,
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const statusCopy: Record<typeof status, { headline: string; body: string; variant: 'info' | 'success' | 'warning' }> = {
INVITATION_SENT: {
headline: 'Visa invitation letter sent',
body: 'Your official visa invitation letter for the Grand Finale has been sent. Please use it when applying for your visa at the relevant consulate or embassy.',
variant: 'info',
},
APPOINTMENT_BOOKED: {
headline: 'Visa appointment booked',
body: 'A visa appointment has been booked on your behalf. Please check your inbox or contact the MOPC team for the appointment details.',
variant: 'info',
},
GRANTED: {
headline: 'Visa granted — see you in Monaco!',
body: 'Congratulations — your visa has been granted. We look forward to welcoming you to the Monaco Ocean Protection Challenge Grand Finale.',
variant: 'success',
},
DENIED: {
headline: 'Visa application outcome',
body: 'Unfortunately, your visa application has not been successful at this time. Please contact the MOPC team as soon as possible so we can discuss next steps.',
variant: 'warning',
},
}
const { headline, body, variant } = statusCopy[status]
const noteHtml = note ? paragraph(`<em>${escapeHtml(note)}</em>`) : ''
const noteText = note ? `\n${note}\n` : ''
const content = `
${sectionTitle('Visa update for the Grand Finale')}
${paragraph(greeting)}
${paragraph(`This is an update regarding your visa for the Grand Finale — project <strong>${escapeHtml(projectTitle)}</strong>.`)}
${infoBox(`<strong>${escapeHtml(headline)}</strong><br>${escapeHtml(body)}`, variant)}
${noteHtml}
${paragraph('If you have any questions, please don\'t hesitate to contact the MOPC team.')}
`
const text = [
greeting,
'',
`This is an update regarding your visa for the Grand Finale — project "${projectTitle}".`,
'',
`${headline}`,
body,
noteText,
'If you have any questions, please don\'t hesitate to contact the MOPC team.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Visa update for the grand finale',
html: getEmailWrapper(content),
text,
}
}
/** /**
* Template registry mapping notification types to template generators * Template registry mapping notification types to template generators
*/ */
@@ -2392,6 +2651,59 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
(ctx.metadata?.programName as string) || 'MOPC', (ctx.metadata?.programName as string) || 'MOPC',
ctx.linkUrl ctx.linkUrl
), ),
// Logistics templates (team/attendee-facing)
FINALIST_REMINDER: (ctx) =>
getFinalistReminderTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
new Date((ctx.metadata?.deadline as string) || Date.now()),
ctx.linkUrl || '',
),
FINALIST_WITHDRAWN: (ctx) =>
getFinalistWithdrawnTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
ctx.metadata?.reason as string | undefined,
),
TRAVEL_CONFIRMED: (ctx) =>
getTravelConfirmedTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
{
arrivalAt: ctx.metadata?.arrivalAt as string | undefined,
arrivalFlightNumber: ctx.metadata?.arrivalFlightNumber as string | undefined,
arrivalAirport: ctx.metadata?.arrivalAirport as string | undefined,
departureAt: ctx.metadata?.departureAt as string | undefined,
departureFlightNumber: ctx.metadata?.departureFlightNumber as string | undefined,
departureAirport: ctx.metadata?.departureAirport as string | undefined,
},
ctx.metadata?.hotel as { name: string; address?: string; link?: string } | undefined,
),
VISA_STATUS_UPDATE: (ctx) =>
getVisaStatusTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
(ctx.metadata?.status as 'INVITATION_SENT' | 'APPOINTMENT_BOOKED' | 'GRANTED' | 'DENIED') || 'INVITATION_SENT',
ctx.metadata?.note as string | undefined,
),
}
/**
* Resolve the email template for a notification type without sending.
* Exported for use by preview endpoints.
*/
export function renderNotificationEmail(
name: string,
type: string,
context: NotificationEmailContext,
subjectOverride?: string,
): EmailTemplate {
const generator = NOTIFICATION_EMAIL_TEMPLATES[type]
const template = generator
? generator({ ...context, linkUrl: ensureAbsoluteUrl(context.linkUrl), name })
: getNotificationEmailTemplate(name, subjectOverride || context.title, context.message, ensureAbsoluteUrl(context.linkUrl))
return subjectOverride ? { ...template, subject: subjectOverride } : template
} }
/** /**
@@ -2404,32 +2716,7 @@ export async function sendStyledNotificationEmail(
context: NotificationEmailContext, context: NotificationEmailContext,
subjectOverride?: string subjectOverride?: string
): Promise<void> { ): Promise<void> {
// Safety net: always ensure linkUrl is absolute before passing to templates const template = renderNotificationEmail(name, type, context, subjectOverride)
const safeContext = {
...context,
linkUrl: ensureAbsoluteUrl(context.linkUrl),
}
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type]
let template: EmailTemplate
if (templateGenerator) {
// Use styled template
template = templateGenerator({ ...safeContext, name })
// Apply subject override if provided
if (subjectOverride) {
template.subject = subjectOverride
}
} else {
// Fall back to generic template
template = getNotificationEmailTemplate(
name,
subjectOverride || safeContext.title,
safeContext.message,
safeContext.linkUrl
)
}
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
} }

View File

@@ -2747,7 +2747,13 @@ export const applicantRouter = router({
*/ */
getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => { getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => {
const project = await ctx.prisma.project.findFirst({ const project = await ctx.prisma.project.findFirst({
where: { teamMembers: { some: { userId: ctx.user.id } } }, where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
finalistConfirmation: { isNot: null },
},
include: { include: {
program: { select: { id: true, defaultAttendeeCap: true } }, program: { select: { id: true, defaultAttendeeCap: true } },
teamMembers: { teamMembers: {
@@ -2858,4 +2864,124 @@ export const applicantRouter = router({
projectId: a.confirmation.project.id, projectId: a.confirmation.project.id,
})) }))
}), }),
/**
* Returns logistics info for the caller's confirmed finalist project:
* hotel details, the caller's own flight detail, and visa visibility + status.
* Returns null when the caller has no confirmed finalist project.
*/
getMyLogistics: protectedProcedure.query(async ({ ctx }) => {
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
finalistConfirmation: { isNot: null },
},
include: {
program: {
select: { id: true, visaStatusVisibleToMembers: true },
},
finalistConfirmation: true,
},
orderBy: { createdAt: 'desc' },
})
if (!project || !project.finalistConfirmation) return null
if (project.finalistConfirmation.status !== 'CONFIRMED') return null
const programId = project.program.id
const confirmationId = project.finalistConfirmation.id
const visaVisible = project.program.visaStatusVisibleToMembers
// Hotel (1:1 per program)
const hotelRow = await ctx.prisma.hotel.findUnique({
where: { programId },
select: { name: true, address: true, link: true, notes: true },
})
const hotel = hotelRow ?? null
// Caller's own AttendingMember + flight + visa
const attendee = await ctx.prisma.attendingMember.findFirst({
where: { confirmationId, userId: ctx.user.id },
include: {
flightDetail: true,
visaApplication: visaVisible,
},
})
const fd = attendee?.flightDetail ?? null
const myFlight = fd
? {
arrivalAt: fd.arrivalAt,
arrivalFlightNumber: fd.arrivalFlightNumber,
arrivalAirport: fd.arrivalAirport,
departureAt: fd.departureAt,
departureFlightNumber: fd.departureFlightNumber,
departureAirport: fd.departureAirport,
status: fd.status,
}
: null
const va = visaVisible ? (attendee?.visaApplication ?? null) : null
const myVisa = va ? { status: va.status, nationality: va.nationality } : null
return {
projectTitle: project.title,
confirmationStatus: project.finalistConfirmation.status,
hotel,
myFlight,
visaVisible,
myVisa,
}
}),
/**
* Allows the authenticated user to self-declare their passport nationality
* on their own VisaApplication when visaStatusVisibleToMembers is true.
*/
updateMyVisaNationality: protectedProcedure
.input(z.object({ nationality: z.string().max(100) }))
.mutation(async ({ ctx, input }) => {
// Find the caller's AttendingMember whose program has visaStatusVisibleToMembers=true
// and which has a visaApplication.
const attendee = await ctx.prisma.attendingMember.findFirst({
where: {
userId: ctx.user.id,
confirmation: {
project: {
program: { visaStatusVisibleToMembers: true },
},
},
visaApplication: { isNot: null },
},
include: { visaApplication: true },
})
if (!attendee || !attendee.visaApplication) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No visa application to update',
})
}
const updated = await ctx.prisma.visaApplication.update({
where: { id: attendee.visaApplication.id },
data: { nationality: input.nationality },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'VISA_NATIONALITY_SELF_SET',
entityType: 'VisaApplication',
entityId: updated.id,
detailsJson: { nationality: input.nationality },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { ok: true }
}),
}) })

View File

@@ -9,11 +9,16 @@ import {
} from '../services/finalist-confirmation' } from '../services/finalist-confirmation'
import { import {
createNotification, createNotification,
notifyAdmins,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { sendFinalistConfirmationEmail } from '@/lib/email' import { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token' import { verifyFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync' import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
import {
resetOrCreatePendingConfirmation,
confirmAttendanceInTx,
} from '../services/finalist-enrollment'
export const finalistRouter = router({ export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */ /** List all per-category finalist slot quotas for a program. */
@@ -294,6 +299,7 @@ export const finalistRouter = router({
project: { project: {
select: { select: {
id: true, id: true,
title: true,
programId: true, programId: true,
program: { select: { defaultAttendeeCap: true } }, program: { select: { defaultAttendeeCap: true } },
teamMembers: { select: { userId: true } }, teamMembers: { select: { userId: true } },
@@ -370,6 +376,22 @@ export const finalistRouter = router({
attendingUserIds: input.attendingUserIds, attendingUserIds: input.attendingUserIds,
}, },
}) })
// Admin alert — best-effort, never throws
try {
await notifyAdmins({
type: NotificationTypes.FINALIST_CONFIRMED,
title: 'Finalist confirmed',
message: `"${confirmation.project.title}" (${confirmation.category}) has confirmed grand-finale attendance.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: confirmation.projectId,
projectTitle: confirmation.project.title,
category: confirmation.category,
},
})
} catch (err) {
console.error('[finalist.confirm] failed to send admin notification:', err)
}
return { ok: true } return { ok: true }
}), }),
@@ -386,7 +408,7 @@ export const finalistRouter = router({
const payload = verifyFinalistToken(input.token) const payload = verifyFinalistToken(input.token)
const confirmation = await ctx.prisma.finalistConfirmation.findUnique({ const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
where: { id: payload.confirmationId }, where: { id: payload.confirmationId },
include: { project: { select: { programId: true } } }, include: { project: { select: { programId: true, title: true } } },
}) })
if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' }) if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
if (confirmation.token !== input.token) { if (confirmation.token !== input.token) {
@@ -416,6 +438,22 @@ export const finalistRouter = router({
reason: input.reason ?? null, reason: input.reason ?? null,
}, },
}) })
// Admin alert — best-effort, never throws
try {
await notifyAdmins({
type: NotificationTypes.FINALIST_DECLINED,
title: 'Finalist declined',
message: `"${confirmation.project.title}" (${confirmation.category}) has declined grand-finale attendance.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: confirmation.projectId,
projectTitle: confirmation.project.title,
category: confirmation.category,
},
})
} catch (err) {
console.error('[finalist.decline] failed to send admin notification:', err)
}
// Promote next waitlist entry in same category. windowHours pulled from // Promote next waitlist entry in same category. windowHours pulled from
// the live grand-finale round in the program (LIVE_FINAL roundType). // the live grand-finale round in the program (LIVE_FINAL roundType).
@@ -490,36 +528,11 @@ export const finalistRouter = router({
} }
await ctx.prisma.$transaction(async (tx) => { await ctx.prisma.$transaction(async (tx) => {
await tx.finalistConfirmation.update({ await confirmAttendanceInTx(tx, {
where: { id: confirmation.id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
})
await tx.attendingMember.createMany({
data: input.attendingUserIds.map((userId) => ({
confirmationId: confirmation.id, confirmationId: confirmation.id,
userId, attendingUserIds: input.attendingUserIds,
needsVisa: input.visaFlags[userId] ?? false, visaFlags: input.visaFlags,
})),
}) })
const visaUsers = input.attendingUserIds.filter(
(uid) => input.visaFlags[uid] === true,
)
if (visaUsers.length > 0) {
const created = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: visaUsers } },
select: { id: true },
})
await tx.visaApplication.createMany({
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
})
}
const allMembers = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
}) })
await logAudit({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,
@@ -546,7 +559,19 @@ export const finalistRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({ const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: input.confirmationId }, where: { id: input.confirmationId },
include: { project: { select: { programId: true } } }, include: {
project: {
select: {
programId: true,
title: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { userId: true },
},
},
},
},
}) })
if (confirmation.status !== 'PENDING') { if (confirmation.status !== 'PENDING') {
throw new TRPCError({ throw new TRPCError({
@@ -573,6 +598,41 @@ export const finalistRouter = router({
reason: input.reason ?? null, reason: input.reason ?? null,
}, },
}) })
// Admin alert — best-effort, never throws
try {
await notifyAdmins({
type: NotificationTypes.FINALIST_DECLINED,
title: 'Finalist declined (admin)',
message: `"${confirmation.project.title}" (${confirmation.category}) was declined by an admin.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: confirmation.projectId,
projectTitle: confirmation.project.title,
category: confirmation.category,
},
})
} catch (err) {
console.error('[finalist.adminDecline] failed to send admin notification:', err)
}
// Withdrawal notification to team lead — best-effort, never throws
try {
const lead = confirmation.project.teamMembers[0]
if (lead) {
const projectTitle = confirmation.project.title
const reason = input.reason
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_WITHDRAWN,
title: 'Grand finale slot withdrawn',
message: `Your team "${projectTitle}" is no longer a confirmed finalist.${reason ? ' Reason: ' + reason : ''}`,
linkUrl: '/applicant',
metadata: { projectTitle, reason },
})
}
} catch (err) {
console.error('[finalist.adminDecline] failed to send withdrawal notification to team:', err)
}
const round = await ctx.prisma.round.findFirst({ const round = await ctx.prisma.round.findFirst({
where: { where: {
@@ -781,6 +841,11 @@ export const finalistRouter = router({
mentorId: true, mentorId: true,
}, },
}, },
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { userId: true },
},
}, },
}, },
}, },
@@ -839,6 +904,25 @@ export const finalistRouter = router({
cascadedAssignmentCount: activeAssignments.length, cascadedAssignmentCount: activeAssignments.length,
}, },
}) })
// Withdrawal notification to team lead — best-effort, never throws
try {
const lead = confirmation.project.teamMembers[0]
if (lead) {
const projectTitle = confirmation.project.title
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_WITHDRAWN,
title: 'Grand finale slot withdrawn',
message: `Your team "${projectTitle}" is no longer a confirmed finalist.`,
linkUrl: '/applicant',
metadata: { projectTitle },
})
}
} catch (err) {
console.error('[finalist.unconfirm] failed to send withdrawal notification to team:', err)
}
return { ok: true, cascadedMentorAssignment } return { ok: true, cascadedMentorAssignment }
}), }),
@@ -929,6 +1013,23 @@ export const finalistRouter = router({
windowHours: input.windowHours, windowHours: input.windowHours,
}, },
}) })
// Admin alert — best-effort, never throws
try {
const projectTitle = project?.title ?? entry.projectId
await notifyAdmins({
type: NotificationTypes.FINALIST_WAITLIST_PROMOTED,
title: 'Waitlist entry promoted',
message: `"${projectTitle}" (${entry.category}) was manually promoted from the waitlist.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: entry.projectId,
projectTitle,
category: entry.category,
},
})
} catch (err) {
console.error('[finalist.manualPromote] failed to send admin notification:', err)
}
return { confirmationId } return { confirmationId }
}), }),
@@ -1114,6 +1215,449 @@ export const finalistRouter = router({
}, },
}) })
return { ok: true }
}),
/**
* List all MENTORING-round projects as enrollment candidates, grouped by
* competitionCategory. Each candidate includes team members, inLiveFinal flag,
* confirmationStatus, and per-category quota + confirmed/pending counts.
* Drives the Finalist Enrollment Card on the LIVE_FINAL round Overview page.
*/
listEnrollmentCandidates: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
// Resolve program (for attendeeCap) and its competitions' rounds
const program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
select: { defaultAttendeeCap: true },
})
// Find the MENTORING and LIVE_FINAL rounds within this program's competitions
const rounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: input.programId },
roundType: { in: ['MENTORING', 'LIVE_FINAL'] },
},
select: { id: true, roundType: true },
orderBy: { sortOrder: 'asc' },
})
const mentoringRound = rounds.find((r) => r.roundType === 'MENTORING') ?? null
const liveFinalRound = rounds.find((r) => r.roundType === 'LIVE_FINAL') ?? null
if (!mentoringRound) {
return {
liveFinalRoundId: liveFinalRound?.id ?? null,
attendeeCap: program.defaultAttendeeCap,
categories: [],
}
}
// Load all PRS in the MENTORING round with full project + team data
const states = await ctx.prisma.projectRoundState.findMany({
where: { roundId: mentoringRound.id },
select: {
project: {
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
finalistConfirmation: { select: { status: true } },
projectRoundStates: {
where: { roundId: liveFinalRound?.id ?? '' },
select: { projectId: true },
take: 1,
},
teamMembers: {
select: {
userId: true,
role: true,
user: { select: { name: true, email: true } },
},
},
},
},
},
orderBy: [{ project: { title: 'asc' } }],
})
// Aggregate confirmed/pending counts per category (mirror listCategoryCounts)
const grouped = await ctx.prisma.finalistConfirmation.groupBy({
by: ['category', 'status'],
where: { project: { programId: input.programId } },
_count: { _all: true },
})
const countsByCategory = new Map<string, { confirmed: number; pending: number }>()
for (const g of grouped) {
const slot = countsByCategory.get(g.category) ?? { confirmed: 0, pending: 0 }
if (g.status === 'CONFIRMED') slot.confirmed = g._count._all
if (g.status === 'PENDING') slot.pending = g._count._all
countsByCategory.set(g.category, slot)
}
// Load quotas for this program
const quotas = await ctx.prisma.finalistSlotQuota.findMany({
where: { programId: input.programId },
select: { category: true, quota: true },
})
const quotaByCategory = new Map(quotas.map((q) => [q.category as string, q.quota]))
// Group candidates by competitionCategory
const categoryMap = new Map<
string,
{
category: string
quota: number | null
confirmedCount: number
pendingCount: number
candidates: Array<{
projectId: string
title: string
teamName: string | null
country: string | null
inLiveFinal: boolean
confirmationStatus: string | null
teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }>
}>
}
>()
for (const s of states) {
const p = s.project
const cat = (p.competitionCategory as string) ?? 'UNKNOWN'
if (!categoryMap.has(cat)) {
const counts = countsByCategory.get(cat) ?? { confirmed: 0, pending: 0 }
categoryMap.set(cat, {
category: cat,
quota: quotaByCategory.get(cat) ?? null,
confirmedCount: counts.confirmed,
pendingCount: counts.pending,
candidates: [],
})
}
const inLiveFinal = p.projectRoundStates.length > 0
categoryMap.get(cat)!.candidates.push({
projectId: p.id,
title: p.title,
teamName: p.teamName,
country: p.country,
inLiveFinal,
confirmationStatus: p.finalistConfirmation?.status ?? null,
teamMembers: p.teamMembers.map((tm) => ({
userId: tm.userId,
name: tm.user.name,
role: tm.role,
email: tm.user.email,
})),
})
}
return {
liveFinalRoundId: liveFinalRound?.id ?? null,
attendeeCap: program.defaultAttendeeCap,
categories: Array.from(categoryMap.values()),
}
}),
/**
* Unified finalist enrollment: advances a set of projects into the LIVE_FINAL
* round (creates ProjectRoundState, skipDuplicates) AND creates/resets their
* FinalistConfirmation in one atomic step.
*
* Two attendee modes per project:
* - EMAIL: sends the self-confirm link to the team lead (never throws in loop)
* - ADMIN_CONFIRM: validates + writes attendees immediately (CONFIRMED status)
*
* Returns { enrolled, emailed, adminConfirmed, skipped }.
*/
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(),
visaFlags: z.record(z.string(), z.boolean()).optional(),
}),
)
.min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Resolve the LIVE_FINAL round + confirmationWindowHours. Validate the
// round belongs to the target program so an admin can't inject projects
// into another edition's round.
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
id: true,
configJson: true,
competition: { select: { programId: true } },
},
})
if (round.competition.programId !== input.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round does not belong to this program',
})
}
const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24
// Validate all projects belong to this program
const projectIds = input.enrollments.map((e) => e.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds }, programId: input.programId },
select: {
id: true,
title: true,
competitionCategory: true,
program: { select: { defaultAttendeeCap: true } },
teamMembers: {
select: {
userId: true,
role: true,
user: { select: { email: true, name: true } },
},
},
},
})
if (projects.length !== projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'One or more project IDs not found in this program',
})
}
const projectMap = new Map(projects.map((p) => [p.id, p]))
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
// Pre-validate every ADMIN_CONFIRM enrollment up front so a bad entry in
// a multi-team batch fails before any project is partially written.
for (const enrollment of input.enrollments) {
if (enrollment.mode !== 'ADMIN_CONFIRM') continue
const project = projectMap.get(enrollment.projectId)!
const cap = project.program.defaultAttendeeCap
const attendingUserIds = enrollment.attendingUserIds ?? []
if (attendingUserIds.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`,
})
}
if (attendingUserIds.length > cap) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`,
})
}
const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId))
for (const uid of attendingUserIds) {
if (!teamUserIds.has(uid)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `User ${uid} is not a team member of project ${project.id}`,
})
}
}
}
let enrolled = 0
let emailed = 0
let adminConfirmed = 0
const skipped: Array<{ projectId: string; reason: string }> = []
for (const enrollment of input.enrollments) {
const project = projectMap.get(enrollment.projectId)!
// Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
await ctx.prisma.projectRoundState.createMany({
data: [{ projectId: enrollment.projectId, roundId: input.roundId }],
skipDuplicates: true,
})
// Step 2: Create or reset the finalist confirmation
const category = project.competitionCategory as CompetitionCategory
const confirmResult = await resetOrCreatePendingConfirmation(ctx.prisma, {
projectId: enrollment.projectId,
category,
windowHours,
})
if (confirmResult.alreadyConfirmed) {
skipped.push({ projectId: enrollment.projectId, reason: 'ALREADY_CONFIRMED' })
enrolled++
continue
}
enrolled++
// Step 3: Mode-specific handling
if (enrollment.mode === 'EMAIL') {
// Send confirmation email to team lead (best-effort — never throw in loop)
const lead = project.teamMembers.find((tm) => tm.role === 'LEAD')?.user
if (lead?.email) {
const confirmUrl = `${baseUrl}/finalist/confirm/${confirmResult.token}`
try {
await sendFinalistConfirmationEmail(
lead.email,
lead.name ?? null,
project.title,
confirmResult.deadline,
confirmUrl,
)
emailed++
} catch (err) {
console.error(
`[finalist.enrollFinalists] failed to send email to ${lead.email} for project ${enrollment.projectId}:`,
err,
)
}
}
} else {
// ADMIN_CONFIRM: write attendees + visa + lunch rows immediately
const attendingUserIds = enrollment.attendingUserIds!
const visaFlags = enrollment.visaFlags ?? {}
await ctx.prisma.$transaction(async (tx) => {
await confirmAttendanceInTx(tx, {
confirmationId: confirmResult.id,
attendingUserIds,
visaFlags,
})
})
adminConfirmed++
}
// Step 4: Audit per enrollment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_ENROLL',
entityType: 'Project',
entityId: enrollment.projectId,
detailsJson: {
projectId: enrollment.projectId,
mode: enrollment.mode,
roundId: input.roundId,
programId: input.programId,
},
})
}
return { enrolled, emailed, adminConfirmed, skipped }
}),
/**
* Reverse enrollment: removes a project from the LIVE_FINAL round and
* deletes its FinalistConfirmation (cascade removes AttendingMember,
* FlightDetail, VisaApplication, and MemberLunchPick rows).
*
* Mentor assignments (tied to the MENTORING round) are intentionally
* left untouched. Safe to call even if the project was never enrolled
* (deleteMany is a no-op when no rows match).
*/
unenroll: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(), // the LIVE_FINAL round
}),
)
.mutation(async ({ ctx, input }) => {
// Guard: the project and round must belong to the same program, so a
// mismatched (projectId, roundId) pair from different editions can't be
// used to delete the wrong project's confirmation or round membership.
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { programId: true },
})
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competition: { select: { programId: true } } },
})
if (project.programId !== round.competition.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Project and round belong to different programs',
})
}
// Step 1: Capture the CONFIRMED confirmation (if any) BEFORE deleting,
// so we can notify the team lead. We only notify when the team had
// already confirmed (CONFIRMED status) — not for PENDING or absent rows.
const confirmedConfirmation = await ctx.prisma.finalistConfirmation.findFirst({
where: { projectId: input.projectId, status: 'CONFIRMED' },
select: {
project: {
select: {
title: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { userId: true },
},
},
},
},
})
// Step 2: Delete the FinalistConfirmation (cascade removes AttendingMember
// / FlightDetail / VisaApplication / MemberLunchPick).
// deleteMany is no-op-safe when no row exists.
await ctx.prisma.finalistConfirmation.deleteMany({
where: { projectId: input.projectId },
})
// Step 3: Delete the LIVE_FINAL ProjectRoundState.
await ctx.prisma.projectRoundState.deleteMany({
where: { projectId: input.projectId, roundId: input.roundId },
})
// Step 4: Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_UNENROLL',
entityType: 'Project',
entityId: input.projectId,
detailsJson: {
projectId: input.projectId,
roundId: input.roundId,
},
})
// Step 5: Withdrawal notification — only when a CONFIRMED row existed.
// Best-effort, never throws.
if (confirmedConfirmation) {
try {
const lead = confirmedConfirmation.project.teamMembers[0]
if (lead) {
const projectTitle = confirmedConfirmation.project.title
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_WITHDRAWN,
title: 'Grand finale slot withdrawn',
message: `Your team "${projectTitle}" is no longer a confirmed finalist.`,
linkUrl: '/applicant',
metadata: { projectTitle },
})
}
} catch (err) {
console.error('[finalist.unenroll] failed to send withdrawal notification to team:', err)
}
}
return { ok: true } return { ok: true }
}), }),
}) })

View File

@@ -3,6 +3,7 @@ import { FlightDetailStatus, VisaStatus } from '@prisma/client'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc' import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import { createNotification, NotificationTypes } from '../services/in-app-notification'
export const logisticsRouter = router({ export const logisticsRouter = router({
/** Read the hotel for a program (1:1). Null if not yet set. */ /** Read the hotel for a program (1:1). Null if not yet set. */
@@ -161,6 +162,12 @@ export const logisticsRouter = router({
for (const [k, v] of Object.entries(rest)) { for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) data[k] = v if (v !== undefined) data[k] = v
} }
if (input.arrivalAt && input.departureAt && input.departureAt < input.arrivalAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Departure must be after arrival',
})
}
const detail = await ctx.prisma.flightDetail.upsert({ const detail = await ctx.prisma.flightDetail.upsert({
where: { attendingMemberId }, where: { attendingMemberId },
create: { attendingMemberId, ...(data as object) }, create: { attendingMemberId, ...(data as object) },
@@ -198,6 +205,56 @@ export const logisticsRouter = router({
entityId: detail.id, entityId: detail.id,
detailsJson: { status: input.status }, detailsJson: { status: input.status },
}) })
// Send travel-confirmed notification to the attendee (best-effort — never throws)
if (input.status === 'CONFIRMED') {
try {
const attendee = await ctx.prisma.attendingMember.findUnique({
where: { id: detail.attendingMemberId },
select: {
userId: true,
confirmation: {
select: {
project: {
select: {
title: true,
programId: true,
},
},
},
},
},
})
if (attendee) {
const projectTitle = attendee.confirmation.project.title
const programId = attendee.confirmation.project.programId
const hotel = await ctx.prisma.hotel.findUnique({
where: { programId },
select: { name: true, address: true, link: true },
})
await createNotification({
userId: attendee.userId,
type: NotificationTypes.TRAVEL_CONFIRMED,
title: 'Your grand-finale travel is confirmed',
message: 'Your flight details for the grand finale are confirmed.',
linkUrl: '/applicant',
metadata: {
projectTitle,
arrivalAt: detail.arrivalAt?.toISOString() ?? null,
arrivalFlightNumber: detail.arrivalFlightNumber ?? null,
arrivalAirport: detail.arrivalAirport ?? null,
departureAt: detail.departureAt?.toISOString() ?? null,
departureFlightNumber: detail.departureFlightNumber ?? null,
departureAirport: detail.departureAirport ?? null,
hotel: hotel ?? undefined,
},
})
}
} catch (err) {
console.error('[setFlightStatus] Failed to send TRAVEL_CONFIRMED notification:', err)
}
}
return detail return detail
}), }),
@@ -281,6 +338,18 @@ export const logisticsRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.visaApplication.findUnique({ const existing = await ctx.prisma.visaApplication.findUnique({
where: { id: input.id }, where: { id: input.id },
include: {
attendingMember: {
select: {
userId: true,
confirmation: {
select: {
project: { select: { title: true } },
},
},
},
},
},
}) })
if (!existing) { if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Visa application not found' }) throw new TRPCError({ code: 'NOT_FOUND', message: 'Visa application not found' })
@@ -313,6 +382,43 @@ export const logisticsRouter = router({
next: data, next: data,
}, },
}) })
// Send visa-status notification to the attendee when a notifiable status changes (best-effort)
const NOTIFIABLE_STATUSES = new Set<VisaStatus>([
'INVITATION_SENT',
'APPOINTMENT_BOOKED',
'GRANTED',
'DENIED',
])
if (
input.status !== undefined &&
input.status !== existing.status &&
NOTIFIABLE_STATUSES.has(input.status)
) {
try {
const projectTitle = existing.attendingMember.confirmation.project.title
const statusMessages: Record<string, string> = {
INVITATION_SENT: 'Your visa invitation letter for the Grand Finale has been sent.',
APPOINTMENT_BOOKED: 'A visa appointment has been booked for you.',
GRANTED: 'Congratulations — your visa for the Grand Finale has been granted!',
DENIED: 'Your visa application outcome is now available. Please contact the MOPC team.',
}
await createNotification({
userId: existing.attendingMember.userId,
type: NotificationTypes.VISA_STATUS_UPDATE,
title: 'Visa update for the grand finale',
message: statusMessages[input.status] ?? 'Your visa status has been updated.',
linkUrl: '/applicant',
metadata: {
projectTitle,
status: input.status,
},
})
} catch (err) {
console.error('[updateVisaApplication] Failed to send VISA_STATUS_UPDATE notification:', err)
}
}
return updated return updated
}), }),

View File

@@ -3,7 +3,8 @@ import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc' import { router, adminProcedure, protectedProcedure } from '../trpc'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import { buildManifest, buildRecapPayload } from '../services/lunch-recap' import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
import { sendLunchRecapEmail } from '@/lib/email' import { selectUnpickedAttendees } from '../services/lunch-reminders'
import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email'
import { csvCell } from '@/lib/csv' import { csvCell } from '@/lib/csv'
// ─── Shared zod schemas ────────────────────────────────────────────────────── // ─── Shared zod schemas ──────────────────────────────────────────────────────
@@ -39,7 +40,7 @@ export const lunchRouter = router({
// ─── Dish CRUD ──────────────────────────────────────────────────────────── // ─── Dish CRUD ────────────────────────────────────────────────────────────
listDishes: adminProcedure listDishes: protectedProcedure
.input(z.object({ lunchEventId: z.string() })) .input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) => .query(({ ctx, input }) =>
ctx.prisma.dish.findMany({ ctx.prisma.dish.findMany({
@@ -346,7 +347,11 @@ export const lunchRouter = router({
await sendLunchRecapEmail(recipients, payload) await sendLunchRecapEmail(recipients, payload)
} catch (e) { } catch (e) {
console.error('[lunch.sendRecap] email send failed', e) console.error('[lunch.sendRecap] email send failed', e)
// Continue — we still stamp recapSentAt and audit so admins see what happened. throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Recap email failed to send: ${e instanceof Error ? e.message : String(e)}`,
cause: e,
})
} }
const updated = await ctx.prisma.lunchEvent.update({ const updated = await ctx.prisma.lunchEvent.update({
where: { programId: input.programId }, where: { programId: input.programId },
@@ -570,6 +575,45 @@ export const lunchRouter = router({
return pick return pick
}), }),
/**
* Manually send lunch pick reminders to all unpicked attendees for a given
* LunchEvent. Unlike the cron, this does NOT gate on reminderSentAt and does
* NOT stamp it — manual sends are repeatable. Returns { sent } count.
*/
sendReminders: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.mutation(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { id: input.lunchEventId },
})
if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' })
const ams = await selectUnpickedAttendees(ctx.prisma, event)
const deadline = event.eventAt
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
: new Date(Date.now() + 48 * 3_600_000)
let sent = 0
for (const am of ams) {
if (!am.user.email) continue
try {
await sendLunchReminderEmail({
to: am.user.email,
memberName: am.user.name ?? am.user.email,
eventAt: event.eventAt ?? new Date(),
venue: event.venue,
changeDeadline: deadline,
pickUrl: `${process.env.NEXTAUTH_URL ?? ''}/applicant`,
})
sent++
} catch (e) {
console.error('[lunch.sendReminders] send failed for', am.user.email, e)
}
}
return { sent }
}),
/** Patch any subset of LunchEvent config fields. Audit-logged. */ /** Patch any subset of LunchEvent config fields. Audit-logged. */
updateEvent: adminProcedure updateEvent: adminProcedure
.input( .input(

View File

@@ -15,7 +15,123 @@ import {
NotificationIcons, NotificationIcons,
NotificationPriorities, NotificationPriorities,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email' import { sendStyledNotificationEmail, renderNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
/**
* Sample data used for test emails and previews, keyed by notification type.
*/
const NOTIFICATION_SAMPLE_DATA: Record<string, Record<string, unknown>> = {
// Team notifications
ADVANCED_SEMIFINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Prepare your presentation for the semi-final round.',
},
ADVANCED_FINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Get ready for the final presentation in Monaco.',
},
MENTOR_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
mentorName: 'Dr. Marine Expert',
mentorBio: 'Expert in marine conservation with 20 years of experience.',
},
NOT_SELECTED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
},
WINNER_ANNOUNCEMENT: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Grand Prize',
prizeDetails: '€50,000 and mentorship program',
},
// Jury notifications
ASSIGNED_TO_PROJECT: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
BATCH_ASSIGNED: {
projectCount: 5,
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
ROUND_NOW_OPEN: {
roundName: 'Semi-Final Round',
projectCount: 12,
deadline: 'Friday, March 15, 2026',
},
REMINDER_24H: {
pendingCount: 3,
roundName: 'Semi-Final Round',
deadline: 'Tomorrow at 5:00 PM',
},
REMINDER_1H: {
pendingCount: 2,
roundName: 'Semi-Final Round',
deadline: 'Today at 5:00 PM',
},
AWARD_VOTING_OPEN: {
awardName: 'Innovation Award',
finalistCount: 6,
deadline: 'Friday, March 15, 2026',
},
// Mentor notifications
MENTEE_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
teamLeadName: 'John Smith',
teamLeadEmail: 'john@example.com',
},
MENTEE_ADVANCED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
nextRoundName: 'Final Round',
},
MENTEE_WON: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Innovation Award',
},
// Admin notifications
NEW_APPLICATION: {
projectName: 'New Ocean Project',
applicantName: 'Jane Doe',
applicantEmail: 'jane@example.com',
programName: 'Monaco Ocean Protection Challenge 2026',
},
FILTERING_COMPLETE: {
roundName: 'Initial Review',
passedCount: 45,
flaggedCount: 12,
filteredCount: 8,
},
FILTERING_FAILED: {
roundName: 'Initial Review',
error: 'Connection timeout',
},
// Logistics notifications
FINALIST_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_DECLINED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_WAITLIST_PROMOTED: { projectTitle: 'Reef Guardians', category: 'STARTUP' },
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now() + 86_400_000).toISOString() },
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
TRAVEL_CONFIRMED: {
projectTitle: 'Ocean Cleanup Initiative',
arrivalAt: new Date(Date.now() + 5 * 86_400_000).toISOString(),
arrivalFlightNumber: 'AF1234',
arrivalAirport: 'NCE',
departureAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
departureFlightNumber: 'AF1235',
departureAirport: 'NCE',
hotel: { name: 'Hotel de Paris', address: 'Place du Casino, Monaco', link: 'https://example.com' },
},
VISA_STATUS_UPDATE: { projectTitle: 'Ocean Cleanup Initiative', status: 'GRANTED' },
}
export const notificationRouter = router({ export const notificationRouter = router({
/** /**
@@ -253,102 +369,7 @@ export const notificationRouter = router({
where: { notificationType }, where: { notificationType },
}) })
// Sample data for test emails based on category const metadata = NOTIFICATION_SAMPLE_DATA[notificationType] || {}
const sampleData: Record<string, Record<string, unknown>> = {
// Team notifications
ADVANCED_SEMIFINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Prepare your presentation for the semi-final round.',
},
ADVANCED_FINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Get ready for the final presentation in Monaco.',
},
MENTOR_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
mentorName: 'Dr. Marine Expert',
mentorBio: 'Expert in marine conservation with 20 years of experience.',
},
NOT_SELECTED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
},
WINNER_ANNOUNCEMENT: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Grand Prize',
prizeDetails: '€50,000 and mentorship program',
},
// Jury notifications
ASSIGNED_TO_PROJECT: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
BATCH_ASSIGNED: {
projectCount: 5,
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
ROUND_NOW_OPEN: {
roundName: 'Semi-Final Round',
projectCount: 12,
deadline: 'Friday, March 15, 2026',
},
REMINDER_24H: {
pendingCount: 3,
roundName: 'Semi-Final Round',
deadline: 'Tomorrow at 5:00 PM',
},
REMINDER_1H: {
pendingCount: 2,
roundName: 'Semi-Final Round',
deadline: 'Today at 5:00 PM',
},
AWARD_VOTING_OPEN: {
awardName: 'Innovation Award',
finalistCount: 6,
deadline: 'Friday, March 15, 2026',
},
// Mentor notifications
MENTEE_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
teamLeadName: 'John Smith',
teamLeadEmail: 'john@example.com',
},
MENTEE_ADVANCED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
nextRoundName: 'Final Round',
},
MENTEE_WON: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Innovation Award',
},
// Admin notifications
NEW_APPLICATION: {
projectName: 'New Ocean Project',
applicantName: 'Jane Doe',
applicantEmail: 'jane@example.com',
programName: 'Monaco Ocean Protection Challenge 2026',
},
FILTERING_COMPLETE: {
roundName: 'Initial Review',
passedCount: 45,
flaggedCount: 12,
filteredCount: 8,
},
FILTERING_FAILED: {
roundName: 'Initial Review',
error: 'Connection timeout',
},
}
const metadata = sampleData[notificationType] || {}
const label = setting?.label || notificationType const label = setting?.label || notificationType
try { try {
@@ -378,4 +399,35 @@ export const notificationRouter = router({
} }
} }
}), }),
/**
* Preview a notification email template without sending it.
* Returns the rendered HTML and subject so admins can see the email before sending.
*/
previewEmailTemplate: adminProcedure
.input(z.object({ notificationType: z.string() }))
.query(async ({ ctx, input }) => {
const setting = await ctx.prisma.notificationEmailSetting.findUnique({
where: { notificationType: input.notificationType },
})
const label = setting?.label || input.notificationType
const metadata = NOTIFICATION_SAMPLE_DATA[input.notificationType] || {}
const rendered = renderNotificationEmail(
ctx.user.name || 'Admin',
input.notificationType,
{
title: label,
message: `Preview of the "${label}" email.`,
linkUrl: `${process.env.NEXTAUTH_URL || ''}/applicant`,
linkLabel: 'Open',
metadata,
},
setting?.emailSubject || undefined,
)
return {
subject: rendered.subject,
html: rendered.html,
hasStyledTemplate: input.notificationType in NOTIFICATION_EMAIL_TEMPLATES,
}
}),
}) })

View File

@@ -2,6 +2,7 @@ import type { CompetitionCategory, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token' import { signFinalistToken } from '@/lib/finalist-token'
import { sendFinalistConfirmationEmail } from '@/lib/email' import { sendFinalistConfirmationEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, createNotification, NotificationTypes } from './in-app-notification'
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'> type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
@@ -104,6 +105,27 @@ export async function promoteNextWaitlistEntry(
} }
} }
// Admin alert — best-effort, never throws
try {
const projectTitle = project?.title ?? entry.projectId
await notifyAdmins({
type: NotificationTypes.FINALIST_WAITLIST_PROMOTED,
title: 'Waitlist entry promoted',
message: `"${projectTitle}" (${args.category}) was promoted from the waitlist.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: entry.projectId,
projectTitle,
category: args.category,
},
})
} catch (err) {
console.error(
`[promoteNextWaitlistEntry] failed to send admin notification for project ${entry.projectId}:`,
err,
)
}
return { promoted: true, entryId: entry.id, confirmationId } return { promoted: true, entryId: entry.id, confirmationId }
} }
@@ -131,6 +153,31 @@ export async function expirePendingPastDeadline(
entityId: c.id, entityId: c.id,
detailsJson: { projectId: c.projectId, category: c.category }, detailsJson: { projectId: c.projectId, category: c.category },
}) })
// Admin alert — best-effort, never throws
try {
// Resolve project title for a meaningful notification message
const proj = await prisma.project.findUnique({
where: { id: c.projectId },
select: { title: true },
})
const projectTitle = proj?.title ?? c.projectId
await notifyAdmins({
type: NotificationTypes.FINALIST_EXPIRED,
title: 'Finalist confirmation expired',
message: `"${projectTitle}" (${c.category}) did not confirm in time — confirmation expired.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: c.projectId,
projectTitle,
category: c.category,
},
})
} catch (err) {
console.error(
`[expirePendingPastDeadline] failed to send admin notification for project ${c.projectId}:`,
err,
)
}
// Resolve windowHours for this program's grand-finale round // Resolve windowHours for this program's grand-finale round
const round = await prisma.round.findFirst({ const round = await prisma.round.findFirst({
where: { where: {
@@ -151,3 +198,116 @@ export async function expirePendingPastDeadline(
} }
return { expired: expired.length, promoted } return { expired: expired.length, promoted }
} }
/**
* Cron entrypoint: send pre-deadline confirmation reminders.
*
* For each PENDING confirmation that has not yet received a reminder
* (reminderSentAt IS NULL) and whose deadline is still in the future but
* within the program's configured `reminderHoursBeforeDeadline` window
* (default 12 h), send a FINALIST_REMINDER in-app notification (+ email via
* the notification pipeline) to the project's LEAD team member, then stamp
* `reminderSentAt` so the row is never processed again.
*
* Best-effort per row — a failure on one row never aborts the rest.
*/
export async function sendDueConfirmationReminders(
prisma: PrismaClient,
): Promise<{ remindersSent: number }> {
const now = new Date()
// Load all candidates: PENDING, no reminder sent yet, deadline still future.
const candidates = await prisma.finalistConfirmation.findMany({
where: {
status: 'PENDING',
reminderSentAt: null,
deadline: { gt: now },
},
include: {
project: {
select: {
id: true,
title: true,
programId: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: {
userId: true,
user: { select: { name: true } },
},
},
},
},
},
})
if (candidates.length === 0) return { remindersSent: 0 }
// Cache reminderHoursBeforeDeadline per programId to avoid repeat queries.
const reminderHoursCache = new Map<string, number>()
async function getReminderHours(programId: string): Promise<number> {
if (reminderHoursCache.has(programId)) {
return reminderHoursCache.get(programId)!
}
const round = await prisma.round.findFirst({
where: {
competition: { programId },
roundType: 'LIVE_FINAL',
},
orderBy: { sortOrder: 'desc' },
select: { configJson: true },
})
const cfg = (round?.configJson ?? {}) as { reminderHoursBeforeDeadline?: number }
const hours = cfg.reminderHoursBeforeDeadline ?? 12
reminderHoursCache.set(programId, hours)
return hours
}
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
let remindersSent = 0
for (const row of candidates) {
try {
const reminderHours = await getReminderHours(row.project.programId)
const windowMs = reminderHours * 3_600_000
const isDue = row.deadline.getTime() <= now.getTime() + windowMs
if (!isDue) continue
const lead = row.project.teamMembers[0]
if (!lead) continue
const confirmUrl = `${baseUrl}/finalist/confirm/${row.token}`
const title = row.project.title
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_REMINDER,
title: 'Reminder: confirm your grand-finale attendance',
message: `Please confirm attendance for "${title}" before the deadline.`,
linkUrl: confirmUrl,
metadata: {
projectTitle: title,
projectId: row.project.id,
deadline: row.deadline.toISOString(),
},
})
await prisma.finalistConfirmation.update({
where: { id: row.id },
data: { reminderSentAt: new Date() },
})
remindersSent++
} catch (err) {
console.error(
`[sendDueConfirmationReminders] failed for confirmation ${row.id} (project ${row.projectId}):`,
err,
)
}
}
return { remindersSent }
}

View File

@@ -0,0 +1,114 @@
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from './lunch-pick-sync'
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,
reminderSentAt: 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 }
}
/**
* Shared confirm transaction: atomically writes CONFIRMED status + attendee
* rows + visa applications + lunch picks.
* Called from both `adminConfirm` and `enrollFinalists` (ADMIN_CONFIRM mode).
*
* Must be called inside a Prisma $transaction block — `tx` is the transaction
* client, NOT the top-level prisma client.
*/
export async function confirmAttendanceInTx(
tx: Prisma.TransactionClient,
args: {
confirmationId: string
attendingUserIds: string[]
visaFlags: Record<string, boolean>
},
): Promise<void> {
await tx.finalistConfirmation.update({
where: { id: args.confirmationId },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
})
await tx.attendingMember.createMany({
data: args.attendingUserIds.map((userId) => ({
confirmationId: args.confirmationId,
userId,
needsVisa: args.visaFlags[userId] ?? false,
})),
})
const visaUsers = args.attendingUserIds.filter((uid) => args.visaFlags[uid] === true)
if (visaUsers.length > 0) {
const created = await tx.attendingMember.findMany({
where: { confirmationId: args.confirmationId, userId: { in: visaUsers } },
select: { id: true },
})
await tx.visaApplication.createMany({
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
})
}
const allMembers = await tx.attendingMember.findMany({
where: { confirmationId: args.confirmationId, userId: { in: args.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
}

View File

@@ -95,6 +95,16 @@ export const NotificationTypes = {
FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED', FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED',
WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED', WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED',
REPORT_AVAILABLE: 'REPORT_AVAILABLE', REPORT_AVAILABLE: 'REPORT_AVAILABLE',
// Logistics
FINALIST_CONFIRMED: 'FINALIST_CONFIRMED',
FINALIST_DECLINED: 'FINALIST_DECLINED',
FINALIST_EXPIRED: 'FINALIST_EXPIRED',
FINALIST_WAITLIST_PROMOTED: 'FINALIST_WAITLIST_PROMOTED',
FINALIST_REMINDER: 'FINALIST_REMINDER',
FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
} as const } as const
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes] export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
@@ -129,6 +139,14 @@ export const NotificationIcons: Record<string, string> = {
[NotificationTypes.AWARD_RESULTS]: 'Trophy', [NotificationTypes.AWARD_RESULTS]: 'Trophy',
[NotificationTypes.AI_RANKING_COMPLETE]: 'BarChart3', [NotificationTypes.AI_RANKING_COMPLETE]: 'BarChart3',
[NotificationTypes.AI_RANKING_FAILED]: 'AlertTriangle', [NotificationTypes.AI_RANKING_FAILED]: 'AlertTriangle',
[NotificationTypes.FINALIST_CONFIRMED]: 'CheckCircle',
[NotificationTypes.FINALIST_DECLINED]: 'XCircle',
[NotificationTypes.FINALIST_EXPIRED]: 'AlertTriangle',
[NotificationTypes.FINALIST_WAITLIST_PROMOTED]: 'TrendingUp',
[NotificationTypes.FINALIST_REMINDER]: 'Clock',
[NotificationTypes.FINALIST_WITHDRAWN]: 'UserMinus',
[NotificationTypes.TRAVEL_CONFIRMED]: 'Plane',
[NotificationTypes.VISA_STATUS_UPDATE]: 'FileText',
} }
// Priority by notification type // Priority by notification type
@@ -155,6 +173,14 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
[NotificationTypes.AWARD_VOTING_OPEN]: 'high', [NotificationTypes.AWARD_VOTING_OPEN]: 'high',
[NotificationTypes.AI_RANKING_COMPLETE]: 'normal', [NotificationTypes.AI_RANKING_COMPLETE]: 'normal',
[NotificationTypes.AI_RANKING_FAILED]: 'high', [NotificationTypes.AI_RANKING_FAILED]: 'high',
[NotificationTypes.FINALIST_EXPIRED]: 'urgent',
[NotificationTypes.FINALIST_REMINDER]: 'high',
[NotificationTypes.FINALIST_WITHDRAWN]: 'high',
[NotificationTypes.FINALIST_CONFIRMED]: 'normal',
[NotificationTypes.FINALIST_DECLINED]: 'high',
[NotificationTypes.FINALIST_WAITLIST_PROMOTED]: 'high',
[NotificationTypes.TRAVEL_CONFIRMED]: 'high',
[NotificationTypes.VISA_STATUS_UPDATE]: 'high',
} }
interface CreateNotificationParams { interface CreateNotificationParams {

View File

@@ -0,0 +1,32 @@
import type { PrismaClient } from '@prisma/client'
/**
* Return all AttendingMember rows (with user) that:
* - belong to a CONFIRMED FinalistConfirmation in the given program
* - have NOT yet picked a lunch dish (no MemberLunchPick row OR pick row with pickedAt=null)
*
* This is extracted from the cron so it can be unit-tested independently and
* reused by the manual `sendReminders` admin action.
*
* Bug context: Prisma `lunchPick: { is: { pickedAt: null } }` only matches rows
* that EXIST but have pickedAt=null. Attendees with no pick row at all fall
* through. The correct filter is the OR form below.
*/
export async function selectUnpickedAttendees(
prisma: PrismaClient,
event: { id: string; programId: string },
) {
return prisma.attendingMember.findMany({
where: {
confirmation: {
project: { programId: event.programId },
status: 'CONFIRMED',
},
OR: [
{ lunchPick: { is: null } },
{ lunchPick: { is: { pickedAt: null } } },
],
},
include: { user: { select: { name: true, email: true } } },
})
}

View File

@@ -0,0 +1,276 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { applicantRouter } from '../../src/server/routers/applicant'
// ─── shared helpers ────────────────────────────────────────────────────────
async function createApplicant() {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: 'Test Applicant',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
/**
* Build a fully-confirmed finalist setup:
* - Program with visaStatusVisibleToMembers=true
* - Project with a TeamMember (LEAD)
* - CONFIRMED FinalistConfirmation
* - Hotel for the program
* - AttendingMember for the caller
* - FlightDetail for that attendee
* - VisaApplication GRANTED for that attendee
*/
async function buildConfirmedFinalist() {
const program = await createTestProgram({
name: `logistics-${uid()}`,
defaultAttendeeCap: 3,
})
await prisma.program.update({
where: { id: program.id },
data: { visaStatusVisibleToMembers: true },
})
const project = await createTestProject(program.id, {
title: 'Ocean Wave Project',
competitionCategory: 'STARTUP',
})
const user = await createApplicant()
await prisma.teamMember.create({
data: { projectId: project.id, userId: user.id, role: 'LEAD' },
})
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
confirmedAt: new Date(),
},
})
// Hotel for the program (1:1)
await prisma.hotel.create({
data: {
programId: program.id,
name: 'Hotel Hermitage',
address: '11 Avenue de la Costa, Monaco',
link: 'https://example.com/hotel',
notes: 'Breakfast included.',
},
})
const attendee = await prisma.attendingMember.create({
data: {
confirmationId: confirmation.id,
userId: user.id,
needsVisa: true,
},
})
// FlightDetail
await prisma.flightDetail.create({
data: {
attendingMemberId: attendee.id,
arrivalFlightNumber: 'AF1234',
arrivalAirport: 'NCE',
arrivalAt: new Date('2026-06-20T10:00:00Z'),
departureFlightNumber: 'AF5678',
departureAirport: 'NCE',
departureAt: new Date('2026-06-23T15:00:00Z'),
status: 'CONFIRMED',
},
})
// VisaApplication GRANTED
await prisma.visaApplication.create({
data: {
attendingMemberId: attendee.id,
status: 'GRANTED',
nationality: 'French',
},
})
return { program, project, user, confirmation, attendee }
}
// ─── Task 1 tests ──────────────────────────────────────────────────────────
describe('applicant.getMyLogistics', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.visaApplication.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.flightDetail.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotel.deleteMany({ where: { programId } })
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('returns hotel, flight, and visa for a confirmed finalist', async () => {
const { program, user } = await buildConfirmedFinalist()
programIds.push(program.id)
userIds.push(user.id)
const caller = createCaller(applicantRouter, {
id: user.id,
email: user.email,
role: 'APPLICANT',
})
const result = await caller.getMyLogistics()
expect(result).not.toBeNull()
expect(result!.projectTitle).toBe('Ocean Wave Project')
expect(result!.confirmationStatus).toBe('CONFIRMED')
expect(result!.hotel).not.toBeNull()
expect(result!.hotel!.name).toBe('Hotel Hermitage')
expect(result!.myFlight).not.toBeNull()
expect(result!.myFlight!.arrivalFlightNumber).toBe('AF1234')
expect(result!.myFlight!.arrivalAirport).toBe('NCE')
expect(result!.visaVisible).toBe(true)
expect(result!.myVisa).not.toBeNull()
expect(result!.myVisa!.status).toBe('GRANTED')
})
it('returns null for a user who has no confirmed finalist project', async () => {
// user with no project at all
const nonFinalist = await createApplicant()
userIds.push(nonFinalist.id)
const caller = createCaller(applicantRouter, {
id: nonFinalist.id,
email: nonFinalist.email,
role: 'APPLICANT',
})
const result = await caller.getMyLogistics()
expect(result).toBeNull()
})
it('returns null when the confirmation is PENDING (not CONFIRMED)', async () => {
const program = await createTestProgram({
name: `logistics-pending-${uid()}`,
defaultAttendeeCap: 2,
})
programIds.push(program.id)
const project = await createTestProject(program.id, {
title: 'Pending Project',
competitionCategory: 'STARTUP',
})
const user = await createApplicant()
userIds.push(user.id)
await prisma.teamMember.create({
data: { projectId: project.id, userId: user.id, role: 'LEAD' },
})
await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
},
})
const caller = createCaller(applicantRouter, {
id: user.id,
email: user.email,
role: 'APPLICANT',
})
const result = await caller.getMyLogistics()
expect(result).toBeNull()
})
})
// ─── Task 2 tests ──────────────────────────────────────────────────────────
describe('applicant.updateMyVisaNationality', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.visaApplication.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.flightDetail.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotel.deleteMany({ where: { programId } })
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('updates VisaApplication.nationality and persists it', async () => {
const { program, user, attendee } = await buildConfirmedFinalist()
programIds.push(program.id)
userIds.push(user.id)
const caller = createCaller(applicantRouter, {
id: user.id,
email: user.email,
role: 'APPLICANT',
})
const result = await caller.updateMyVisaNationality({ nationality: 'Kenya' })
expect(result).toEqual({ ok: true })
const updated = await prisma.visaApplication.findUnique({
where: { attendingMemberId: attendee.id },
})
expect(updated!.nationality).toBe('Kenya')
})
it('throws NOT_FOUND when the caller has no visible visa application', async () => {
const nonVisa = await createApplicant()
userIds.push(nonVisa.id)
const caller = createCaller(applicantRouter, {
id: nonVisa.id,
email: nonVisa.email,
role: 'APPLICANT',
})
await expect(
caller.updateMyVisaNationality({ nationality: 'Kenya' }),
).rejects.toMatchObject({ code: 'NOT_FOUND' })
})
})

View File

@@ -0,0 +1,255 @@
/**
* Task 3: Admin alerts on confirmation lifecycle
*
* Verifies that in-app notifications are created for SUPER_ADMIN users
* on confirm, decline, and expiry events.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { finalistRouter } from '../../src/server/routers/finalist'
import { expirePendingPastDeadline } from '../../src/server/services/finalist-confirmation'
beforeAll(() => {
process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens'
process.env.NEXTAUTH_URL = 'http://localhost:3001'
})
describe('finalist admin alert notifications (Task 3)', () => {
const programIds: string[] = []
const userIds: string[] = []
let adminUserId: string
afterAll(async () => {
// Clean up notifications for admin
if (adminUserId) {
await prisma.inAppNotification.deleteMany({ where: { userId: adminUserId } })
}
for (const programId of programIds) {
await prisma.inAppNotification.deleteMany({
where: {
metadata: { path: ['projectId'], string_contains: '' },
},
})
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await prisma.waitlistEntry.deleteMany({ where: { programId } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
/**
* Set up a PENDING confirmation with a team lead + teammate, and a
* SUPER_ADMIN user to verify notifications against.
*/
async function setupWithAdmin(programName: string) {
// Create the admin once (shared) or per test
const admin = await prisma.user.create({
data: {
id: uid('user'),
email: `admin_${uid()}@test.local`,
name: 'Super Admin',
role: 'SUPER_ADMIN',
roles: ['SUPER_ADMIN'],
status: 'ACTIVE',
},
})
userIds.push(admin.id)
adminUserId = admin.id
const program = await createTestProgram({ name: programName })
programIds.push(program.id)
const lead = await prisma.user.create({
data: {
id: uid('user'),
email: `lead_${uid()}@test.local`,
name: 'Team Lead',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(lead.id)
const teammate = await prisma.user.create({
data: {
id: uid('user'),
email: `mate_${uid()}@test.local`,
name: 'Teammate',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(teammate.id)
const project = await createTestProject(program.id, {
title: 'Notification Test Project',
competitionCategory: 'STARTUP',
})
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: teammate.id, role: 'MEMBER' },
],
})
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { confirmationWindowHours: 24 },
})
// Use the admin caller to selectFinalists and get a PENDING confirmation
const adminCaller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const round = await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id } },
})
await adminCaller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [project.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
return { admin, program, lead, teammate, project, confirmation, round }
}
it('Test 1: finalist.confirm fires FINALIST_CONFIRMED notification to admin', async () => {
const { admin, lead, teammate, confirmation } = await setupWithAdmin(
`comms-confirm-${uid()}`,
)
// Clear any existing notifications for this admin before the action
await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } })
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await publicCaller.confirm({
token: confirmation.token,
attendingUserIds: [lead.id, teammate.id],
visaFlags: {},
})
const notification = await prisma.inAppNotification.findFirst({
where: {
userId: admin.id,
type: 'FINALIST_CONFIRMED',
},
})
expect(notification).not.toBeNull()
expect(notification?.type).toBe('FINALIST_CONFIRMED')
})
it('Test 2: finalist.decline fires FINALIST_DECLINED notification to admin', async () => {
const { admin, confirmation } = await setupWithAdmin(`comms-decline-${uid()}`)
// Clear existing notifications
await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } })
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await publicCaller.decline({ token: confirmation.token, reason: 'Cannot attend' })
const notification = await prisma.inAppNotification.findFirst({
where: {
userId: admin.id,
type: 'FINALIST_DECLINED',
},
})
expect(notification).not.toBeNull()
expect(notification?.type).toBe('FINALIST_DECLINED')
})
it('Test 3: expirePendingPastDeadline fires FINALIST_EXPIRED notification to admin', async () => {
// Create a fresh admin for this test (isolated)
const admin = await prisma.user.create({
data: {
id: uid('user'),
email: `admin_expire_${uid()}@test.local`,
name: 'Super Admin Expire',
role: 'SUPER_ADMIN',
roles: ['SUPER_ADMIN'],
status: 'ACTIVE',
},
})
userIds.push(admin.id)
const program = await createTestProgram({ name: `comms-expire-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, {
title: 'Expire Test Project',
competitionCategory: 'STARTUP',
})
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { confirmationWindowHours: 24 },
})
// Manually create a PENDING confirmation with a past deadline
const { signFinalistToken } = await import('../../src/lib/finalist-token')
const confirmationId = `cmfc_expire_${uid()}`
const expiredExp = Math.floor(Date.now() / 1000) - 60
const token = signFinalistToken({ confirmationId, exp: expiredExp })
await prisma.finalistConfirmation.create({
data: {
id: confirmationId,
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline: new Date(Date.now() - 60_000),
token,
},
})
// Clear any existing notifications for this admin
await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } })
await expirePendingPastDeadline(prisma)
const notification = await prisma.inAppNotification.findFirst({
where: {
userId: admin.id,
type: 'FINALIST_EXPIRED',
},
})
expect(notification).not.toBeNull()
expect(notification?.type).toBe('FINALIST_EXPIRED')
// Cleanup this test's notifications
await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } })
})
})

View File

@@ -0,0 +1,517 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
import { finalistRouter } from '../../src/server/routers/finalist'
async function createApplicantUser(role: 'LEAD' | 'MEMBER' = 'MEMBER') {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Test ${role}`,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
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)
})
})
// ─── finalist.enrollFinalists ──────────────────────────────────────────────
describe('finalist.enrollFinalists', () => {
const programIds: string[] = []
const userIds: 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 prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId: id } } } })
await cleanupTestData(id, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
async function setupEnrollFixture(programName: string) {
const program = await createTestProgram({ name: programName, defaultAttendeeCap: 3 })
const competition = await createTestCompetition(program.id)
const mentoringRound = await createTestRound(competition.id, {
roundType: 'MENTORING',
sortOrder: 60,
})
const liveFinalRound = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
sortOrder: 70,
configJson: { confirmationWindowHours: 24 },
})
const project = await createTestProject(program.id, {
title: 'Enroll Test Project',
competitionCategory: 'STARTUP',
})
const lead = await createApplicantUser('LEAD')
const member = await createApplicantUser('MEMBER')
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
],
})
// Put the project in the MENTORING round (as candidates)
await prisma.projectRoundState.create({
data: { projectId: project.id, roundId: mentoringRound.id },
})
return { program, competition, mentoringRound, liveFinalRound, project, lead, member }
}
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-email-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [{ projectId: project.id, mode: 'EMAIL' }],
})
expect(result.enrolled).toBe(1)
expect(result.skipped).toHaveLength(0)
const prs = await prisma.projectRoundState.findFirst({
where: { projectId: project.id, roundId: liveFinalRound.id },
})
expect(prs).not.toBeNull()
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(conf.status).toBe('PENDING')
const attendeeCount = await prisma.attendingMember.count({
where: { confirmationId: conf.id },
})
expect(attendeeCount).toBe(0)
})
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-adminconfirm-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [
{
projectId: project.id,
mode: 'ADMIN_CONFIRM',
attendingUserIds: [lead.id, member.id],
visaFlags: { [member.id]: true },
},
],
})
expect(result.enrolled).toBe(1)
expect(result.adminConfirmed).toBe(1)
expect(result.skipped).toHaveLength(0)
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(conf.status).toBe('CONFIRMED')
const attendeeCount = await prisma.attendingMember.count({
where: { confirmationId: conf.id },
})
expect(attendeeCount).toBe(2)
const visaCount = await prisma.visaApplication.count({
where: { attendingMember: { confirmationId: conf.id } },
})
expect(visaCount).toBe(1)
})
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-reinvite-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
// Pre-create a DECLINED confirmation
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: 'schedule conflict',
},
})
// Pre-create a PRS row (simulating prior enrollment)
await prisma.projectRoundState.create({
data: { projectId: project.id, roundId: liveFinalRound.id },
})
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Re-enroll in EMAIL mode — should reset DECLINED to PENDING without crashing
await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [{ projectId: project.id, mode: 'EMAIL' }],
})
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(conf.status).toBe('PENDING')
expect(conf.declinedAt).toBeNull()
// Exactly one PRS row (skipDuplicates kept it idempotent)
const prsRows = await prisma.projectRoundState.findMany({
where: { projectId: project.id, roundId: liveFinalRound.id },
})
expect(prsRows).toHaveLength(1)
})
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
`enroll-overcap-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
// Create 2 extra members so we can pass 4 (cap = 3)
const extra1 = await createApplicantUser('MEMBER')
const extra2 = await createApplicantUser('MEMBER')
userIds.push(extra1.id, extra2.id)
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: extra1.id, role: 'MEMBER' },
{ projectId: project.id, userId: extra2.id, role: 'MEMBER' },
],
})
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [
{
projectId: project.id,
mode: 'ADMIN_CONFIRM',
attendingUserIds: [lead.id, member.id, extra1.id, extra2.id], // 4 > cap 3
},
],
}),
).rejects.toThrow(/cap/i)
})
it('rejects a roundId that belongs to a different program', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const a = await setupEnrollFixture(`enroll-xprogram-a-${uid()}`)
const b = await setupEnrollFixture(`enroll-xprogram-b-${uid()}`)
programIds.push(a.program.id, b.program.id)
userIds.push(a.lead.id, a.member.id, b.lead.id, b.member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Program A's project + Program B's LIVE_FINAL round → must be rejected.
await expect(
caller.enrollFinalists({
programId: a.program.id,
roundId: b.liveFinalRound.id,
enrollments: [{ projectId: a.project.id, mode: 'EMAIL' }],
}),
).rejects.toThrow(/does not belong to this program/i)
})
})
// ─── finalist.listEnrollmentCandidates ────────────────────────────────────
describe('finalist.listEnrollmentCandidates', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const id of programIds) {
await prisma.finalistSlotQuota.deleteMany({ where: { programId: id } })
await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } })
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } })
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId: id } } } })
await cleanupTestData(id, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('returns the STARTUP project under the STARTUP category with inLiveFinal: false and confirmationStatus: null', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({
name: `list-candidates-${uid()}`,
defaultAttendeeCap: 3,
})
programIds.push(program.id)
const competition = await createTestCompetition(program.id)
const mentoringRound = await createTestRound(competition.id, {
roundType: 'MENTORING',
sortOrder: 60,
})
const liveFinalRound = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
sortOrder: 70,
configJson: { confirmationWindowHours: 24 },
})
const project = await createTestProject(program.id, {
title: 'Candidate Project',
competitionCategory: 'STARTUP',
})
const lead = await createApplicantUser('LEAD')
const member = await createApplicantUser('MEMBER')
userIds.push(lead.id, member.id)
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
],
})
// Put project in MENTORING round (this makes it a candidate)
await prisma.projectRoundState.create({
data: { projectId: project.id, roundId: mentoringRound.id },
})
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.listEnrollmentCandidates({ programId: program.id })
// Top-level shape
expect(result.liveFinalRoundId).toBe(liveFinalRound.id)
expect(result.attendeeCap).toBe(3)
// There should be exactly one category entry for STARTUP
const startupCat = result.categories.find((c: { category: string }) => c.category === 'STARTUP')
expect(startupCat).toBeDefined()
expect(startupCat!.quota).toBeNull() // no quota set
expect(startupCat!.confirmedCount).toBe(0)
expect(startupCat!.pendingCount).toBe(0)
// One candidate
expect(startupCat!.candidates).toHaveLength(1)
const candidate = startupCat!.candidates[0]
expect(candidate.projectId).toBe(project.id)
expect(candidate.title).toBe('Candidate Project')
expect(candidate.inLiveFinal).toBe(false)
expect(candidate.confirmationStatus).toBeNull()
// Team members listed
expect(candidate.teamMembers).toHaveLength(2)
const memberIds = candidate.teamMembers.map((tm: { userId: string }) => tm.userId)
expect(memberIds).toContain(lead.id)
expect(memberIds).toContain(member.id)
const leadMember = candidate.teamMembers.find((tm: { userId: string }) => tm.userId === lead.id)
expect(leadMember!.role).toBe('LEAD')
expect(leadMember!.email).toBeTruthy()
})
it('reflects inLiveFinal: true when the project has a LIVE_FINAL ProjectRoundState', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({
name: `list-candidates-enrolled-${uid()}`,
defaultAttendeeCap: 3,
})
programIds.push(program.id)
const competition = await createTestCompetition(program.id)
const mentoringRound = await createTestRound(competition.id, {
roundType: 'MENTORING',
sortOrder: 60,
})
const liveFinalRound = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
sortOrder: 70,
})
const project = await createTestProject(program.id, {
title: 'Enrolled Candidate',
competitionCategory: 'STARTUP',
})
const lead = await createApplicantUser('LEAD')
userIds.push(lead.id)
await prisma.teamMember.create({
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
})
// In MENTORING round AND in LIVE_FINAL round
await prisma.projectRoundState.createMany({
data: [
{ projectId: project.id, roundId: mentoringRound.id },
{ projectId: project.id, roundId: liveFinalRound.id },
],
})
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.listEnrollmentCandidates({ programId: program.id })
const startupCat = result.categories.find((c: { category: string }) => c.category === 'STARTUP')
expect(startupCat).toBeDefined()
const candidate = startupCat!.candidates.find((c: { projectId: string }) => c.projectId === project.id)
expect(candidate).toBeDefined()
expect(candidate!.inLiveFinal).toBe(true)
})
it('returns empty categories and null liveFinalRoundId when no MENTORING round exists', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `list-candidates-empty-${uid()}` })
programIds.push(program.id)
// Competition with only an EVALUATION round (no MENTORING)
const competition = await createTestCompetition(program.id)
await createTestRound(competition.id, { roundType: 'EVALUATION', sortOrder: 10 })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.listEnrollmentCandidates({ programId: program.id })
expect(result.categories).toHaveLength(0)
expect(result.liveFinalRoundId).toBeNull()
})
})

View File

@@ -0,0 +1,215 @@
/**
* Task 5: Confirmation reminder cron
*
* Tests that sendDueConfirmationReminders:
* - sends a FINALIST_REMINDER notification for leads whose deadline is within the window
* - stamps reminderSentAt so the second call is idempotent
* - skips rows whose deadline is further away than reminderHoursBeforeDeadline
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { sendDueConfirmationReminders } from '../../src/server/services/finalist-confirmation'
beforeAll(() => {
process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens'
process.env.NEXTAUTH_URL = 'http://localhost:3001'
})
describe('sendDueConfirmationReminders', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.inAppNotification.deleteMany({
where: { metadata: { path: ['projectId'], string_contains: '' } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('sends FINALIST_REMINDER for a lead whose deadline is within the reminder window', async () => {
const program = await createTestProgram({ name: `reminder-due-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
// LIVE_FINAL round with 12h reminder window
await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { reminderHoursBeforeDeadline: 12 },
})
const lead = await prisma.user.create({
data: {
id: uid('user'),
email: `lead-reminder-${uid()}@test.local`,
name: 'Reminder Lead',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(lead.id)
const project = await createTestProject(program.id, {
title: 'Reminder Project',
competitionCategory: 'STARTUP',
})
await prisma.teamMember.create({
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
})
// Deadline 6 hours from now — within the 12h window
const deadline = new Date(Date.now() + 6 * 3_600_000)
const token = `tok_reminder_${uid()}`
await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline,
token,
reminderSentAt: null,
},
})
const result = await sendDueConfirmationReminders(prisma)
expect(result.remindersSent).toBe(1)
// Notification created for the lead
const notification = await prisma.inAppNotification.findFirst({
where: { userId: lead.id, type: 'FINALIST_REMINDER' },
})
expect(notification).not.toBeNull()
expect(notification?.metadata).toMatchObject({ projectTitle: 'Reminder Project' })
// reminderSentAt is stamped
const updated = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(updated.reminderSentAt).not.toBeNull()
// Clean up notification so it doesn't interfere with idempotency test
await prisma.inAppNotification.deleteMany({ where: { userId: lead.id, type: 'FINALIST_REMINDER' } })
})
it('is idempotent — second call sends 0 reminders for the same row', async () => {
// Reuse the row created above — reminderSentAt is now set
const program = await createTestProgram({ name: `reminder-idempotent-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { reminderHoursBeforeDeadline: 12 },
})
const lead = await prisma.user.create({
data: {
id: uid('user'),
email: `lead-idempotent-${uid()}@test.local`,
name: 'Idempotent Lead',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(lead.id)
const project = await createTestProject(program.id, {
title: 'Idempotent Project',
competitionCategory: 'STARTUP',
})
await prisma.teamMember.create({
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
})
const deadline = new Date(Date.now() + 6 * 3_600_000)
const token = `tok_idempotent_${uid()}`
await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline,
token,
reminderSentAt: null,
},
})
// First call — should send 1
const first = await sendDueConfirmationReminders(prisma)
expect(first.remindersSent).toBeGreaterThanOrEqual(1)
// Second call — same row, reminderSentAt is now set → 0
const second = await sendDueConfirmationReminders(prisma)
expect(second.remindersSent).toBe(0)
})
it('does NOT send for a PENDING row whose deadline is outside the reminder window (48h from now, 12h window)', async () => {
const program = await createTestProgram({ name: `reminder-notdue-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { reminderHoursBeforeDeadline: 12 },
})
const lead = await prisma.user.create({
data: {
id: uid('user'),
email: `lead-notdue-${uid()}@test.local`,
name: 'Not-Due Lead',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(lead.id)
const project = await createTestProject(program.id, {
title: 'Not Due Project',
competitionCategory: 'STARTUP',
})
await prisma.teamMember.create({
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
})
// Deadline 48 hours from now — far outside the 12h window
const deadline = new Date(Date.now() + 48 * 3_600_000)
const token = `tok_notdue_${uid()}`
await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline,
token,
reminderSentAt: null,
},
})
const result = await sendDueConfirmationReminders(prisma)
// The only unflagged row in this program has deadline 48h out, should not be sent
const notification = await prisma.inAppNotification.findFirst({
where: { userId: lead.id, type: 'FINALIST_REMINDER' },
})
expect(notification).toBeNull()
// reminderSentAt still null
const row = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(row.reminderSentAt).toBeNull()
void result // result.remindersSent may be > 0 from other programs' rows already in DB
})
})

View File

@@ -0,0 +1,228 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { finalistRouter } from '../../src/server/routers/finalist'
async function createApplicantUser(role: 'LEAD' | 'MEMBER' = 'MEMBER') {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Test ${role}`,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
describe('finalist.unenroll', () => {
const programIds: string[] = []
const userIds: 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 prisma.projectRoundState.deleteMany({
where: { round: { competition: { programId: id } } },
})
await cleanupTestData(id, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
async function setupUnenrollFixture(programName: string) {
const program = await createTestProgram({ name: programName, defaultAttendeeCap: 3 })
const competition = await createTestCompetition(program.id)
const mentoringRound = await createTestRound(competition.id, {
roundType: 'MENTORING',
sortOrder: 60,
})
const liveFinalRound = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
sortOrder: 70,
configJson: { confirmationWindowHours: 24 },
})
const project = await createTestProject(program.id, {
title: 'Unenroll Test Project',
competitionCategory: 'STARTUP',
})
const lead = await createApplicantUser('LEAD')
const member = await createApplicantUser('MEMBER')
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
],
})
// Put the project in the MENTORING round (as candidates)
await prisma.projectRoundState.create({
data: { projectId: project.id, roundId: mentoringRound.id },
})
return { program, competition, mentoringRound, liveFinalRound, project, lead, member }
}
it('removes FinalistConfirmation, AttendingMember rows, and LIVE_FINAL PRS; logs FINALIST_UNENROLL audit', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupUnenrollFixture(
`unenroll-main-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// First enroll via ADMIN_CONFIRM so we have a full set of rows to verify removal
await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [
{
projectId: project.id,
mode: 'ADMIN_CONFIRM',
attendingUserIds: [lead.id, member.id],
visaFlags: { [member.id]: true },
},
],
})
// Verify pre-conditions: confirmation + attendees + PRS exist
const confBefore = await prisma.finalistConfirmation.findUnique({
where: { projectId: project.id },
})
expect(confBefore).not.toBeNull()
expect(confBefore!.status).toBe('CONFIRMED')
const attendeesBefore = await prisma.attendingMember.count({
where: { confirmationId: confBefore!.id },
})
expect(attendeesBefore).toBe(2)
const prsBefore = await prisma.projectRoundState.findFirst({
where: { projectId: project.id, roundId: liveFinalRound.id },
})
expect(prsBefore).not.toBeNull()
// Now unenroll
const result = await caller.unenroll({
projectId: project.id,
roundId: liveFinalRound.id,
})
expect(result).toEqual({ ok: true })
// FinalistConfirmation is gone
const confAfter = await prisma.finalistConfirmation.findUnique({
where: { projectId: project.id },
})
expect(confAfter).toBeNull()
// AttendingMember rows are gone (cascade from FinalistConfirmation deletion)
const attendeesAfter = await prisma.attendingMember.count({
where: { confirmationId: confBefore!.id },
})
expect(attendeesAfter).toBe(0)
// LIVE_FINAL ProjectRoundState is gone
const prsAfter = await prisma.projectRoundState.findFirst({
where: { projectId: project.id, roundId: liveFinalRound.id },
})
expect(prsAfter).toBeNull()
// MENTORING PRS is NOT touched
const mentoringPrs = await prisma.projectRoundState.findFirst({
where: { projectId: project.id, roundId: { not: liveFinalRound.id } },
})
expect(mentoringPrs).not.toBeNull()
// Audit row exists
const auditRow = await prisma.auditLog.findFirst({
where: {
action: 'FINALIST_UNENROLL',
entityType: 'Project',
entityId: project.id,
},
orderBy: { timestamp: 'desc' },
})
expect(auditRow).not.toBeNull()
expect((auditRow!.detailsJson as Record<string, unknown>).projectId).toBe(project.id)
expect((auditRow!.detailsJson as Record<string, unknown>).roundId).toBe(liveFinalRound.id)
})
it('is a no-op when the project was never enrolled (no rows to delete)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead, member } = await setupUnenrollFixture(
`unenroll-noop-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// No prior enrollment — should not throw
const result = await caller.unenroll({
projectId: project.id,
roundId: liveFinalRound.id,
})
expect(result).toEqual({ ok: true })
// Nothing exists after
const confAfter = await prisma.finalistConfirmation.findUnique({
where: { projectId: project.id },
})
expect(confAfter).toBeNull()
const prsAfter = await prisma.projectRoundState.findFirst({
where: { projectId: project.id, roundId: liveFinalRound.id },
})
expect(prsAfter).toBeNull()
})
it('rejects a project/round pair from different programs', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const a = await setupUnenrollFixture(`unenroll-xprogram-a-${uid()}`)
const b = await setupUnenrollFixture(`unenroll-xprogram-b-${uid()}`)
programIds.push(a.program.id, b.program.id)
userIds.push(a.lead.id, a.member.id, b.lead.id, b.member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Program A's project + Program B's round → rejected before any delete.
await expect(
caller.unenroll({ projectId: a.project.id, roundId: b.liveFinalRound.id }),
).rejects.toThrow(/different programs/i)
})
})

View File

@@ -0,0 +1,265 @@
/**
* Task 4: Withdrawal emails to teams
*
* Verifies that FINALIST_WITHDRAWN in-app notifications are sent to the team
* lead when an admin withdraws a team's grand-finale slot via:
* - adminDecline (PENDING → DECLINED)
* - unconfirm (CONFIRMED → SUPERSEDED)
* - unenroll (only when a CONFIRMED confirmation existed; NOT when never enrolled)
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { finalistRouter } from '../../src/server/routers/finalist'
beforeAll(() => {
process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens'
process.env.NEXTAUTH_URL = 'http://localhost:3001'
})
import { beforeAll } from 'vitest'
// ── helpers ────────────────────────────────────────────────────────────────
async function createApplicant(label = 'user') {
const id = uid(label)
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Test ${label}`,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
/** Creates a program + competition + round (LIVE_FINAL) + project + team lead,
* returns all. Callers can then create a FinalistConfirmation themselves so they
* control the initial status. */
async function setupBase(label: string) {
const program = await createTestProgram({ name: `withdrawal-${label}-${uid()}`, defaultAttendeeCap: 3 })
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
await createTestRound(competition.id, {
roundType: 'MENTORING',
sortOrder: 60,
})
const liveFinalRound = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
sortOrder: 70,
configJson: { confirmationWindowHours: 24 },
})
const project = await createTestProject(program.id, {
title: `Withdrawal Test ${label}`,
competitionCategory: 'STARTUP',
})
const lead = await createApplicant('lead')
await prisma.teamMember.create({
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
})
// Put project in MENTORING round (enrollment candidate)
await prisma.projectRoundState.create({
data: {
projectId: project.id,
roundId: (
await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id }, roundType: 'MENTORING' },
})
).id,
},
})
return { program, liveFinalRound, project, lead }
}
// ── suite ──────────────────────────────────────────────────────────────────
describe('finalist withdrawal notifications (Task 4)', () => {
const programIds: string[] = []
const userIds: string[] = []
const notificationIds: string[] = []
afterAll(async () => {
if (notificationIds.length > 0) {
await prisma.inAppNotification.deleteMany({ where: { id: { in: notificationIds } } })
}
for (const programId of programIds) {
await prisma.inAppNotification.deleteMany({
where: { metadata: { path: ['projectTitle'], string_contains: 'Withdrawal Test' } },
})
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await prisma.waitlistEntry.deleteMany({ where: { programId } })
await prisma.projectRoundState.deleteMany({
where: { round: { competition: { programId } } },
})
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('Test 1: adminDecline on PENDING sends FINALIST_WITHDRAWN to team lead', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, project, lead } = await setupBase('adminDecline')
programIds.push(program.id)
userIds.push(lead.id)
// Create a PENDING FinalistConfirmation manually
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
},
})
// Clear any prior notifications for lead
await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.adminDecline({
confirmationId: confirmation.id,
reason: 'No longer eligible',
})
const notification = await prisma.inAppNotification.findFirst({
where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' },
})
expect(notification, 'lead should have FINALIST_WITHDRAWN notification').not.toBeNull()
expect(notification?.type).toBe('FINALIST_WITHDRAWN')
if (notification) notificationIds.push(notification.id)
})
it('Test 2: unconfirm (CONFIRMED→SUPERSEDED) sends FINALIST_WITHDRAWN to team lead', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, project, lead } = await setupBase('unconfirm')
programIds.push(program.id)
userIds.push(lead.id)
// Create a CONFIRMED FinalistConfirmation manually
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
confirmedAt: new Date(),
},
})
await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.unconfirm({
confirmationId: confirmation.id,
reason: 'Quota change required — test',
})
const notification = await prisma.inAppNotification.findFirst({
where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' },
})
expect(notification, 'lead should have FINALIST_WITHDRAWN notification after unconfirm').not.toBeNull()
expect(notification?.type).toBe('FINALIST_WITHDRAWN')
if (notification) notificationIds.push(notification.id)
})
it('Test 3: unenroll of CONFIRMED team sends FINALIST_WITHDRAWN to team lead', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead } = await setupBase('unenroll-confirmed')
programIds.push(program.id)
userIds.push(lead.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Enroll via ADMIN_CONFIRM → status = CONFIRMED
await caller.enrollFinalists({
programId: program.id,
roundId: liveFinalRound.id,
enrollments: [
{
projectId: project.id,
mode: 'ADMIN_CONFIRM',
attendingUserIds: [lead.id],
visaFlags: {},
},
],
})
// Verify the confirmation is indeed CONFIRMED before we unenroll
const confBefore = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
expect(confBefore.status).toBe('CONFIRMED')
await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } })
await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id })
const notification = await prisma.inAppNotification.findFirst({
where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' },
})
expect(notification, 'lead should have FINALIST_WITHDRAWN after unenrolling a CONFIRMED team').not.toBeNull()
expect(notification?.type).toBe('FINALIST_WITHDRAWN')
if (notification) notificationIds.push(notification.id)
})
it('Test 4: unenroll of never-enrolled project does NOT send FINALIST_WITHDRAWN', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, liveFinalRound, project, lead } = await setupBase('unenroll-none')
programIds.push(program.id)
userIds.push(lead.id)
await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// No prior enrollment — should be a no-op
await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id })
const notification = await prisma.inAppNotification.findFirst({
where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' },
})
expect(notification, 'no FINALIST_WITHDRAWN when project was never enrolled').toBeNull()
})
})

View File

@@ -0,0 +1,259 @@
/**
* Task 6: Travel + visa attendee emails
* Tests that setFlightStatus(CONFIRMED) and updateVisaApplication(status change)
* create the right InAppNotification rows for the attending member.
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { logisticsRouter } from '../../src/server/routers/logistics'
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
async function createApplicant(tag: string) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${tag}_${id}@test.local`,
name: `Test User ${tag}`,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
/**
* Creates a confirmed attendee with a flight detail row in PENDING status.
* Returns all entities needed by the tests.
*/
async function setupFlightScenario(label: string) {
const program = await createTestProgram({ name: `comms-flight-${label}-${uid()}` })
const user = await createApplicant(`flight_${label}`)
const project = await createTestProject(program.id, {
title: `Project ${label}`,
competitionCategory: 'STARTUP',
})
await prisma.teamMember.create({
data: { projectId: project.id, userId: user.id, role: 'LEAD' },
})
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
confirmedAt: new Date(),
},
})
const attendingMember = await prisma.attendingMember.create({
data: { confirmationId: confirmation.id, userId: user.id, needsVisa: false },
})
const flightDetail = await prisma.flightDetail.create({
data: {
attendingMemberId: attendingMember.id,
arrivalAt: new Date('2026-06-28T12:00:00Z'),
arrivalFlightNumber: 'AF7400',
arrivalAirport: 'NCE',
departureAt: new Date('2026-07-01T16:00:00Z'),
departureFlightNumber: 'AF7401',
departureAirport: 'NCE',
status: 'PENDING',
},
})
return { program, user, project, confirmation, attendingMember, flightDetail }
}
/**
* Creates a confirmed attendee with a visa application in REQUESTED status.
*/
async function setupVisaScenario(label: string) {
const program = await createTestProgram({ name: `comms-visa-${label}-${uid()}` })
const user = await createApplicant(`visa_${label}`)
const project = await createTestProject(program.id, {
title: `Visa Project ${label}`,
competitionCategory: 'STARTUP',
})
await prisma.teamMember.create({
data: { projectId: project.id, userId: user.id, role: 'LEAD' },
})
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
confirmedAt: new Date(),
},
})
const attendingMember = await prisma.attendingMember.create({
data: { confirmationId: confirmation.id, userId: user.id, needsVisa: true },
})
const visaApplication = await prisma.visaApplication.create({
data: { attendingMemberId: attendingMember.id, status: 'REQUESTED' },
})
return { program, user, project, confirmation, attendingMember, visaApplication }
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('logistics comms: flight confirmed notification', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
// Clean up InAppNotification rows first
for (const userId of userIds) {
await prisma.inAppNotification.deleteMany({ where: { userId } })
}
for (const programId of programIds) {
await prisma.flightDetail.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('creates TRAVEL_CONFIRMED notification when flight status set to CONFIRMED', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, user, flightDetail } = await setupFlightScenario('confirm')
programIds.push(program.id)
userIds.push(user.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.setFlightStatus({ flightDetailId: flightDetail.id, status: 'CONFIRMED' })
const notification = await prisma.inAppNotification.findFirst({
where: { userId: user.id, type: 'TRAVEL_CONFIRMED' },
})
expect(notification).not.toBeNull()
expect(notification!.type).toBe('TRAVEL_CONFIRMED')
})
it('does NOT create TRAVEL_CONFIRMED notification when status is set to PENDING', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, user, flightDetail } = await setupFlightScenario('pending')
programIds.push(program.id)
userIds.push(user.id)
// First set to CONFIRMED to have a baseline, then revert to PENDING
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Start with CONFIRMED so we know a notification would be created
await caller.setFlightStatus({ flightDetailId: flightDetail.id, status: 'CONFIRMED' })
// Clear notifications for this user so we can test the PENDING case cleanly
await prisma.inAppNotification.deleteMany({ where: { userId: user.id } })
// Now set back to PENDING — must NOT create a new TRAVEL_CONFIRMED notification
await caller.setFlightStatus({ flightDetailId: flightDetail.id, status: 'PENDING' })
const notification = await prisma.inAppNotification.findFirst({
where: { userId: user.id, type: 'TRAVEL_CONFIRMED' },
})
expect(notification).toBeNull()
})
})
describe('logistics comms: visa status update notification', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
// Clean up InAppNotification rows first
for (const userId of userIds) {
await prisma.inAppNotification.deleteMany({ where: { userId } })
}
for (const programId of programIds) {
await prisma.visaApplication.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('creates VISA_STATUS_UPDATE notification when visa status updated to GRANTED', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, user, visaApplication } = await setupVisaScenario('granted')
programIds.push(program.id)
userIds.push(user.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.updateVisaApplication({ id: visaApplication.id, status: 'GRANTED' })
const notification = await prisma.inAppNotification.findFirst({
where: { userId: user.id, type: 'VISA_STATUS_UPDATE' },
})
expect(notification).not.toBeNull()
expect(notification!.type).toBe('VISA_STATUS_UPDATE')
})
it('does NOT create duplicate VISA_STATUS_UPDATE when status is unchanged', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, user, visaApplication } = await setupVisaScenario('no-dup')
programIds.push(program.id)
userIds.push(user.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// First update: REQUESTED → GRANTED (creates a notification)
await caller.updateVisaApplication({ id: visaApplication.id, status: 'GRANTED' })
// Second update: GRANTED → GRANTED again (same status, should NOT create another)
await caller.updateVisaApplication({ id: visaApplication.id, status: 'GRANTED' })
const notifications = await prisma.inAppNotification.findMany({
where: { userId: user.id, type: 'VISA_STATUS_UPDATE' },
})
expect(notifications).toHaveLength(1)
})
})

View File

@@ -174,6 +174,64 @@ describe('logistics flight detail procedures', () => {
expect(count).toBe(1) expect(count).toBe(1)
}) })
it('upsertFlightDetail rejects when departureAt < arrivalAt', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, lead, attendingMember } = await setupConfirmedFinalist(
`flight-dep-before-arr-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const arrivalDate = new Date('2026-06-28T14:00:00Z')
const departureBeforeArrival = new Date('2026-06-28T10:00:00Z') // 4 hours earlier
await expect(
caller.upsertFlightDetail({
attendingMemberId: attendingMember.id,
arrivalAt: arrivalDate,
departureAt: departureBeforeArrival,
arrivalFlightNumber: 'AF7400',
departureFlightNumber: 'AF7405',
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Departure must be after arrival' })
})
it('upsertFlightDetail succeeds when departureAt >= arrivalAt', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, lead, attendingMember } = await setupConfirmedFinalist(
`flight-dep-after-arr-${uid()}`,
)
programIds.push(program.id)
userIds.push(lead.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const arrivalDate = new Date('2026-06-28T10:00:00Z')
const departureAfterArrival = new Date('2026-06-30T14:00:00Z') // 2 days later
const result = await caller.upsertFlightDetail({
attendingMemberId: attendingMember.id,
arrivalAt: arrivalDate,
departureAt: departureAfterArrival,
arrivalFlightNumber: 'AF7400',
departureFlightNumber: 'AF7405',
})
expect(result.arrivalFlightNumber).toBe('AF7400')
expect(result.departureFlightNumber).toBe('AF7405')
})
it('setFlightStatus toggles PENDING ↔ CONFIRMED', async () => { it('setFlightStatus toggles PENDING ↔ CONFIRMED', async () => {
const admin = await createTestUser('SUPER_ADMIN') const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id) userIds.push(admin.id)

View File

@@ -0,0 +1,35 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
import { lunchRouter } from '@/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')
})
})

View File

@@ -0,0 +1,140 @@
/**
* Regression: selectUnpickedAttendees must return attendees with NO MemberLunchPick
* row at all, not just attendees whose pick row has pickedAt=null.
*
* Bug: the old cron filter used `lunchPick: { is: { pickedAt: null } }` which
* only matches rows that exist but have pickedAt=null. Attendees with no pick
* row at all were silently skipped.
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { selectUnpickedAttendees } from '@/server/services/lunch-reminders'
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.memberLunchPick.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await prisma.lunchEvent.deleteMany({ where: { programId } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
describe('selectUnpickedAttendees', () => {
it('returns attendees with no pick row AND unpicked rows; excludes picked', async () => {
const program = await createTestProgram({ name: `rfilter-${uid()}` })
programIds.push(program.id)
// Three confirmed attendees on the same program
const u1 = await createTestUser('APPLICANT') // no MemberLunchPick row at all
const u2 = await createTestUser('APPLICANT') // MemberLunchPick with pickedAt=null
const u3 = await createTestUser('APPLICANT') // MemberLunchPick with pickedAt set (PICKED)
userIds.push(u1.id, u2.id, u3.id)
const project = await createTestProject(program.id, {
title: `rfilter-proj-${uid()}`,
competitionCategory: 'STARTUP',
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok-${uid()}`,
},
})
const am1 = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u1.id },
})
const am2 = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u2.id },
})
const am3 = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u3.id },
})
// am1: NO pick row
// am2: pick row exists but pickedAt=null
await prisma.memberLunchPick.create({
data: { attendingMemberId: am2.id },
})
// am3: pick row with pickedAt set (has picked)
await prisma.memberLunchPick.create({
data: { attendingMemberId: am3.id, pickedAt: new Date() },
})
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const result = await selectUnpickedAttendees(prisma, {
id: event.id,
programId: program.id,
})
const returnedIds = result.map((am) => am.id).sort()
expect(returnedIds).toContain(am1.id)
expect(returnedIds).toContain(am2.id)
expect(returnedIds).not.toContain(am3.id)
expect(result).toHaveLength(2)
})
it('excludes non-CONFIRMED confirmations', async () => {
const program = await createTestProgram({ name: `rfilter-nc-${uid()}` })
programIds.push(program.id)
const u = await createTestUser('APPLICANT')
userIds.push(u.id)
const project = await createTestProject(program.id, {
title: `rfilter-nc-${uid()}`,
competitionCategory: 'STARTUP',
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'PENDING', // NOT confirmed
deadline: new Date(Date.now() + 86_400_000),
token: `tok-${uid()}`,
},
})
await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u.id },
})
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const result = await selectUnpickedAttendees(prisma, {
id: event.id,
programId: program.id,
})
expect(result).toHaveLength(0)
})
})

View File

@@ -0,0 +1,62 @@
/**
* Task 1: renderNotificationEmail + previewEmailTemplate
*
* Tests that:
* 1. previewEmailTemplate returns rendered HTML and a non-empty subject for a
* type that has a styled template (VISA_STATUS_UPDATE).
* 2. previewEmailTemplate returns non-empty HTML for a fallback type
* (FINALIST_EXPIRED — no registered template, uses generic fallback).
*/
import { afterAll, describe, expect, it } from 'vitest'
import { createCaller, prisma } from '../setup'
import { createTestUser, cleanupTestData, uid } from '../helpers'
import { notificationRouter } from '../../src/server/routers/notification'
describe('notification.previewEmailTemplate', () => {
let adminId: string
let adminEmail: string
afterAll(async () => {
if (adminId) {
await prisma.user.deleteMany({ where: { id: adminId } })
}
})
async function getAdminCaller() {
if (!adminId) {
const admin = await createTestUser('SUPER_ADMIN', {
name: 'Preview Admin',
email: `preview-admin-${uid()}@test.local`,
})
adminId = admin.id
adminEmail = admin.email
}
return createCaller(notificationRouter, {
id: adminId,
email: adminEmail,
name: 'Preview Admin',
role: 'SUPER_ADMIN',
})
}
it('returns HTML containing a visa-related string and a non-empty subject for VISA_STATUS_UPDATE', async () => {
const caller = await getAdminCaller()
const result = await caller.previewEmailTemplate({ notificationType: 'VISA_STATUS_UPDATE' })
expect(result.subject).toBeTruthy()
expect(result.html).toBeTruthy()
// The visa template includes 'visa' or 'Grand' in its content
const lower = result.html.toLowerCase()
expect(lower.includes('visa') || lower.includes('grand')).toBe(true)
expect(result.hasStyledTemplate).toBe(true)
})
it('returns non-empty HTML for FINALIST_EXPIRED (fallback — no styled template)', async () => {
const caller = await getAdminCaller()
const result = await caller.previewEmailTemplate({ notificationType: 'FINALIST_EXPIRED' })
expect(result.html).toBeTruthy()
expect(result.subject).toBeTruthy()
expect(result.hasStyledTemplate).toBe(false)
})
})