-
Coming in Layer 4
-
- This feature will be implemented in the next phase.
-
+
+
+
+
+
+
+ Available imports today
+ Run from the command line until the UI catches up.
+
+
+
+
+ Berths from NocoDB:
+
+
+ pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
+
+
+ Idempotent. Skips rows where updated_at > last_imported_at unless
+ you pass --force. Add --update-snapshot to also rewrite{' '}
+ src/lib/db/seed-data/berths.json.
+
+
+
+
+ Storage backend migration:
+
+
+ pnpm tsx scripts/migrate-storage.ts
+
+
+ Run after switching system_settings.storage_backend in System Settings.
+
+
+
+
+ Seed (rebuild dev fixtures):
+
+
+ pnpm db:seed
+
+
+
+
+
+
+
+ What this page will become
+ Planned UI for self-serve imports.
+
+
+
+ Drag-and-drop CSV / XLSX upload with column-mapping UI.
+ Dry-run preview that shows new vs. matched-existing rows before commit.
+ Conflict-resolution choices (skip, update, dedup-by-email) per import type.
+ Per-port import history with rollback.
+ Templates for clients, yachts, companies, berths, reservations, expenses.
+
+
+ Imports run against the BullMQ import queue (concurrency 1) so partial
+ failures don’t leave the database half-loaded.
+
+
+
);
diff --git a/src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx b/src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
new file mode 100644
index 0000000..92b2989
--- /dev/null
+++ b/src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
@@ -0,0 +1,5 @@
+import { InquiryInbox } from '@/components/admin/inquiry-inbox';
+
+export default function InquiriesPage() {
+ return
;
+}
diff --git a/src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx b/src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx
index d4f9abf..0c1956e 100644
--- a/src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx
@@ -1,15 +1,114 @@
+import Link from 'next/link';
+
import { PageHeader } from '@/components/shared/page-header';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+
+interface ChecklistItem {
+ href: string;
+ label: string;
+ description: string;
+}
+
+const CHECKLIST: ChecklistItem[] = [
+ {
+ href: 'branding',
+ label: 'Set port name, logo, primary colour',
+ description: 'Branding flows into the navbar, emails, and EOI PDFs.',
+ },
+ {
+ href: 'email',
+ label: 'Configure outgoing email',
+ description:
+ 'From-address, signature, footer, plus per-port SMTP overrides if you don’t use the global account.',
+ },
+ {
+ href: 'documenso',
+ label: 'Connect Documenso for EOIs',
+ description:
+ 'API credentials and the EOI template id, plus the in-app vs Documenso pathway choice.',
+ },
+ {
+ href: 'settings',
+ label: 'Tune business rules + recommender weights',
+ description:
+ 'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).',
+ },
+ {
+ href: 'roles',
+ label: 'Create roles & assign users',
+ description: 'Per-port roles inherit from the global system roles; override permissions here.',
+ },
+ {
+ href: 'invitations',
+ label: 'Invite the rest of the team',
+ description:
+ 'Invitations track pending, expired, and accepted state and can be resent or revoked.',
+ },
+ {
+ href: 'tags',
+ label: 'Define starter tags',
+ description: 'Color-coded labels used across clients, yachts, companies, and interests.',
+ },
+ {
+ href: 'forms',
+ label: 'Wire the website intake forms',
+ description:
+ 'Inquiry forms on the marketing site dual-write into the CRM via /api/public/website-inquiries.',
+ },
+];
export default function OnboardingPage() {
return (
-
-
-
-
Coming in Layer 4
-
- This feature will be implemented in the next phase.
-
-
+
+
+
+
+
+ Setup checklist
+
+ Work through these in order. The future onboarding wizard will track progress per port;
+ for now this is a guided index.
+
+
+
+
+ {CHECKLIST.map((item, idx) => (
+
+
+ {idx + 1}
+
+
+
+ {item.label}
+
+
{item.description}
+
+
+ ))}
+
+
+
+
+
+
+ What this page will become
+
+ A guided wizard that walks per-port admins through the same steps with progress
+ tracking.
+
+
+
+ The wizard will record completion per port in system_settings, gate the
+ public marketing-site cutover until required steps are done, and surface a banner on the
+ dashboard when onboarding is incomplete.
+
+
);
}
diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx
index 97bd3ce..bb0b670 100644
--- a/src/app/(dashboard)/[portSlug]/admin/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx
@@ -5,6 +5,7 @@ import {
Database,
FileText,
HardDrive,
+ Inbox,
Key,
LayoutDashboard,
Mail,
@@ -120,6 +121,12 @@ const GROUPS: AdminGroup[] = [
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
+ {
+ href: 'email-templates',
+ label: 'Email Templates',
+ description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
+ icon: Mail,
+ },
{
href: 'tags',
label: 'Tags',
@@ -138,6 +145,19 @@ const GROUPS: AdminGroup[] = [
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
+ {
+ href: 'inquiries',
+ label: 'Inquiry Inbox',
+ description:
+ 'Submissions captured from the public marketing site (berth, residence, contact).',
+ icon: Inbox,
+ },
+ {
+ href: 'sends',
+ label: 'Send Log',
+ description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
+ icon: Mail,
+ },
{
href: 'duplicates',
label: 'Duplicates',
diff --git a/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx b/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx
index 25ac86e..201f9a7 100644
--- a/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx
@@ -1,18 +1,5 @@
-import { PageHeader } from '@/components/shared/page-header';
+import { ReportsDashboard } from '@/components/admin/reports-dashboard';
-export default function ScheduledReportsPage() {
- return (
-
-
-
-
Coming in Layer 3
-
- This feature will be implemented in the next phase.
-
-
-
- );
+export default function AdminReportsPage() {
+ return
;
}
diff --git a/src/app/(dashboard)/[portSlug]/admin/sends/page.tsx b/src/app/(dashboard)/[portSlug]/admin/sends/page.tsx
new file mode 100644
index 0000000..86ee5ce
--- /dev/null
+++ b/src/app/(dashboard)/[portSlug]/admin/sends/page.tsx
@@ -0,0 +1,5 @@
+import { SendsLog } from '@/components/admin/sends-log';
+
+export default function SendsPage() {
+ return
;
+}
diff --git a/src/app/api/v1/admin/dashboard-stats/route.ts b/src/app/api/v1/admin/dashboard-stats/route.ts
new file mode 100644
index 0000000..34dfb90
--- /dev/null
+++ b/src/app/api/v1/admin/dashboard-stats/route.ts
@@ -0,0 +1,114 @@
+import { NextResponse } from 'next/server';
+import { and, eq, isNull, gte, sql } from 'drizzle-orm';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { db } from '@/lib/db';
+import { interests } from '@/lib/db/schema/interests';
+import { berths } from '@/lib/db/schema/berths';
+import { clients } from '@/lib/db/schema/clients';
+import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
+import { errorResponse } from '@/lib/errors';
+import { PIPELINE_STAGES } from '@/lib/constants';
+
+export const GET = withAuth(
+ withPermission('reports', 'view_dashboard', async (_req, ctx) => {
+ try {
+ const portId = ctx.portId;
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+
+ const [pipelineRows, berthStatusRows, totals, recent] = await Promise.all([
+ db
+ .select({
+ stage: interests.pipelineStage,
+ count: sql
`count(*)::int`,
+ })
+ .from(interests)
+ .where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
+ .groupBy(interests.pipelineStage),
+
+ db
+ .select({
+ status: berths.status,
+ count: sql`count(*)::int`,
+ })
+ .from(berths)
+ .where(eq(berths.portId, portId))
+ .groupBy(berths.status),
+
+ Promise.all([
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(clients)
+ .where(and(eq(clients.portId, portId), isNull(clients.archivedAt))),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(interests)
+ .where(and(eq(interests.portId, portId), isNull(interests.archivedAt))),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(berths)
+ .where(eq(berths.portId, portId)),
+ ]),
+
+ Promise.all([
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(websiteSubmissions)
+ .where(
+ and(
+ eq(websiteSubmissions.portId, portId),
+ gte(websiteSubmissions.receivedAt, sevenDaysAgo),
+ ),
+ ),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(interests)
+ .where(
+ and(
+ eq(interests.portId, portId),
+ eq(interests.pipelineStage, 'completed'),
+ gte(interests.updatedAt, thirtyDaysAgo),
+ ),
+ ),
+ ]),
+ ]);
+
+ const pipeline = Object.fromEntries(PIPELINE_STAGES.map((s) => [s, 0])) as Record<
+ string,
+ number
+ >;
+ for (const row of pipelineRows) pipeline[row.stage] = row.count;
+
+ const berthStatus: Record = {
+ available: 0,
+ under_offer: 0,
+ sold: 0,
+ };
+ for (const row of berthStatusRows) berthStatus[row.status] = row.count;
+
+ const totalClients = totals[0][0]?.count ?? 0;
+ const totalInterests = totals[1][0]?.count ?? 0;
+ const totalBerths = totals[2][0]?.count ?? 0;
+ const newInquiries7d = recent[0][0]?.count ?? 0;
+ const completed30d = recent[1][0]?.count ?? 0;
+
+ const closedTotal = pipeline['completed'] ?? 0;
+ const openTotal = totalInterests - closedTotal;
+ const conversionPct =
+ totalInterests > 0 ? Math.round((closedTotal / totalInterests) * 100) : 0;
+
+ return NextResponse.json({
+ data: {
+ totals: { totalClients, totalInterests, totalBerths },
+ recent: { newInquiries7d, completed30d },
+ pipeline,
+ berthStatus,
+ conversion: { closedTotal, openTotal, conversionPct },
+ },
+ });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/admin/document-sends/route.ts b/src/app/api/v1/admin/document-sends/route.ts
new file mode 100644
index 0000000..c7e7642
--- /dev/null
+++ b/src/app/api/v1/admin/document-sends/route.ts
@@ -0,0 +1,68 @@
+import { NextResponse } from 'next/server';
+import { and, desc, eq, isNotNull, isNull, sql, type SQL } from 'drizzle-orm';
+import { z } from 'zod';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseQuery } from '@/lib/api/route-helpers';
+import { db } from '@/lib/db';
+import { documentSends } from '@/lib/db/schema/brochures';
+import { errorResponse } from '@/lib/errors';
+
+const querySchema = z.object({
+ limit: z.coerce.number().int().min(1).max(100).default(50),
+ status: z.enum(['all', 'sent', 'failed']).default('all'),
+ kind: z.enum(['berth_pdf', 'brochure']).optional(),
+ cursorAt: z.string().optional(),
+ cursorId: z.string().optional(),
+});
+
+export const GET = withAuth(
+ withPermission('admin', 'view_audit_log', async (req, ctx) => {
+ try {
+ const query = parseQuery(req, querySchema);
+ const conds: SQL[] = [eq(documentSends.portId, ctx.portId)];
+ if (query.kind) conds.push(eq(documentSends.documentKind, query.kind));
+ if (query.status === 'failed') conds.push(isNotNull(documentSends.failedAt));
+ if (query.status === 'sent') conds.push(isNull(documentSends.failedAt));
+ if (query.cursorAt && query.cursorId) {
+ const cursorAt = new Date(query.cursorAt).toISOString();
+ conds.push(
+ sql`(${documentSends.sentAt}, ${documentSends.id}) < (${cursorAt}::timestamptz, ${query.cursorId})`,
+ );
+ }
+
+ const rows = await db
+ .select()
+ .from(documentSends)
+ .where(and(...conds))
+ .orderBy(desc(documentSends.sentAt), desc(documentSends.id))
+ .limit(query.limit + 1);
+
+ const hasMore = rows.length > query.limit;
+ const page = hasMore ? rows.slice(0, query.limit) : rows;
+ const last = page[page.length - 1];
+ const nextCursor =
+ hasMore && last ? { sentAt: last.sentAt.toISOString(), id: last.id } : null;
+
+ // Counts for the filter chips
+ const sentCountRows = await db
+ .select({ count: sql`count(*)::int` })
+ .from(documentSends)
+ .where(and(eq(documentSends.portId, ctx.portId), isNull(documentSends.failedAt)));
+ const failedCountRows = await db
+ .select({ count: sql`count(*)::int` })
+ .from(documentSends)
+ .where(and(eq(documentSends.portId, ctx.portId), isNotNull(documentSends.failedAt)));
+
+ const counts = {
+ sent: sentCountRows[0]?.count ?? 0,
+ failed: failedCountRows[0]?.count ?? 0,
+ all: (sentCountRows[0]?.count ?? 0) + (failedCountRows[0]?.count ?? 0),
+ };
+
+ return NextResponse.json({ data: page, pagination: { nextCursor }, counts });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/admin/email-templates/route.ts b/src/app/api/v1/admin/email-templates/route.ts
new file mode 100644
index 0000000..08e40e8
--- /dev/null
+++ b/src/app/api/v1/admin/email-templates/route.ts
@@ -0,0 +1,91 @@
+import { NextResponse } from 'next/server';
+import { eq, inArray } from 'drizzle-orm';
+import { z } from 'zod';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseBody } from '@/lib/api/route-helpers';
+import { db } from '@/lib/db';
+import { systemSettings } from '@/lib/db/schema/system';
+import {
+ TEMPLATE_CATALOG,
+ TEMPLATE_KEYS,
+ settingKeyForSubject,
+ type TemplateKey,
+} from '@/lib/email/template-catalog';
+import { upsertSetting, deleteSetting } from '@/lib/services/settings.service';
+import { errorResponse } from '@/lib/errors';
+
+const upsertSchema = z.object({
+ key: z.enum(TEMPLATE_KEYS),
+ subject: z.string().max(300).nullable(),
+});
+
+export const GET = withAuth(
+ withPermission('admin', 'manage_settings', async (_req, ctx) => {
+ try {
+ const subjectKeys = TEMPLATE_KEYS.map(settingKeyForSubject);
+ const rows = await db
+ .select({
+ key: systemSettings.key,
+ value: systemSettings.value,
+ portId: systemSettings.portId,
+ })
+ .from(systemSettings)
+ .where(inArray(systemSettings.key, subjectKeys));
+
+ const byKey = new Map();
+ for (const r of rows) {
+ const slot = byKey.get(r.key) ?? {};
+ if (r.portId === ctx.portId && typeof r.value === 'string') slot.port = r.value;
+ if (r.portId === null && typeof r.value === 'string') slot.global = r.value;
+ byKey.set(r.key, slot);
+ }
+
+ const data = TEMPLATE_KEYS.map((key) => {
+ const meta = TEMPLATE_CATALOG[key];
+ const settingKey = settingKeyForSubject(key);
+ const overrides = byKey.get(settingKey) ?? {};
+ const effective = overrides.port ?? overrides.global ?? meta.defaultSubject;
+ return {
+ key,
+ label: meta.label,
+ description: meta.description,
+ mergeTokens: meta.mergeTokens,
+ defaultSubject: meta.defaultSubject,
+ subjectOverride: overrides.port ?? null,
+ effectiveSubject: effective,
+ };
+ });
+
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const PUT = withAuth(
+ withPermission('admin', 'manage_settings', async (req, ctx) => {
+ try {
+ const body = await parseBody(req, upsertSchema);
+ const settingKey = settingKeyForSubject(body.key as TemplateKey);
+ const meta = {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ };
+ if (body.subject === null || body.subject === '') {
+ // Clear the override (and only at the per-port level — never touch global).
+ await deleteSetting(settingKey, ctx.portId, meta);
+ } else {
+ await upsertSetting(settingKey, body.subject, ctx.portId, meta);
+ }
+ return NextResponse.json({ data: { ok: true } });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+void eq;
diff --git a/src/app/api/v1/admin/website-submissions/route.ts b/src/app/api/v1/admin/website-submissions/route.ts
new file mode 100644
index 0000000..eddde2b
--- /dev/null
+++ b/src/app/api/v1/admin/website-submissions/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from 'next/server';
+import { and, desc, eq, lt, sql, type SQL } from 'drizzle-orm';
+import { z } from 'zod';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseQuery } from '@/lib/api/route-helpers';
+import { db } from '@/lib/db';
+import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
+import { errorResponse } from '@/lib/errors';
+
+const querySchema = z.object({
+ limit: z.coerce.number().int().min(1).max(100).default(50),
+ kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']).optional(),
+ cursorAt: z.string().optional(),
+ cursorId: z.string().optional(),
+});
+
+export const GET = withAuth(
+ withPermission('admin', 'view_audit_log', async (req, ctx) => {
+ try {
+ const query = parseQuery(req, querySchema);
+ const conds: SQL[] = [eq(websiteSubmissions.portId, ctx.portId)];
+ if (query.kind) conds.push(eq(websiteSubmissions.kind, query.kind));
+ if (query.cursorAt && query.cursorId) {
+ const cursorAt = new Date(query.cursorAt).toISOString();
+ conds.push(
+ sql`(${websiteSubmissions.receivedAt}, ${websiteSubmissions.id}) < (${cursorAt}::timestamptz, ${query.cursorId})`,
+ );
+ }
+
+ const rows = await db
+ .select()
+ .from(websiteSubmissions)
+ .where(and(...conds))
+ .orderBy(desc(websiteSubmissions.receivedAt), desc(websiteSubmissions.id))
+ .limit(query.limit + 1);
+
+ const hasMore = rows.length > query.limit;
+ const page = hasMore ? rows.slice(0, query.limit) : rows;
+ const last = page[page.length - 1];
+ const nextCursor =
+ hasMore && last ? { receivedAt: last.receivedAt.toISOString(), id: last.id } : null;
+
+ // Lightweight count by kind for the page header
+ const countsRows = await db
+ .select({ kind: websiteSubmissions.kind, count: sql`count(*)::int` })
+ .from(websiteSubmissions)
+ .where(eq(websiteSubmissions.portId, ctx.portId))
+ .groupBy(websiteSubmissions.kind);
+ const counts = Object.fromEntries(countsRows.map((r) => [r.kind, r.count])) as Record<
+ string,
+ number
+ >;
+
+ return NextResponse.json({
+ data: page,
+ pagination: { nextCursor },
+ counts,
+ });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+// Suppress lt unused-import lint
+void lt;
diff --git a/src/components/admin/email-templates-admin.tsx b/src/components/admin/email-templates-admin.tsx
new file mode 100644
index 0000000..5bff0b8
--- /dev/null
+++ b/src/components/admin/email-templates-admin.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { RotateCcw, Save } from 'lucide-react';
+
+import { PageHeader } from '@/components/shared/page-header';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { apiFetch } from '@/lib/api/client';
+
+interface TemplateRow {
+ key: string;
+ label: string;
+ description: string;
+ mergeTokens: string[];
+ defaultSubject: string;
+ subjectOverride: string | null;
+ effectiveSubject: string;
+}
+
+export function EmailTemplatesAdmin() {
+ const qc = useQueryClient();
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['admin-email-templates'],
+ queryFn: () => apiFetch<{ data: TemplateRow[] }>('/api/v1/admin/email-templates'),
+ });
+
+ const [drafts, setDrafts] = useState>({});
+ const [savingKey, setSavingKey] = useState(null);
+ const [message, setMessage] = useState<{ key: string; kind: 'ok' | 'err'; text: string } | null>(
+ null,
+ );
+
+ const rows = useMemo(() => data?.data ?? [], [data]);
+
+ useEffect(() => {
+ // Hydrate drafts from server values whenever the source-of-truth list refreshes.
+ const next: Record = {};
+ for (const row of rows) {
+ next[row.key] = row.subjectOverride ?? row.defaultSubject;
+ }
+ setDrafts(next);
+ }, [rows]);
+
+ async function save(row: TemplateRow, mode: 'save' | 'reset') {
+ setSavingKey(row.key);
+ setMessage(null);
+ try {
+ const subject = mode === 'reset' ? null : (drafts[row.key] ?? '');
+ await apiFetch('/api/v1/admin/email-templates', {
+ method: 'PUT',
+ body: { key: row.key, subject },
+ });
+ await qc.invalidateQueries({ queryKey: ['admin-email-templates'] });
+ setMessage({
+ key: row.key,
+ kind: 'ok',
+ text: mode === 'reset' ? 'Reset to default' : 'Saved',
+ });
+ } catch (err) {
+ setMessage({
+ key: row.key,
+ kind: 'err',
+ text: err instanceof Error ? err.message : 'Failed',
+ });
+ } finally {
+ setSavingKey(null);
+ }
+ }
+
+ return (
+
+
+
+
+ {isLoading ? (
+
Loading…
+ ) : error ? (
+
+ Failed to load templates: {error instanceof Error ? error.message : 'unknown error'}
+
+ ) : (
+ rows.map((row) => {
+ const draft = drafts[row.key] ?? row.defaultSubject;
+ const dirty =
+ draft !== (row.subjectOverride ?? row.defaultSubject) ||
+ (row.subjectOverride !== null && draft === row.defaultSubject);
+ const overridden = row.subjectOverride !== null;
+ return (
+
+
+
+ {row.label}
+ {overridden ? (
+ Overridden
+ ) : (
+ Default
+ )}
+
+ {row.description}
+
+
+
+
+ Subject
+
+
+ setDrafts((prev) => ({ ...prev, [row.key]: e.target.value }))
+ }
+ className="mt-1 font-mono text-sm"
+ />
+
+
+ Default: {row.defaultSubject}
+
+
+ Available tokens:{' '}
+ {row.mergeTokens.map((t) => (
+ {`{{${t}}}`}
+ ))}
+
+
+ save(row, 'save')}
+ disabled={savingKey === row.key || !dirty}
+ >
+ Save
+
+ {overridden ? (
+ save(row, 'reset')}
+ disabled={savingKey === row.key}
+ >
+ Reset to default
+
+ ) : null}
+ {message?.key === row.key ? (
+
+ {message.text}
+
+ ) : null}
+
+
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/src/components/admin/inquiry-inbox.tsx b/src/components/admin/inquiry-inbox.tsx
new file mode 100644
index 0000000..8648482
--- /dev/null
+++ b/src/components/admin/inquiry-inbox.tsx
@@ -0,0 +1,206 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { formatDistanceToNow } from 'date-fns';
+
+import { PageHeader } from '@/components/shared/page-header';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { apiFetch } from '@/lib/api/client';
+
+interface Submission {
+ id: string;
+ portId: string;
+ submissionId: string;
+ kind: 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
+ payload: Record | null;
+ legacyNocodbId: string | null;
+ sourceIp: string | null;
+ userAgent: string | null;
+ receivedAt: string;
+}
+
+interface ListResponse {
+ data: Submission[];
+ pagination: { nextCursor: { receivedAt: string; id: string } | null };
+ counts: Record;
+}
+
+const KIND_LABELS: Record = {
+ berth_inquiry: 'Berth inquiry',
+ residence_inquiry: 'Residence inquiry',
+ contact_form: 'Contact form',
+};
+
+const KIND_COLORS: Record = {
+ berth_inquiry: 'bg-blue-100 text-blue-800',
+ residence_inquiry: 'bg-amber-100 text-amber-800',
+ contact_form: 'bg-slate-100 text-slate-800',
+};
+
+function pickName(payload: Record | null): string {
+ if (!payload) return '';
+ const candidates = ['name', 'fullName', 'full_name', 'firstName', 'first_name'];
+ for (const k of candidates) {
+ const v = payload[k];
+ if (typeof v === 'string' && v.trim()) return v.trim();
+ }
+ return '';
+}
+
+function pickEmail(payload: Record | null): string {
+ if (!payload) return '';
+ const v = payload['email'];
+ return typeof v === 'string' ? v : '';
+}
+
+function pickPhone(payload: Record | null): string {
+ if (!payload) return '';
+ const v = payload['phone'] ?? payload['phoneNumber'] ?? payload['phone_number'];
+ return typeof v === 'string' ? v : '';
+}
+
+export function InquiryInbox() {
+ const [kind, setKind] = useState('all');
+ const [expanded, setExpanded] = useState(null);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['inquiry-inbox', kind],
+ queryFn: () =>
+ apiFetch(
+ `/api/v1/admin/website-submissions${kind === 'all' ? '' : `?kind=${kind}`}`,
+ ),
+ });
+
+ const counts = data?.counts ?? {};
+ const totalAll = useMemo(() => Object.values(counts).reduce((sum, n) => sum + n, 0), [counts]);
+
+ const rows = data?.data ?? [];
+
+ return (
+
+
+
+
+ setKind('all')}
+ />
+ setKind('berth_inquiry')}
+ />
+ setKind('residence_inquiry')}
+ />
+ setKind('contact_form')}
+ />
+
+
+
+ {isLoading ? (
+
Loading…
+ ) : error ? (
+
+ Failed to load inquiries: {error instanceof Error ? error.message : 'unknown error'}
+
+ ) : rows.length === 0 ? (
+
+
+ No website submissions yet for this filter.
+
+
+ ) : (
+
+ {rows.map((row) => {
+ const name = pickName(row.payload);
+ const email = pickEmail(row.payload);
+ const phone = pickPhone(row.payload);
+ const ago = formatDistanceToNow(new Date(row.receivedAt), { addSuffix: true });
+ const isOpen = expanded === row.id;
+ return (
+
+
+
+
+
+ {KIND_LABELS[row.kind]}
+
+ {ago}
+
+
+
+ {name || '(no name supplied)'}
+
+
+ {email ? {email} : null}
+ {phone ? {phone} : null}
+ {row.sourceIp ? (
+ from {row.sourceIp}
+ ) : null}
+
+
+
setExpanded(isOpen ? null : row.id)}
+ >
+ {isOpen ? 'Hide payload' : 'View payload'}
+
+
+
+ {isOpen && (
+
+
+ {JSON.stringify(row.payload, null, 2)}
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+function FilterChip({
+ label,
+ active,
+ onClick,
+}: {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/components/admin/reports-dashboard.tsx b/src/components/admin/reports-dashboard.tsx
new file mode 100644
index 0000000..2394bdf
--- /dev/null
+++ b/src/components/admin/reports-dashboard.tsx
@@ -0,0 +1,206 @@
+'use client';
+
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { useQuery } from '@tanstack/react-query';
+
+import { PageHeader } from '@/components/shared/page-header';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { apiFetch } from '@/lib/api/client';
+import { PIPELINE_STAGES, STAGE_LABELS, type PipelineStage } from '@/lib/constants';
+
+interface DashboardStats {
+ totals: { totalClients: number; totalInterests: number; totalBerths: number };
+ recent: { newInquiries7d: number; completed30d: number };
+ pipeline: Record;
+ berthStatus: { available: number; under_offer: number; sold: number };
+ conversion: { closedTotal: number; openTotal: number; conversionPct: number };
+}
+
+const BERTH_STATUS_COLORS: Record = {
+ available: 'bg-green-500',
+ under_offer: 'bg-amber-500',
+ sold: 'bg-slate-500',
+};
+
+const BERTH_STATUS_LABELS: Record = {
+ available: 'Available',
+ under_offer: 'Under offer',
+ sold: 'Sold',
+};
+
+export function ReportsDashboard() {
+ const params = useParams<{ portSlug: string }>();
+ const portSlug = params?.portSlug ?? '';
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['admin-dashboard-stats'],
+ queryFn: () => apiFetch<{ data: DashboardStats }>('/api/v1/admin/dashboard-stats'),
+ refetchInterval: 60_000,
+ });
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+ if (error || !data) {
+ return (
+
+
+
+ Failed to load stats: {error instanceof Error ? error.message : 'unknown error'}
+
+
+ );
+ }
+
+ const stats = data.data;
+ const maxStageCount = Math.max(1, ...Object.values(stats.pipeline));
+ const totalBerths =
+ stats.berthStatus.available + stats.berthStatus.under_offer + stats.berthStatus.sold;
+
+ return (
+
+
+
+ {/* KPI tiles */}
+
+
+
+
+ 0 ? 'success' : undefined}
+ />
+
+
+
+ {/* Pipeline funnel */}
+
+
+ Pipeline funnel
+ Open interests by stage (excludes archived).
+
+
+
+
+ Conversion (completed / total): {' '}
+ {stats.conversion.conversionPct}%
+
+
+
+
+ {/* Berth occupancy */}
+
+
+ Berth occupancy
+
+ Current public-status mix for {totalBerths} berths in this port.
+
+
+
+
+ {(['available', 'under_offer', 'sold'] as const).map((status) => {
+ const n = stats.berthStatus[status];
+ const pct = totalBerths === 0 ? 0 : Math.round((n / totalBerths) * 100);
+ return (
+
+
+
+
+ {BERTH_STATUS_LABELS[status]}
+
+
+ {n} · {pct}%
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ Need scheduled or downloadable reports?{' '}
+
+ Open the report generator
+ {' '}
+ to produce PDF exports of these views.
+
+
+ );
+}
+
+function KpiTile({
+ label,
+ value,
+ href,
+ accent,
+}: {
+ label: string;
+ value: number;
+ href?: string;
+ accent?: 'success' | 'danger';
+}) {
+ const accentClass =
+ accent === 'success' ? 'text-green-700' : accent === 'danger' ? 'text-red-700' : '';
+ const inner = (
+
+
+ {value}
+ {label}
+
+
+ );
+ return href ? (
+
+ {inner}
+
+ ) : (
+ inner
+ );
+}
diff --git a/src/components/admin/roles/role-form.tsx b/src/components/admin/roles/role-form.tsx
index fc7fde7..fd89a97 100644
--- a/src/components/admin/roles/role-form.tsx
+++ b/src/components/admin/roles/role-form.tsx
@@ -17,7 +17,8 @@ import {
} from '@/components/ui/accordion';
import { apiFetch } from '@/lib/api/client';
-/** Default permissions structure matching RolePermissions type */
+/** Default permissions structure matching RolePermissions type in
+ * src/lib/db/schema/users.ts. Keep this in sync when actions are added. */
const DEFAULT_PERMISSIONS: Record> = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
interests: {
@@ -33,6 +34,7 @@ const DEFAULT_PERMISSIONS: Record> = {
documents: {
view: false,
create: false,
+ edit: false,
send_for_signing: false,
upload_signed: false,
delete: false,
@@ -54,7 +56,7 @@ const DEFAULT_PERMISSIONS: Record> = {
record_payment: false,
export: false,
},
- files: { view: false, upload: false, delete: false, manage_folders: false },
+ files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
email: { view: false, send: false, configure_account: false },
reminders: {
view_own: false,
@@ -67,6 +69,10 @@ const DEFAULT_PERMISSIONS: Record> = {
calendar: { connect: false, view_events: false },
reports: { view_dashboard: false, view_analytics: false, export: false },
document_templates: { view: false, generate: false, manage: false },
+ yachts: { view: false, create: false, edit: false, delete: false, transfer: false },
+ companies: { view: false, create: false, edit: false, delete: false },
+ memberships: { view: false, manage: false },
+ reservations: { view: false, create: false, activate: false, cancel: false },
admin: {
manage_users: false,
view_audit_log: false,
@@ -101,7 +107,13 @@ const GROUP_LABELS: Record = {
calendar: 'Calendar',
reports: 'Reports',
document_templates: 'Document Templates',
+ yachts: 'Yachts',
+ companies: 'Companies',
+ memberships: 'Company Memberships',
+ reservations: 'Reservations',
admin: 'Administration',
+ residential_clients: 'Residential Clients',
+ residential_interests: 'Residential Interests',
};
function formatAction(action: string): string {
diff --git a/src/components/admin/sends-log.tsx b/src/components/admin/sends-log.tsx
new file mode 100644
index 0000000..937c5cb
--- /dev/null
+++ b/src/components/admin/sends-log.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { formatDistanceToNow, format } from 'date-fns';
+
+import { PageHeader } from '@/components/shared/page-header';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { apiFetch } from '@/lib/api/client';
+
+interface SendRow {
+ id: string;
+ portId: string;
+ recipientEmail: string;
+ documentKind: 'berth_pdf' | 'brochure' | string;
+ fromAddress: string;
+ bodyMarkdown: string | null;
+ sentAt: string;
+ failedAt: string | null;
+ errorReason: string | null;
+ fallbackToLinkReason: string | null;
+ messageId: string | null;
+ berthId: string | null;
+ brochureId: string | null;
+ clientId: string | null;
+ interestId: string | null;
+}
+
+interface ListResponse {
+ data: SendRow[];
+ pagination: { nextCursor: { sentAt: string; id: string } | null };
+ counts: { sent: number; failed: number; all: number };
+}
+
+export function SendsLog() {
+ const [status, setStatus] = useState<'all' | 'sent' | 'failed'>('all');
+ const [expanded, setExpanded] = useState(null);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['document-sends', status],
+ queryFn: () => apiFetch(`/api/v1/admin/document-sends?status=${status}`),
+ });
+
+ const counts = data?.counts ?? { sent: 0, failed: 0, all: 0 };
+ const rows = data?.data ?? [];
+
+ return (
+
+
+
+
+ setStatus('all')}
+ />
+ setStatus('sent')}
+ />
+ setStatus('failed')}
+ accent={counts.failed > 0 ? 'danger' : undefined}
+ />
+
+
+
+ {isLoading ? (
+
Loading…
+ ) : error ? (
+
+ Failed to load sends: {error instanceof Error ? error.message : 'unknown error'}
+
+ ) : rows.length === 0 ? (
+
+
+ No sends recorded for this filter yet.
+
+
+ ) : (
+
+ {rows.map((row) => {
+ const sent = new Date(row.sentAt);
+ const ago = formatDistanceToNow(sent, { addSuffix: true });
+ const isOpen = expanded === row.id;
+ const failed = !!row.failedAt;
+ return (
+
+
+
+
+
+
+ {failed ? 'Failed' : 'Sent'}
+
+
+ {row.documentKind === 'berth_pdf'
+ ? 'Berth PDF'
+ : row.documentKind === 'brochure'
+ ? 'Brochure'
+ : row.documentKind}
+
+ {row.fallbackToLinkReason ? (
+
+ Switched to download link
+
+ ) : null}
+
+ {ago} · {format(sent, 'PP p')}
+
+
+
+ {row.recipientEmail}
+
+
+ From {row.fromAddress}
+ {row.messageId ? (
+ {row.messageId}
+ ) : null}
+
+ {failed && row.errorReason ? (
+
+ {row.errorReason}
+
+ ) : null}
+ {row.fallbackToLinkReason ? (
+
+ Attachment dropped → sent as link. Reason: {row.fallbackToLinkReason}
+
+ ) : null}
+
+ {row.bodyMarkdown ? (
+
setExpanded(isOpen ? null : row.id)}
+ >
+ {isOpen ? 'Hide body' : 'View body'}
+
+ ) : null}
+
+
+ {isOpen && row.bodyMarkdown ? (
+
+
+ {row.bodyMarkdown}
+
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+function FilterChip({
+ label,
+ active,
+ onClick,
+ accent,
+}: {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+ accent?: 'danger';
+}) {
+ const base = active
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background text-foreground border-border hover:bg-muted';
+ const dangerActive =
+ accent === 'danger' && active ? 'bg-red-600 text-white border-red-600' : null;
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx
index f9a7eb1..c038b2f 100644
--- a/src/components/admin/settings/settings-manager.tsx
+++ b/src/components/admin/settings/settings-manager.tsx
@@ -118,6 +118,77 @@ const KNOWN_SETTINGS: Array<{
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
},
},
+ // ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ──────
+ {
+ key: 'recommender_max_oversize_pct',
+ label: 'Recommender — max oversize %',
+ description:
+ 'Cap on how much larger a berth can be than the desired length/width/draft before it stops being suggested. Default 30.',
+ type: 'number',
+ defaultValue: 30,
+ },
+ {
+ key: 'recommender_top_n_default',
+ label: 'Recommender — default result count',
+ description: 'Default number of berth recommendations returned per request. Default 8.',
+ type: 'number',
+ defaultValue: 8,
+ },
+ {
+ key: 'fallthrough_policy',
+ label: 'Recommender — fall-through policy',
+ description:
+ 'How berths re-enter the recommender after a lost deal. One of: immediate_with_heat, cooldown, never_auto_recommend.',
+ type: 'string',
+ defaultValue: 'immediate_with_heat',
+ },
+ {
+ key: 'fallthrough_cooldown_days',
+ label: 'Recommender — fall-through cooldown (days)',
+ description:
+ 'Days a berth stays out of the recommender after a lost deal when the policy is `cooldown`. Default 30.',
+ type: 'number',
+ defaultValue: 30,
+ },
+ {
+ key: 'heat_weight_recency',
+ label: 'Heat weight — recency',
+ description: 'Weight given to how recently the prior interest fell through. Default 30.',
+ type: 'number',
+ defaultValue: 30,
+ },
+ {
+ key: 'heat_weight_furthest_stage',
+ label: 'Heat weight — furthest stage',
+ description:
+ 'Weight given to how close the prior interest got to closing before falling through. Default 40.',
+ type: 'number',
+ defaultValue: 40,
+ },
+ {
+ key: 'heat_weight_interest_count',
+ label: 'Heat weight — historical interest count',
+ description:
+ 'Weight given to how often this berth has attracted interest historically. Default 15.',
+ type: 'number',
+ defaultValue: 15,
+ },
+ {
+ key: 'heat_weight_eoi_count',
+ label: 'Heat weight — historical EOI count',
+ description:
+ 'Weight given to how often interest in this berth has reached EOI signing. Default 15.',
+ type: 'number',
+ defaultValue: 15,
+ },
+ {
+ key: 'tier_ladder_hide_late_stage',
+ label: 'Recommender — hide late-stage tier',
+ description:
+ 'Hide berths whose only active interests are late-stage (close to closing) from recommendations.',
+ type: 'boolean',
+ defaultValue: true,
+ },
];
export function SettingsManager() {
diff --git a/src/lib/email/template-catalog.ts b/src/lib/email/template-catalog.ts
new file mode 100644
index 0000000..c061bed
--- /dev/null
+++ b/src/lib/email/template-catalog.ts
@@ -0,0 +1,103 @@
+/**
+ * Catalog of transactional email templates that admins can customize from
+ * /admin/email-templates. v1 supports subject-line overrides only; body
+ * overrides (HTML / merge-token authoring) are a follow-on iteration.
+ *
+ * To add a template here:
+ * 1. Pick a stable `key` and add it to TEMPLATE_KEYS (used for the
+ * `system_settings` row name).
+ * 2. List the merge tokens that the template renders so the admin
+ * knows what placeholders are valid in any future override.
+ * 3. Provide a `defaultSubject` string identical to what the code
+ * template emits when no override is set. Subject comparisons in
+ * the admin UI rely on this.
+ */
+
+export const TEMPLATE_KEYS = [
+ 'portal_activation',
+ 'portal_reset',
+ 'portal_invite_resend',
+ 'crm_invite',
+ 'inquiry_client_confirmation',
+ 'inquiry_sales_notification',
+ 'residential_inquiry_client_confirmation',
+ 'residential_inquiry_sales_alert',
+] as const;
+
+export type TemplateKey = (typeof TEMPLATE_KEYS)[number];
+
+export interface TemplateMetadata {
+ key: TemplateKey;
+ label: string;
+ description: string;
+ /** Token names available inside the subject (and future body) overrides. */
+ mergeTokens: string[];
+ /** The literal subject the code template uses when no override is set. */
+ defaultSubject: string;
+}
+
+export const TEMPLATE_CATALOG: Record = {
+ portal_activation: {
+ key: 'portal_activation',
+ label: 'Portal — activation invite',
+ description:
+ 'Sent to a client when an admin invites them to activate their portal account. Contains the activation link.',
+ mergeTokens: ['portName', 'recipientName', 'ttlHours'],
+ defaultSubject: 'Activate your {{portName}} client portal account',
+ },
+ portal_reset: {
+ key: 'portal_reset',
+ label: 'Portal — password reset',
+ description:
+ 'Sent when a portal user requests a password reset. Contains the reset link with a short TTL.',
+ mergeTokens: ['portName', 'recipientName', 'ttlMinutes'],
+ defaultSubject: 'Reset your {{portName}} client portal password',
+ },
+ portal_invite_resend: {
+ key: 'portal_invite_resend',
+ label: 'Portal — invite resend',
+ description: 'Re-sent activation email when an admin resends a pending portal invite.',
+ mergeTokens: ['portName', 'recipientName', 'ttlHours'],
+ defaultSubject: 'Activate your {{portName}} client portal account',
+ },
+ crm_invite: {
+ key: 'crm_invite',
+ label: 'CRM — staff invite',
+ description: 'Sent to a new staff user when an admin invites them to the CRM.',
+ mergeTokens: ['portName', 'recipientName', 'ttlHours'],
+ defaultSubject: 'You have been invited to {{portName}} CRM',
+ },
+ inquiry_client_confirmation: {
+ key: 'inquiry_client_confirmation',
+ label: 'Inquiry — client confirmation',
+ description: 'Auto-reply confirmation sent to the client after a website berth inquiry.',
+ mergeTokens: ['portName', 'recipientName', 'mooringNumber'],
+ defaultSubject: 'We received your inquiry — {{portName}}',
+ },
+ inquiry_sales_notification: {
+ key: 'inquiry_sales_notification',
+ label: 'Inquiry — sales notification',
+ description: 'Internal alert sent to the sales team when a new website inquiry arrives.',
+ mergeTokens: ['portName', 'clientName', 'mooringNumber', 'email'],
+ defaultSubject: 'New berth inquiry — {{clientName}}',
+ },
+ residential_inquiry_client_confirmation: {
+ key: 'residential_inquiry_client_confirmation',
+ label: 'Residential inquiry — client confirmation',
+ description: 'Auto-reply sent to the client after a residential property inquiry.',
+ mergeTokens: ['portName', 'recipientName'],
+ defaultSubject: 'We received your residential inquiry — {{portName}}',
+ },
+ residential_inquiry_sales_alert: {
+ key: 'residential_inquiry_sales_alert',
+ label: 'Residential inquiry — sales alert',
+ description: 'Internal alert sent to residential sales recipients when an inquiry arrives.',
+ mergeTokens: ['portName', 'clientName', 'email', 'phone'],
+ defaultSubject: 'New residential inquiry — {{clientName}}',
+ },
+};
+
+/** system_settings key for a template's subject override. */
+export function settingKeyForSubject(key: TemplateKey): string {
+ return `email_template_${key}_subject`;
+}
diff --git a/src/lib/email/template-overrides.ts b/src/lib/email/template-overrides.ts
new file mode 100644
index 0000000..6f7fa92
--- /dev/null
+++ b/src/lib/email/template-overrides.ts
@@ -0,0 +1,44 @@
+import { and, eq } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import { systemSettings } from '@/lib/db/schema/system';
+import { settingKeyForSubject, type TemplateKey } from '@/lib/email/template-catalog';
+
+/**
+ * Returns the per-port subject override for a transactional email template,
+ * or null if no override is configured.
+ *
+ * Per-port row wins over global (null portId). String values are returned
+ * as-is; non-string values are ignored (treated as no override).
+ */
+export async function loadSubjectOverride(
+ portId: string,
+ key: TemplateKey,
+): Promise {
+ const settingKey = settingKeyForSubject(key);
+ const rows = await db
+ .select({ value: systemSettings.value, portId: systemSettings.portId })
+ .from(systemSettings)
+ .where(eq(systemSettings.key, settingKey));
+
+ // Prefer per-port row; fall back to global (null portId).
+ const portRow = rows.find((r) => r.portId === portId);
+ const globalRow = rows.find((r) => r.portId === null);
+ const value = portRow?.value ?? globalRow?.value ?? null;
+ return typeof value === 'string' && value.trim() ? value : null;
+}
+
+/** Synchronous client-side helper for substituting {{token}} placeholders. */
+export function applySubjectTokens(
+ template: string,
+ tokens: Record,
+): string {
+ return template.replace(/\{\{(\w+)\}\}/g, (match, name: string) => {
+ const v = tokens[name];
+ return v === undefined || v === null ? match : String(v);
+ });
+}
+
+// Suppress unused-import lint when the helper is not yet referenced from
+// every template — every consumer uses `and` once it integrates.
+void and;
diff --git a/src/lib/email/templates/portal-auth.ts b/src/lib/email/templates/portal-auth.ts
index 45f9c0d..25dc82e 100644
--- a/src/lib/email/templates/portal-auth.ts
+++ b/src/lib/email/templates/portal-auth.ts
@@ -50,12 +50,20 @@ function shell(opts: { title: string; body: string }): string {