From c90876abad778d83448b13f35aa442d22bbad8ae Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 14:58:17 +0200 Subject: [PATCH] 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) --- .../[portSlug]/admin/backup/page.tsx | 64 +++++- .../[portSlug]/admin/email-templates/page.tsx | 5 + .../[portSlug]/admin/import/page.tsx | 75 ++++++- .../[portSlug]/admin/inquiries/page.tsx | 5 + .../[portSlug]/admin/onboarding/page.tsx | 115 +++++++++- src/app/(dashboard)/[portSlug]/admin/page.tsx | 20 ++ .../[portSlug]/admin/reports/page.tsx | 19 +- .../[portSlug]/admin/sends/page.tsx | 5 + src/app/api/v1/admin/dashboard-stats/route.ts | 114 ++++++++++ src/app/api/v1/admin/document-sends/route.ts | 68 ++++++ src/app/api/v1/admin/email-templates/route.ts | 91 ++++++++ .../api/v1/admin/website-submissions/route.ts | 67 ++++++ .../admin/email-templates-admin.tsx | 166 ++++++++++++++ src/components/admin/inquiry-inbox.tsx | 206 ++++++++++++++++++ src/components/admin/reports-dashboard.tsx | 206 ++++++++++++++++++ src/components/admin/roles/role-form.tsx | 16 +- src/components/admin/sends-log.tsx | 200 +++++++++++++++++ .../admin/settings/settings-manager.tsx | 71 ++++++ src/lib/email/template-catalog.ts | 103 +++++++++ src/lib/email/template-overrides.ts | 44 ++++ src/lib/email/templates/portal-auth.ts | 24 +- src/lib/services/portal-auth.service.ts | 73 ++++++- 22 files changed, 1703 insertions(+), 54 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/admin/email-templates/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/admin/sends/page.tsx create mode 100644 src/app/api/v1/admin/dashboard-stats/route.ts create mode 100644 src/app/api/v1/admin/document-sends/route.ts create mode 100644 src/app/api/v1/admin/email-templates/route.ts create mode 100644 src/app/api/v1/admin/website-submissions/route.ts create mode 100644 src/components/admin/email-templates-admin.tsx create mode 100644 src/components/admin/inquiry-inbox.tsx create mode 100644 src/components/admin/reports-dashboard.tsx create mode 100644 src/components/admin/sends-log.tsx create mode 100644 src/lib/email/template-catalog.ts create mode 100644 src/lib/email/template-overrides.ts diff --git a/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx b/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx index d9d8131..9181433 100644 --- a/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx @@ -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 ( -
- -
-

Coming in Layer 4

-

- This feature will be implemented in the next phase. -

+
+ + +
+ + + Current backup posture + + Database snapshots run outside the app — there is no in-app trigger yet. + + + +

+ PostgreSQL: snapshotted by the platform’s nightly{' '} + pg_dump job. Retention is set at the infrastructure layer (see{' '} + docs/operations/ if a runbook exists). Restores are manual. +

+

+ Object storage: when{' '} + system_settings.storage_backend = ‘s3’, the bucket is + versioned by the provider. When the filesystem backend is in use, the host’s + snapshot policy is the only safety net — switch to s3 before relying on point-in-time + recovery. +

+

+ Redis / queue state: ephemeral. Failed jobs sit on the{' '} + removeOnFail retention window (7 days) and then disappear. Anything + durable belongs in PostgreSQL. +

+
+
+ + + + What this page will become + Planned admin surface, prioritised in upcoming work. + + +
    +
  • List recent snapshot files with timestamp, size, and origin (cron vs manual).
  • +
  • “Take backup now” button that enqueues a maintenance job.
  • +
  • + Per-port logical export (“give me everything for port-nimara”) for + compliance. +
  • +
  • Restore preview that shows row-counts that would change before commit.
  • +
  • GDPR per-client export bundled here.
  • +
+

+ Until this lands, treat ops/devops as the source of truth for backup state. +

+
+
); diff --git a/src/app/(dashboard)/[portSlug]/admin/email-templates/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email-templates/page.tsx new file mode 100644 index 0000000..4e5faa9 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/email-templates/page.tsx @@ -0,0 +1,5 @@ +import { EmailTemplatesAdmin } from '@/components/admin/email-templates-admin'; + +export default function EmailTemplatesPage() { + return ; +} diff --git a/src/app/(dashboard)/[portSlug]/admin/import/page.tsx b/src/app/(dashboard)/[portSlug]/admin/import/page.tsx index 9e45a80..592344d 100644 --- a/src/app/(dashboard)/[portSlug]/admin/import/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/import/page.tsx @@ -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 ( -
- -
-

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) => ( +
  1. + + {idx + 1} + +
    + + {item.label} + +

    {item.description}

    +
    +
  2. + ))} +
+
+
+ + + + 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} +
+ +
+ + + 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}}}`} + ))} +
+
+ + {overridden ? ( + + ) : 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} +
+
+ +
+
+ {isOpen && ( + +
+                        {JSON.stringify(row.payload, null, 2)}
+                      
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} + +function FilterChip({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} 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 ( +
+ +

Loading…

+
+ ); + } + 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). + + +
    + {PIPELINE_STAGES.map((stage) => { + const n = stats.pipeline[stage] ?? 0; + const pct = (n / maxStageCount) * 100; + return ( +
  • +
    + {STAGE_LABELS[stage as PipelineStage]} + {n} +
    +
    +
    +
    +
  • + ); + })} +
+
+ 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 ? ( + + ) : 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 ( + + ); +} 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 { `; } -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 = ` diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index 780096e..f2bbdce 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -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 { 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) ───────────────────