docs(logistics): Wave 3 plan — enable Email Templates tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user