feat(admin): inquiry inbox, send log, email-template overrides, reports dashboard, recommender keys, role-editor coverage; replace placeholder pages

Closes the bulk of audit-pass-#1 admin gaps in one batch.

New admin pages:
- /admin/inquiries reads website_submissions with filter chips for
  berth/residence/contact + payload viewer per row.
- /admin/sends reads document_sends with sent/failed filter chips and
  expandable body markdown; failures surface errorReason and any
  fallback-to-link reason from the SMTP retry.
- /admin/email-templates lets per-port admins override the subject of
  each transactional template (8 templates catalogued in
  template-catalog.ts). Body editing is a follow-on; portal_activation
  + portal_reset are wired to honor the override via loadSubjectOverride.
- /admin/reports replaces the "Coming in Layer 3" placeholder with a
  KPI dashboard: 4 KPI tiles, pipeline funnel bars, berth occupancy
  donut-bars, conversion %, refresh every 60s.
- backup/import/onboarding admin pages replace placeholders with
  actionable guidance: backup posture + planned features, available CLI
  imports + planned UI, ordered onboarding checklist linking to admin
  pages.

Existing pages widened:
- settings-manager exposes the 9 berth-recommender tunables that were
  previously code-only (recommender_*, heat_weight_*, fallthrough_*,
  tier_ladder_hide_late_stage).
- role-form covers all 19 RolePermissions schema groups; previously
  missing yachts/companies/memberships/reservations + missing
  documents.edit + files.edit checkboxes. snake_case residential
  labels replaced with friendly text.

