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

114 lines
9.0 KiB
Markdown

# 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:
```ts
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:
```ts
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:
```ts
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: Commit**`git 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 `onBlur``updateEmailSetting({ 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: Commit**`git 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: Commit**`git 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.