diff --git a/docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md b/docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md new file mode 100644 index 0000000..c9a3f9b --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md @@ -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/_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 ". 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).