portal-auth.service.ts now also writes audit_log rows for portal
invite, resend, activate, password-reset request, and reset (closes one
more audit-pass-#2 gap while we were touching the file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 14:58:17 +02:00
parent 8cdee99310
commit c90876abad
22 changed files with 1703 additions and 54 deletions

View File

@@ -1,14 +1,64 @@
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function BackupManagementPage() {
return (
<div className="space-y-6">
<PageHeader title="Backup Management" description="Manage system backups and restoration" />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
<div>
<PageHeader
title="Backup &amp; Restore"
description="How backups are taken today and what an in-app backup admin will look like."
/>
<div className="grid gap-4 mt-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Current backup posture</CardTitle>
<CardDescription>
Database snapshots run outside the app there is no in-app trigger yet.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
<strong>PostgreSQL:</strong> snapshotted by the platform&rsquo;s nightly{' '}
<code>pg_dump</code> job. Retention is set at the infrastructure layer (see{' '}
<code>docs/operations/</code> if a runbook exists). Restores are manual.
</p>
<p>
<strong>Object storage:</strong> when{' '}
<code>system_settings.storage_backend = &lsquo;s3&rsquo;</code>, the bucket is
versioned by the provider. When the filesystem backend is in use, the host&rsquo;s
snapshot policy is the only safety net switch to s3 before relying on point-in-time
recovery.
</p>
<p>
<strong>Redis / queue state:</strong> ephemeral. Failed jobs sit on the{' '}
<code>removeOnFail</code> retention window (7 days) and then disappear. Anything
durable belongs in PostgreSQL.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>What this page will become</CardTitle>
<CardDescription>Planned admin surface, prioritised in upcoming work.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<ul className="list-disc pl-5 space-y-1">
<li>List recent snapshot files with timestamp, size, and origin (cron vs manual).</li>
<li>&ldquo;Take backup now&rdquo; button that enqueues a maintenance job.</li>
<li>
Per-port logical export (&ldquo;give me everything for port-nimara&rdquo;) for
compliance.
</li>
<li>Restore preview that shows row-counts that would change before commit.</li>
<li>GDPR per-client export bundled here.</li>
</ul>
<p className="text-xs text-muted-foreground pt-2">
Until this lands, treat ops/devops as the source of truth for backup state.
</p>
</CardContent>
</Card>
</div>
</div>
);

View File

@@ -0,0 +1,5 @@
import { EmailTemplatesAdmin } from '@/components/admin/email-templates-admin';
export default function EmailTemplatesPage() {
return <EmailTemplatesAdmin />;
}

View File

@@ -1,14 +1,75 @@
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function DataImportPage() {
return (
<div className="space-y-6">
<PageHeader title="Data Import" description="Import data from external sources" />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
<div>
<PageHeader
title="Data import"
description="What you can import today and what an in-app importer will look like."
/>
<div className="grid gap-4 mt-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Available imports today</CardTitle>
<CardDescription>Run from the command line until the UI catches up.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div>
<p>
<strong>Berths from NocoDB:</strong>
</p>
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
</pre>
<p className="text-xs text-muted-foreground mt-1">
Idempotent. Skips rows where <code>updated_at &gt; last_imported_at</code> unless
you pass <code>--force</code>. Add <code>--update-snapshot</code> to also rewrite{' '}
<code>src/lib/db/seed-data/berths.json</code>.
</p>
</div>
<div>
<p>
<strong>Storage backend migration:</strong>
</p>
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
pnpm tsx scripts/migrate-storage.ts
</pre>
<p className="text-xs text-muted-foreground mt-1">
Run after switching <code>system_settings.storage_backend</code> in System Settings.
</p>
</div>
<div>
<p>
<strong>Seed (rebuild dev fixtures):</strong>
</p>
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
pnpm db:seed
</pre>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>What this page will become</CardTitle>
<CardDescription>Planned UI for self-serve imports.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<ul className="list-disc pl-5 space-y-1">
<li>Drag-and-drop CSV / XLSX upload with column-mapping UI.</li>
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
<li>Per-port import history with rollback.</li>
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li>
</ul>
<p className="text-xs text-muted-foreground pt-2">
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
failures don&rsquo;t leave the database half-loaded.
</p>
</CardContent>
</Card>
</div>
</div>
);

View File

@@ -0,0 +1,5 @@
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
export default function InquiriesPage() {
return <InquiryInbox />;
}

View File

@@ -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&rsquo;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 &amp; 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 (
<div className="space-y-6">
<PageHeader title="Onboarding" description="Guided setup for new port configurations" />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
<div>
<PageHeader
title="Port onboarding"
description="Recommended order to bring a new port live. Each step links to the right admin page."
/>
<Card className="mt-6">
<CardHeader>
<CardTitle>Setup checklist</CardTitle>
<CardDescription>
Work through these in order. The future onboarding wizard will track progress per port;
for now this is a guided index.
</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-4">
{CHECKLIST.map((item, idx) => (
<li key={item.href} className="flex gap-4">
<span className="flex-none w-7 h-7 rounded-full bg-primary/10 text-primary font-medium text-sm flex items-center justify-center mt-0.5">
{idx + 1}
</span>
<div className="flex-1">
<Link
href={`./${item.href}` as never}
className="text-sm font-medium hover:underline"
>
{item.label}
</Link>
<p className="text-sm text-muted-foreground mt-0.5">{item.description}</p>
</div>
</li>
))}
</ol>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle>What this page will become</CardTitle>
<CardDescription>
A guided wizard that walks per-port admins through the same steps with progress
tracking.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
The wizard will record completion per port in <code>system_settings</code>, gate the
public marketing-site cutover until required steps are done, and surface a banner on the
dashboard when onboarding is incomplete.
</CardContent>
</Card>
</div>
);
}

View File

@@ -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',

View File

@@ -1,18 +1,5 @@
import { PageHeader } from '@/components/shared/page-header';
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
export default function ScheduledReportsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Scheduled Reports"
description="Configure and manage automated report delivery"
/>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
export default function AdminReportsPage() {
return <ReportsDashboard />;
}

View File

@@ -0,0 +1,5 @@
import { SendsLog } from '@/components/admin/sends-log';
export default function SendsPage() {
return <SendsLog />;
}

View File

@@ -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<number>`count(*)::int`,
})
.from(interests)
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
.groupBy(interests.pipelineStage),
db
.select({
status: berths.status,
count: sql<number>`count(*)::int`,
})
.from(berths)
.where(eq(berths.portId, portId))
.groupBy(berths.status),
Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(clients)
.where(and(eq(clients.portId, portId), isNull(clients.archivedAt))),
db
.select({ count: sql<number>`count(*)::int` })
.from(interests)
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt))),
db
.select({ count: sql<number>`count(*)::int` })
.from(berths)
.where(eq(berths.portId, portId)),
]),
Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(websiteSubmissions)
.where(
and(
eq(websiteSubmissions.portId, portId),
gte(websiteSubmissions.receivedAt, sevenDaysAgo),
),
),
db
.select({ count: sql<number>`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<string, number> = {
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);
}
}),
);

View File

@@ -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<number>`count(*)::int` })
.from(documentSends)
.where(and(eq(documentSends.portId, ctx.portId), isNull(documentSends.failedAt)));
const failedCountRows = await db
.select({ count: sql<number>`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);
}
}),
);

