Files
MOPC-Portal/docs/superpowers/plans/2026-06-04-wave3-logistics-email-templates-tab.md
Matt 27bdf8cdef docs(logistics): Wave 3 plan — enable Email Templates tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:36:05 +02:00

9.0 KiB

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:
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:
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:
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: Commitgit 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 onBlurupdateEmailSetting({ 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: Commitgit 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: Commitgit 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.