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 byNotificationEmailSetting(no row ORsendEmail=false→ no email) + usernotificationPreference ∈ {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 fallbackgetNotificationEmailTemplate(: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 viadocker/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 viateamMembers where role:'LEAD'; attendee email viaAttendingMember.user. FinalistConfirmationhas NOreminderSentAtyet (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.sqltests/unit/finalist-comms.test.ts— admin alerts + withdrawal notifications firetests/unit/finalist-reminders.test.ts— reminder cron sends + stamps + idempotenttests/unit/logistics-comms.test.ts— flight-confirmed + visa-status emails firetests/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 8NotificationTypesconstants (+ icons/priorities optional)src/lib/email.ts— 4 custom templates + register inNOTIFICATION_EMAIL_TEMPLATESprisma/seed-notification-settings.ts— 8 new setting rows (categorylogistics)src/server/routers/finalist.ts— admin alerts inconfirm/decline/adminDecline/manualPromote; withdrawal inadminDecline/unconfirm/unenrollsrc/server/services/finalist-confirmation.ts— admin alert inexpirePendingPastDeadline+promoteNextWaitlistEntry; newsendDueConfirmationReminderssrc/app/api/cron/finalist-confirmations/route.ts— callsendDueConfirmationReminderssrc/server/routers/logistics.ts— emails insetFlightStatus(→CONFIRMED) +updateVisaApplication(status transitions)src/app/api/cron/lunch-reminders/route.ts— fix attendee OR-filtersrc/server/routers/lunch.ts— surface recap send failure; addsendRemindersmutationsrc/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?(nearconfirmedAt/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
NotificationTypesobject (in-app-notification.ts:15), grouped under a// Logisticscomment, e.g.FINALIST_CONFIRMED: 'FINALIST_CONFIRMED',… throughVISA_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 usesgetEmailWrapper+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, ifhotelprovided, hotel name/address/link.getVisaStatusTemplate(name, projectTitle, status, note?)— status-specific copy forINVITATION_SENT/APPOINTMENT_BOOKED/GRANTED/DENIED. Then register them inNOTIFICATION_EMAIL_TEMPLATES(:2196) keyed by the type string, reading fields fromctx.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_SETTINGSinprisma/seed-notification-settings.ts(category'logistics'), allsendEmail: true, with clearlabel/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 (
promoteNextWaitlistEntryandmanualPromote) →FINALIST_WAITLIST_PROMOTED. -
Step 1: Failing tests — for
confirm,decline, andexpirePendingPastDeadline, assert anInAppNotificationrow with the righttypeis created for an admin user after the action (set up aSUPER_ADMINwithstatus:'ACTIVE'). Use the existing finalist test setup patterns. -
Step 2: Run → fail.
-
Step 3: Implement the
notifyAdminscalls. Import from../services/in-app-notification(note:finalist-confirmation.tsis a service — import directly). -
Step 4: Run → pass; re-run
tests/unit/finalist-confirmation.test.tsfor 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 aFINALIST_WITHDRAWNInAppNotificationexists 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. Inunenroll, 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-confirmsuites. - 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
FinalistConfirmationwherestatus:'PENDING' AND reminderSentAt IS NULL AND deadline > now AND deadline <= now + reminderHours. (Simplest: load all PENDING withreminderSentAt: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 } }), thenupdate reminderSentAt = now. Best-effort per row (try/catch). -
Reset
reminderSentAtis NOT needed (deadlines don't move here; if re-invited,resetOrCreatePendingConfirmationshould also clearreminderSentAt— ADD that field reset in the Wave 1 helper). -
Step 1: In
resetOrCreatePendingConfirmation(src/server/services/finalist-enrollment.ts), addreminderSentAt: nullto the resetupdatedata (so re-invited teams get a fresh reminder window). -
Step 2: Failing test — create a PENDING confirmation with
deadline = now + 6handreminderSentAt:null, a LIVE_FINAL round withreminderHoursBeforeDeadline: 12, a lead user; callsendDueConfirmationReminders; assertremindersSent===1, aFINALIST_REMINDERnotification exists for the lead, andreminderSentAtis now set. Second call →remindersSent===0(idempotent). -
Step 3: Run → fail.
-
Step 4: Implement
sendDueConfirmationRemindersand call it from the cron route (before or afterexpirePendingPastDeadline). -
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 toCONFIRMED: 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→ wheninput.statuschanges to one ofINVITATION_SENT|APPOINTMENT_BOOKED|GRANTED|DENIED(and differs fromexisting.status):createNotification({ userId: attendee.userId, type: VISA_STATUS_UPDATE, ..., metadata:{ projectTitle, status, note } }). Gate on nothing (visa outcomes are always relevant); include the adminnotesonly if appropriate — default: don't leak internal notes, send status-only copy. -
Step 1: Failing tests — set a flight to CONFIRMED → assert
TRAVEL_CONFIRMEDnotification for the attendee; update a visa toGRANTED→ assertVISA_STATUS_UPDATEnotification. (Reuselogistics-flight.test.ts/visa-admin.test.tssetup patterns.) -
Step 2: Run → fail.
-
Step 3: Implement. For
setFlightStatus, the procedure currently only hasflightDetailId; join toattendingMember.user+ program hotel. ForupdateVisaApplication, the existing row read already givesattendingMember— extend the select to includeuser+ 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
MemberLunchPickrow should be counted as needing a reminder. Assert the cron's selection query (extract it into a small exported helperselectUnpickedAttendees(prisma, event)for testability) returns that attendee. - Step 2: Run → fail (current
isfilter misses null-relation rows). - Step 3: Fix the filter to
OR: [{ lunchPick: { is: null } }, { lunchPick: { is: { pickedAt: null } } }](cronroute.ts:44). - Step 4: Recap failure surfacing (
lunch.ts:345): only stamprecapSentAt+ auditLUNCH_RECAP_SENTifsendLunchRecapEmailresolved; on failure, re-throw (or return{ ok:false, error }) so the admin sees a failure toast. Updatelunch-recap-actions.tsxto show the error. - Step 5: Add
lunch.sendRemindersmutation (admin) that runs the same selection +sendLunchReminderEmailloop as the cron for a givenlunchEventId, returns{ sent }; add a "Send reminders now" button tolunch-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.tsto 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 anInAppNotificationrow exists for the attendee (query DB) forTRAVEL_CONFIRMEDandVISA_STATUS_UPDATE. Decline a PENDING team → confirm adminFINALIST_DECLINED+ teamFINALIST_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 theInAppNotificationrow, not on actual delivery. - Prod gets the new
NotificationEmailSettingrows automatically viadocker-entrypoint.shrunningseed-notification-settings.tson deploy. - Deferred to later waves: Email Templates admin tab (Wave 3), team-facing "My Logistics" (Wave 4).