View File

@@ -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<string, { port?: string; global?: string }>();
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;

View File

@@ -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<number>`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;

View File

@@ -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<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(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<string, string> = {};
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 (
<div>
<PageHeader
title="Email templates"
description="Customize the subject line of transactional emails per port. Body editing is the next iteration; for now the layout and HTML stay locked to the default template."
/>
<div className="mt-6 space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground py-6">Loading</p>
) : error ? (
<p className="text-sm text-red-600 py-6">
Failed to load templates: {error instanceof Error ? error.message : 'unknown error'}
</p>
) : (
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 (
<Card key={row.key}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base font-medium">{row.label}</CardTitle>
{overridden ? (
<Badge className="bg-blue-100 text-blue-800">Overridden</Badge>
) : (
<Badge variant="secondary">Default</Badge>
)}
</div>
<CardDescription>{row.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<label className="text-xs uppercase tracking-wide text-muted-foreground">
Subject
</label>
<Input
value={draft}
onChange={(e) =>
setDrafts((prev) => ({ ...prev, [row.key]: e.target.value }))
}
className="mt-1 font-mono text-sm"
/>
</div>
<div className="text-xs text-muted-foreground">
Default: <code className="font-mono">{row.defaultSubject}</code>
</div>
<div className="text-xs text-muted-foreground">
Available tokens:{' '}
{row.mergeTokens.map((t) => (
<code key={t} className="mr-1 font-mono">{`{{${t}}}`}</code>
))}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => save(row, 'save')}
disabled={savingKey === row.key || !dirty}
>
<Save className="h-3.5 w-3.5 mr-1.5" /> Save
</Button>
{overridden ? (
<Button
size="sm"
variant="outline"
onClick={() => save(row, 'reset')}
disabled={savingKey === row.key}
>
<RotateCcw className="h-3.5 w-3.5 mr-1.5" /> Reset to default
</Button>
) : null}
{message?.key === row.key ? (
<span
className={
message.kind === 'ok' ? 'text-sm text-green-600' : 'text-sm text-red-600'
}
>
{message.text}
</span>
) : null}
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
);
}

View File

@@ -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<string, unknown> | 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<string, number>;
}
const KIND_LABELS: Record<Submission['kind'], string> = {
berth_inquiry: 'Berth inquiry',
residence_inquiry: 'Residence inquiry',
contact_form: 'Contact form',
};
const KIND_COLORS: Record<Submission['kind'], string> = {
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<string, unknown> | 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<string, unknown> | null): string {
if (!payload) return '';
const v = payload['email'];
return typeof v === 'string' ? v : '';
}
function pickPhone(payload: Record<string, unknown> | 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<Submission['kind'] | 'all'>('all');
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['inquiry-inbox', kind],
queryFn: () =>
apiFetch<ListResponse>(
`/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 (
<div>
<PageHeader
title="Inquiry inbox"
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
/>
<div className="flex items-center gap-2 mt-6 flex-wrap">
<FilterChip
label={`All (${totalAll})`}
active={kind === 'all'}
onClick={() => setKind('all')}
/>
<FilterChip
label={`Berth inquiries (${counts.berth_inquiry ?? 0})`}
active={kind === 'berth_inquiry'}
onClick={() => setKind('berth_inquiry')}
/>
<FilterChip
label={`Residence (${counts.residence_inquiry ?? 0})`}
active={kind === 'residence_inquiry'}
onClick={() => setKind('residence_inquiry')}
/>
<FilterChip
label={`Contact (${counts.contact_form ?? 0})`}
active={kind === 'contact_form'}
onClick={() => setKind('contact_form')}
/>
</div>
<div className="mt-6">
{isLoading ? (
<p className="text-sm text-muted-foreground py-6">Loading</p>
) : error ? (
<p className="text-sm text-red-600 py-6">
Failed to load inquiries: {error instanceof Error ? error.message : 'unknown error'}
</p>
) : rows.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No website submissions yet for this filter.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{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 (
<Card key={row.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2">
<Badge className={KIND_COLORS[row.kind]}>{KIND_LABELS[row.kind]}</Badge>
<span
className="text-xs text-muted-foreground"
title={new Date(row.receivedAt).toISOString()}
>
{ago}
</span>
</div>
<CardTitle className="mt-2 text-base font-medium">
{name || '(no name supplied)'}
</CardTitle>
<div className="text-sm text-muted-foreground mt-1 space-x-3">
{email ? <span>{email}</span> : null}
{phone ? <span>{phone}</span> : null}
{row.sourceIp ? (
<span className="text-xs">from {row.sourceIp}</span>
) : null}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setExpanded(isOpen ? null : row.id)}
>
{isOpen ? 'Hide payload' : 'View payload'}
</Button>
</div>
</CardHeader>
{isOpen && (
<CardContent>
<pre className="bg-muted/40 rounded-md p-3 text-xs overflow-auto max-h-96">
{JSON.stringify(row.payload, null, 2)}
</pre>
</CardContent>
)}
</Card>
);
})}
</div>
)}
</div>
</div>
);
}
function FilterChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`px-3 py-1.5 rounded-full text-sm border transition ${
active
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-muted'
}`}
>
{label}
</button>
);
}

