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:
@@ -1,14 +1,64 @@
|
|||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
export default function BackupManagementPage() {
|
export default function BackupManagementPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
<PageHeader title="Backup Management" description="Manage system backups and restoration" />
|
<PageHeader
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
title="Backup & Restore"
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
description="How backups are taken today and what an in-app backup admin will look like."
|
||||||
<p className="text-sm text-muted-foreground">
|
/>
|
||||||
This feature will be implemented in the next phase.
|
|
||||||
|
<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’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>
|
||||||
|
<p>
|
||||||
|
<strong>Object storage:</strong> when{' '}
|
||||||
|
<code>system_settings.storage_backend = ‘s3’</code>, 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.
|
||||||
|
</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>“Take backup now” button that enqueues a maintenance job.</li>
|
||||||
|
<li>
|
||||||
|
Per-port logical export (“give me everything for port-nimara”) 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { EmailTemplatesAdmin } from '@/components/admin/email-templates-admin';
|
||||||
|
|
||||||
|
export default function EmailTemplatesPage() {
|
||||||
|
return <EmailTemplatesAdmin />;
|
||||||
|
}
|
||||||
@@ -1,14 +1,75 @@
|
|||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
export default function DataImportPage() {
|
export default function DataImportPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
<PageHeader title="Data Import" description="Import data from external sources" />
|
<PageHeader
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
title="Data import"
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
description="What you can import today and what an in-app importer will look like."
|
||||||
<p className="text-sm text-muted-foreground">
|
/>
|
||||||
This feature will be implemented in the next phase.
|
|
||||||
|
<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>
|
</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 > 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’t leave the database half-loaded.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
|
||||||
|
|
||||||
|
export default function InquiriesPage() {
|
||||||
|
return <InquiryInbox />;
|
||||||
|
}
|
||||||
@@ -1,15 +1,114 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
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() {
|
export default function OnboardingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
<PageHeader title="Onboarding" description="Guided setup for new port configurations" />
|
<PageHeader
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
title="Port onboarding"
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
description="Recommended order to bring a new port live. Each step links to the right admin page."
|
||||||
<p className="text-sm text-muted-foreground">
|
/>
|
||||||
This feature will be implemented in the next phase.
|
|
||||||
</p>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Inbox,
|
||||||
Key,
|
Key,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -120,6 +121,12 @@ const GROUPS: AdminGroup[] = [
|
|||||||
description: 'PDF + email templates with merge-field placeholders.',
|
description: 'PDF + email templates with merge-field placeholders.',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: 'email-templates',
|
||||||
|
label: 'Email Templates',
|
||||||
|
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: 'tags',
|
href: 'tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
@@ -138,6 +145,19 @@ const GROUPS: AdminGroup[] = [
|
|||||||
title: 'Data Quality',
|
title: 'Data Quality',
|
||||||
description: 'Cleanup, imports, and the audit trail.',
|
description: 'Cleanup, imports, and the audit trail.',
|
||||||
sections: [
|
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',
|
href: 'duplicates',
|
||||||
label: 'Duplicates',
|
label: 'Duplicates',
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
|
||||||
|
|
||||||
export default function ScheduledReportsPage() {
|
export default function AdminReportsPage() {
|
||||||
return (
|
return <ReportsDashboard />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/app/(dashboard)/[portSlug]/admin/sends/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/sends/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SendsLog } from '@/components/admin/sends-log';
|
||||||
|
|
||||||
|
export default function SendsPage() {
|
||||||
|
return <SendsLog />;
|
||||||
|
}
|
||||||
114
src/app/api/v1/admin/dashboard-stats/route.ts
Normal file
114
src/app/api/v1/admin/dashboard-stats/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
68
src/app/api/v1/admin/document-sends/route.ts
Normal file
68
src/app/api/v1/admin/document-sends/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
91
src/app/api/v1/admin/email-templates/route.ts
Normal file
91
src/app/api/v1/admin/email-templates/route.ts
Normal 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;
|
||||||
67
src/app/api/v1/admin/website-submissions/route.ts
Normal file
67
src/app/api/v1/admin/website-submissions/route.ts
Normal 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;
|
||||||
166
src/components/admin/email-templates-admin.tsx
Normal file
166
src/components/admin/email-templates-admin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/admin/inquiry-inbox.tsx
Normal file
206
src/components/admin/inquiry-inbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/admin/reports-dashboard.tsx
Normal file
206
src/components/admin/reports-dashboard.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
} from '@/components/ui/accordion';
|
} from '@/components/ui/accordion';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
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>> = {
|
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||||
interests: {
|
interests: {
|
||||||
@@ -33,6 +34,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
|||||||
documents: {
|
documents: {
|
||||||
view: false,
|
view: false,
|
||||||
create: false,
|
create: false,
|
||||||
|
edit: false,
|
||||||
send_for_signing: false,
|
send_for_signing: false,
|
||||||
upload_signed: false,
|
upload_signed: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
@@ -54,7 +56,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: 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 },
|
email: { view: false, send: false, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: false,
|
view_own: false,
|
||||||
@@ -67,6 +69,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
|||||||
calendar: { connect: false, view_events: false },
|
calendar: { connect: false, view_events: false },
|
||||||
reports: { view_dashboard: false, view_analytics: false, export: false },
|
reports: { view_dashboard: false, view_analytics: false, export: false },
|
||||||
document_templates: { view: false, generate: false, manage: 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: {
|
admin: {
|
||||||
manage_users: false,
|
manage_users: false,
|
||||||
view_audit_log: false,
|
view_audit_log: false,
|
||||||
@@ -101,7 +107,13 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
calendar: 'Calendar',
|
calendar: 'Calendar',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
document_templates: 'Document Templates',
|
document_templates: 'Document Templates',
|
||||||
|
yachts: 'Yachts',
|
||||||
|
companies: 'Companies',
|
||||||
|
memberships: 'Company Memberships',
|
||||||
|
reservations: 'Reservations',
|
||||||
admin: 'Administration',
|
admin: 'Administration',
|
||||||
|
residential_clients: 'Residential Clients',
|
||||||
|
residential_interests: 'Residential Interests',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAction(action: string): string {
|
function formatAction(action: string): string {
|
||||||
|
|||||||
200
src/components/admin/sends-log.tsx
Normal file
200
src/components/admin/sends-log.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -118,6 +118,77 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
|
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() {
|
export function SettingsManager() {
|
||||||
|
|||||||
103
src/lib/email/template-catalog.ts
Normal file
103
src/lib/email/template-catalog.ts
Normal 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`;
|
||||||
|
}
|
||||||
44
src/lib/email/template-overrides.ts
Normal file
44
src/lib/email/template-overrides.ts
Normal 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;
|
||||||
@@ -50,12 +50,20 @@ function shell(opts: { title: string; body: string }): string {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activationEmail(data: ActivationData): {
|
export function activationEmail(
|
||||||
|
data: ActivationData,
|
||||||
|
overrides?: { subject?: string | null },
|
||||||
|
): {
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
text: 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 greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
@@ -97,8 +105,16 @@ export function activationEmail(data: ActivationData): {
|
|||||||
return { subject, html: shell({ title: subject, body }), text };
|
return { subject, html: shell({ title: subject, body }), text };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetEmail(data: ResetData): { subject: string; html: string; text: string } {
|
export function resetEmail(
|
||||||
const subject = `Reset your ${data.portName} client portal password`;
|
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 greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { systemSettings } from '@/lib/db/schema/system';
|
|||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
||||||
|
import { loadSubjectOverride } from '@/lib/email/template-overrides';
|
||||||
import {
|
import {
|
||||||
CodedError,
|
CodedError,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { createPortalToken } from '@/lib/portal/auth';
|
import { createPortalToken } from '@/lib/portal/auth';
|
||||||
import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal/passwords';
|
import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal/passwords';
|
||||||
|
import { createAuditLog } from '@/lib/audit';
|
||||||
|
|
||||||
const ACTIVATION_TOKEN_TTL_HOURS = 72;
|
const ACTIVATION_TOKEN_TTL_HOURS = 72;
|
||||||
const RESET_TOKEN_TTL_MINUTES = 30;
|
const RESET_TOKEN_TTL_MINUTES = 30;
|
||||||
@@ -84,6 +86,15 @@ export async function createPortalUser(args: {
|
|||||||
|
|
||||||
await issueActivationToken(user.id, normalizedEmail, args.portId);
|
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 };
|
return { portalUserId: user.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +117,15 @@ async function issueActivationToken(
|
|||||||
const portName = port?.name ?? 'Port Nimara';
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
|
|
||||||
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
||||||
const { subject, html, text } = activationEmail({
|
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
||||||
|
const { subject, html, text } = activationEmail(
|
||||||
|
{
|
||||||
portName,
|
portName,
|
||||||
link,
|
link,
|
||||||
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
|
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
|
||||||
});
|
},
|
||||||
|
{ subject: subjectOverride },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(email, subject, html, undefined, text);
|
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');
|
throw new ConflictError('Portal user has already activated their account');
|
||||||
}
|
}
|
||||||
await issueActivationToken(user.id, user.email, user.portId);
|
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 ──────────────────────────
|
// ─── Activation: client sets their initial password ──────────────────────────
|
||||||
@@ -154,6 +178,14 @@ export async function activateAccount(rawToken: string, password: string): Promi
|
|||||||
.update(portalUsers)
|
.update(portalUsers)
|
||||||
.set({ passwordHash, updatedAt: new Date() })
|
.set({ passwordHash, updatedAt: new Date() })
|
||||||
.where(eq(portalUsers.id, tokenRow.portalUserId));
|
.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) ──────────────────────────────────────────────
|
// ─── Sign in (email + password) ──────────────────────────────────────────────
|
||||||
@@ -234,14 +266,27 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
|||||||
expiresAt,
|
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 port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
|
||||||
const portName = port?.name ?? 'Port Nimara';
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
||||||
const { subject, html, text } = resetEmail({
|
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
||||||
|
const { subject, html, text } = resetEmail(
|
||||||
|
{
|
||||||
portName,
|
portName,
|
||||||
link,
|
link,
|
||||||
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
|
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
|
||||||
});
|
},
|
||||||
|
{ subject: subjectOverride },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(user.email, subject, html, undefined, text);
|
await sendEmail(user.email, subject, html, undefined, text);
|
||||||
@@ -268,6 +313,14 @@ export async function resetPassword(rawToken: string, password: string): Promise
|
|||||||
.update(portalUsers)
|
.update(portalUsers)
|
||||||
.set({ passwordHash, updatedAt: new Date() })
|
.set({ passwordHash, updatedAt: new Date() })
|
||||||
.where(eq(portalUsers.id, tokenRow.portalUserId));
|
.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) ───────────────────
|
// ─── Token consumption (shared between activation + reset) ───────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user