Files
MOPC-Portal/docs/superpowers/plans/2026-06-04-wave2-logistics-comms.md
2026-06-04 16:02:12 +02:00

17 KiB

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.prismaFinalistConfirmation.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: Commitgit 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: Commitgit 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: Commitgit 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: Commitgit 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: Commitgit 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: Commitgit 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: Commitgit 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).