View File

@@ -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<string, number>;
berthStatus: { available: number; under_offer: number; sold: number };
conversion: { closedTotal: number; openTotal: number; conversionPct: number };
}
const BERTH_STATUS_COLORS: Record<string, string> = {
available: 'bg-green-500',
under_offer: 'bg-amber-500',
sold: 'bg-slate-500',
};
const BERTH_STATUS_LABELS: Record<string, string> = {
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 (
<div>
<PageHeader title="Reports" description="Pipeline, occupancy, and recent activity." />
<p className="text-sm text-muted-foreground py-6">Loading</p>
</div>
);
}
if (error || !data) {
return (
<div>
<PageHeader title="Reports" description="Pipeline, occupancy, and recent activity." />
<p className="text-sm text-red-600 py-6">
Failed to load stats: {error instanceof Error ? error.message : 'unknown error'}
</p>
</div>
);
}
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 (
<div>
<PageHeader
title="Reports"
description="Live snapshot of clients, pipeline, and berth occupancy. Refreshes every minute."
/>
{/* KPI tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-6">
<KpiTile label="Clients" value={stats.totals.totalClients} href={`/${portSlug}/clients`} />
<KpiTile
label="Open interests"
value={stats.conversion.openTotal}
href={`/${portSlug}/interests`}
/>
<KpiTile
label="Inquiries (7 days)"
value={stats.recent.newInquiries7d}
href={`/${portSlug}/admin/inquiries`}
/>
<KpiTile
label="Completed (30 days)"
value={stats.recent.completed30d}
accent={stats.recent.completed30d > 0 ? 'success' : undefined}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
{/* Pipeline funnel */}
<Card>
<CardHeader>
<CardTitle>Pipeline funnel</CardTitle>
<CardDescription>Open interests by stage (excludes archived).</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{PIPELINE_STAGES.map((stage) => {
const n = stats.pipeline[stage] ?? 0;
const pct = (n / maxStageCount) * 100;
return (
<li key={stage} className="text-sm">
<div className="flex items-center justify-between mb-1">
<span>{STAGE_LABELS[stage as PipelineStage]}</span>
<span className="font-medium">{n}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
<div className="mt-4 pt-4 border-t text-sm">
<span className="text-muted-foreground">Conversion (completed / total):</span>{' '}
<span className="font-medium">{stats.conversion.conversionPct}%</span>
</div>
</CardContent>
</Card>
{/* Berth occupancy */}
<Card>
<CardHeader>
<CardTitle>Berth occupancy</CardTitle>
<CardDescription>
Current public-status mix for {totalBerths} berths in this port.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(['available', 'under_offer', 'sold'] as const).map((status) => {
const n = stats.berthStatus[status];
const pct = totalBerths === 0 ? 0 : Math.round((n / totalBerths) * 100);
return (
<div key={status} className="text-sm">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${BERTH_STATUS_COLORS[status]}`}
/>
<span>{BERTH_STATUS_LABELS[status]}</span>
</div>
<span className="font-medium">
{n} <span className="text-muted-foreground">· {pct}%</span>
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${BERTH_STATUS_COLORS[status]}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
<p className="text-xs text-muted-foreground mt-6">
Need scheduled or downloadable reports?{' '}
<Link href={`/${portSlug}/reports` as never} className="underline">
Open the report generator
</Link>{' '}
to produce PDF exports of these views.
</p>
</div>
);
}
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 = (
<Card className="h-full transition hover:border-primary/40">
<CardContent className="py-5">
<div className={`text-3xl font-semibold ${accentClass}`}>{value}</div>
<div className="text-sm text-muted-foreground mt-1">{label}</div>
</CardContent>
</Card>
);
return href ? (
<Link href={href as never} className="block">
{inner}
</Link>
) : (
inner
);
}

View File

@@ -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<string, Record<string, boolean>> = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
interests: {
@@ -33,6 +34,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
documents: {
view: false,
create: false,
edit: false,
send_for_signing: false,
upload_signed: false,
delete: false,
@@ -54,7 +56,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
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<string, Record<string, boolean>> = {
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<string, string> = {
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 {

View File

@@ -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<string | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['document-sends', status],
queryFn: () => apiFetch<ListResponse>(`/api/v1/admin/document-sends?status=${status}`),
});
const counts = data?.counts ?? { sent: 0, failed: 0, all: 0 };
const rows = data?.data ?? [];
return (
<div>
<PageHeader
title="Send log"
description="Every brochure and per-berth PDF sent from the CRM, with delivery failures surfaced for retry."
/>
<div className="flex items-center gap-2 mt-6 flex-wrap">
<FilterChip
label={`All (${counts.all})`}
active={status === 'all'}
onClick={() => setStatus('all')}
/>
<FilterChip
label={`Sent (${counts.sent})`}
active={status === 'sent'}
onClick={() => setStatus('sent')}
/>
<FilterChip
label={`Failed (${counts.failed})`}
active={status === 'failed'}
onClick={() => setStatus('failed')}
accent={counts.failed > 0 ? 'danger' : undefined}
/>
</div>
<div className="mt-6">
{isLoading ? (
<p className="text-sm text-muted-foreground py-6">Loading</p>
) : error ? (
<p className="text-sm text-red-600 py-6">
Failed to load sends: {error instanceof Error ? error.message : 'unknown error'}
</p>
) : rows.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No sends recorded for this filter yet.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{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 (
<Card key={row.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={
failed ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
}
>
{failed ? 'Failed' : 'Sent'}
</Badge>
<Badge variant="secondary">
{row.documentKind === 'berth_pdf'
? 'Berth PDF'
: row.documentKind === 'brochure'
? 'Brochure'
: row.documentKind}
</Badge>
{row.fallbackToLinkReason ? (
<Badge className="bg-amber-100 text-amber-800">
Switched to download link
</Badge>
) : null}
<span
className="text-xs text-muted-foreground"
title={sent.toISOString()}
>
{ago} · {format(sent, 'PP p')}
</span>
</div>
<CardTitle className="mt-2 text-base font-medium">
{row.recipientEmail}
</CardTitle>
<div className="text-sm text-muted-foreground mt-1">
From {row.fromAddress}
{row.messageId ? (
<span className="text-xs ml-2 font-mono">{row.messageId}</span>
) : null}
</div>
{failed && row.errorReason ? (
<div className="mt-2 text-sm text-red-700 bg-red-50 rounded-md p-2">
{row.errorReason}
</div>
) : null}
{row.fallbackToLinkReason ? (
<div className="mt-2 text-sm text-amber-700 bg-amber-50 rounded-md p-2">
Attachment dropped sent as link. Reason: {row.fallbackToLinkReason}
</div>
) : null}
</div>
{row.bodyMarkdown ? (
<Button
size="sm"
variant="outline"
onClick={() => setExpanded(isOpen ? null : row.id)}
>
{isOpen ? 'Hide body' : 'View body'}
</Button>
) : null}
</div>
</CardHeader>
{isOpen && row.bodyMarkdown ? (
<CardContent>
<pre className="bg-muted/40 rounded-md p-3 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
{row.bodyMarkdown}
</pre>
</CardContent>
) : null}
</Card>
);
})}
</div>
)}
</div>
</div>
);
}
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 (
<button
type="button"
onClick={onClick}
className={`px-3 py-1.5 rounded-full text-sm border transition ${dangerActive ?? base}`}
>
{label}
</button>
);
}

View File

@@ -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() {

View File

@@ -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<TemplateKey, TemplateMetadata> = {
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`;
}

View File

@@ -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<string | null> {
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, string | number | undefined>,
): 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;

View File

@@ -50,12 +50,20 @@ function shell(opts: { title: string; body: string }): string {
</html>`;
}
export function activationEmail(data: ActivationData): {
export function activationEmail(
data: ActivationData,
overrides?: { subject?: string | null },
): {
subject: string;
html: string;
text: string;
} {
const subject = `Activate your ${data.portName} client portal account`;
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
: `Activate your ${data.portName} client portal account`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
const body = `
@@ -97,8 +105,16 @@ export function activationEmail(data: ActivationData): {
return { subject, html: shell({ title: subject, body }), text };
}
export function resetEmail(data: ResetData): { subject: string; html: string; text: string } {
const subject = `Reset your ${data.portName} client portal password`;
export function resetEmail(
data: ResetData,
overrides?: { subject?: string | null },
): { subject: string; html: string; text: string } {
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
: `Reset your ${data.portName} client portal password`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
const body = `

View File

@@ -8,6 +8,7 @@ import { systemSettings } from '@/lib/db/schema/system';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { loadSubjectOverride } from '@/lib/email/template-overrides';
import {
CodedError,
ConflictError,
@@ -18,6 +19,7 @@ import {
import { logger } from '@/lib/logger';
import { createPortalToken } from '@/lib/portal/auth';
import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal/passwords';
import { createAuditLog } from '@/lib/audit';
const ACTIVATION_TOKEN_TTL_HOURS = 72;
const RESET_TOKEN_TTL_MINUTES = 30;
@@ -84,6 +86,15 @@ export async function createPortalUser(args: {
await issueActivationToken(user.id, normalizedEmail, args.portId);
void createAuditLog({
portId: args.portId,
userId: args.createdBy,
action: 'portal_invite',
entityType: 'portal_user',
entityId: user.id,
metadata: { clientId: args.clientId, email: normalizedEmail },
});
return { portalUserId: user.id };
}
@@ -106,11 +117,15 @@ async function issueActivationToken(
const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
const { subject, html, text } = activationEmail({
portName,
link,
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
});
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
const { subject, html, text } = activationEmail(
{
portName,
link,
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
},
{ subject: subjectOverride },
);
try {
await sendEmail(email, subject, html, undefined, text);
@@ -133,6 +148,15 @@ export async function resendActivation(portalUserId: string, portId: string): Pr
throw new ConflictError('Portal user has already activated their account');
}
await issueActivationToken(user.id, user.email, user.portId);
void createAuditLog({
portId: user.portId,
userId: null,
action: 'resend_invite',
entityType: 'portal_user',
entityId: user.id,
metadata: { email: user.email },
});
}
// ─── Activation: client sets their initial password ──────────────────────────
@@ -154,6 +178,14 @@ export async function activateAccount(rawToken: string, password: string): Promi
.update(portalUsers)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(portalUsers.id, tokenRow.portalUserId));
void createAuditLog({
portId: portalUser.portId,
userId: null,
action: 'portal_activate',
entityType: 'portal_user',
entityId: portalUser.id,
});
}
// ─── Sign in (email + password) ──────────────────────────────────────────────
@@ -234,14 +266,27 @@ export async function requestPasswordReset(email: string): Promise<void> {
expiresAt,
});
void createAuditLog({
portId: user.portId,
userId: null,
action: 'portal_password_reset_request',
entityType: 'portal_user',
entityId: user.id,
metadata: { email: user.email },
});
const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
const { subject, html, text } = resetEmail({
portName,
link,
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
});
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
const { subject, html, text } = resetEmail(
{
portName,
link,
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
},
{ subject: subjectOverride },
);
try {
await sendEmail(user.email, subject, html, undefined, text);
@@ -268,6 +313,14 @@ export async function resetPassword(rawToken: string, password: string): Promise
.update(portalUsers)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(portalUsers.id, tokenRow.portalUserId));
void createAuditLog({
portId: portalUser.portId,
userId: null,
action: 'portal_password_reset',
entityType: 'portal_user',
entityId: portalUser.id,
});
}
// ─── Token consumption (shared between activation + reset) ───────────────────