Files
MOPC-Portal/docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md

187 lines
17 KiB
Markdown
Raw Normal View History

# 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).