Compare commits
26 Commits
6b37e9bb9e
...
97951deb68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97951deb68 | ||
|
|
53b623fb20 | ||
|
|
74cd111e3a | ||
|
|
d03c705642 | ||
|
|
ed426a6fb4 | ||
|
|
ee8d65a300 | ||
|
|
0058b2b73b | ||
|
|
e5788b3e9d | ||
|
|
27bdf8cdef | ||
|
|
3f25ba112b | ||
|
|
884c96c710 | ||
|
|
1b4ab6be18 | ||
|
|
d501624c56 | ||
|
|
b2826d595f | ||
|
|
f529e79cd7 | ||
|
|
0ea949309a | ||
|
|
8afef1ecba | ||
|
|
34bb2bad57 | ||
|
|
8ee517f6ca | ||
|
|
2a98f0cacf | ||
|
|
e80710487c | ||
|
|
375aeb08af | ||
|
|
f1e62fdd3b | ||
|
|
dde8ea9345 | ||
|
|
ca9edcd038 | ||
|
|
647e7f09a7 |
@@ -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 ~1528–1531, above `FinalistSlotsCard`).
|
||||
- `src/components/admin/grand-finale/waitlist-card.tsx` — add an "Add to waitlist" control (wires the existing `finalist.addToWaitlist`, which has no UI today).
|
||||
- `src/components/admin/logistics/confirmations-tab.tsx` — fix the dead-end empty-state copy; add **Un-confirm** and **Re-invite** row actions (surface existing `unconfirm` + new re-invite via `enrollFinalists`).
|
||||
|
||||
---
|
||||
|
||||
## Background facts the executor needs (verified 2026-06-04)
|
||||
|
||||
- **Two disconnected systems.** A project is "in" the Grand Final round iff it has a `ProjectRoundState{ roundId: <LIVE_FINAL>, ... }`. A project is a "confirmed finalist (logistics)" iff it has a `FinalistConfirmation`. `selectFinalists` only ever created the latter and **had zero UI callers**; `addToWaitlist` also had zero UI callers. This wave joins them.
|
||||
- **Rounds for the live edition:** R5 EVALUATION → **R6 MENTORING (active)** → **R7 LIVE_FINAL** → R8 DELIBERATION. Candidates to enroll = projects with a `ProjectRoundState` in the MENTORING round.
|
||||
- **`FinalistConfirmation.projectId` is `@unique`** (`prisma/schema.prisma:2755`). `createPendingConfirmation` does a naive `.create()` → a second invite for a previously DECLINED/EXPIRED project throws Prisma P2002. The new `resetOrCreatePendingConfirmation()` fixes this.
|
||||
- **Cap:** `Program.defaultAttendeeCap` (live value: 3) bounds attendees per team.
|
||||
- **Reference code to mirror:**
|
||||
- PRS creation in target round: `src/server/routers/round.ts:545-551` (`createMany … skipDuplicates`).
|
||||
- Admin confirm transaction (attendees + visa rows + lunch picks): `src/server/routers/finalist.ts:492-523`.
|
||||
- Pending-confirmation + token + email: `src/server/services/finalist-confirmation.ts:12-42` and `src/server/routers/finalist.ts:191-219`.
|
||||
- Candidate data source: `src/server/routers/round.ts:323` (`listMentoringProjects`).
|
||||
- Test harness: `tests/unit/finalist-admin-confirm.test.ts` (factories, `createCaller`, cleanup pattern).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Unblock the applicant lunch picker (BLOCKER)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/lunch.ts:42`
|
||||
- Test: `tests/unit/lunch-list-dishes-perm.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** — a non-admin (APPLICANT) can call `listDishes`.
|
||||
|
||||
```ts
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { prisma, createCaller } from '../setup'
|
||||
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
|
||||
import { lunchRouter } from '../../src/server/routers/lunch'
|
||||
|
||||
describe('lunch.listDishes permission', () => {
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
afterAll(async () => {
|
||||
for (const id of programIds) {
|
||||
await prisma.dish.deleteMany({ where: { lunchEvent: { programId: id } } })
|
||||
await prisma.lunchEvent.deleteMany({ where: { programId: id } })
|
||||
await cleanupTestData(id, [])
|
||||
}
|
||||
if (userIds.length) await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
})
|
||||
|
||||
it('lets a non-admin (APPLICANT) read the dish list', async () => {
|
||||
const program = await createTestProgram({ name: `dish-perm-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const event = await prisma.lunchEvent.create({
|
||||
data: { programId: program.id, enabled: true },
|
||||
})
|
||||
await prisma.dish.create({ data: { lunchEventId: event.id, name: 'Sea bass', sortOrder: 0 } })
|
||||
|
||||
const applicant = await createTestUser('APPLICANT')
|
||||
userIds.push(applicant.id)
|
||||
const caller = createCaller(lunchRouter, {
|
||||
id: applicant.id, email: applicant.email, role: 'APPLICANT',
|
||||
})
|
||||
const dishes = await caller.listDishes({ lunchEventId: event.id })
|
||||
expect(dishes).toHaveLength(1)
|
||||
expect(dishes[0].name).toBe('Sea bass')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
> Note: confirm the exact `Dish` field names (`lunchEventId`, `name`, `sortOrder`) against `prisma/schema.prisma` before running; adjust the factory data if they differ.
|
||||
|
||||
- [ ] **Step 2: Run it, verify it fails** — `npx vitest run tests/unit/lunch-list-dishes-perm.test.ts`. Expected: FAIL with an `UNAUTHORIZED` TRPCError (APPLICANT blocked by `adminProcedure`).
|
||||
|
||||
- [ ] **Step 3: Change the procedure** — in `src/server/routers/lunch.ts:42`, change `listDishes: adminProcedure` to `listDishes: protectedProcedure`. Ensure `protectedProcedure` is imported in that file (it is used elsewhere in the router; if not, add it to the import from `../trpc`).
|
||||
|
||||
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "fix(lunch): allow non-admins to read dish list (unblocks applicant picker)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Re-invite-safe confirmation helper (fixes the P2002 dead-end)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/services/finalist-enrollment.ts`
|
||||
- Test: covered indirectly in Task 3; add a focused unit here.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (add to `tests/unit/finalist-enrollment.test.ts`, created here):
|
||||
|
||||
```ts
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers'
|
||||
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
|
||||
|
||||
describe('resetOrCreatePendingConfirmation', () => {
|
||||
const programIds: string[] = []
|
||||
afterAll(async () => {
|
||||
for (const id of programIds) {
|
||||
await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } })
|
||||
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } })
|
||||
await cleanupTestData(id, [])
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a fresh PENDING row when none exists', async () => {
|
||||
const program = await createTestProgram({ name: `reinvite-new-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||
const res = await resetOrCreatePendingConfirmation(prisma, {
|
||||
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
||||
})
|
||||
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
|
||||
expect(row.status).toBe('PENDING')
|
||||
expect(res.alreadyConfirmed).toBe(false)
|
||||
})
|
||||
|
||||
it('resets a DECLINED row to a fresh PENDING (no unique-constraint crash)', async () => {
|
||||
const program = await createTestProgram({ name: `reinvite-declined-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||
await prisma.finalistConfirmation.create({
|
||||
data: { projectId: project.id, category: 'STARTUP', status: 'DECLINED',
|
||||
deadline: new Date(Date.now() - 1000), token: `tok_${uid()}`, declinedAt: new Date(),
|
||||
declineReason: 'busy' },
|
||||
})
|
||||
const res = await resetOrCreatePendingConfirmation(prisma, {
|
||||
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
||||
})
|
||||
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
|
||||
expect(row.status).toBe('PENDING')
|
||||
expect(row.declinedAt).toBeNull()
|
||||
expect(row.declineReason).toBeNull()
|
||||
expect(res.alreadyConfirmed).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op flagged alreadyConfirmed when row is CONFIRMED', async () => {
|
||||
const program = await createTestProgram({ name: `reinvite-confirmed-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||
await prisma.finalistConfirmation.create({
|
||||
data: { projectId: project.id, category: 'STARTUP', status: 'CONFIRMED',
|
||||
deadline: new Date(Date.now() + 1000), token: `tok_${uid()}`, confirmedAt: new Date() },
|
||||
})
|
||||
const res = await resetOrCreatePendingConfirmation(prisma, {
|
||||
projectId: project.id, category: 'STARTUP', windowHours: 24,
|
||||
})
|
||||
expect(res.alreadyConfirmed).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, verify it fails** — `npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (module/function not found).
|
||||
|
||||
- [ ] **Step 3: Implement the helper** — create `src/server/services/finalist-enrollment.ts`:
|
||||
|
||||
```ts
|
||||
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
|
||||
import { signFinalistToken } from '@/lib/finalist-token'
|
||||
|
||||
type TxClient = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
/**
|
||||
* Re-invite-safe variant of createPendingConfirmation. If a confirmation row
|
||||
* already exists for the project (projectId is @unique), reset any
|
||||
* non-CONFIRMED row back to a fresh PENDING with a new token/deadline and
|
||||
* clear stale attendee rows; report CONFIRMED rows as a no-op so callers can
|
||||
* skip them. Returns the row id + token + deadline for the email step.
|
||||
*/
|
||||
export async function resetOrCreatePendingConfirmation(
|
||||
prisma: TxClient,
|
||||
args: { projectId: string; category: CompetitionCategory; windowHours: number },
|
||||
): Promise<{ id: string; token: string; deadline: Date; alreadyConfirmed: boolean }> {
|
||||
const deadline = new Date(Date.now() + args.windowHours * 3_600_000)
|
||||
const existing = await prisma.finalistConfirmation.findUnique({
|
||||
where: { projectId: args.projectId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (existing?.status === 'CONFIRMED') {
|
||||
return { id: existing.id, token: '', deadline, alreadyConfirmed: true }
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const token = signFinalistToken({
|
||||
confirmationId: existing.id,
|
||||
exp: Math.floor(deadline.getTime() / 1000),
|
||||
})
|
||||
// Clear any attendee rows from a prior cycle (cascade-deletes flight/visa/lunch).
|
||||
await prisma.attendingMember.deleteMany({ where: { confirmationId: existing.id } })
|
||||
await prisma.finalistConfirmation.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
category: args.category,
|
||||
status: 'PENDING',
|
||||
deadline,
|
||||
token,
|
||||
confirmedAt: null,
|
||||
declinedAt: null,
|
||||
declineReason: null,
|
||||
expiredAt: null,
|
||||
},
|
||||
})
|
||||
return { id: existing.id, token, deadline, alreadyConfirmed: false }
|
||||
}
|
||||
|
||||
const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||
const token = signFinalistToken({
|
||||
confirmationId: id,
|
||||
exp: Math.floor(deadline.getTime() / 1000),
|
||||
})
|
||||
await prisma.finalistConfirmation.create({
|
||||
data: {
|
||||
id,
|
||||
projectId: args.projectId,
|
||||
category: args.category,
|
||||
status: 'PENDING',
|
||||
deadline,
|
||||
token,
|
||||
},
|
||||
})
|
||||
return { id, token, deadline, alreadyConfirmed: false }
|
||||
}
|
||||
```
|
||||
|
||||
> Confirm `AttendingMember`→`FlightDetail`/`VisaApplication`/`MemberLunchPick` are `onDelete: Cascade` (they are, per schema 2789+); the `deleteMany` then cleans dependents. If `signFinalistToken`'s import path differs, match `src/server/services/finalist-confirmation.ts:2`.
|
||||
|
||||
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(finalist): re-invite-safe confirmation reset helper"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `finalist.enrollFinalists` — the unified entry point
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/finalist.ts` (add procedure; import the helper)
|
||||
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
|
||||
|
||||
**Procedure contract:**
|
||||
|
||||
```ts
|
||||
enrollFinalists: adminProcedure
|
||||
.input(z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string(), // the LIVE_FINAL round
|
||||
enrollments: z.array(z.object({
|
||||
projectId: z.string(),
|
||||
mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
|
||||
attendingUserIds: z.array(z.string()).optional(), // required for ADMIN_CONFIRM
|
||||
visaFlags: z.record(z.string(), z.boolean()).optional(),
|
||||
})).min(1),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => { /* see steps */ })
|
||||
```
|
||||
|
||||
Behavior per enrollment:
|
||||
1. Validate the project is in `programId` and read its `competitionCategory`, `defaultAttendeeCap`, team members + LEAD email.
|
||||
2. **Round membership:** `projectRoundState.createMany({ data: [{ projectId, roundId }], skipDuplicates: true })` (mirror `round.ts:545`).
|
||||
3. **Confirmation:** `resetOrCreatePendingConfirmation()`. If `alreadyConfirmed`, record `skipped: 'ALREADY_CONFIRMED'` and continue.
|
||||
4. If `mode === 'EMAIL'`: send `sendFinalistConfirmationEmail(lead.email, lead.name, project.title, deadline, confirmUrl)` inside a try/catch (never throw in the loop — mirror `finalist.ts:213`).
|
||||
5. If `mode === 'ADMIN_CONFIRM'`: validate `attendingUserIds` (non-empty, ≤ cap, all team members) then run the confirm transaction (attendees + visa rows + lunch picks) exactly as `finalist.ts:492-523`. No email.
|
||||
6. Audit `FINALIST_ENROLL` with `{ projectId, mode }`.
|
||||
7. Return `{ enrolled, emailed, adminConfirmed, skipped: [...] }`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (extend `finalist-enrollment.test.ts`). Build an admin caller via `createCaller(finalistRouter, {…SUPER_ADMIN})`, a program (`defaultAttendeeCap: 3`), a competition with a `LIVE_FINAL` round (`createTestRound(comp.id, { roundType: 'LIVE_FINAL', sortOrder: 99, configJson: { confirmationWindowHours: 24 } })`) and a MENTORING round with a `ProjectRoundState` for the project. Assert:
|
||||
|
||||
```ts
|
||||
// EMAIL mode
|
||||
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
|
||||
// ... call enrollFinalists with one { projectId, mode: 'EMAIL' }
|
||||
const prs = await prisma.projectRoundState.findFirst({ where: { projectId, roundId: liveFinalId } })
|
||||
expect(prs).not.toBeNull()
|
||||
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
|
||||
expect(conf.status).toBe('PENDING')
|
||||
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(0)
|
||||
})
|
||||
|
||||
// ADMIN_CONFIRM mode
|
||||
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
|
||||
// ... call with { projectId, mode: 'ADMIN_CONFIRM', attendingUserIds: [lead.id, member.id], visaFlags: { [member.id]: true } }
|
||||
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
|
||||
expect(conf.status).toBe('CONFIRMED')
|
||||
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(2)
|
||||
expect(await prisma.visaApplication.count({ where: { attendingMember: { confirmationId: conf.id } } })).toBe(1)
|
||||
})
|
||||
|
||||
// idempotent membership + re-invite
|
||||
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
|
||||
// pre-create DECLINED confirmation + a PRS; enroll EMAIL again
|
||||
// expect status PENDING and exactly one PRS row for (projectId, liveFinalId)
|
||||
})
|
||||
|
||||
// ADMIN_CONFIRM over cap rejects
|
||||
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
|
||||
await expect(/* 4 attendees with cap 3 */).rejects.toThrow(/cap/i)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify fail** — `npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (`enrollFinalists` not a function).
|
||||
|
||||
- [ ] **Step 3: Implement** the procedure in `finalist.ts`. Reuse: import `resetOrCreatePendingConfirmation` from `../services/finalist-enrollment`; reuse `sendFinalistConfirmationEmail`, `ensureLunchPickForAttendingMember`, `logAudit`, `signFinalistToken` already imported. For the ADMIN_CONFIRM transaction body, copy the exact `$transaction` block from `adminConfirm` (`finalist.ts:492-523`). Resolve `windowHours` from the LIVE_FINAL round's `configJson.confirmationWindowHours ?? 24` (mirror `finalist.ts:145`). Build `confirmUrl` from `NEXTAUTH_URL` (mirror `finalist.ts:191-204`).
|
||||
|
||||
- [ ] **Step 4: Run, verify pass** — same command. Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Refactor for DRY (optional within budget)** — extract the shared confirm transaction into `confirmAttendanceInTx(tx, { confirmationId, attendingUserIds, visaFlags })` in `finalist-enrollment.ts` and call it from both `adminConfirm` and `enrollFinalists`. Re-run `npx vitest run tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-enrollment.test.ts`. Expected: PASS both.
|
||||
|
||||
- [ ] **Step 6: Commit** — `git commit -am "feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `finalist.unenroll` — reverse membership + confirmation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/finalist.ts`
|
||||
- Test: `tests/unit/finalist-unenroll.test.ts`
|
||||
|
||||
**Contract:** `unenroll({ projectId, roundId })` →
|
||||
1. Delete the `FinalistConfirmation` for the project (cascade removes AttendingMember/FlightDetail/VisaApplication/lunch picks).
|
||||
2. Delete the LIVE_FINAL `ProjectRoundState` (`deleteMany({ where: { projectId, roundId } })`).
|
||||
3. Audit `FINALIST_UNENROLL`.
|
||||
4. Return `{ ok: true }`. (Mentor assignments are tied to the MENTORING round and are intentionally left untouched.)
|
||||
|
||||
- [ ] **Step 1: Failing test** — enroll (ADMIN_CONFIRM) a project, then `unenroll`; assert no confirmation, no attendees, no LIVE_FINAL PRS, and an audit row exists.
|
||||
- [ ] **Step 2: Run, verify fail.**
|
||||
- [ ] **Step 3: Implement** the procedure.
|
||||
- [ ] **Step 4: Run, verify pass** — `npx vitest run tests/unit/finalist-unenroll.test.ts`.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): unenroll reverses round membership + confirmation"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `finalist.listEnrollmentCandidates` — data for the UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/finalist.ts`
|
||||
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
|
||||
|
||||
**Contract:** `listEnrollmentCandidates({ programId })` resolves the program's competition, finds the MENTORING round and the LIVE_FINAL round, and returns:
|
||||
|
||||
```ts
|
||||
{
|
||||
liveFinalRoundId: string | null,
|
||||
attendeeCap: number,
|
||||
categories: Array<{
|
||||
category: CompetitionCategory,
|
||||
quota: number | null,
|
||||
confirmedCount: number,
|
||||
pendingCount: number,
|
||||
candidates: Array<{
|
||||
projectId: string,
|
||||
title: string,
|
||||
teamName: string | null,
|
||||
country: string | null,
|
||||
inLiveFinal: boolean, // has a LIVE_FINAL ProjectRoundState
|
||||
confirmationStatus: FinalistConfirmationStatus | null,
|
||||
teamMembers: Array<{ userId: string, name: string | null, role: 'LEAD' | 'MEMBER', email: string }>,
|
||||
}>,
|
||||
}>,
|
||||
}
|
||||
```
|
||||
|
||||
Source candidates from `ProjectRoundState` in the MENTORING round (mirror `round.ts:335`), join `project.finalistConfirmation.status`, a `ProjectRoundState` existence check against the LIVE_FINAL round for `inLiveFinal`, and `project.teamMembers` (for the ADMIN_CONFIRM picker). Group by `competitionCategory`; merge per-category `FinalistSlotQuota` + confirmed/pending counts (reuse the count logic from `finalist.listCategoryCounts`).
|
||||
|
||||
- [ ] **Step 1: Failing test** — set up a MENTORING round with one STARTUP project (PRS), assert the project appears under the STARTUP category with `inLiveFinal: false`, `confirmationStatus: null`, and its team members listed.
|
||||
- [ ] **Step 2: Run, verify fail.**
|
||||
- [ ] **Step 3: Implement.**
|
||||
- [ ] **Step 4: Run, verify pass.**
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(finalist): listEnrollmentCandidates query for enrollment UI"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Enrollment UI card on the LIVE_FINAL round page
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/grand-finale/finalist-enrollment-card.tsx`
|
||||
- Create: `src/components/admin/grand-finale/enroll-attendees-dialog.tsx`
|
||||
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (render the card in the grand-finale block, ~line 1528, above `FinalistSlotsCard`)
|
||||
|
||||
**Card UX (follow the visual pattern of `finalist-slots-card.tsx` / `waitlist-card.tsx`):**
|
||||
- `trpc.finalist.listEnrollmentCandidates.useQuery({ programId })`. Loading → Skeleton; empty → "No mentoring-round teams to enroll yet."
|
||||
- Group candidates by category with a header showing `confirmed/quota` (e.g. "Startup — 2/8 confirmed, 1 pending").
|
||||
- Each candidate row: checkbox, title + teamName + country, and a status badge (`Not enrolled` / `In round` / `Pending` / `Confirmed` / `Declined`). Confirmed/declined rows show an **Un-enroll** button instead of a checkbox.
|
||||
- Per selected row, a small mode toggle: **Email team** (default) or **Set attendees now**. Choosing "Set attendees now" opens `EnrollAttendeesDialog` (a ≤cap member multi-select with per-member "needs visa" checkbox) and stashes the chosen `attendingUserIds`/`visaFlags` on that row.
|
||||
- Footer: **Enroll selected** and **Enroll all eligible** (eligible = not already CONFIRMED). Calls `trpc.finalist.enrollFinalists.useMutation`, then `utils.finalist.listEnrollmentCandidates.invalidate()` + `utils.logistics.listConfirmations.invalidate()`. Toast the `{ enrolled, emailed, adminConfirmed, skipped }` summary.
|
||||
- Un-enroll buttons call `trpc.finalist.unenroll` behind an `AlertDialog` confirm ("This removes them from the Grand Final round and deletes their attendance record. Continue?").
|
||||
|
||||
> Use shadcn `AlertDialog` for confirms (no native `confirm()`), per house style and the no-keyboard-shortcuts preference (visible affordances only).
|
||||
|
||||
- [ ] **Step 1:** Build `enroll-attendees-dialog.tsx` (props: `members`, `cap`, `onConfirm(attendingUserIds, visaFlags)`).
|
||||
- [ ] **Step 2:** Build `finalist-enrollment-card.tsx` per the UX above.
|
||||
- [ ] **Step 3:** Render `<FinalistEnrollmentCard programId={programId} roundId={round.id} />` in the LIVE_FINAL grand-finale block in the round page.
|
||||
- [ ] **Step 4: Typecheck** — `npm run typecheck`. Expected: clean.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(grand-finale): finalist enrollment card on LIVE_FINAL round page"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Waitlist populate UI + confirmations-tab dead-end fixes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/grand-finale/waitlist-card.tsx`
|
||||
- Modify: `src/components/admin/logistics/confirmations-tab.tsx`
|
||||
|
||||
- [ ] **Step 1: Add-to-waitlist control** in `waitlist-card.tsx` — a category + project picker (project options = enrollment candidates not already confirmed/waitlisted) that calls the existing `trpc.finalist.addToWaitlist` mutation (which had no UI). Invalidate `listWaitlist` on success. Confirm `addToWaitlist`'s exact input shape at `finalist.ts:629` before wiring.
|
||||
|
||||
- [ ] **Step 2: Fix the dead-end copy** in `confirmations-tab.tsx:127` — replace "Use the grand-finale round page to send confirmations." with "Enroll finalists from the Grand Final round's Overview tab to start confirmations." (Or, if scope allows, render an inline `<Link>` to the round page.)
|
||||
|
||||
- [ ] **Step 3: Add row actions** to `confirmations-tab.tsx` (currently only PENDING rows show Confirm/Decline; all others show "—"):
|
||||
- CONFIRMED rows → **Un-confirm** button calling existing `trpc.finalist.unconfirm` (behind `AlertDialog`).
|
||||
- DECLINED / EXPIRED rows → **Re-invite** button calling `trpc.finalist.enrollFinalists` with `{ projectId, mode: 'EMAIL', roundId: liveFinalRoundId }` (re-invite-safe via Task 2). Needs the LIVE_FINAL roundId — fetch via `listEnrollmentCandidates` or add it to `listConfirmations`' payload.
|
||||
|
||||
- [ ] **Step 4: Typecheck** — `npm run typecheck`. Expected: clean.
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full verification
|
||||
|
||||
- [ ] **Step 1: Run the whole finalist/lunch/logistics suite** — `npx vitest run tests/unit/finalist-enrollment.test.ts tests/unit/finalist-unenroll.test.ts tests/unit/lunch-list-dishes-perm.test.ts tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-quotas.test.ts tests/unit/finalist-confirmation.test.ts`. Expected: all PASS (regression check on the refactor).
|
||||
- [ ] **Step 2: Typecheck + build** — `npm run typecheck && npm run build`. Expected: clean (build is required before any push, per CLAUDE.md).
|
||||
- [ ] **Step 3: Dev smoke (Playwright, server already on :3001 as super-admin)** — verify the end-to-end the audit proved was impossible:
|
||||
1. Mentoring round (R6) → ensure a project has a `ProjectRoundState` (advance one in if needed).
|
||||
2. Grand Final round (R7) Overview → the new Enrollment card lists it; enroll it in EMAIL mode.
|
||||
3. `/admin/logistics` → Confirmations tab now shows a PENDING row (no longer the dead-end empty state).
|
||||
4. Enroll a second team in ADMIN_CONFIRM mode with 2 attendees → Confirmations shows CONFIRMED with attendee count 2; Travel/Visas tabs now list those attendees.
|
||||
5. Confirm the LIVE_FINAL round's Projects tab now shows the enrolled teams (jury can see them).
|
||||
- [ ] **Step 4: Final commit / branch** — ensure work is on a feature branch (not `main`); summarize for review.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (coverage vs. the 4 BLOCKERs)
|
||||
|
||||
- BLOCKER 1 (no select-finalists UI) → Tasks 3 + 6 (`enrollFinalists` + enrollment card).
|
||||
- BLOCKER 2 (no waitlist populate UI) → Task 7 step 1.
|
||||
- BLOCKER 3 (lunch picker broken) → Task 1.
|
||||
- BLOCKER 4 (re-invite crash) → Task 2 (`resetOrCreatePendingConfirmation`), surfaced via Task 7 step 3 (Re-invite action).
|
||||
- Unified enroll + both attendee modes (locked decisions) → Task 3.
|
||||
- Un-confirm dead-end (HIGH) → Task 7 step 3.
|
||||
|
||||
**Out of scope for Wave 1 (deferred to later waves):** all the new transactional emails/notifications and reminders (Wave 2), the Email Templates tab (Wave 3), team-facing "My Logistics" + timezone/validation/export UX fixes (Wave 4). The only email this wave sends is the existing `sendFinalistConfirmationEmail` (now reachable via EMAIL-mode enroll and Re-invite).
|
||||
186
docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md
Normal file
186
docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md
Normal 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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FinalistConfirmation" ADD COLUMN "reminderSentAt" TIMESTAMP(3);
|
||||
@@ -2761,6 +2761,7 @@ model FinalistConfirmation {
|
||||
declinedAt DateTime?
|
||||
declineReason String? // optional free-text on decline
|
||||
expiredAt DateTime?
|
||||
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
|
||||
promotedFromWaitlistEntryId String? @unique // null for original finalists
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -214,6 +214,64 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
||||
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)
|
||||
{
|
||||
notificationType: 'FILTERING_COMPLETE',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TravelTab } from '@/components/admin/logistics/travel-tab'
|
||||
import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
|
||||
import { VisasTab } from '@/components/admin/logistics/visas-tab'
|
||||
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
|
||||
import { EmailTemplatesTab } from '@/components/admin/logistics/email-templates-tab'
|
||||
|
||||
export default function LogisticsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
@@ -56,9 +57,8 @@ export default function LogisticsPage() {
|
||||
<TabsTrigger value="lunch">
|
||||
<Salad className="mr-2 h-4 w-4" /> Lunch
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="email-templates" disabled>
|
||||
<TabsTrigger value="email-templates">
|
||||
<ScrollText className="mr-2 h-4 w-4" /> Email Templates
|
||||
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -77,6 +77,9 @@ export default function LogisticsPage() {
|
||||
<TabsContent value="lunch">
|
||||
<LunchTab programId={programId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="email-templates">
|
||||
<EmailTemplatesTab programId={programId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -95,6 +95,7 @@ import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round
|
||||
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
|
||||
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-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 { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||
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 */}
|
||||
{isGrandFinale && programId && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FinalistSlotsCard programId={programId} />
|
||||
<WaitlistCard programId={programId} />
|
||||
</div>
|
||||
<>
|
||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FinalistSlotsCard programId={programId} />
|
||||
<WaitlistCard programId={programId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Round Info + Project Breakdown */}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { CompetitionTimelineSidebar } from '@/components/applicant/competition-t
|
||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-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 { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||
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) */}
|
||||
<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) */}
|
||||
<MentorConversationCard projectId={project.id} />
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -180,8 +181,11 @@ function FinalistConfirmContent({ token }: { token: string }) {
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We'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>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/applicant">Go to my dashboard</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
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> {
|
||||
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 })
|
||||
}
|
||||
try {
|
||||
const result = await expirePendingPastDeadline(prisma)
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
const [expireResult, reminderResult] = await Promise.all([
|
||||
expirePendingPastDeadline(prisma),
|
||||
sendDueConfirmationReminders(prisma),
|
||||
])
|
||||
return NextResponse.json({ ok: true, ...expireResult, ...reminderResult })
|
||||
} catch (error) {
|
||||
console.error('[Cron] finalist-confirmations failed:', error)
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
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
|
||||
@@ -35,16 +36,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
)
|
||||
if (now < reminderAt || now >= deadline) continue
|
||||
|
||||
const ams = await prisma.attendingMember.findMany({
|
||||
where: {
|
||||
confirmation: {
|
||||
project: { programId: event.programId },
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
lunchPick: { is: { pickedAt: null } },
|
||||
},
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
})
|
||||
const ams = await selectUnpickedAttendees(prisma, event)
|
||||
for (const am of ams) {
|
||||
if (!am.user.email) continue
|
||||
try {
|
||||
|
||||
159
src/components/admin/grand-finale/enroll-attendees-dialog.tsx
Normal file
159
src/components/admin/grand-finale/enroll-attendees-dialog.tsx
Normal 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
|
||||
"Visa?" 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>
|
||||
)
|
||||
}
|
||||
456
src/components/admin/grand-finale/finalist-enrollment-card.tsx
Normal file
456
src/components/admin/grand-finale/finalist-enrollment-card.tsx
Normal 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'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)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'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'
|
||||
@@ -17,7 +18,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} 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 type { CompetitionCategory } from '@prisma/client'
|
||||
|
||||
@@ -25,6 +33,145 @@ interface Props {
|
||||
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' }> = {
|
||||
WAITING: { label: 'Waiting', variant: 'outline' },
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No waitlist entries yet.
|
||||
</p>
|
||||
<AddToWaitlistForm programId={programId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -161,6 +309,7 @@ export function WaitlistCard({ programId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<AddToWaitlistForm programId={programId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -14,6 +15,19 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
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 type { FinalistConfirmationStatus } from '@prisma/client'
|
||||
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
|
||||
@@ -52,17 +66,52 @@ function relativeFromNow(d: Date): string {
|
||||
}
|
||||
|
||||
export function ConfirmationsTab({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
open: boolean
|
||||
mode: AttendanceMode
|
||||
confirmationId: string | 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(
|
||||
{ programId },
|
||||
{ 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(() => {
|
||||
if (!data) return []
|
||||
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
|
||||
@@ -124,7 +173,7 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||
{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.'}
|
||||
</p>
|
||||
) : (
|
||||
@@ -218,6 +267,43 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
Decline
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
@@ -241,6 +327,60 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
222
src/components/admin/logistics/email-templates-tab.tsx
Normal file
222
src/components/admin/logistics/email-templates-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -11,15 +11,28 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} 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'
|
||||
|
||||
export function LunchRecapActions({
|
||||
programId,
|
||||
lunchEventId,
|
||||
recapSentAt,
|
||||
extraRecipientCount,
|
||||
}: {
|
||||
programId: string
|
||||
lunchEventId: string
|
||||
recapSentAt: Date | null
|
||||
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 } =
|
||||
trpc.lunch.getRecapPreview.useQuery(
|
||||
{ programId },
|
||||
@@ -68,6 +90,31 @@ export function LunchRecapActions({
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" /> Send recap now
|
||||
</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't picked a lunch dish yet. You can do this multiple
|
||||
times — it won't affect the automatic reminder window.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => sendReminders.mutate({ lunchEventId })}
|
||||
>
|
||||
Send reminders
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{recapSentAt
|
||||
|
||||
@@ -32,6 +32,7 @@ export function LunchTab({ programId }: { programId: string }) {
|
||||
/>
|
||||
<LunchRecapActions
|
||||
programId={programId}
|
||||
lunchEventId={event.id}
|
||||
recapSentAt={event.recapSentAt}
|
||||
extraRecipientCount={event.extraRecipients.length}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Loader2, Plane } from 'lucide-react'
|
||||
import { Download, Loader2, Plane } from 'lucide-react'
|
||||
import type { FlightDetailStatus } from '@prisma/client'
|
||||
|
||||
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) {
|
||||
const utils = trpc.useUtils()
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
@@ -306,11 +347,31 @@ export function TravelTab({ programId }: Props) {
|
||||
<Plane className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
|
||||
</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="unfilled" label="Unfilled" count={totals.unfilled} />
|
||||
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
|
||||
<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>
|
||||
</CardHeader>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} 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 type { VisaStatus } from '@prisma/client'
|
||||
|
||||
@@ -56,6 +56,53 @@ function nextDate(row: {
|
||||
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) {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
|
||||
@@ -117,12 +164,34 @@ export function VisasTab({ programId }: Props) {
|
||||
continue to flow over email and are never stored on this platform.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/settings?tab=edition">
|
||||
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
|
||||
Edition settings
|
||||
</Link>
|
||||
</Button>
|
||||
<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>
|
||||
<Link href="/admin/settings?tab=edition">
|
||||
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
|
||||
Edition settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusPill value="all" label="All" count={(data ?? []).length} />
|
||||
|
||||
291
src/components/applicant/my-logistics-card.tsx
Normal file
291
src/components/applicant/my-logistics-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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
|
||||
const CATEGORIES = {
|
||||
@@ -17,6 +17,7 @@ const CATEGORIES = {
|
||||
mentor: { label: 'Mentors', icon: GraduationCap },
|
||||
observer: { label: 'Observers', icon: Eye },
|
||||
admin: { label: 'Administrators', icon: Shield },
|
||||
logistics: { label: 'Logistics', icon: Plane },
|
||||
}
|
||||
|
||||
type NotificationSetting = {
|
||||
|
||||
341
src/lib/email.ts
341
src/lib/email.ts
@@ -336,7 +336,7 @@ function statCard(label: string, value: string | number): string {
|
||||
// Email Templates
|
||||
// =============================================================================
|
||||
|
||||
interface EmailTemplate {
|
||||
export interface EmailTemplate {
|
||||
subject: string
|
||||
html: 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
|
||||
*/
|
||||
@@ -2392,6 +2651,59 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
||||
(ctx.metadata?.programName as string) || 'MOPC',
|
||||
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,
|
||||
subjectOverride?: string
|
||||
): Promise<void> {
|
||||
// Safety net: always ensure linkUrl is absolute before passing to templates
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
const template = renderNotificationEmail(name, type, context, subjectOverride)
|
||||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||||
}
|
||||
|
||||
|
||||
@@ -2747,7 +2747,13 @@ export const applicantRouter = router({
|
||||
*/
|
||||
getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => {
|
||||
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: {
|
||||
program: { select: { id: true, defaultAttendeeCap: true } },
|
||||
teamMembers: {
|
||||
@@ -2858,4 +2864,124 @@ export const applicantRouter = router({
|
||||
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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
} from '../services/finalist-confirmation'
|
||||
import {
|
||||
createNotification,
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||
import { verifyFinalistToken } from '@/lib/finalist-token'
|
||||
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
|
||||
import {
|
||||
resetOrCreatePendingConfirmation,
|
||||
confirmAttendanceInTx,
|
||||
} from '../services/finalist-enrollment'
|
||||
|
||||
export const finalistRouter = router({
|
||||
/** List all per-category finalist slot quotas for a program. */
|
||||
@@ -294,6 +299,7 @@ export const finalistRouter = router({
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
programId: true,
|
||||
program: { select: { defaultAttendeeCap: true } },
|
||||
teamMembers: { select: { userId: true } },
|
||||
@@ -370,6 +376,22 @@ export const finalistRouter = router({
|
||||
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 }
|
||||
}),
|
||||
|
||||
@@ -386,7 +408,7 @@ export const finalistRouter = router({
|
||||
const payload = verifyFinalistToken(input.token)
|
||||
const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
|
||||
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.token !== input.token) {
|
||||
@@ -416,6 +438,22 @@ export const finalistRouter = router({
|
||||
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
|
||||
// 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 tx.finalistConfirmation.update({
|
||||
where: { id: confirmation.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
await confirmAttendanceInTx(tx, {
|
||||
confirmationId: confirmation.id,
|
||||
attendingUserIds: input.attendingUserIds,
|
||||
visaFlags: input.visaFlags,
|
||||
})
|
||||
await tx.attendingMember.createMany({
|
||||
data: input.attendingUserIds.map((userId) => ({
|
||||
confirmationId: confirmation.id,
|
||||
userId,
|
||||
needsVisa: input.visaFlags[userId] ?? false,
|
||||
})),
|
||||
})
|
||||
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({
|
||||
prisma: ctx.prisma,
|
||||
@@ -546,7 +559,19 @@ export const finalistRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
|
||||
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') {
|
||||
throw new TRPCError({
|
||||
@@ -573,6 +598,41 @@ export const finalistRouter = router({
|
||||
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({
|
||||
where: {
|
||||
@@ -781,6 +841,11 @@ export const finalistRouter = router({
|
||||
mentorId: true,
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
where: { role: 'LEAD' },
|
||||
take: 1,
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -839,6 +904,25 @@ export const finalistRouter = router({
|
||||
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 }
|
||||
}),
|
||||
|
||||
@@ -929,6 +1013,23 @@ export const finalistRouter = router({
|
||||
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 }
|
||||
}),
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FlightDetailStatus, VisaStatus } from '@prisma/client'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { createNotification, NotificationTypes } from '../services/in-app-notification'
|
||||
|
||||
export const logisticsRouter = router({
|
||||
/** 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)) {
|
||||
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({
|
||||
where: { attendingMemberId },
|
||||
create: { attendingMemberId, ...(data as object) },
|
||||
@@ -198,6 +205,56 @@ export const logisticsRouter = router({
|
||||
entityId: detail.id,
|
||||
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
|
||||
}),
|
||||
|
||||
@@ -281,6 +338,18 @@ export const logisticsRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.visaApplication.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
attendingMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
confirmation: {
|
||||
select: {
|
||||
project: { select: { title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Visa application not found' })
|
||||
@@ -313,6 +382,43 @@ export const logisticsRouter = router({
|
||||
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
|
||||
}),
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
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'
|
||||
|
||||
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
||||
@@ -39,7 +40,7 @@ export const lunchRouter = router({
|
||||
|
||||
// ─── Dish CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
listDishes: adminProcedure
|
||||
listDishes: protectedProcedure
|
||||
.input(z.object({ lunchEventId: z.string() }))
|
||||
.query(({ ctx, input }) =>
|
||||
ctx.prisma.dish.findMany({
|
||||
@@ -346,7 +347,11 @@ export const lunchRouter = router({
|
||||
await sendLunchRecapEmail(recipients, payload)
|
||||
} catch (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({
|
||||
where: { programId: input.programId },
|
||||
@@ -570,6 +575,45 @@ export const lunchRouter = router({
|
||||
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. */
|
||||
updateEvent: adminProcedure
|
||||
.input(
|
||||
|
||||
@@ -15,7 +15,123 @@ import {
|
||||
NotificationIcons,
|
||||
NotificationPriorities,
|
||||
} 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({
|
||||
/**
|
||||
@@ -253,102 +369,7 @@ export const notificationRouter = router({
|
||||
where: { notificationType },
|
||||
})
|
||||
|
||||
// Sample data for test emails based on category
|
||||
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 metadata = NOTIFICATION_SAMPLE_DATA[notificationType] || {}
|
||||
const label = setting?.label || notificationType
|
||||
|
||||
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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||
import { signFinalistToken } from '@/lib/finalist-token'
|
||||
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { notifyAdmins, createNotification, NotificationTypes } from './in-app-notification'
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -131,6 +153,31 @@ export async function expirePendingPastDeadline(
|
||||
entityId: c.id,
|
||||
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
|
||||
const round = await prisma.round.findFirst({
|
||||
where: {
|
||||
@@ -151,3 +198,116 @@ export async function expirePendingPastDeadline(
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
114
src/server/services/finalist-enrollment.ts
Normal file
114
src/server/services/finalist-enrollment.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,16 @@ export const NotificationTypes = {
|
||||
FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED',
|
||||
WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED',
|
||||
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
|
||||
|
||||
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
|
||||
@@ -129,6 +139,14 @@ export const NotificationIcons: Record<string, string> = {
|
||||
[NotificationTypes.AWARD_RESULTS]: 'Trophy',
|
||||
[NotificationTypes.AI_RANKING_COMPLETE]: 'BarChart3',
|
||||
[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
|
||||
@@ -155,6 +173,14 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
|
||||
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
|
||||
[NotificationTypes.AI_RANKING_COMPLETE]: 'normal',
|
||||
[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 {
|
||||
|
||||
32
src/server/services/lunch-reminders.ts
Normal file
32
src/server/services/lunch-reminders.ts
Normal 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 } } },
|
||||
})
|
||||
}
|
||||
276
tests/unit/applicant-my-logistics.test.ts
Normal file
276
tests/unit/applicant-my-logistics.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
255
tests/unit/finalist-comms.test.ts
Normal file
255
tests/unit/finalist-comms.test.ts
Normal 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 } })
|
||||
})
|
||||
})
|
||||
517
tests/unit/finalist-enrollment.test.ts
Normal file
517
tests/unit/finalist-enrollment.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
215
tests/unit/finalist-reminders.test.ts
Normal file
215
tests/unit/finalist-reminders.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
228
tests/unit/finalist-unenroll.test.ts
Normal file
228
tests/unit/finalist-unenroll.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
265
tests/unit/finalist-withdrawal.test.ts
Normal file
265
tests/unit/finalist-withdrawal.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
259
tests/unit/logistics-comms.test.ts
Normal file
259
tests/unit/logistics-comms.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -174,6 +174,64 @@ describe('logistics flight detail procedures', () => {
|
||||
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 () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
35
tests/unit/lunch-list-dishes-perm.test.ts
Normal file
35
tests/unit/lunch-list-dishes-perm.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
140
tests/unit/lunch-reminder-filter.test.ts
Normal file
140
tests/unit/lunch-reminder-filter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
tests/unit/notification-preview.test.ts
Normal file
62
tests/unit/notification-preview.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user