23 Commits

Author SHA1 Message Date
5b9560531e fix(ui): remove PN brand mark from mobile topbar; balance title with spacer
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m59s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:55:18 +02:00
f55be14813 test(berths): CM-2 — drop unused var in price-reconcile test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:53:24 +02:00
6bc81270b9 feat(interests): CM-2 Part B — deal-price override route + UI on linked berths
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:48:38 +02:00
38e392e38b feat(interests): CM-2 Part B — EOI/doc generation honours berth price override
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:41:42 +02:00
039ef25fe5 feat(interests): CM-2 Part B — interest_berths price override (data + resolver)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:17 +02:00
b3753b96a1 feat(berths): CM-2 — bulk price-reconcile admin page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:38:08 +02:00
9147f2857e feat(berths): CM-2 — price-reconcile API (list + bulk apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:35:09 +02:00
47778796ad feat(berths): CM-2 — bulk price-reconcile service (parse + apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:33:40 +02:00
f7425d1231 fix(berths): CM-2 — robust purchase-price extraction (clean-token + magnitude floor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:30:12 +02:00
df8c26d1b3 feat(proxies): CM-9 UI — ProxyCard on client, interest, and yacht detail pages
- shared ProxyCard (view/add/edit/remove point-of-contact) reading each entity's
  /[id]/proxy sub-resource; permission-gated on the entity's edit right
- wired into the client overview, interest overview, and yacht overview tabs

Completes CM-9. tsc clean, lint 0 errors, prod build green, 1638 vitest pass.
Comms send-side wiring (route EOIs/emails through resolveEffectiveProxy) is a
deliberate follow-up — the resolver + data are ready for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:01:08 +02:00
91703bdb00 feat(proxies): CM-9 backend — polymorphic point-of-contact + resolver
- proxies table (migration 0095, port_id cascade), one per client/interest/yacht
- service: get/set(upsert)/clear + resolveEffectiveProxy (yacht → interest →
  client precedence), port-scoped with entity-in-port guard
- per-entity sub-resource routes (/clients|interests|yachts/[id]/proxy) reusing
  each entity's existing view/edit permission (no new permission resource)
- 3 integration tests (CRUD/upsert, tenant guard, resolution precedence)

Backend only — ProxyCard UI on the 3 detail pages to follow. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:54:47 +02:00
3165ec651f feat(client-groups): CM-1 API routes + UI (list, member viewer, copy-emails)
- /api/v1/client-groups (list/create), /[id] (get/patch/delete),
  /[id]/members (get/set) — route.ts + handlers.ts split, client_groups perms
- Client Groups list page (grid + create dialog) and detail page
  (member viewer, per-row copy email, "Copy all emails" → To:-bar format,
  manage-members picker over /api/v1/clients)
- Sidebar nav entry (UsersRound icon)

tsc clean, lint 0 errors, prod build green. Completes CM-1 (Mailchimp push
still deferred until client creds/account).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:29 +02:00
661187cc79 feat(client-groups): CM-1 data layer — groups entity, membership, service, Mailchimp scaffold
- client_groups + client_group_members tables (migration 0094, port_id cascade)
- client_groups permission resource (view/manage) in catalog + role backfill
- service: CRUD + wipe-and-rewrite membership + member email resolution
- mailchimp.service scaffold: config reader + inert one-way sync (mapping
  deferred until the client's MC account is wired, per CM-1 decision)
- 4 integration tests (CRUD, membership, email resolution, port-scope guard)

Backend only — API routes + UI to follow. tsc clean, 1635 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:28:20 +02:00
4dc0bdd8c4 feat(crm): client-meeting batch — contact-pill cleanup, assignment toggle, receipt manual mode
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 9m16s
CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest
  detail headers; relocate GDPR export into the client-header action cluster
  as a compact icon. Keeps the interest "Log contact" quick action.
CM-5: gate the interest assignment feature behind a per-port `assignment_enabled`
  setting (default OFF for single-rep ports). Hides the AssignedToChip +
  residential assigned-to row and skips tier-2/3 auto-assign on create; the
  column + data are preserved and reversible. Tests cover the auto-assign guard.
CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form).
  Threaded through ocr-config.service, the admin OCR form, the scan-receipt
  route, and the scanner shell (skips Tesseract + the server call). Tests cover
  the save/resolve round-trip.

Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:42:36 +02:00
7f04c765f4 fix(crm): inquiry detail polish, EOI preview mime, EOI next-step, documenso v1 banner
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 7m43s
- inquiries: format triage badges with labels (Open/Assigned/Converted/Dismissed),
  surface the lead's free-text message for every kind, and gate the raw-payload
  tab to super admins (was exposing raw JSON to all users)
- file preview: fall back to the server-resolved mime (getPreviewUrl already
  returns it) so files whose stored name lacks a .pdf extension — e.g.
  migration-backfilled signed EOIs — render instead of "preview not supported"
- interest overview: a signed EOI left at stage=eoi no longer shows as
  "NEXT STEP"; completion ordering rolls the next step to Reservation (display
  only, no pipeline_stage change)
- documenso admin: warning banner discouraging the deprecated v1 API + what
  breaks on it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:35 +02:00
4d018be800 feat(inquiries): one-off NocoDB historical contact-form import (idempotent, dry-run default)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:28:01 +02:00
95d7776bb6 test(inquiries): drop unused import 2026-06-17 18:25:13 +02:00
0cc05f302f feat(inquiries): top-level Inquiries page (list + detail + convert), nav entries; retire admin inbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:23:13 +02:00
54554a0928 feat(inquiries): list/get/triage/convert service + API routes (find-or-create client)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:09:59 +02:00
9879b82e5f feat(inquiries): website_submissions tracking + display columns; capture populates contact name/email
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:03:47 +02:00
08adb4aeea feat(permissions): add inquiries resource (view/manage) + idempotent role backfill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:59:32 +02:00
6c4490f653 feat(alerts): always-visible dismiss/ack actions + Dismiss all (service, endpoint, UI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:53:12 +02:00
13efe177a5 feat(alerts): split interest.stale into worked-then-quiet + new-untouched (interest.no_activity)
- interest.stale now fires only for interests with real in-system follow-up
  (contact log / note / update audit) that went quiet 14+ days.
- new interest.no_activity rule covers never-touched, non-imported interests.
- guard interest.high_value_silent against imported-untouched hot leads.
- keys off migration_source_links ledger to identify the bulk import, so the
  imported backlog matches neither rule and the engine auto-resolves the flood.
- test teardown: delete interest_contact_log + test migration ledger rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:49:22 +02:00
107 changed files with 5191 additions and 269 deletions

View File

@@ -0,0 +1,176 @@
/**
* One-off import of historical "Website Contact Form Submissions" from NocoDB
* into the CRM `website_submissions` table, so they show up in the Inquiries
* workbench alongside post-cutover submissions.
*
* The cutover migration imported interests / residential / berths / expenses but
* NOT the contact-form table — those general contact-page inquiries (the
* "broker"/"investor"/"owner" enquiries) were left behind in NocoDB.
*
* Idempotent: each row maps to a deterministic `submission_id`
* (`nocodb-cf-<id>`) guarded by the unique index, plus a `migration_source_links`
* ledger row (`source_system='nocodb_website_submissions'`). Re-running is a
* no-op for already-imported rows.
*
* Usage:
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts # dry-run
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply # write
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply --port-slug port-nimara
*
* Requires NOCODB_URL + NOCODB_TOKEN in env (same as the migration). Writes to
* whatever DATABASE_URL points at — point it at prod ONLY with explicit approval.
*/
import 'dotenv/config';
import { eq } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import {
loadNocoDbConfig,
fetchAllRows,
NOCO_TABLES,
type NocoDbRow,
} from '@/lib/dedup/nocodb-source';
const SOURCE_SYSTEM = 'nocodb_website_submissions';
const APPLIED_ID = 'import-website-inquiries';
function arg(name: string): string | undefined {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
if (hit) return hit.split('=')[1];
const idx = process.argv.indexOf(`--${name}`);
if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1]!.startsWith('--')) {
return process.argv[idx + 1];
}
return undefined;
}
function str(row: NocoDbRow, ...keys: string[]): string {
for (const k of keys) {
const v = row[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
}
function parseDate(row: NocoDbRow): Date {
const raw = str(row, 'CreatedAt', 'created_at', 'Created At', 'createdAt');
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
}
return new Date();
}
async function main() {
const apply = process.argv.includes('--apply');
const portSlug = arg('port-slug') ?? 'port-nimara';
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, portSlug))
.limit(1);
if (!port) throw new Error(`Unknown port slug: ${portSlug}`);
const config = loadNocoDbConfig();
console.log(`[import] Fetching contact-form submissions from NocoDB…`);
const rows = await fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config);
console.log(`[import] Fetched ${rows.length} rows from NocoDB.`);
let inserted = 0;
let skipped = 0;
const samples: Array<Record<string, unknown>> = [];
for (const row of rows) {
const legacyId = String(row.Id);
const submissionId = `nocodb-cf-${legacyId}`;
const fullName = str(row, 'Full Name', 'Name', 'full_name');
const email = str(row, 'Email Address', 'Email', 'email');
const interest = str(row, 'Type of Interest', 'interest');
const comments = str(row, 'Comments', 'comments');
const receivedAt = parseDate(row);
const payload = {
name: fullName,
email,
interest,
comments,
imported_from: 'nocodb_contact_form',
legacy_nocodb_id: legacyId,
};
if (samples.length < 3) {
samples.push({
submissionId,
fullName,
email,
interest,
receivedAt: receivedAt.toISOString(),
});
}
if (!apply) {
// Dry-run: count how many are not yet present.
const [existing] = await db
.select({ id: websiteSubmissions.id })
.from(websiteSubmissions)
.where(eq(websiteSubmissions.submissionId, submissionId))
.limit(1);
if (existing) skipped += 1;
else inserted += 1;
continue;
}
const result = await db
.insert(websiteSubmissions)
.values({
portId: port.id,
submissionId,
kind: 'contact_form',
payload,
contactName: fullName || null,
contactEmail: email || null,
legacyNocodbId: legacyId,
receivedAt,
triageState: 'open',
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });
if (result[0]) {
inserted += 1;
await db
.insert(migrationSourceLinks)
.values({
sourceSystem: SOURCE_SYSTEM,
sourceId: legacyId,
targetEntityType: 'website_submission',
targetEntityId: result[0].id,
appliedId: APPLIED_ID,
})
.onConflictDoNothing();
} else {
skipped += 1;
}
}
console.log('\n[import] Sample rows:');
for (const s of samples) console.log(' ', JSON.stringify(s));
console.log(
`\n[import] ${apply ? 'APPLIED' : 'DRY-RUN'} — port=${portSlug}: ${inserted} ${
apply ? 'inserted' : 'would insert'
}, ${skipped} skipped (already present).`,
);
if (!apply) console.log('[import] Re-run with --apply to write these rows.');
await closeDb();
}
main().catch((err) => {
console.error('[import] FAILED:', err);
process.exitCode = 1;
});

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import type { Route } from 'next';
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch,
},
{
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
label: 'Price reconciliation',
description:
'Parse the purchase price from each berths current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
icon: BadgeDollarSign,
},
] as const;
return (

View File

@@ -0,0 +1,15 @@
import { PageHeader } from '@/components/shared/page-header';
import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table';
export default function BerthPriceReconcilePage() {
return (
<div className="space-y-6">
<PageHeader
title="Berth price reconciliation"
eyebrow="ADMIN"
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
/>
<BerthPriceReconcileTable />
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { WarningCallout } from '@/components/ui/warning-callout';
// All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
@@ -22,6 +23,35 @@ export default function DocumensoSettingsPage() {
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
/>
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
<p>
The CRM&apos;s signing features are built for Documenso 2.x (v2). Set the API version
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
</p>
<ul className="ms-4 mt-1 list-disc space-y-1">
<li>
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
</li>
<li>
<strong>Upload-and-send contracts / reservations</strong> fall back to v1&apos;s
per-field placement: page size is assumed to be A4, and rich field metadata (required
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
</li>
<li>
<strong>One-call send with per-recipient signing links</strong>,{' '}
<strong>sequential signing enforcement</strong>, and the{' '}
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
are unavailable or ignored on v1.
</li>
</ul>
<p className="mt-1">
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
the test-connection button to confirm.
</p>
</WarningCallout>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">

View File

@@ -1,5 +1,15 @@
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
import { redirect } from 'next/navigation';
export default function InquiriesPage() {
return <InquiryInbox />;
/**
* The inquiry inbox is now a top-level, permission-gated page at
* `/[portSlug]/inquiries` (resource `inquiries`), no longer admin-only.
* Redirect the legacy admin URL so old bookmarks/links still land.
*/
interface AdminInquiriesRedirectProps {
params: Promise<{ portSlug: string }>;
}
export default async function AdminInquiriesRedirect({ params }: AdminInquiriesRedirectProps) {
const { portSlug } = await params;
redirect(`/${portSlug}/inquiries`);
}

View File

@@ -0,0 +1,10 @@
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
export default async function ClientGroupDetailPage({
params,
}: {
params: Promise<{ portSlug: string; groupId: string }>;
}) {
const { groupId } = await params;
return <ClientGroupDetail groupId={groupId} />;
}

View File

@@ -0,0 +1,5 @@
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
export default function ClientGroupsPage() {
return <ClientGroupsList />;
}

View File

@@ -0,0 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { InquiryDetail } from '@/components/inquiries/inquiry-detail';
interface InquiryDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function InquiryDetailPage({ params }: InquiryDetailPageProps) {
const { id } = await params;
return <InquiryDetail id={id} />;
}

View File

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

View File

@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
export const metadata: Metadata = {
title: 'Scan receipt',
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
// CM-6: manual-entry mode is resolved server-side so the client can skip
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
return (
<ScanShell
logoUrl={branding?.logoUrl ?? null}
portName={port?.name ?? null}
manualEntry={ocr?.manualEntry ?? false}
/>
);
}

View File

@@ -25,6 +25,7 @@ import {
autoPromoteWebsiteBerthInquiry,
isWebsiteBerthAutopromoteEnabled,
} from '@/lib/services/website-intake-promote.service';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
/**
* POST /api/public/website-inquiries
@@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
// hits, `returning()` yields zero rows and we look up the existing row to
// return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup.
// Extract contact name/email into real columns so the inquiry list can
// search/sort/display without digging into the JSONB payload per row.
const fields = extractInquiryFields(parsed.payload);
const insertResult = await db
.insert(websiteSubmissions)
.values({
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
kind: parsed.kind,
payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
contactName: fields.fullName || null,
contactEmail: fields.email || null,
sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null,
utmSource: parsed.utm_source ?? null,

View File

@@ -15,6 +15,7 @@ const saveSchema = z.object({
clearApiKey: z.boolean().optional(),
useGlobal: z.boolean().optional(),
aiEnabled: z.boolean().optional(),
manualEntry: z.boolean().optional(),
});
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
@@ -58,6 +59,7 @@ export const PUT = withAuth(
clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal,
aiEnabled: body.aiEnabled,
manualEntry: body.manualEntry,
},
ctx.userId,
);

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { ALERT_RULES } from '@/lib/db/schema/insights';
import { dismissAllForPort } from '@/lib/services/alerts.service';
const bodySchema = z.object({
ruleId: z.enum(ALERT_RULES).optional(),
severity: z.enum(['info', 'warning', 'critical']).optional(),
});
export const POST = withAuth(async (req, ctx) => {
try {
const { ruleId, severity } = await parseBody(req, bodySchema);
const dismissed = await dismissAllForPort(ctx.portId, ctx.userId, { ruleId, severity });
return NextResponse.json({ data: { dismissed } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,36 @@
/**
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
*
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
* integration tests can call it directly.
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
const bodySchema = z.object({
approvals: z
.array(
z.object({
berthId: z.string().min(1),
price: z.number().nonnegative(),
currency: z.string().min(1).max(8),
}),
)
.min(1),
});
export const postHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { postHandler } from './handlers';
export const POST = withAuth(withPermission('berths', 'edit', postHandler));

View File

@@ -0,0 +1,21 @@
/**
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
*
* In handlers.ts so integration tests can call them directly, bypassing the
* auth/permission middleware (per CLAUDE.md "Route handler exports").
*/
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
export const getHandler: RouteHandler = async (_req, ctx) => {
try {
const data = await listPriceReconciliation(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler } from './handlers';
export const GET = withAuth(withPermission('berths', 'edit', getHandler));

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveClientGroup,
getClientGroupById,
updateClientGroup,
} from '@/lib/services/client-groups.service';
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const group = await getClientGroupById(params.id!, ctx.portId);
return NextResponse.json({ data: group });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateClientGroupSchema);
const updated = await updateClientGroup(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveClientGroup(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
try {
const members = await listGroupMembers(params.id!, ctx.portId);
return NextResponse.json({ data: members, total: members.length });
} catch (error) {
return errorResponse(error);
}
};
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
try {
const { clientIds } = await parseBody(req, setGroupMembersSchema);
await setGroupMembers(params.id!, ctx.portId, clientIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,6 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getMembersHandler, putMembersHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));

View File

@@ -0,0 +1,7 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
import { createClientGroupSchema } from '@/lib/validators/client-groups';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const groups = await listClientGroups(ctx.portId);
return NextResponse.json({ data: groups, total: groups.length });
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createClientGroupSchema);
const group = await createClientGroup(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: group }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,6 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client');
export const GET = withAuth(withPermission('clients', 'view', getHandler));
export const PUT = withAuth(withPermission('clients', 'edit', putHandler));
export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler));

View File

@@ -48,6 +48,14 @@ export const POST = withAuth(
}
const config = await getResolvedOcrConfig(ctx.portId);
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
// types the details by hand. The client should skip this route entirely
// in manual mode, but we guard server-side too.
if (config.manualEntry) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
});
}
// Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { convertInquiryToClient, convertInquiryToInterest } from '@/lib/services/inquiries.service';
import { convertInquirySchema } from '@/lib/validators/inquiries';
export const POST = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { target } = await parseBody(req, convertInquirySchema);
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const data =
target === 'interest'
? await convertInquiryToInterest(id, ctx.portId, meta)
: await convertInquiryToClient(id, ctx.portId, meta);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { getInquiryById } from '@/lib/services/inquiries.service';
export const GET = withAuth(
withPermission('inquiries', 'view', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const data = await getInquiryById(id, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { triageInquiry } from '@/lib/services/inquiries.service';
import { triageInquirySchema } from '@/lib/validators/inquiries';
export const PATCH = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { state } = await parseBody(req, triageInquirySchema);
const data = await triageInquiry(id, ctx.portId, state, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listInquiries } from '@/lib/services/inquiries.service';
import { listInquiriesSchema } from '@/lib/validators/inquiries';
export const GET = withAuth(
withPermission('inquiries', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listInquiriesSchema);
const result = await listInquiries(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,39 @@
/**
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
*
* Sets or clears the deal-specific price override for one (interest, berth).
* In handlers.ts so integration tests can call it directly.
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
const bodySchema = z.object({
price: z.number().nonnegative().nullable(),
currency: z.string().min(1).max(8).optional(),
});
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
req,
ctx,
params,
) => {
try {
const body = await parseBody(req, bodySchema);
await setBerthPriceOverride(
params.id!,
params.berthId!,
body.price,
body.currency ?? null,
ctx.portId,
);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { putHandler } from './handlers';
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest');
export const GET = withAuth(withPermission('interests', 'view', getHandler));
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler));

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht');
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
export const PUT = withAuth(withPermission('yachts', 'edit', putHandler));
export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler));

View File

@@ -30,6 +30,7 @@ interface ConfigResp {
hasApiKey: boolean;
useGlobal: boolean;
aiEnabled: boolean;
manualEntry: boolean;
};
models: Record<Provider, string[]>;
}
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
// Key the body on the loaded payload so useState initializers seed
// from server values cleanly.
const sig = data?.data
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}`
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
: 'loading';
return (
<SettingsBlockBody
@@ -89,6 +90,7 @@ function SettingsBlockBody({
const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null,
);
@@ -105,6 +107,7 @@ function SettingsBlockBody({
clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled,
manualEntry: scope === 'global' ? false : manualEntry,
},
}),
onSuccess: () => {
@@ -190,6 +193,25 @@ function SettingsBlockBody({
</div>
) : null}
{scope === 'port' ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`manualEntry-${scope}`}
checked={manualEntry}
onCheckedChange={(v) => setManualEntry(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
Manual entry only (skip receipt scanning)
</Label>
<p className="text-xs text-muted-foreground">
When on, staff just attach a receipt photo and type the details by hand - no
on-device or AI parsing runs. Takes precedence over AI parsing above.
</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label>

View File

@@ -103,6 +103,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
delete: false,
change_stage: false,
},
inquiries: {
view: false,
manage: false,
},
};
const GROUP_LABELS: Record<string, string> = {
@@ -126,6 +130,7 @@ const GROUP_LABELS: Record<string, string> = {
admin: 'Administration',
residential_clients: 'Residential Clients',
residential_interests: 'Residential Interests',
inquiries: 'Inquiries',
};
function formatAction(action: string): string {

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean',
defaultValue: true,
},
{
key: 'assignment_enabled',
label: 'Interest Assignment',
description:
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
type: 'boolean',
defaultValue: false,
},
{
key: 'tenancies_module_enabled',
label: 'Tenancies Module',

View File

@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
</div>
</div>
{!readOnly ? (
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
<div className="flex shrink-0 items-start gap-1 text-muted-foreground">
{!acknowledged ? (
<Button
variant="ghost"

View File

@@ -4,10 +4,11 @@ import { useState } from 'react';
import { ShieldAlert } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
import { useAlertCount, useAlertList, useAlertRealtime, useDismissAll } from './use-alerts';
import type { AlertStatus } from './types';
/**
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
const total = count?.total ?? 0;
const alerts = data?.data ?? [];
const dismissAll = useDismissAll();
return (
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
</TabsList>
<TabsContent value={tab} className="mt-4 space-y-2">
{tab === 'open' && alerts.length > 0 ? (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
>
Dismiss all
</Button>
</div>
) : null}
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />

View File

@@ -41,6 +41,15 @@ export function useAlertActions() {
return { acknowledge, dismiss };
}
export function useDismissAll() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (filter: { ruleId?: string; severity?: string } = {}) =>
apiFetch('/api/v1/alerts/dismiss-all', { method: 'POST', body: filter }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }),
});
}
export function useAlertRealtime() {
useRealtimeInvalidation({
'alert:created': [['alerts']],

View File

@@ -0,0 +1,172 @@
'use client';
/**
* Bulk berth price-reconcile table (CM-2 Part A).
*
* Lists the price parsed from each berth's current spec sheet next to the stored
* price, with per-row + select-all approval. Nothing is written until the rep
* approves — the apply mutation posts only the checked, changed rows.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { EmptyState } from '@/components/ui/empty-state';
interface Row {
berthId: string;
mooringNumber: string;
area: string | null;
currentPrice: number | null;
currentCurrency: string;
parsedPrice: number | null;
parsedCurrency: string | null;
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
warning?: string;
}
const STATUS_STYLE: Record<Row['status'], string> = {
changed: 'bg-amber-100 text-amber-800',
matched: 'bg-muted text-muted-foreground',
needs_review: 'bg-red-100 text-red-700',
no_pdf: 'bg-slate-100 text-slate-500',
};
const STATUS_LABEL: Record<Row['status'], string> = {
changed: 'Changed',
matched: 'Matched',
needs_review: 'Needs review',
no_pdf: 'No PDF',
};
const fmt = (n: number | null, ccy: string | null) =>
n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim();
export function BerthPriceReconcileTable() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ data: Row[] }>({
queryKey: ['berths', 'price-reconcile'],
queryFn: () => apiFetch('/api/v1/berths/price-reconcile'),
});
const rows = useMemo(() => data?.data ?? [], [data]);
const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]);
const [checked, setChecked] = useState<Record<string, boolean>>({});
const apply = useMutation({
mutationFn: async (): Promise<{ data: { updated: number } }> => {
const approvals = selectable
.filter((r) => checked[r.berthId] && r.parsedPrice != null)
.map((r) => ({
berthId: r.berthId,
price: r.parsedPrice as number,
currency: r.parsedCurrency ?? r.currentCurrency,
}));
return apiFetch('/api/v1/berths/price-reconcile/apply', {
method: 'POST',
body: { approvals },
});
},
onSuccess: (res) => {
toast.success(`Updated ${res.data.updated} berth price(s).`);
setChecked({});
void qc.invalidateQueries({ queryKey: ['berths'] });
},
onError: (e: Error) => toastError(e),
});
if (isLoading) {
return <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets</p>;
}
if (rows.length === 0) {
return (
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
);
}
const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]);
const selectedCount = selectable.filter((r) => checked[r.berthId]).length;
const reviewCount = rows.filter((r) => r.status === 'needs_review').length;
const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
</p>
<Button
size="sm"
disabled={selectedCount === 0 || apply.isPending}
onClick={() => apply.mutate()}
>
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
<th className="w-10 p-2 ps-3">
<Checkbox
aria-label="Select all changed"
checked={allChecked}
onCheckedChange={(c) =>
setChecked(
c === true
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
: {},
)
}
/>
</th>
<th className="p-2">Mooring</th>
<th className="p-2">Area</th>
<th className="p-2 text-end">Current</th>
<th className="p-2 text-end">Parsed</th>
<th className="p-2">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.berthId} className="border-b last:border-0">
<td className="p-2 ps-3">
{r.status === 'changed' ? (
<Checkbox
aria-label={`Approve ${r.mooringNumber}`}
checked={!!checked[r.berthId]}
onCheckedChange={(c) =>
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
}
/>
) : null}
</td>
<td className="p-2 font-medium">{r.mooringNumber}</td>
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.currentPrice, r.currentCurrency)}
</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.parsedPrice, r.parsedCurrency)}
</td>
<td className="p-2">
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
{STATUS_LABEL[r.status]}
</span>
{r.warning ? (
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,304 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { EmptyState } from '@/components/shared/empty-state';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface GroupMember {
clientId: string;
fullName: string;
email: string | null;
}
interface ClientOption {
id: string;
fullName: string;
primaryEmail: string | null;
}
async function copyToClipboard(text: string, successMsg: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMsg);
} catch {
toast.error('Copy failed — clipboard unavailable');
}
}
export function ClientGroupDetail({ groupId }: { groupId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const qc = useQueryClient();
const [manageOpen, setManageOpen] = useState(false);
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
queryKey: ['client-group', groupId],
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
});
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
queryKey: ['client-group', groupId, 'members'],
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
});
const group = groupResp?.data;
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
const archive = useMutation({
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Group archived');
qc.invalidateQueries({ queryKey: ['client-groups'] });
router.push(`/${portSlug}/client-groups` as Route);
},
onError: (err) => toastError(err),
});
return (
<div className="space-y-6">
<Link
href={`/${portSlug}/client-groups` as Route}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
All groups
</Link>
<PageHeader
title={group?.name ?? 'Group'}
eyebrow="Mailing group"
kpiLine={
<span className="inline-flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" aria-hidden />
{members.length} {members.length === 1 ? 'member' : 'members'}
{emails.length < members.length ? (
<span className="text-amber-700">
· {members.length - emails.length} without email
</span>
) : null}
</span>
}
variant="gradient"
actions={
<>
<Button
variant="outline"
disabled={emails.length === 0}
onClick={() =>
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
}
>
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
Copy all emails
</Button>
<PermissionGate resource="client_groups" action="manage">
<Button variant="outline" onClick={() => setManageOpen(true)}>
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
Manage members
</Button>
</PermissionGate>
<PermissionGate resource="client_groups" action="manage">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
archive.mutate();
}
}}
>
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
Archive
</Button>
</PermissionGate>
</>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading members</p>
) : members.length === 0 ? (
<EmptyState
icon={Users}
title="No members yet"
description="Use “Manage members” to add clients to this group."
/>
) : (
<div className="overflow-hidden rounded-xl border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Client</th>
<th className="px-4 py-2 font-medium">Email</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{members.map((m) => (
<tr key={m.clientId} className="hover:bg-muted/30">
<td className="px-4 py-2">
<Link
href={`/${portSlug}/clients/${m.clientId}` as Route}
className="text-foreground hover:underline"
>
{m.fullName}
</Link>
</td>
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
<td className="px-4 py-2 text-end">
{m.email ? (
<button
type="button"
onClick={() => copyToClipboard(m.email!, 'Email copied')}
aria-label={`Copy ${m.email}`}
title="Copy email"
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<Copy className="h-4 w-4" aria-hidden />
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{manageOpen ? (
<ManageMembersDialog
groupId={groupId}
open={manageOpen}
onOpenChange={setManageOpen}
currentIds={members.map((m) => m.clientId)}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
qc.invalidateQueries({ queryKey: ['client-groups'] });
}}
/>
) : null}
</div>
);
}
function ManageMembersDialog({
groupId,
open,
onOpenChange,
currentIds,
onSaved,
}: {
groupId: string;
open: boolean;
onOpenChange: (v: boolean) => void;
currentIds: string[];
onSaved: () => void;
}) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
queryKey: ['clients', 'group-picker'],
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
enabled: open,
});
const clients = data?.data ?? [];
const filtered = clients.filter((c) =>
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
);
const save = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
method: 'PUT',
body: { clientIds: Array.from(selected) },
}),
onSuccess: () => {
toast.success('Members updated');
onSaved();
onOpenChange(false);
},
onError: (err) => toastError(err),
});
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Manage members</DialogTitle>
<DialogDescription>
Tick the clients who belong in this group. {selected.size} selected.
</DialogDescription>
</DialogHeader>
<Input
placeholder="Search clients…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{isLoading ? (
<p className="p-2 text-sm text-muted-foreground">Loading clients</p>
) : filtered.length === 0 ? (
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
) : (
filtered.map((c) => (
<label
key={c.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
{c.primaryEmail ? (
<span className="block truncate text-xs text-muted-foreground">
{c.primaryEmail}
</span>
) : null}
</span>
</label>
))
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Save members
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Users } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { EmptyState } from '@/components/shared/empty-state';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface ClientGroupRow {
id: string;
name: string;
description: string | null;
color: string;
memberCount: number;
}
export function ClientGroupsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState('#6B7280');
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
queryKey: ['client-groups'],
queryFn: () => apiFetch('/api/v1/client-groups'),
});
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/client-groups', {
method: 'POST',
body: { name: name.trim(), description: description.trim() || null, color },
}),
onSuccess: () => {
toast.success('Group created');
qc.invalidateQueries({ queryKey: ['client-groups'] });
setOpen(false);
setName('');
setDescription('');
setColor('#6B7280');
},
onError: (err) => toastError(err),
});
const groups = data?.data ?? [];
return (
<div className="space-y-6">
<PageHeader
title="Client Groups"
eyebrow="Mailing"
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
variant="gradient"
actions={
<PermissionGate resource="client_groups" action="manage">
<Button onClick={() => setOpen(true)}>
<Plus className="me-1.5 h-4 w-4" aria-hidden />
New group
</Button>
</PermissionGate>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : groups.length === 0 ? (
<EmptyState
icon={Users}
title="No groups yet"
description="Create a group to start organising clients into mailing lists."
/>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groups.map((g) => (
<Link
key={g.id}
href={`/${portSlug}/client-groups/${g.id}` as Route}
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: g.color }}
aria-hidden
/>
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
</div>
{g.description ? (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
) : null}
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5" aria-hidden />
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
</p>
</Link>
))}
</div>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New client group</DialogTitle>
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="cg-name">Name</Label>
<Input
id="cg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Newsletter subscribers"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cg-desc">Description (optional)</Label>
<Input
id="cg-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cg-color">Color</Label>
<input
id="cg-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
Create group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -3,11 +3,9 @@
import { useParams, useRouter } from 'next/navigation';
import type { Route } from 'next';
import { useState } from 'react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate';
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.value;
const primaryPhoneContact =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
const primaryPhone = primaryPhoneContact?.value;
// wa.me requires the E.164 number without the leading "+". Strip from the
// canonical E.164 form when available; otherwise strip non-digits from the
// display value as a best-effort fallback.
const whatsappNumber = primaryPhoneContact?.valueE164
? primaryPhoneContact.valueE164.replace(/^\+/, '')
: primaryPhoneContact?.value
? primaryPhoneContact.value.replace(/[^\d]/g, '')
: null;
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</p>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{primaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
<Mail />
Email
</a>
</Button>
) : null}
{primaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{!isArchived && client.clientPortalEnabled === true ? (
{/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
request. GDPR export moved to the top-right action cluster.
Portal-invite stays as the one primary CTA here. */}
{!isArchived && client.clientPortalEnabled === true ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<div className="hidden sm:inline-flex">
<PortalInviteButton
clientId={client.id}
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail}
/>
</div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div>
</div>
) : null}
{client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */}
<div className="flex items-start gap-1">
{/* CM-4: GDPR export relocated here as a compact icon trigger,
alongside reminder/archive/delete. Self-gates on permission. */}
<GdprExportButton clientId={client.id} variant="icon" />
{isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients">
<button

View File

@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
@@ -156,6 +157,9 @@ function OverviewTab({
<ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
{/* CM-9: point-of-contact (default level for the client). */}
<ProxyCard entityType="client" entityId={clientId} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">

View File

@@ -48,7 +48,15 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
failed: 'destructive',
};
export function GdprExportButton({ clientId }: { clientId: string }) {
export function GdprExportButton({
clientId,
variant = 'button',
}: {
clientId: string;
/** `button` = standalone outline button (default). `icon` = compact icon-only
* trigger for the detail-header top-right action cluster (CM-4). */
variant?: 'button' | 'icon';
}) {
const { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export
</Button>
{variant === 'icon' ? (
<button
type="button"
aria-label="GDPR export"
title="GDPR export"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<FileDown className="size-4" aria-hidden />
</button>
) : (
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>

View File

@@ -104,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set.
const previewQuery = useQuery<{ data: { url: string } }>({
const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId,
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null;
const kind = previewKindFor(mimeType, fileName);
// Prefer the caller-supplied mime, but fall back to the server's resolved
// mime (getPreviewUrl returns it). Without this, callers that pass only a
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
// through to the "unknown" surface even though the server knows it's a PDF.
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
const kind = previewKindFor(resolvedMime, fileName);
return (
<Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -0,0 +1,35 @@
'use client';
import Link from 'next/link';
import { formatDistanceToNowStrict } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { KIND_LABELS, TRIAGE_TONE, type InquiryRow } from '@/components/inquiries/inquiry-columns';
export function InquiryCard({ inquiry, portSlug }: { inquiry: InquiryRow; portSlug: string }) {
return (
<Link href={`/${portSlug}/inquiries/${inquiry.id}`} className="block">
<Card className="transition-shadow hover:shadow-sm">
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{inquiry.contactName || '(no name)'}</p>
{inquiry.contactEmail ? (
<p className="truncate text-sm text-muted-foreground">{inquiry.contactEmail}</p>
) : null}
</div>
<Badge className={TRIAGE_TONE[inquiry.triageState]}>{inquiry.triageState}</Badge>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{KIND_LABELS[inquiry.kind]}</span>
<span>·</span>
<span>
{formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
</span>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import Link from 'next/link';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { MoreHorizontal, UserCheck, X, ExternalLink } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export type InquiryKind = 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
export type InquiryTriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
export interface InquiryRow {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
receivedAt: string;
triageState: InquiryTriageState;
convertedClientId: string | null;
convertedInterestId: string | null;
sourceIp: string | null;
utmSource?: string | null;
}
export const KIND_LABELS: Record<InquiryKind, string> = {
berth_inquiry: 'Berth',
residence_inquiry: 'Residence',
contact_form: 'Contact',
};
const KIND_TONE: Record<InquiryKind, string> = {
berth_inquiry: 'bg-blue-100 text-blue-800',
residence_inquiry: 'bg-amber-100 text-amber-900',
contact_form: 'bg-slate-100 text-slate-800',
};
export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
open: 'bg-blue-100 text-blue-800',
assigned: 'bg-amber-100 text-amber-900',
converted: 'bg-emerald-100 text-emerald-800',
dismissed: 'bg-slate-100 text-slate-600',
};
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
open: 'Open',
assigned: 'Assigned',
converted: 'Converted',
dismissed: 'Dismissed',
};
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'contactEmail', label: 'Email' },
{ id: 'kind', label: 'Type' },
{ id: 'triageState', label: 'Status' },
{ id: 'utmSource', label: 'UTM source' },
{ id: 'receivedAt', label: 'Received' },
];
export const INQUIRY_DEFAULT_HIDDEN: string[] = ['utmSource'];
interface GetColumnsOptions {
portSlug: string;
onTriage: (row: InquiryRow, state: InquiryTriageState) => void;
}
export function getInquiryColumns({
portSlug,
onTriage,
}: GetColumnsOptions): ColumnDef<InquiryRow, unknown>[] {
return [
{
id: 'contactName',
accessorKey: 'contactName',
header: 'Name',
cell: ({ row }) => (
<Link
href={`/${portSlug}/inquiries/${row.original.id}`}
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.contactName || '(no name)'}
</Link>
),
},
{
id: 'contactEmail',
accessorKey: 'contactEmail',
header: 'Email',
enableSorting: false,
cell: ({ getValue }) => {
const email = getValue() as string | null;
return email ? (
<span className="text-sm">{email}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'kind',
accessorKey: 'kind',
header: 'Type',
cell: ({ getValue }) => {
const kind = getValue() as InquiryKind;
return <Badge className={KIND_TONE[kind]}>{KIND_LABELS[kind]}</Badge>;
},
},
{
id: 'triageState',
accessorKey: 'triageState',
header: 'Status',
cell: ({ row }) => {
const state = row.original.triageState;
return (
<div className="flex items-center gap-1.5">
<Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
{row.original.convertedInterestId ? (
<Link
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
interest
</Link>
) : row.original.convertedClientId ? (
<Link
href={`/${portSlug}/clients/${row.original.convertedClientId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
client
</Link>
) : null}
</div>
);
},
},
{
id: 'utmSource',
accessorKey: 'utmSource',
header: 'UTM source',
enableSorting: false,
cell: ({ getValue }) => {
const utm = getValue() as string | null;
return utm ? (
<span className="text-sm">{utm}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'receivedAt',
accessorKey: 'receivedAt',
header: 'Received',
cell: ({ getValue }) => {
const iso = getValue() as string;
const d = new Date(iso);
return (
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
{formatDistanceToNowStrict(d, { addSuffix: true })}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => {
const isResolved =
row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Row actions for ${row.original.contactName ?? 'inquiry'}`}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/inquiries/${row.original.id}`}>
<ExternalLink className="mr-2 h-3.5 w-3.5" aria-hidden />
Open
</Link>
</DropdownMenuItem>
{!isResolved ? (
<>
<DropdownMenuItem onClick={() => onTriage(row.original, 'assigned')}>
<UserCheck className="mr-2 h-3.5 w-3.5" aria-hidden />
Assign to me
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTriage(row.original, 'dismissed')}>
<X className="mr-2 h-3.5 w-3.5" aria-hidden />
Dismiss
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => onTriage(row.original, 'open')}>
Reopen
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ArrowRight, UserPlus, UserCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface InquiryConvertActionsProps {
portSlug: string;
inquiry: {
id: string;
triageState: string;
convertedClientId: string | null;
convertedInterestId: string | null;
};
}
export function InquiryConvertActions({ portSlug, inquiry }: InquiryConvertActionsProps) {
const router = useRouter();
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
};
const convert = useMutation({
mutationFn: (target: 'client' | 'interest') =>
apiFetch<{ data: { clientId: string; interestId: string | null } }>(
`/api/v1/inquiries/${inquiry.id}/convert`,
{ method: 'POST', body: { target } },
),
onSuccess: (res, target) => {
invalidate();
if (target === 'interest' && res.data.interestId) {
toast.success('Converted to interest.');
router.push(`/${portSlug}/interests/${res.data.interestId}`);
} else {
toast.success('Converted to client.');
router.push(`/${portSlug}/clients/${res.data.clientId}`);
}
},
onError: (err: unknown) => toastError(err, 'Convert failed'),
});
const triage = useMutation({
mutationFn: (state: 'open' | 'assigned' | 'dismissed') =>
apiFetch(`/api/v1/inquiries/${inquiry.id}/triage`, { method: 'PATCH', body: { state } }),
onSuccess: (_d, state) => {
invalidate();
toast.success(`Marked ${state}.`);
},
onError: (err: unknown) => toastError(err, 'Update failed'),
});
const busy = convert.isPending || triage.isPending;
const alreadyInterest = Boolean(inquiry.convertedInterestId);
return (
<PermissionGate resource="inquiries" action="manage">
<div className="flex flex-wrap items-center gap-2">
{alreadyInterest ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/interests/${inquiry.convertedInterestId}`}>View interest</a>
</Button>
) : (
<Button size="sm" disabled={busy} onClick={() => convert.mutate('interest')}>
<ArrowRight className="mr-1.5 h-4 w-4" aria-hidden />
Convert to interest
</Button>
)}
{inquiry.convertedClientId ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/clients/${inquiry.convertedClientId}`}>View client</a>
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => convert.mutate('client')}
>
<UserPlus className="mr-1.5 h-4 w-4" aria-hidden />
Convert to client
</Button>
)}
{inquiry.triageState === 'open' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('assigned')}
>
<UserCheck className="mr-1.5 h-4 w-4" aria-hidden />
Assign to me
</Button>
) : null}
{inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('dismissed')}
>
<X className="mr-1.5 h-4 w-4" aria-hidden />
Dismiss
</Button>
) : null}
</div>
</PermissionGate>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { format } from 'date-fns';
import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import {
KIND_LABELS,
TRIAGE_LABELS,
TRIAGE_TONE,
type InquiryKind,
type InquiryTriageState,
} from '@/components/inquiries/inquiry-columns';
import { InquiryConvertActions } from '@/components/inquiries/inquiry-convert-actions';
interface InquiryDetailData {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
payload: Record<string, unknown> | null;
receivedAt: string;
sourceIp: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
triageState: InquiryTriageState;
triagedAt: string | null;
convertedClientId: string | null;
convertedInterestId: string | null;
convertedClient: { id: string; fullName: string } | null;
convertedInterest: { id: string; pipelineStage: string } | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2 py-1.5 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 break-words">
{value || <span className="text-muted-foreground"></span>}
</span>
</div>
);
}
export function InquiryDetail({ id }: { id: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { isSuperAdmin } = usePermissions();
const { data, isLoading, error } = useQuery<InquiryDetailData>({
queryKey: ['inquiries', id],
queryFn: () =>
apiFetch<{ data: InquiryDetailData }>(`/api/v1/inquiries/${id}`).then((r) => r.data),
retry: (count, err) => {
const status = (err as { status?: number })?.status;
return status === 404 || status === 403 ? false : count < 2;
},
});
if (error && !isLoading) {
const status = (error as { status?: number })?.status;
return (
<DetailNotFound
entity="inquiry"
backHref={`/${portSlug}/inquiries`}
backLabel="Back to inquiries"
status={status}
/>
);
}
const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
// The free-text message a lead left. Website forms use different keys
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
// common ones and surface it for every inquiry kind.
const comment = str('comments') || str('message') || str('comment') || str('notes');
const tabs: DetailTab[] = [
{
id: 'overview',
label: 'Overview',
content: (
<div className="max-w-xl">
<Row label="Name" value={data?.contactName} />
<Row label="Email" value={data?.contactEmail} />
<Row label="Phone" value={str('phone')} />
{data?.kind === 'residence_inquiry' ? (
<Row label="Place of residence" value={str('address')} />
) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{comment ? (
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
) : null}
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
<Row label="Source IP" value={data?.sourceIp} />
<Row label="UTM source" value={data?.utmSource} />
<Row label="UTM medium" value={data?.utmMedium} />
<Row label="UTM campaign" value={data?.utmCampaign} />
</div>
),
},
{
id: 'tracking',
label: 'Tracking',
content: (
<div className="max-w-xl">
<Row
label="Status"
value={
data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : (
''
)
}
/>
<Row
label="Triaged at"
value={data?.triagedAt ? format(new Date(data.triagedAt), 'PPpp') : ''}
/>
<Row
label="Converted client"
value={
data?.convertedClient ? (
<a
href={`/${portSlug}/clients/${data.convertedClient.id}`}
className="text-primary hover:underline"
>
{data.convertedClient.fullName}
</a>
) : null
}
/>
<Row
label="Converted interest"
value={
data?.convertedInterest ? (
<a
href={`/${portSlug}/interests/${data.convertedInterest.id}`}
className="text-primary hover:underline"
>
View interest ({data.convertedInterest.pipelineStage})
</a>
) : null
}
/>
</div>
),
},
{
id: 'payload',
label: 'Raw payload',
content: (
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
{JSON.stringify(data?.payload ?? {}, null, 2)}
</pre>
),
},
].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
return (
<DetailLayout
isLoading={isLoading}
header={
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
{data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{data ? KIND_LABELS[data.kind] : ''} inquiry
{data?.contactEmail ? ` · ${data.contactEmail}` : ''}
</p>
</div>
{data ? <InquiryConvertActions portSlug={portSlug} inquiry={data} /> : null}
</div>
}
tabs={tabs}
defaultTab="overview"
/>
);
}

View File

@@ -0,0 +1,33 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const inquiryFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search name or email…',
},
{
key: 'kind',
label: 'Type',
type: 'select',
options: [
{ label: 'Berth', value: 'berth_inquiry' },
{ label: 'Residence', value: 'residence_inquiry' },
{ label: 'Contact', value: 'contact_form' },
],
},
{
key: 'state',
label: 'Status',
type: 'select',
options: [
{ label: 'Inbox (open + assigned)', value: 'inbox' },
{ label: 'Open', value: 'open' },
{ label: 'Assigned', value: 'assigned' },
{ label: 'Converted', value: 'converted' },
{ label: 'Dismissed', value: 'dismissed' },
{ label: 'All', value: 'all' },
],
},
];

View File

@@ -0,0 +1,127 @@
'use client';
import { useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { ColumnPicker } from '@/components/shared/column-picker';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { inquiryFilterDefinitions } from '@/components/inquiries/inquiry-filters';
import {
getInquiryColumns,
INQUIRY_COLUMN_OPTIONS,
INQUIRY_DEFAULT_HIDDEN,
type InquiryRow,
type InquiryTriageState,
} from '@/components/inquiries/inquiry-columns';
import { InquiryCard } from '@/components/inquiries/inquiry-card';
export function InquiryList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Inquiries', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<InquiryRow>({
queryKey: ['inquiries'],
endpoint: '/api/v1/inquiries',
initialSort: { field: 'receivedAt', direction: 'desc' },
filterDefinitions: inquiryFilterDefinitions,
});
const triageMutation = useMutation({
mutationFn: (args: { id: string; state: InquiryTriageState }) =>
apiFetch(`/api/v1/inquiries/${args.id}/triage`, {
method: 'PATCH',
body: { state: args.state },
}),
onSuccess: (_d, vars) => {
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
toast.success(`Marked ${vars.state}.`);
},
onError: (err: unknown) => toastError(err, 'Update failed'),
});
const columns = getInquiryColumns({
portSlug,
onTriage: (row, state) => triageMutation.mutate({ id: row.id, state }),
});
const { hidden, setHidden } = useTablePreferences('inquiries', INQUIRY_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
return (
<div className="space-y-4">
<PageHeader
title="Inquiries"
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
variant="gradient"
/>
<div className="flex flex-wrap items-center gap-2">
<FilterBar
filters={inquiryFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<div className="ml-auto flex flex-wrap items-center gap-2">
<ColumnPicker columns={INQUIRY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
</div>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
cardRender={(row) => <InquiryCard inquiry={row.original} portSlug={portSlug} />}
emptyState={
<EmptyState
title="No inquiries found"
description="Submissions from the marketing site will appear here."
/>
}
/>
)}
</div>
);
}

View File

@@ -11,14 +11,11 @@ import {
Trophy,
XCircle,
RefreshCcw,
Mail,
MessageSquarePlus,
Phone,
AlarmClock,
User,
} from 'lucide-react';
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
@@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip';
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { formatOutcome } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils';
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
id: string;
clientId: string;
clientName: string | null;
/** Primary contact channels resolved from the linked client. The header
* uses these to render Email / Call / WhatsApp buttons so the rep
* doesn't have to navigate to the client page just to reach out. */
/** Primary contact channels resolved from the linked client. The
* Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
* for downstream reuse (e.g. proxy comms routing, CM-9). */
clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [logContactOpen, setLogContactOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
// CM-5: assignment UI is hidden when the per-port toggle is off (default).
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome;
// Contact deep-links - resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
const whatsappNumber = interest.clientPrimaryPhoneE164
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
: interest.clientPrimaryPhone
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
: null;
const reopenMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount}
</span>
) : null}
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
{assignmentEnabled ? (
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
) : null}
<MultiEoiChip interestId={interest.id} />
<DealPulseChip
interest={{
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div>
)}
{/* Contact deep-links - let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail ||
interest.clientPrimaryPhone ||
whatsappNumber ||
interest.clientId ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
) : null}
{interest.clientPrimaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`mailto:${interest.clientPrimaryEmail}`}
aria-label={`Email ${interest.clientPrimaryEmail}`}
>
<Mail />
Email
</a>
</Button>
) : null}
{interest.clientPrimaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`tel:${interest.clientPrimaryPhone}`}
aria-label={`Call ${interest.clientPrimaryPhone}`}
>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
Client-page link + Log-contact action stay - the rep can still
jump to the client and record outreach without leaving here. */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
>
<MessageSquarePlus />
Log contact
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
</div>
) : null}
) : null}
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
>
<MessageSquarePlus />
Log contact
</Button>
</div>
</div>
{/* Top-right actions. Won/Lost are sales-critical and read as text

View File

@@ -19,6 +19,7 @@ import {
AccordionTrigger,
} from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
@@ -848,7 +849,18 @@ function OverviewTab({
deposit_paid: 'deposit',
contract: 'contract',
};
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
const stageOwnedMilestoneRaw =
STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
// B2 (2026-06-18): if the stage-owned milestone is already COMPLETE — e.g. a
// migrated deal left at stage=eoi with a signed EOI that never auto-advanced —
// don't pin it as the current "NEXT STEP". Falling back to null makes phaseFor
// use completion ordering, so the signed milestone shows as done/past and the
// next incomplete one (Reservation) becomes current. Display-only; the
// pipeline_stage column is unchanged.
const stageOwnedMilestoneComplete = stageOwnedMilestoneRaw
? milestoneCompletion[stageOwnedMilestoneRaw]
: false;
const stageOwnedMilestone = stageOwnedMilestoneComplete ? null : stageOwnedMilestoneRaw;
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
const phaseFor = (k: (typeof order)[number]): Phase => {
// Stage owns this milestone → always current, never collapsed.
@@ -1122,6 +1134,9 @@ function OverviewTab({
archivedAt={null}
/>
{/* CM-9: per-deal point-of-contact (overrides the client's default). */}
<ProxyCard entityType="interest" entityId={interestId} />
{/* Qualification checklist - surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */}

View File

@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
addedBy: string | null;
addedAt: string;
notes: string | null;
priceOverride: string | null;
priceOverrideCurrency: string | null;
mooringNumber: string | null;
area: string | null;
status: string;
@@ -193,6 +195,24 @@ function useRemoveLink(interestId: string) {
});
}
// CM-2 Part B: set/clear the deal-specific price override for one berth.
function useSetBerthPrice(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { berthId: string; price: number | null }) =>
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
method: 'PUT',
body: { price: args.price },
}),
onSuccess: (_data, args) => {
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
qc.invalidateQueries({ queryKey: ['interests', interestId] });
},
onError: (e: Error) => toastError(e),
});
}
// ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps {
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
}: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
const setBerthPrice = useSetBerthPrice(interestId);
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed';
const commitPrice = () => {
const raw = priceDraft.replace(/[,\s]/g, '');
const next = raw === '' ? null : Number(raw);
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
if (next === prev) return;
setBerthPrice.mutate({ berthId: row.berthId, price: next });
};
return (
<div
className={cn(
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
</div>
</TooltipProvider>
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
this interest only; flows into the EOI/document {{berth.price}} token. */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Deal price</p>
<p className="text-xs text-muted-foreground">
Overrides the berth&apos;s list price for this deal only. Leave blank to use the list
price.
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="numeric"
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
placeholder="List price"
value={priceDraft}
disabled={isPending || setBerthPrice.isPending}
onChange={(e) => setPriceDraft(e.target.value)}
onBlur={commitPrice}
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
/>
{row.priceOverrideCurrency ? (
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
) : null}
</div>
</div>
{showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't

View File

@@ -37,7 +37,12 @@ import { cn } from '@/lib/utils';
import { useNotifications } from '@/hooks/use-notifications';
import { NotificationItem } from '@/components/notifications/notification-item';
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts';
import {
useAlertCount,
useAlertList,
useAlertRealtime,
useDismissAll,
} from '@/components/alerts/use-alerts';
interface NotificationListResponse {
data: Array<{
@@ -66,6 +71,7 @@ export function Inbox() {
const systemCritical = alertCount?.bySeverity.critical ?? 0;
const systemAlerts = alertList?.data ?? [];
const systemTop = systemAlerts.slice(0, 8);
const dismissAll = useDismissAll();
// ── Personal (notifications) ──
const { unreadCount: personalUnread } = useNotifications();
@@ -230,13 +236,25 @@ export function Inbox() {
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active alerts
</h4>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
</Link>
<div className="flex items-center gap-3">
{systemAlerts.length > 0 ? (
<button
type="button"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Dismiss all
</button>
) : null}
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
</Link>
</div>
</div>
<Separator />
<ScrollArea className="max-h-[400px]">

View File

@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
/**
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
* deep-navy gradient surface with white type, the brand "PN" mark on the
* left when there's no back affordance, and a soft glow shadow underneath
* for depth instead of a hard divider line.
* deep-navy gradient surface with white type, a back arrow on the left when
* there's a back affordance (otherwise a balancing spacer), and a soft glow
* shadow underneath for depth instead of a hard divider line.
*
* Slots: title (auto-truncating), back arrow, primary action - all driven by
* `useMobileChrome()` from the active page. When no page has set a title the
@@ -47,17 +47,6 @@ export function MobileTopbar() {
portTitle ||
'CRM';
// Brand-mark initials derived from the port slug
// ("port-nimara" → "PN", "marina-alpha" → "MA"). Cheap, self-contained,
// no extra DB round-trip.
const initials = portSlug
? portSlug
.split('-')
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
.slice(0, 2)
: 'CR';
return (
<header
className={cn(
@@ -71,15 +60,10 @@ export function MobileTopbar() {
{backTarget ? (
<BackButton variant="mobile" />
) : (
<div
aria-label={portTitle || 'Home'}
className={cn(
'size-9 shrink-0 rounded-lg flex items-center justify-center',
'bg-[#3a7bc8] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25)]',
)}
>
<span className="text-white font-bold text-[13px] tracking-tight">{initials}</span>
</div>
// No back affordance on top-level pages. Render an empty spacer the
// same width as the right-hand action slot so the centered title
// stays optically centered (the brand "PN" mark was removed here).
<div className="size-11 shrink-0" aria-hidden />
)}
<h1

View File

@@ -6,6 +6,7 @@ import {
Bookmark,
Building2,
FileSignature,
MailQuestion,
FileText,
Globe,
Home,
@@ -53,6 +54,7 @@ const MORE_GROUPS: MoreGroup[] = [
items: [
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Inquiries', icon: MailQuestion, segment: 'inquiries' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Residential', icon: Home, segment: 'residential/clients' },

View File

@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
UsersRound,
Bookmark,
Anchor,
KeyRound,
@@ -16,6 +17,7 @@ import {
FileText,
FileBarChart,
Inbox,
MailQuestion,
Camera,
Globe,
Settings,
@@ -112,9 +114,11 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
{ href: `${base}/client-groups`, label: 'Client Groups', icon: UsersRound },
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
{ href: `${base}/inquiries`, label: 'Inquiries', icon: MailQuestion },
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
{
href: `${base}/tenancies`,

View File

@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { SOURCES } from '@/lib/constants';
interface ResidentialInterest {
@@ -95,6 +96,8 @@ function OverviewTab({
stageOptions: Array<{ value: string; label: string }>;
}) {
const update = useInterestPatch(interestId);
// CM-5: residential assignment row hidden when the per-port toggle is off.
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next });
};
@@ -105,6 +108,7 @@ function OverviewTab({
}>({
queryKey: ['residential-assignable-users'],
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
enabled: assignmentEnabled,
});
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
value: u.id,
@@ -132,15 +136,17 @@ function OverviewTab({
onSave={save('source')}
/>
</Row>
<Row label="Assigned to">
<InlineEditableField
variant="select"
options={assigneeOptions}
value={interest.assignedTo}
onSave={save('assignedTo')}
placeholder="Unassigned"
/>
</Row>
{assignmentEnabled ? (
<Row label="Assigned to">
<InlineEditableField
variant="select"
options={assigneeOptions}
value={interest.assignedTo}
onSave={save('assignedTo')}
placeholder="Unassigned"
/>
</Row>
) : null}
</div>
<div className="space-y-1">

View File

@@ -320,9 +320,11 @@ interface ScanShellProps {
* imagery. */
logoUrl?: string | null;
portName?: string | null;
/** CM-6: when true, skip ALL parsing - open an empty form for manual entry. */
manualEntry?: boolean;
}
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
export function ScanShell({ logoUrl, portName, manualEntry = false }: ScanShellProps = {}) {
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null);
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file));
setCurrentFile(file);
// CM-6: manual-entry mode - the port admin disabled scanning. Skip
// Tesseract AND the server call entirely; go straight to an empty form.
if (manualEntry) {
setState({
kind: 'verify',
parsed: {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
},
source: 'manual',
reason: 'manual-mode',
});
return;
}
setState({ kind: 'processing', engine: 'tesseract' });
// Always run Tesseract first - it's free, on-device, and gives us a

View File

@@ -0,0 +1,249 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail, Phone, UserCheck, UserPlus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { usePermissions } from '@/hooks/use-permissions';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type ProxyEntityType = 'client' | 'interest' | 'yacht';
interface Proxy {
id: string;
name: string;
email: string | null;
phone: string | null;
relationship: string | null;
notes: string | null;
}
const RESOURCE: Record<ProxyEntityType, 'clients' | 'interests' | 'yachts'> = {
client: 'clients',
interest: 'interests',
yacht: 'yachts',
};
/**
* CM-9: point-of-contact ("proxy") panel for a client / interest / yacht detail
* page. Reads + edits the per-entity proxy via the entity's sub-resource route.
*/
export function ProxyCard({
entityType,
entityId,
}: {
entityType: ProxyEntityType;
entityId: string;
}) {
const { can } = usePermissions();
const canManage = can(RESOURCE[entityType], 'edit');
const qc = useQueryClient();
const base = `/api/v1/${RESOURCE[entityType]}/${entityId}/proxy`;
const queryKey = ['proxy', entityType, entityId];
const { data } = useQuery<{ data: Proxy | null }>({
queryKey,
queryFn: () => apiFetch(base),
});
const proxy = data?.data ?? null;
const [open, setOpen] = useState(false);
const remove = useMutation({
mutationFn: () => apiFetch(base, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Point of contact removed');
qc.invalidateQueries({ queryKey });
},
onError: (err) => toastError(err),
});
return (
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
<UserCheck className="h-4 w-4 text-muted-foreground" aria-hidden />
Point of contact
</h3>
{canManage ? (
<Button variant="ghost" size="sm" className="h-7" onClick={() => setOpen(true)}>
{proxy ? (
'Edit'
) : (
<>
<UserPlus className="me-1 h-3.5 w-3.5" aria-hidden />
Add
</>
)}
</Button>
) : null}
</div>
{proxy ? (
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">
{proxy.name}
{proxy.relationship ? (
<span className="ms-2 text-xs font-normal text-muted-foreground">
{proxy.relationship}
</span>
) : null}
</p>
{proxy.email ? (
<a
href={`mailto:${proxy.email}`}
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
>
<Mail className="h-3.5 w-3.5" aria-hidden />
{proxy.email}
</a>
) : null}
{proxy.phone ? (
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
<Phone className="h-3.5 w-3.5" aria-hidden />
{proxy.phone}
</p>
) : null}
{proxy.notes ? <p className="text-xs text-muted-foreground">{proxy.notes}</p> : null}
{canManage ? (
<button
type="button"
onClick={() => remove.mutate()}
disabled={remove.isPending}
className="pt-1 text-xs text-destructive hover:underline disabled:opacity-50"
>
Remove
</button>
) : null}
</div>
) : (
<p className="text-sm text-muted-foreground">
No proxy set comms go to the {entityType} directly.
</p>
)}
{open ? (
<ProxyDialog
open={open}
onOpenChange={setOpen}
base={base}
existing={proxy}
entityType={entityType}
onSaved={() => qc.invalidateQueries({ queryKey })}
/>
) : null}
</div>
);
}
function ProxyDialog({
open,
onOpenChange,
base,
existing,
entityType,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
base: string;
existing: Proxy | null;
entityType: ProxyEntityType;
onSaved: () => void;
}) {
const [name, setName] = useState(existing?.name ?? '');
const [email, setEmail] = useState(existing?.email ?? '');
const [phone, setPhone] = useState(existing?.phone ?? '');
const [relationship, setRelationship] = useState(existing?.relationship ?? '');
const [notes, setNotes] = useState(existing?.notes ?? '');
// State seeds from `existing` at mount; the dialog is remounted on each open
// (the parent renders it conditionally), so no reseed effect is needed.
const save = useMutation({
mutationFn: () =>
apiFetch(base, {
method: 'PUT',
body: { name: name.trim(), email, phone, relationship, notes },
}),
onSuccess: () => {
toast.success('Point of contact saved');
onSaved();
onOpenChange(false);
},
onError: (err) => toastError(err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Point of contact</DialogTitle>
<DialogDescription>
A person who acts as the point of contact for this {entityType}. Used to address
outbound comms.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="proxy-name">Name</Label>
<Input
id="proxy-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="proxy-email">Email</Label>
<Input
id="proxy-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-phone">Phone</Label>
<Input id="proxy-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-rel">Relationship (optional)</Label>
<Input
id="proxy-rel"
placeholder="e.g. broker, spouse, assistant, legal"
value={relationship}
onChange={(e) => setRelationship(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-notes">Notes (optional)</Label>
<Input id="proxy-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => save.mutate()} disabled={!name.trim() || save.isPending}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -12,6 +12,7 @@ import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialo
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { ProxyCard } from '@/components/shared/proxy-card';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
@@ -176,6 +177,10 @@ function OverviewTab({
return (
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* CM-9: per-vessel point-of-contact (overrides interest + client). */}
<div className="md:col-span-2">
<ProxyCard entityType="yacht" entityId={yachtId} />
</div>
{/* Identity */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>

View File

@@ -0,0 +1,56 @@
/**
* CM-9: shared GET/PUT/DELETE handlers for the per-entity proxy sub-resource
* (`/api/v1/{clients|interests|yachts}/[id]/proxy`). Each entity's route.ts
* binds these with its own permission resource so we reuse existing
* clients/interests/yachts gating instead of a new permission.
*/
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { clearProxy, getProxy, setProxy } from '@/lib/services/proxies.service';
import { setProxySchema, type ProxyEntityType } from '@/lib/validators/proxies';
export function makeProxyHandlers(entityType: ProxyEntityType) {
const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const proxy = await getProxy(ctx.portId, entityType, params.id!);
return NextResponse.json({ data: proxy });
} catch (error) {
return errorResponse(error);
}
};
const putHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, setProxySchema);
const proxy = await setProxy(ctx.portId, entityType, params.id!, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: proxy });
} catch (error) {
return errorResponse(error);
}
};
const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await clearProxy(ctx.portId, entityType, params.id!, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
return { getHandler, putHandler, deleteHandler };
}

View File

@@ -69,6 +69,8 @@ export const PERMISSION_CATALOG = {
],
residential_clients: ['view', 'create', 'edit', 'delete'],
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
inquiries: ['view', 'manage'],
client_groups: ['view', 'manage'],
} as const satisfies {
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
};

View File

@@ -0,0 +1,25 @@
-- 0092_inquiries_permission.sql
-- ----------------------------------------------------------------------------
-- New `inquiries` permission resource (view/manage) backing the top-level
-- Inquiries workbench (previously the inbox lived under /admin and was gated on
-- admin.view_audit_log, which sales roles don't have).
--
-- Existing role rows are backfilled so the resource defaults to whatever the
-- role's `clients` access is: view ⟵ clients.view, manage ⟵ clients.create.
-- This lights up the right roles (anyone who can see/create clients) without a
-- manual per-role edit, and defaults to deny for read-only roles.
--
-- New-key only and idempotent via the `? 'inquiries'` guard, so re-running is a
-- no-op. Per-user / port-role override tables are intentionally left untouched:
-- the deep-merge resolver fills missing leaves from the base role (same
-- reasoning as 0041).
UPDATE roles
SET permissions = permissions || jsonb_build_object(
'inquiries', jsonb_build_object(
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
)
)
WHERE permissions IS NOT NULL
AND NOT (permissions ? 'inquiries');

View File

@@ -0,0 +1,31 @@
-- 0093_website_submissions_inquiry_cols.sql
-- ----------------------------------------------------------------------------
-- Inquiries workbench: tracking + display columns on website_submissions.
-- converted_client_id / converted_interest_id - set when an operator converts
-- an inquiry into CRM entities (FK to clients/interests).
-- contact_name / contact_email - extracted from the JSONB payload at capture
-- time so the list view can search/sort/display via real columns.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS + a
-- COALESCE backfill that only fills nulls. Safe to re-run.
ALTER TABLE website_submissions
ADD COLUMN IF NOT EXISTS converted_client_id text REFERENCES clients(id),
ADD COLUMN IF NOT EXISTS converted_interest_id text REFERENCES interests(id),
ADD COLUMN IF NOT EXISTS contact_name text,
ADD COLUMN IF NOT EXISTS contact_email text;
CREATE INDEX IF NOT EXISTS idx_ws_contact_email
ON website_submissions (port_id, contact_email);
-- Backfill display columns from existing payloads (only where still null).
UPDATE website_submissions
SET contact_email = COALESCE(contact_email, NULLIF(payload->>'email', '')),
contact_name = COALESCE(
contact_name,
NULLIF(TRIM(CONCAT_WS(' ', payload->>'first_name', payload->>'last_name')), ''),
NULLIF(payload->>'name', ''),
NULLIF(payload->>'fullName', ''),
NULLIF(payload->>'full_name', '')
)
WHERE contact_email IS NULL OR contact_name IS NULL;

View File

@@ -0,0 +1,52 @@
-- 0094_client_groups.sql
-- ----------------------------------------------------------------------------
-- CM-1: first-class client groups (mailing/segment lists) + the membership
-- join, plus the new `client_groups` permission resource (view/manage).
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + a guarded role backfill.
-- Safe to re-run.
-- ─── 1. client_groups (per-port named group) ────────────────────────────────
CREATE TABLE IF NOT EXISTS client_groups (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
color text NOT NULL DEFAULT '#6B7280',
mailchimp_tag text,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_client_groups_port ON client_groups(port_id);
-- Per-port, case-insensitive name uniqueness among non-archived groups.
CREATE UNIQUE INDEX IF NOT EXISTS idx_client_groups_port_name
ON client_groups(port_id, lower(name))
WHERE archived_at IS NULL;
-- ─── 2. client_group_members (M2M join; carries port_id for tenant isolation) ─
CREATE TABLE IF NOT EXISTS client_group_members (
group_id text NOT NULL REFERENCES client_groups(id) ON DELETE CASCADE,
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, client_id)
);
CREATE INDEX IF NOT EXISTS idx_cgm_client ON client_group_members(client_id);
CREATE INDEX IF NOT EXISTS idx_cgm_port ON client_group_members(port_id);
-- ─── 3. `client_groups` permission resource (view/manage) ────────────────────
-- New-key only + idempotent via the `? 'client_groups'` guard. Defaults to the
-- role's clients access (view ⟵ clients.view, manage ⟵ clients.create) so the
-- right roles light up without a manual per-role edit.
UPDATE roles
SET permissions = permissions || jsonb_build_object(
'client_groups', jsonb_build_object(
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
)
)
WHERE permissions IS NOT NULL
AND NOT (permissions ? 'client_groups');

View File

@@ -0,0 +1,24 @@
-- 0095_proxies.sql
-- ----------------------------------------------------------------------------
-- CM-9: per-entity point-of-contact ("proxy") attachable to a client, interest,
-- or yacht. At most one per entity; outbound comms resolve the most specific
-- via yacht → interest → client. entity_id is polymorphic (no FK; validated in
-- the service against the right table). Idempotent — safe to re-run.
CREATE TABLE IF NOT EXISTS proxies (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
entity_type text NOT NULL,
entity_id text NOT NULL,
name text NOT NULL,
email text,
phone text,
relationship text,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_proxies_entity ON proxies(port_id, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_proxies_entity ON proxies(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_proxies_port ON proxies(port_id);

View File

@@ -0,0 +1,7 @@
-- CM-2 Part B: per-interest, per-berth deal-price override.
-- Null = use the berth's canonical list price (berths.price). When set, this
-- supersedes the list price for THIS interest's generated documents
-- (resolved in eoi-context via resolveBerthPriceForInterest).
ALTER TABLE interest_berths
ADD COLUMN IF NOT EXISTS price_override numeric,
ADD COLUMN IF NOT EXISTS price_override_currency text;

View File

@@ -0,0 +1,73 @@
/**
* Client groups (CM-1) - first-class mailing/segment groups for clients.
*
* A `client_groups` row is a named, per-port group (e.g. a mailing list).
* `client_group_members` is the M2M join to `clients`. Membership carries its
* own `port_id` for defense-in-depth tenant isolation (same doctrine as the
* document-folders aggregated projection - port_id at every join).
*
* Optional Mailchimp mapping lives on the group row: `mailchimpTag` is the
* tag/segment name pushed to the port's single Mailchimp audience. Null until
* an admin wires Mailchimp up (the integration is inert without creds).
*/
import { sql } from 'drizzle-orm';
import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { clients } from './clients';
import { ports } from './ports';
export const clientGroups = pgTable(
'client_groups',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
/** Chip color in the CRM UI. */
color: text('color').notNull().default('#6B7280'),
/** CM-1 Mailchimp: the tag/segment name this group maps to in the port's
* single Mailchimp audience. Null = not synced. */
mailchimpTag: text('mailchimp_tag'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_client_groups_port').on(table.portId),
// Per-port, case-insensitive name uniqueness among non-archived groups.
uniqueIndex('idx_client_groups_port_name')
.on(table.portId, sql`lower(${table.name})`)
.where(sql`${table.archivedAt} IS NULL`),
],
);
export const clientGroupMembers = pgTable(
'client_group_members',
{
groupId: text('group_id')
.notNull()
.references(() => clientGroups.id, { onDelete: 'cascade' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.groupId, table.clientId] }),
index('idx_cgm_client').on(table.clientId),
index('idx_cgm_port').on(table.portId),
],
);
export type ClientGroup = typeof clientGroups.$inferSelect;
export type NewClientGroup = typeof clientGroups.$inferInsert;
export type ClientGroupMember = typeof clientGroupMembers.$inferSelect;
export type NewClientGroupMember = typeof clientGroupMembers.$inferInsert;

View File

@@ -7,6 +7,12 @@ export * from './users';
// Clients
export * from './clients';
// Client groups (CM-1 - mailing/segment groups)
export * from './client-groups';
// Proxies / points-of-contact (CM-9 - polymorphic across client/interest/yacht)
export * from './proxies';
// Companies
export * from './companies';

View File

@@ -99,6 +99,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
export const ALERT_RULES = [
'reservation.no_agreement',
'interest.stale',
'interest.no_activity',
'document.signer_overdue',
'berth.under_offer_stalled',
'expense.duplicate',

View File

@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
addedBy: text('added_by'),
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
notes: text('notes'),
// CM-2 Part B: deal-specific price for THIS (interest, berth). Null = use
// the berth's canonical list price. Does not touch berths.price.
priceOverride: numeric('price_override'),
priceOverrideCurrency: text('price_override_currency'),
},
(table) => [
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),

View File

@@ -0,0 +1,48 @@
/**
* Proxies / points-of-contact (CM-9).
*
* A `proxy` is a designated contact person who acts on behalf of an entity.
* Polymorphic: attachable to a `client` (default), an `interest` (per-deal
* override), or a `yacht` (per-vessel override). At most one proxy per entity
* (unique index). Outbound comms resolve the most specific proxy via the chain
* yacht → interest → client (see resolveEffectiveProxy in proxies.service).
*
* `entity_id` is polymorphic (no FK) — validated against the right table in the
* service, same pattern as polymorphic yacht ownership / notes.
*/
import { index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { ports } from './ports';
export const proxies = pgTable(
'proxies',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
/** 'client' | 'interest' | 'yacht' */
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
name: text('name').notNull(),
email: text('email'),
phone: text('phone'),
/** Free-form relationship label, e.g. broker / spouse / assistant / legal. */
relationship: text('relationship'),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// At most one proxy per entity.
uniqueIndex('uniq_proxies_entity').on(table.portId, table.entityType, table.entityId),
index('idx_proxies_entity').on(table.entityType, table.entityId),
index('idx_proxies_port').on(table.portId),
],
);
export type Proxy = typeof proxies.$inferSelect;
export type NewProxy = typeof proxies.$inferInsert;

View File

@@ -162,6 +162,14 @@ export type RolePermissions = {
delete: boolean;
change_stage: boolean;
};
inquiries: {
view: boolean;
manage: boolean;
};
client_groups: {
view: boolean;
manage: boolean;
};
};
/**

View File

@@ -51,6 +51,18 @@ export const websiteSubmissions = pgTable(
* same form submission. Useful for reconciling: pick any submission
* here, look up the matching NocoDB row, confirm both halves agree. */
legacyNocodbId: text('legacy_nocodb_id'),
/** Contact name + email extracted from `payload` at capture time so the
* inquiry list can search/sort/display via real columns (payload stays
* JSONB and isn't searched directly). Populated by the capture endpoint
* and backfilled in migration 0093. */
contactName: text('contact_name'),
contactEmail: text('contact_email'),
/** Set when an operator converts this inquiry into CRM entities. FK enforced
* at the DB level (migration 0093); typed as plain text here to avoid a
* circular schema import — `clients`/`interests` already reference
* `website_submissions`. */
convertedClientId: text('converted_client_id'),
convertedInterestId: text('converted_interest_id'),
/** Capture-time metadata for debugging. */
sourceIp: text('source_ip'),
userAgent: text('user_agent'),

View File

@@ -88,6 +88,14 @@ export const ALL_PERMISSIONS: RolePermissions = {
delete: true,
change_stage: true,
},
inquiries: {
view: true,
manage: true,
},
client_groups: {
view: true,
manage: true,
},
};
export const DIRECTOR_PERMISSIONS: RolePermissions = {
@@ -167,6 +175,14 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
delete: true,
change_stage: true,
},
inquiries: {
view: true,
manage: true,
},
client_groups: {
view: true,
manage: true,
},
};
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
@@ -246,6 +262,14 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: false,
},
inquiries: {
view: true,
manage: true,
},
client_groups: {
view: true,
manage: true,
},
};
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
@@ -325,6 +349,14 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: false,
},
inquiries: {
view: true,
manage: true,
},
client_groups: {
view: true,
manage: true,
},
};
export const VIEWER_PERMISSIONS: RolePermissions = {
@@ -410,6 +442,14 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: false,
},
inquiries: {
view: true,
manage: false,
},
client_groups: {
view: true,
manage: false,
},
};
// Residential Partner - for an outside party who handles residential
@@ -498,4 +538,12 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: true,
},
inquiries: {
view: false,
manage: false,
},
client_groups: {
view: false,
manage: false,
},
};

View File

@@ -32,6 +32,46 @@ function daysAgo(n: number): Date {
return new Date(Date.now() - n * DAY_MS);
}
// ─── shared interest-activity fragments ───────────────────────────────────────
// Correlated subqueries keyed on `interests.id`, reused by the interest rules.
/**
* True when the interest was created by the legacy→CRM bulk import. The
* migration ledger is the only reliable marker (no column on `interests`).
*/
const isImportedSql = sql`EXISTS (
SELECT 1 FROM migration_source_links msl
WHERE msl.source_system = 'nocodb_interests'
AND msl.target_entity_type = 'interest'
AND msl.target_entity_id = ${interests.id}
)`;
/**
* True when a real user has worked the interest in-system: a logged contact, a
* note, or an UPDATE audit by a real user. The initial create-audit is excluded
* (action='update' only) so a bare, never-touched creation does not count.
*/
const hasFollowupSql = sql`(
EXISTS (SELECT 1 FROM interest_contact_log icl WHERE icl.interest_id = ${interests.id})
OR EXISTS (SELECT 1 FROM interest_notes inn WHERE inn.interest_id = ${interests.id})
OR EXISTS (
SELECT 1 FROM audit_logs al
WHERE al.entity_type = 'interest' AND al.entity_id = ${interests.id}
AND al.user_id IS NOT NULL AND al.action = 'update'
)
)`;
/**
* Most recent genuine in-system touch, used as the staleness clock. Coalesced to
* '-infinity' so GREATEST never returns NULL.
*/
const lastTouchAtSql = sql`GREATEST(
COALESCE(${interests.dateLastContact}, '-infinity'::timestamptz),
COALESCE((SELECT max(icl.occurred_at) FROM interest_contact_log icl WHERE icl.interest_id = ${interests.id}), '-infinity'::timestamptz),
COALESCE((SELECT max(inn.created_at) FROM interest_notes inn WHERE inn.interest_id = ${interests.id}), '-infinity'::timestamptz),
COALESCE((SELECT max(al.created_at) FROM audit_logs al WHERE al.entity_type='interest' AND al.entity_id=${interests.id} AND al.user_id IS NOT NULL AND al.action='update'), '-infinity'::timestamptz)
)`;
// ─── reservation.no_agreement ─────────────────────────────────────────────────
// Active reservations > 3 days old that have no reservation_agreement document
// in any non-cancelled state.
@@ -70,22 +110,18 @@ async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]>
}));
}
// Mid-funnel stages where silence is a problem. EOI / reservation / deposit /
// contract stages have their own dedicated alerts (eoi.unsigned_long,
// reservation.no_agreement, etc.), so these rules sit before signing kicks in.
const ACTIVE_EARLY_STAGES = ['enquiry', 'qualified', 'nurturing'];
// ─── interest.stale ───────────────────────────────────────────────────────────
// Pipeline stuck in mid-funnel stages with no contact for 14+ days.
// A lead a user actually WORKED in-system (logged a contact / note / made an
// update) that has since gone quiet for 14+ days. Interests that were merely
// imported and never touched are handled by interest.no_activity, not here — so
// the bulk-import backlog never lands in this rule.
async function interestStale(portId: string): Promise<AlertCandidate[]> {
// Mid-funnel stages where silence is a problem. EOI / reservation /
// deposit / contract stages have their own dedicated alerts
// (eoi.unsigned_long, reservation.no_agreement, deposit_overdue, etc.),
// so this alert sits before signing kicks in.
//
// 2026-05-14 pipeline-refactor sweep: the prior values
// ('details_sent', 'in_communication', 'eoi_sent') were collapsed by
// migration 0062 into the 7-stage canon (enquiry / qualified /
// nurturing / eoi / ...). Until this fix landed, this alert never
// fired because no row in the new schema carried the dead stage
// strings.
const STALE_STAGES = ['enquiry', 'qualified', 'nurturing'];
const rows = await db
.select({
id: interests.id,
@@ -97,25 +133,10 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
.where(
and(
eq(interests.portId, portId),
inArray(interests.pipelineStage, STALE_STAGES),
inArray(interests.pipelineStage, ACTIVE_EARLY_STAGES),
isNull(interests.archivedAt),
// An interest can't be "stale for 14+ days" if it has only existed in
// THIS system for less than 14 days. Without this floor, a bulk import
// (which backdates dateLastContact to the legacy value) instantly flags
// every migrated interest as stale and floods the alert rail.
//
// We floor on updatedAt, NOT createdAt: the legacy→CRM migration
// backfilled created_at to each interest's real origination date (so
// analytics date-ranges work), which would make every migrated row look
// 14+ days old and re-open the flood. updated_at is left at the
// migration timestamp, so it's the reliable "entered/last-touched this
// system" clock — migrated rows stay suppressed for 14 days, then the
// contact-based OR below governs.
lt(interests.updatedAt, daysAgo(14)),
or(
lt(interests.dateLastContact, daysAgo(14)),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),
),
sql`${hasFollowupSql}`,
sql`${lastTouchAtSql} < now() - interval '14 days'`,
),
);
@@ -123,7 +144,7 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
ruleId: 'interest.stale',
severity: 'info',
title: `Stale interest: ${r.clientName}`,
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' with no contact for 14+ days.`,
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' — worked but no activity for 14+ days.`,
link: `/[port]/interests/${r.id}`,
entityType: 'interest',
entityId: r.id,
@@ -131,6 +152,42 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
}));
}
// ─── interest.no_activity ─────────────────────────────────────────────────────
// A brand-new inbound interest nobody has touched in-system, 14+ days after it
// arrived. Excludes bulk-imported rows (those live in migration_source_links)
// so the historical backlog never nags.
async function interestNoActivity(portId: string): Promise<AlertCandidate[]> {
const rows = await db
.select({
id: interests.id,
stage: interests.pipelineStage,
clientName: sql<string>`coalesce((SELECT full_name FROM clients WHERE id = ${interests.clientId}), 'unknown')`,
})
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.pipelineStage, ACTIVE_EARLY_STAGES),
isNull(interests.archivedAt),
lt(interests.createdAt, daysAgo(14)),
sql`NOT ${hasFollowupSql}`,
sql`NOT ${isImportedSql}`,
),
);
return rows.map((r) => ({
ruleId: 'interest.no_activity',
severity: 'info',
title: `New inquiry untouched: ${r.clientName}`,
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' — no activity since it arrived 14+ days ago.`,
link: `/[port]/interests/${r.id}`,
entityType: 'interest',
entityId: r.id,
metadata: { stage: r.stage },
}));
}
// ─── document.signer_overdue ──────────────────────────────────────────────────
// Pending signer for >14d, last reminder >7d ago (or never).
@@ -282,6 +339,10 @@ async function interestHighValueSilent(portId: string): Promise<AlertCandidate[]
eq(interests.portId, portId),
eq(interests.leadCategory, 'hot_lead'),
isNull(interests.archivedAt),
// Don't flood from imported-but-never-touched hot leads (their
// dateLastContact is back-dated to a legacy date). Once a user works one
// in-system, it becomes eligible again.
sql`( NOT ${isImportedSql} OR ${hasFollowupSql} )`,
or(
lt(interests.dateLastContact, cutoff),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, cutoff)),
@@ -335,6 +396,7 @@ async function eoiUnsignedLong(portId: string): Promise<AlertCandidate[]> {
export const RULE_REGISTRY: Record<AlertRuleId, RuleEvaluator> = {
'reservation.no_agreement': reservationNoAgreement,
'interest.stale': interestStale,
'interest.no_activity': interestNoActivity,
'document.signer_overdue': documentSignerOverdue,
'berth.under_offer_stalled': berthUnderOfferStalled,
'expense.duplicate': expenseDuplicate,

View File

@@ -120,6 +120,42 @@ export async function dismissAlert(alertId: string, portId: string, userId: stri
}
}
/**
* Bulk-dismiss every open (non-dismissed, non-resolved) alert for a port,
* optionally narrowed to a single rule and/or severity. Returns the count
* dismissed. Port-scoped so it can never touch another tenant's alerts.
*/
export async function dismissAllForPort(
portId: string,
userId: string,
filter: { ruleId?: AlertRuleId; severity?: AlertSeverity } = {},
): Promise<number> {
const conds = [eq(alerts.portId, portId), isNull(alerts.dismissedAt), isNull(alerts.resolvedAt)];
if (filter.ruleId) conds.push(eq(alerts.ruleId, filter.ruleId));
if (filter.severity) conds.push(eq(alerts.severity, filter.severity));
const rows = await db
.update(alerts)
.set({ dismissedAt: sql`now()`, dismissedBy: userId })
.where(and(...conds))
.returning({ id: alerts.id });
for (const r of rows) {
emitToRoom(`port:${portId}`, 'alert:dismissed', { alertId: r.id, portId });
}
if (rows.length > 0) {
void createAuditLog({
portId,
userId,
action: 'update',
entityType: 'alert',
entityId: portId, // port-wide bulk action — no single alert subject
metadata: { kind: 'dismiss_all', count: rows.length, filter },
});
}
return rows.length;
}
export async function acknowledgeAlert(
alertId: string,
portId: string,

View File

@@ -357,10 +357,14 @@ export function extractFromOcrText(rawText: string): {
};
}
// Purchase price: "PURCHASE PRICE:\nFEE SIMPLE OR STRATA LOT\n3,880,800 USD"
const priceMatch = text.match(/PURCHASE\s+PRICE[\s\S]{0,80}?([0-9][0-9,]+)\s*USD/i);
if (priceMatch) {
out.price = { value: Number(priceMatch[1]!.replace(/,/g, '')), confidence: 0.7, engine: 'ocr' };
// Purchase price: the single clean comma-grouped currency figure. The rates
// on the same sheet are letter-spaced (garble) and below the floor, so they
// never collide with the main price. See extractPurchasePrice().
const priceResult = extractPurchasePrice(text);
if (priceResult.value != null) {
out.price = { value: priceResult.value, confidence: priceResult.confidence, engine: 'ocr' };
} else if (priceResult.warning) {
warnings.push(priceResult.warning);
}
// Pricing validity: "ALL PRICES ABOVE ARE CONFIRMED THROUGH UNTIL SEPTEMBER 15TH, 2025"
@@ -507,6 +511,62 @@ function coerceFieldValue(key: keyof ExtractedBerthFields, raw: string): string
return numeric < 0 ? null : numeric;
}
/**
* Floor that separates a 67-figure purchase price from the ≤~12k weekly/daily
* lease rates printed on the same sheet. Observed prices: 277,200 … 5,433,120;
* observed weekly highs ≤ 11,341. A wide-margin separator.
*/
export const PURCHASE_PRICE_FLOOR = 50_000;
/**
* Strict clean comma-grouped currency token. On the real spec sheets the
* purchase price is the one figure rendered WITHOUT letter-spacing (the large
* bold number); the weekly/daily rates ARE letter-spaced and garble in text
* extraction, so they never match this pattern. The floor is a second guard
* for clean/synthetic PDFs where rates would also extract cleanly.
*/
const PRICE_TOKEN_RE = /\b(\d{1,3}(?:,\d{3})+)\s?(USD|EUR|GBP)\b/gi;
/**
* Extract the single main purchase price from raw PDF text. Returns
* `value: null` (with a warning) when zero above-floor tokens are found, or
* when two or more DISTINCT above-floor values appear (genuinely ambiguous —
* flag for human review rather than guess).
*/
export function extractPurchasePrice(rawText: string): {
value: number | null;
currency: string | null;
confidence: number;
warning?: string;
} {
const candidates: Array<{ value: number; currency: string }> = [];
for (const m of rawText.matchAll(PRICE_TOKEN_RE)) {
const value = Number(m[1]!.replace(/,/g, ''));
if (Number.isFinite(value) && value >= PURCHASE_PRICE_FLOOR) {
candidates.push({ value, currency: m[2]!.toUpperCase() });
}
}
if (candidates.length === 0) {
return {
value: null,
currency: null,
confidence: 0,
warning: 'No purchase-price token found (no clean figure ≥ floor).',
};
}
const distinct = [...new Set(candidates.map((c) => c.value))];
if (distinct.length > 1) {
return {
value: null,
currency: null,
confidence: 0,
warning: `Multiple purchase-price candidates (${distinct.join(', ')}) — needs review.`,
};
}
const best = candidates[0]!;
return { value: best.value, currency: best.currency, confidence: 0.95 };
}
/** Parse a human date like "September 15 2025" → "2025-09-15". */
export function parseHumanDate(raw: string): string | null {
const cleaned = raw.replace(/(\d+)(st|nd|rd|th)/i, '$1').trim();

View File

@@ -0,0 +1,166 @@
/**
* Bulk berth price reconciliation (CM-2 Part A).
*
* Re-parses each berth's CURRENT spec-sheet PDF (stored parseResults are
* stale/wrong — the old purchase-price regex matched 0/113 real sheets),
* surfaces old→new price diffs for an admin review page, and applies only the
* rows a rep explicitly approves. Nothing is written until apply.
*/
import pLimit from 'p-limit';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthPdfVersions } from '@/lib/db/schema/berths';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';
import { parseBerthPdf, extractPurchasePrice } from './berth-pdf-parser';
export interface PriceReconcileRow {
berthId: string;
mooringNumber: string;
area: string | null;
currentPrice: number | null;
currentCurrency: string;
parsedPrice: number | null;
parsedCurrency: string | null;
versionId: string | null;
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
warning?: string;
}
async function streamToBuffer(stream: AsyncIterable<Buffer | string>): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
/**
* For every active berth in the port, re-parse the current spec-sheet PDF and
* report the parsed main price alongside the stored price. Tenant-scoped by
* `portId`. Bounded concurrency keeps the S3/filesystem round-trips in check.
*/
export async function listPriceReconciliation(portId: string): Promise<PriceReconcileRow[]> {
const rows = await db
.select({
berthId: berths.id,
mooringNumber: berths.mooringNumber,
area: berths.area,
currentPrice: berths.price,
currentCurrency: berths.priceCurrency,
versionId: berths.currentPdfVersionId,
storageKey: berthPdfVersions.storageKey,
})
.from(berths)
.leftJoin(berthPdfVersions, eq(berthPdfVersions.id, berths.currentPdfVersionId))
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
.orderBy(berths.mooringNumber);
const backend = await getStorageBackend();
const limit = pLimit(8);
return Promise.all(
rows.map((r) =>
limit(async (): Promise<PriceReconcileRow> => {
const currentPrice = r.currentPrice == null ? null : Number(r.currentPrice);
const base = {
berthId: r.berthId,
mooringNumber: r.mooringNumber,
area: r.area,
currentPrice,
currentCurrency: r.currentCurrency,
versionId: r.versionId,
};
if (!r.versionId || !r.storageKey) {
return { ...base, parsedPrice: null, parsedCurrency: null, status: 'no_pdf' };
}
try {
const buffer = await streamToBuffer(
(await backend.get(r.storageKey)) as AsyncIterable<Buffer | string>,
);
const parse = await parseBerthPdf(buffer);
const price = extractPurchasePrice(parse.rawText ?? '');
if (price.value == null) {
return {
...base,
parsedPrice: null,
parsedCurrency: null,
status: 'needs_review',
warning: price.warning,
};
}
const status = currentPrice === price.value ? 'matched' : 'changed';
return { ...base, parsedPrice: price.value, parsedCurrency: price.currency, status };
} catch (err) {
logger.warn({ berthId: r.berthId, err }, 'price-reconcile: parse failed');
return {
...base,
parsedPrice: null,
parsedCurrency: null,
status: 'needs_review',
warning: 'PDF could not be parsed.',
};
}
}),
),
);
}
/**
* Apply a rep-approved slice of parsed prices to `berths.price`/`priceCurrency`.
* Tenant-scoped: cross-port berth ids are silently skipped (defense in depth on
* top of the route's permission gate). Stamps each berth's current PDF version
* `parseResults.bulkPriceApplied` for audit.
*/
export async function applyBulkBerthPrices(
portId: string,
approvals: Array<{ berthId: string; price: number; currency: string }>,
actingUserId: string,
): Promise<{ updated: number }> {
if (approvals.length === 0) return { updated: 0 };
const ids = approvals.map((a) => a.berthId);
const owned = await db
.select({ id: berths.id, vid: berths.currentPdfVersionId })
.from(berths)
.where(and(eq(berths.portId, portId), inArray(berths.id, ids)));
const ownedVid = new Map(owned.map((b) => [b.id, b.vid]));
let updated = 0;
await db.transaction(async (tx) => {
for (const a of approvals) {
if (!ownedVid.has(a.berthId)) continue; // cross-port → skip
if (!Number.isFinite(a.price) || a.price < 0) continue;
await tx
.update(berths)
.set({ price: String(a.price), priceCurrency: a.currency, updatedAt: new Date() })
.where(and(eq(berths.id, a.berthId), eq(berths.portId, portId)));
const vid = ownedVid.get(a.berthId);
if (vid) {
const [ver] = await tx
.select({ pr: berthPdfVersions.parseResults })
.from(berthPdfVersions)
.where(eq(berthPdfVersions.id, vid));
const prior = (ver?.pr as Record<string, unknown> | null) ?? {};
await tx
.update(berthPdfVersions)
.set({
parseResults: {
...prior,
bulkPriceApplied: {
price: a.price,
currency: a.currency,
by: actingUserId,
at: new Date().toISOString(),
},
},
})
.where(eq(berthPdfVersions.id, vid));
}
updated += 1;
}
});
return { updated };
}

View File

@@ -0,0 +1,205 @@
/**
* CM-1: client groups (mailing/segment lists) service.
*
* CRUD for `client_groups` + membership management on `client_group_members`,
* plus a member viewer that resolves each client's primary email for the
* copy-emails feature. All reads/writes are port-scoped. Membership replace is
* a wipe-and-rewrite transaction (same shape as setEntityTags).
*/
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { db } from '@/lib/db';
import { clientGroupMembers, clientGroups, clients } from '@/lib/db/schema';
import { withTransaction } from '@/lib/db/utils';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { syncGroupToMailchimp } from '@/lib/services/mailchimp.service';
import type {
CreateClientGroupInput,
UpdateClientGroupInput,
} from '@/lib/validators/client-groups';
export interface ClientGroupWithCount {
id: string;
name: string;
description: string | null;
color: string;
mailchimpTag: string | null;
memberCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface GroupMember {
clientId: string;
fullName: string;
email: string | null;
}
async function assertGroup(id: string, portId: string) {
const group = await db.query.clientGroups.findFirst({
where: and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)),
});
if (!group || group.archivedAt) throw new NotFoundError('Client group not found');
return group;
}
export async function listClientGroups(portId: string): Promise<ClientGroupWithCount[]> {
const groups = await db
.select()
.from(clientGroups)
.where(and(eq(clientGroups.portId, portId), sql`${clientGroups.archivedAt} IS NULL`))
.orderBy(desc(clientGroups.createdAt));
// Member counts in one grouped query (port-scoped).
const counts = await db
.select({ groupId: clientGroupMembers.groupId, n: sql<number>`count(*)::int` })
.from(clientGroupMembers)
.where(eq(clientGroupMembers.portId, portId))
.groupBy(clientGroupMembers.groupId);
const countMap = new Map(counts.map((c) => [c.groupId, c.n]));
return groups.map((g) => ({
id: g.id,
name: g.name,
description: g.description,
color: g.color,
mailchimpTag: g.mailchimpTag,
memberCount: countMap.get(g.id) ?? 0,
createdAt: g.createdAt,
updatedAt: g.updatedAt,
}));
}
export async function getClientGroupById(id: string, portId: string) {
return assertGroup(id, portId);
}
export async function createClientGroup(
portId: string,
data: CreateClientGroupInput,
meta: AuditMeta,
) {
const [group] = await db
.insert(clientGroups)
.values({
portId,
name: data.name,
description: data.description ?? null,
color: data.color ?? '#6B7280',
mailchimpTag: data.mailchimpTag ?? null,
})
.returning();
if (!group) throw new ValidationError('Failed to create client group');
void createAuditLog({
...meta,
action: 'create',
entityType: 'client_group',
entityId: group.id,
newValue: toAuditJson(group),
});
return group;
}
export async function updateClientGroup(
id: string,
portId: string,
data: UpdateClientGroupInput,
meta: AuditMeta,
) {
await assertGroup(id, portId);
const [updated] = await db
.update(clientGroups)
.set({
...(data.name !== undefined ? { name: data.name } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.color !== undefined ? { color: data.color } : {}),
...(data.mailchimpTag !== undefined ? { mailchimpTag: data.mailchimpTag } : {}),
updatedAt: new Date(),
})
.where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Client group not found');
void createAuditLog({
...meta,
action: 'update',
entityType: 'client_group',
entityId: id,
newValue: toAuditJson(data),
});
return updated;
}
export async function archiveClientGroup(id: string, portId: string, meta: AuditMeta) {
await assertGroup(id, portId);
await db
.update(clientGroups)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)));
void createAuditLog({
...meta,
action: 'archive',
entityType: 'client_group',
entityId: id,
});
}
/** Members of a group, each with their primary email (for copy-emails). */
export async function listGroupMembers(groupId: string, portId: string): Promise<GroupMember[]> {
await assertGroup(groupId, portId);
const rows = await db
.select({
clientId: clients.id,
fullName: clients.fullName,
email: sql<string | null>`(
SELECT cc.value FROM client_contacts cc
WHERE cc.client_id = ${clients.id} AND cc.channel = 'email'
ORDER BY cc.is_primary DESC
LIMIT 1
)`,
})
.from(clientGroupMembers)
.innerJoin(clients, eq(clientGroupMembers.clientId, clients.id))
.where(and(eq(clientGroupMembers.groupId, groupId), eq(clientGroupMembers.portId, portId)))
.orderBy(clients.fullName);
return rows;
}
/** Replace a group's membership with exactly `clientIds` (wipe-and-rewrite). */
export async function setGroupMembers(
groupId: string,
portId: string,
clientIds: string[],
meta: AuditMeta,
): Promise<void> {
await assertGroup(groupId, portId);
const unique = Array.from(new Set(clientIds));
// Tenant-scope guard: every client must belong to this port.
if (unique.length > 0) {
const valid = await db
.select({ id: clients.id })
.from(clients)
.where(and(inArray(clients.id, unique), eq(clients.portId, portId)));
if (valid.length !== unique.length) {
throw new ValidationError('One or more clients are not in this port');
}
}
await withTransaction(async (tx) => {
await tx.delete(clientGroupMembers).where(eq(clientGroupMembers.groupId, groupId));
if (unique.length > 0) {
await tx
.insert(clientGroupMembers)
.values(unique.map((clientId) => ({ groupId, clientId, portId })));
}
});
void createAuditLog({
...meta,
action: 'update',
entityType: 'client_group_members',
entityId: groupId,
newValue: toAuditJson({ clientIds: unique }),
});
// CM-1 Mailchimp: fire-and-forget one-way push (inert until configured).
void syncGroupToMailchimp(groupId, portId).catch(() => {});
}

View File

@@ -303,6 +303,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
const today = now.toISOString().slice(0, 10);
const year = String(now.getFullYear());
// CM-2 Part B: deal-specific price override. The base berth price is the
// canonical list price; an interest_berths.price_override (when set)
// supersedes it for THIS interest's documents via the existing
// {{berth.price}} / {{berth.priceCurrency}} tokens.
let resolvedBerthPrice = berth?.price ?? null;
let resolvedBerthCurrency = berth?.priceCurrency ?? port.defaultCurrency;
if (berth && primaryBerthId) {
const [ibOverride] = await db
.select({
priceOverride: interestBerths.priceOverride,
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
})
.from(interestBerths)
.where(
and(eq(interestBerths.interestId, interest.id), eq(interestBerths.berthId, primaryBerthId)),
);
if (ibOverride?.priceOverride != null) {
resolvedBerthPrice = ibOverride.priceOverride;
resolvedBerthCurrency = ibOverride.priceOverrideCurrency ?? berth.priceCurrency;
}
}
return {
client: {
id: client.id,
@@ -337,8 +359,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
mooringNumber: berth.mooringNumber,
area: berth.area,
lengthFt: berth.lengthFt,
price: berth.price,
priceCurrency: berth.priceCurrency,
price: resolvedBerthPrice,
priceCurrency: resolvedBerthCurrency,
tenureType: berth.tenureType,
}
: null,

View File

@@ -0,0 +1,242 @@
/**
* Inquiries workbench service — read/triage/convert over `website_submissions`.
*
* The capture endpoint (`/api/public/website-inquiries`) writes raw submissions;
* this service is the operator-facing layer: list/filter, triage state changes,
* and converting an inquiry into proper CRM entities (client and/or interest)
* with the submission row linked back to what it produced.
*/
import { and, eq, inArray, isNull, sql, type SQL } from 'drizzle-orm';
import { db } from '@/lib/db';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { createClient } from './clients.service';
import { createInterest } from './interests.service';
import { extractInquiryFields } from './website-intake-fields';
import { createClientSchema } from '@/lib/validators/clients';
import { createInterestSchema } from '@/lib/validators/interests';
import type { ListInquiriesInput } from '@/lib/validators/inquiries';
type TriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
const SORTABLE = {
receivedAt: websiteSubmissions.receivedAt,
kind: websiteSubmissions.kind,
triageState: websiteSubmissions.triageState,
contactName: websiteSubmissions.contactName,
} as const;
export async function listInquiries(portId: string, query: ListInquiriesInput) {
const filters: SQL[] = [];
if (query.kind) filters.push(eq(websiteSubmissions.kind, query.kind));
if (query.state === 'inbox') {
filters.push(inArray(websiteSubmissions.triageState, ['open', 'assigned']));
} else if (query.state !== 'all') {
filters.push(eq(websiteSubmissions.triageState, query.state));
}
const sortColumn =
query.sort && query.sort in SORTABLE
? SORTABLE[query.sort as keyof typeof SORTABLE]
: undefined;
return buildListQuery<typeof websiteSubmissions.$inferSelect>({
table: websiteSubmissions,
portIdColumn: websiteSubmissions.portId,
portId,
idColumn: websiteSubmissions.id,
// website_submissions has no updatedAt; receivedAt is the natural clock and
// the deterministic tail-sort.
updatedAtColumn: websiteSubmissions.receivedAt,
searchColumns: [websiteSubmissions.contactName, websiteSubmissions.contactEmail],
searchTerm: query.search,
filters,
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
page: query.page,
pageSize: query.limit,
});
}
export async function getInquiryById(id: string, portId: string) {
const row = await loadInquiry(id, portId);
const convertedClient = row.convertedClientId
? ((
await db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(eq(clients.id, row.convertedClientId))
.limit(1)
)[0] ?? null)
: null;
const convertedInterest = row.convertedInterestId
? ((
await db
.select({ id: interests.id, pipelineStage: interests.pipelineStage })
.from(interests)
.where(eq(interests.id, row.convertedInterestId))
.limit(1)
)[0] ?? null)
: null;
return { ...row, convertedClient, convertedInterest };
}
export async function triageInquiry(
id: string,
portId: string,
state: TriageState,
meta: AuditMeta,
) {
const [updated] = await db
.update(websiteSubmissions)
.set({ triageState: state, triagedAt: new Date(), triagedBy: meta.userId })
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('inquiry');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'website_submission',
entityId: id,
fieldChanged: 'triageState',
newValue: { triageState: state },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return updated;
}
export async function convertInquiryToClient(id: string, portId: string, meta: AuditMeta) {
const row = await loadInquiry(id, portId);
// Idempotent: if already linked to a client, return it rather than duplicate.
if (row.convertedClientId) return { clientId: row.convertedClientId, interestId: null };
const clientId = await findOrCreateClientFromInquiry(row, meta);
await markConverted(id, portId, { clientId }, meta);
return { clientId, interestId: null };
}
export async function convertInquiryToInterest(id: string, portId: string, meta: AuditMeta) {
const row = await loadInquiry(id, portId);
if (row.convertedInterestId) {
throw new ConflictError('Inquiry has already been converted to an interest.');
}
const clientId = row.convertedClientId ?? (await findOrCreateClientFromInquiry(row, meta));
const interestData = createInterestSchema.parse({
clientId,
pipelineStage: 'enquiry',
source: 'website',
});
const interest = await createInterest(portId, interestData, meta);
await markConverted(id, portId, { clientId, interestId: interest.id }, meta);
return { clientId, interestId: interest.id };
}
// ─── internals ────────────────────────────────────────────────────────────────
async function loadInquiry(id: string, portId: string) {
const [row] = await db
.select()
.from(websiteSubmissions)
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)))
.limit(1);
if (!row) throw new NotFoundError('inquiry');
return row;
}
/**
* Find a single in-port client whose email contact matches the inquiry's email,
* else create a new client from the payload. Returns the client id.
*/
async function findOrCreateClientFromInquiry(
row: typeof websiteSubmissions.$inferSelect,
meta: AuditMeta,
): Promise<string> {
const fields = extractInquiryFields((row.payload ?? {}) as Record<string, unknown>);
const email = (row.contactEmail ?? fields.email ?? '').trim();
if (email) {
const matches = await db
.selectDistinct({ id: clients.id })
.from(clients)
.innerJoin(clientContacts, eq(clientContacts.clientId, clients.id))
.where(
and(
eq(clients.portId, meta.portId),
isNull(clients.archivedAt),
eq(clientContacts.channel, 'email'),
sql`lower(${clientContacts.value}) = ${email.toLowerCase()}`,
),
)
.limit(2);
// Only auto-link on an unambiguous single match.
if (matches.length === 1) return matches[0]!.id;
}
const contacts: Array<{
channel: 'email' | 'phone' | 'other';
value: string;
isPrimary?: boolean;
}> = [];
if (email) contacts.push({ channel: 'email', value: email, isPrimary: true });
const phone = (fields.phone ?? '').trim();
if (phone) contacts.push({ channel: 'phone', value: phone });
if (contacts.length === 0) {
// Schema requires ≥1 contact; fall back to a name-bearing "other" contact.
contacts.push({ channel: 'other', value: row.contactName ?? 'Website inquiry' });
}
const fullName = (row.contactName ?? fields.fullName ?? email ?? '').trim() || 'Website inquiry';
const clientData = createClientSchema.parse({
fullName,
contacts,
source: 'website',
sourceInquiryId: row.id,
});
const client = await createClient(meta.portId, clientData, meta);
return client.id;
}
async function markConverted(
id: string,
portId: string,
refs: { clientId: string; interestId?: string },
meta: AuditMeta,
) {
await db
.update(websiteSubmissions)
.set({
convertedClientId: refs.clientId,
...(refs.interestId ? { convertedInterestId: refs.interestId } : {}),
triageState: 'converted',
triagedAt: new Date(),
triagedBy: meta.userId,
})
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'website_submission',
entityId: id,
fieldChanged: 'triageState',
newValue: { triageState: 'converted', ...refs },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}

View File

@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
addedBy: interestBerths.addedBy,
addedAt: interestBerths.addedAt,
notes: interestBerths.notes,
priceOverride: interestBerths.priceOverride,
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
.delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
}
// ─── Per-interest price override (CM-2 Part B) ───────────────────────────────
/**
* Resolve the effective price for a berth in the context of an interest. The
* deal-specific override (when set) supersedes the berth's canonical list
* price; the override carries its own currency, falling back to the base
* currency when null. Pure — safe to unit-test without a DB.
*/
export function resolveBerthPriceForInterest(
override: { priceOverride: string | null; priceOverrideCurrency: string | null },
base: { price: string | null; priceCurrency: string },
): { price: string | null; currency: string } {
if (override.priceOverride != null) {
return {
price: override.priceOverride,
currency: override.priceOverrideCurrency ?? base.priceCurrency,
};
}
return { price: base.price, currency: base.priceCurrency };
}
/**
* Set (or clear, when `price` is null) the deal-specific price for one
* (interest, berth). Tenant-scoped: the interest must belong to `portId`.
* Does not touch `berths.price`.
*/
export async function setBerthPriceOverride(
interestId: string,
berthId: string,
price: number | null,
currency: string | null,
portId: string,
): Promise<void> {
const interestRow = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interestRow) throw new NotFoundError('Interest');
await db
.update(interestBerths)
.set({
priceOverride: price == null ? null : String(price),
priceOverrideCurrency: price == null ? null : (currency ?? 'USD'),
})
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
}

View File

@@ -833,7 +833,12 @@ export async function createInterest(portId: string, data: CreateInterestInput,
// every new lead. Falls back to null (Unassigned) when none of
// the above resolve.
let resolvedAssignedTo = interestData.assignedTo ?? null;
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
// CM-5: tiers 2 & 3 (port default-owner + auto-assign-to-creator) only run
// when the per-port assignment feature is enabled. Tier 1 (an explicit
// assignedTo from the caller) is always honored. Default is OFF.
const assignmentSetting = await getSetting('assignment_enabled', portId);
const assignmentEnabled = assignmentSetting?.value === true;
if (assignmentEnabled && resolvedAssignedTo === null && !('assignedTo' in interestData)) {
const defaultOwner = await getSetting('default_new_interest_owner', portId);
const v = defaultOwner?.value as { userId?: string } | null | undefined;
if (v?.userId) {

View File

@@ -0,0 +1,67 @@
/**
* CM-1: Mailchimp Marketing API integration (one-way push, CRM → Mailchimp).
*
* SCOPE NOTE: per the locked CM-1 decision, the exact group → tag/segment
* mapping is finalised only once we have the client's actual Mailchimp account.
* So this module ships the config plumbing + an inert sync that no-ops until
* (a) an admin stores an API key + audience ID and (b) the mapping is wired.
* The members viewer + copy-emails features do NOT depend on Mailchimp.
*
* Settings keys (per-port, in system_settings):
* - `mailchimp_api_key` (AES-encrypted at rest, like SMTP/IMAP creds)
* - `mailchimp_audience_id` (the single audience all groups map into)
*/
import { logger } from '@/lib/logger';
import { getSetting } from '@/lib/services/settings.service';
import { decrypt } from '@/lib/utils/encryption';
export interface MailchimpConfig {
apiKey: string;
audienceId: string;
/** Datacenter prefix derived from the key suffix (e.g. `us21`). */
serverPrefix: string;
}
/** Resolve + decrypt the per-port Mailchimp config, or null when unset. */
export async function getMailchimpConfig(portId: string): Promise<MailchimpConfig | null> {
const keyRow = await getSetting('mailchimp_api_key', portId);
const audRow = await getSetting('mailchimp_audience_id', portId);
const encKey = typeof keyRow?.value === 'string' ? keyRow.value : null;
const audienceId = typeof audRow?.value === 'string' ? audRow.value : null;
if (!encKey || !audienceId) return null;
let apiKey: string;
try {
apiKey = decrypt(encKey);
} catch {
return null;
}
// Mailchimp keys are `<hex>-<dc>`; the datacenter is the API host prefix.
const serverPrefix = apiKey.split('-')[1] ?? '';
if (!serverPrefix) return null;
return { apiKey, audienceId, serverPrefix };
}
export async function isMailchimpConfigured(portId: string): Promise<boolean> {
return (await getMailchimpConfig(portId)) !== null;
}
export type MailchimpSyncResult = { skipped: string } | { synced: true; count: number };
/**
* Push a group's members to Mailchimp as a tag/segment on the port's audience.
* Inert until configured AND the mapping is confirmed (see SCOPE NOTE).
*/
export async function syncGroupToMailchimp(
groupId: string,
portId: string,
): Promise<MailchimpSyncResult> {
const config = await getMailchimpConfig(portId);
if (!config) return { skipped: 'not-configured' };
// TODO(CM-1): mapping pending the client's Mailchimp account. Once confirmed,
// upsert each member via
// PUT https://{serverPrefix}.api.mailchimp.com/3.0/lists/{audienceId}/members/{md5(lowercased-email)}
// then apply the group's tag. Only push subscribed/opted-in contacts (GDPR).
logger.info({ groupId, portId }, 'Mailchimp sync requested (mapping pending client account)');
return { skipped: 'mapping-pending' };
}

View File

@@ -37,6 +37,12 @@ export interface OcrConfigPublic {
* provider is never called even if a key is configured.
*/
aiEnabled: boolean;
/**
* CM-6: manual-entry mode. When true the scanner skips ALL parsing
* (Tesseract + AI) and presents an empty form for the operator to fill in
* by hand. Per-port; takes precedence over `aiEnabled`. Default false.
*/
manualEntry: boolean;
}
/** Internal shape including the decrypted key - server-side only. */
@@ -52,6 +58,7 @@ interface StoredOcrConfig {
apiKeyEncrypted: string | null;
useGlobal: boolean;
aiEnabled?: boolean;
manualEntry?: boolean;
}
const KEY = 'ocr.config';
@@ -106,12 +113,14 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: false,
useGlobal: portRow?.useGlobal === true,
aiEnabled: false,
manualEntry: portRow?.manualEntry === true,
source: 'none',
};
}
// The aiEnabled flag is per-port: even if the port falls back to a global
// key, the port admin still has to flip the switch on this port.
// The aiEnabled / manualEntry flags are per-port: even if the port falls back
// to a global key, the port admin still has to flip these on this port.
const aiEnabled = portRow?.aiEnabled === true;
const manualEntry = portRow?.manualEntry === true;
return {
provider: sourceRow.provider,
model: sourceRow.model,
@@ -119,6 +128,7 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
useGlobal: portRow?.useGlobal === true,
aiEnabled,
manualEntry,
source: useGlobal ? 'global' : 'port',
};
}
@@ -133,6 +143,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: false,
useGlobal: false,
aiEnabled: false,
manualEntry: false,
};
}
return {
@@ -141,6 +152,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: Boolean(row.apiKeyEncrypted),
useGlobal: row.useGlobal,
aiEnabled: row.aiEnabled === true,
manualEntry: row.manualEntry === true,
};
}
@@ -154,6 +166,8 @@ export interface SaveOcrConfigInput {
useGlobal?: boolean;
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */
aiEnabled?: boolean;
/** Per-port toggle: manual entry (skip all parsing). Defaults to false. */
manualEntry?: boolean;
}
export async function saveOcrConfig(
@@ -171,6 +185,9 @@ export async function saveOcrConfig(
// AI is meaningful only at the port scope. Preserve the existing flag if the
// caller didn't pass one (so toggling provider/model doesn't re-disable AI).
const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false);
// Manual entry is also port-only; preserve when the caller omits it.
const manualEntry =
portId === null ? false : (input.manualEntry ?? existing?.manualEntry ?? false);
await writeRow(
portId,
{
@@ -179,6 +196,7 @@ export async function saveOcrConfig(
apiKeyEncrypted,
useGlobal: portId === null ? false : Boolean(input.useGlobal),
aiEnabled,
manualEntry,
},
userId,
);

View File

@@ -0,0 +1,164 @@
/**
* CM-9: proxy / point-of-contact service.
*
* A proxy is a designated contact attached to a client, interest, or yacht
* (one per entity). `resolveEffectiveProxy` picks the most specific for an
* outbound-comms context via the chain yacht → interest → client. All
* operations are port-scoped; the entity is verified to belong to the port.
*/
import { and, eq } from 'drizzle-orm';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { db } from '@/lib/db';
import { clients, interests, proxies, yachts } from '@/lib/db/schema';
import type { Proxy } from '@/lib/db/schema';
import { NotFoundError } from '@/lib/errors';
import type { ProxyEntityType, SetProxyInput } from '@/lib/validators/proxies';
const norm = (v?: string | null): string | null => {
const t = v?.trim();
return t ? t : null;
};
async function assertEntityInPort(
entityType: ProxyEntityType,
entityId: string,
portId: string,
): Promise<void> {
let exists = false;
if (entityType === 'client') {
const [r] = await db
.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, entityId), eq(clients.portId, portId)))
.limit(1);
exists = !!r;
} else if (entityType === 'interest') {
const [r] = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.id, entityId), eq(interests.portId, portId)))
.limit(1);
exists = !!r;
} else {
const [r] = await db
.select({ id: yachts.id })
.from(yachts)
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
.limit(1);
exists = !!r;
}
if (!exists) throw new NotFoundError(`${entityType} not found in this port`);
}
export async function getProxy(
portId: string,
entityType: ProxyEntityType,
entityId: string,
): Promise<Proxy | null> {
const [row] = await db
.select()
.from(proxies)
.where(
and(
eq(proxies.portId, portId),
eq(proxies.entityType, entityType),
eq(proxies.entityId, entityId),
),
)
.limit(1);
return row ?? null;
}
export async function setProxy(
portId: string,
entityType: ProxyEntityType,
entityId: string,
data: SetProxyInput,
meta: AuditMeta,
): Promise<Proxy> {
await assertEntityInPort(entityType, entityId, portId);
const next = {
name: data.name.trim(),
email: norm(data.email),
phone: norm(data.phone),
relationship: norm(data.relationship),
notes: norm(data.notes),
updatedAt: new Date(),
};
const [row] = await db
.insert(proxies)
.values({ portId, entityType, entityId, ...next })
.onConflictDoUpdate({
target: [proxies.portId, proxies.entityType, proxies.entityId],
set: next,
})
.returning();
if (!row) throw new NotFoundError('Failed to save proxy');
void createAuditLog({
...meta,
action: 'update',
entityType: `proxy_${entityType}`,
entityId,
newValue: toAuditJson(next),
});
return row;
}
export async function clearProxy(
portId: string,
entityType: ProxyEntityType,
entityId: string,
meta: AuditMeta,
): Promise<void> {
await db
.delete(proxies)
.where(
and(
eq(proxies.portId, portId),
eq(proxies.entityType, entityType),
eq(proxies.entityId, entityId),
),
);
void createAuditLog({
...meta,
action: 'delete',
entityType: `proxy_${entityType}`,
entityId,
});
}
export interface EffectiveProxy {
proxy: Proxy;
/** Which level the proxy was resolved from. */
source: ProxyEntityType;
}
/**
* Resolve the most specific proxy for an outbound-comms context.
* Precedence: yacht override → interest override → client default.
* Returns null when no proxy is set anywhere in the chain (caller falls back
* to the client themselves).
*/
export async function resolveEffectiveProxy(args: {
portId: string;
clientId?: string | null;
interestId?: string | null;
yachtId?: string | null;
}): Promise<EffectiveProxy | null> {
const { portId, clientId, interestId, yachtId } = args;
if (yachtId) {
const p = await getProxy(portId, 'yacht', yachtId);
if (p) return { proxy: p, source: 'yacht' };
}
if (interestId) {
const p = await getProxy(portId, 'interest', interestId);
if (p) return { proxy: p, source: 'interest' };
}
if (clientId) {
const p = await getProxy(portId, 'client', clientId);
if (p) return { proxy: p, source: 'client' };
}
return null;
}

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
/** CM-1: client groups (mailing/segment lists). */
export const createClientGroupSchema = z.object({
name: z.string().trim().min(1, 'Group name is required').max(120),
description: z.string().trim().max(2000).nullish(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex value like #6B7280')
.optional(),
/** Mailchimp tag/segment name this group maps to. Null until wired up. */
mailchimpTag: z.string().trim().max(200).nullish(),
});
export const updateClientGroupSchema = createClientGroupSchema.partial();
/** Wipe-and-rewrite the group's membership to exactly this set of clients. */
export const setGroupMembersSchema = z.object({
clientIds: z.array(z.string().min(1)).max(5000),
});
export type CreateClientGroupInput = z.infer<typeof createClientGroupSchema>;
export type UpdateClientGroupInput = z.infer<typeof updateClientGroupSchema>;
export type SetGroupMembersInput = z.infer<typeof setGroupMembersSchema>;

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
/**
* List query for the inquiries workbench (over `website_submissions`).
* `state` defaults to 'inbox' (open + assigned) so resolved/dismissed roll off
* the active queue; pass 'all' for the full history.
*/
export const listInquiriesSchema = baseListQuerySchema.extend({
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']).optional(),
state: z.enum(['inbox', 'open', 'assigned', 'converted', 'dismissed', 'all']).default('inbox'),
});
export const triageInquirySchema = z.object({
state: z.enum(['open', 'assigned', 'converted', 'dismissed']),
});
export const convertInquirySchema = z.object({
target: z.enum(['client', 'interest']),
});
export type ListInquiriesInput = z.infer<typeof listInquiriesSchema>;
export type TriageInquiryInput = z.infer<typeof triageInquirySchema>;
export type ConvertInquiryInput = z.infer<typeof convertInquirySchema>;

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
/** CM-9: proxy / point-of-contact. */
export const PROXY_ENTITY_TYPES = ['client', 'interest', 'yacht'] as const;
export type ProxyEntityType = (typeof PROXY_ENTITY_TYPES)[number];
export const setProxySchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(200),
// Loose contact fields — empty strings are normalised to null in the service.
email: z.string().trim().max(320).nullish(),
phone: z.string().trim().max(50).nullish(),
relationship: z.string().trim().max(100).nullish(),
notes: z.string().trim().max(2000).nullish(),
});
export type SetProxyInput = z.infer<typeof setProxySchema>;

View File

@@ -30,6 +30,7 @@ export async function teardown() {
)
-- Cascade-delete dependent rows. Order respects FK chains.
, del_audit AS (DELETE FROM audit_logs WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_ws AS (DELETE FROM website_submissions WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_bml AS (DELETE FROM berth_maintenance_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_resv AS (DELETE FROM berth_tenancies WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_caddr AS (DELETE FROM client_addresses WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
@@ -47,6 +48,7 @@ export async function teardown() {
, del_files AS (DELETE FROM files WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_ft AS (DELETE FROM form_templates WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_gr AS (DELETE FROM generated_reports WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_icl AS (DELETE FROM interest_contact_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_int AS (DELETE FROM interests WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_ib AS (DELETE FROM interest_berths WHERE berth_id IN (SELECT id FROM berths WHERE port_id IN (SELECT id FROM doomed)) RETURNING 1)
, del_inv AS (DELETE FROM invoices WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
@@ -69,5 +71,8 @@ export async function teardown() {
, del_ports AS (DELETE FROM ports WHERE id IN (SELECT id FROM doomed) RETURNING 1)
SELECT 1
`);
// migration_source_links has no port_id FK; purge test-only ledger rows by
// the marker applied_id our alert tests use.
await db.execute(sql`DELETE FROM migration_source_links WHERE applied_id = 'test-apply'`);
await closeDb();
}

View File

@@ -384,6 +384,8 @@ export function makeFullPermissions(): RolePermissions {
delete: true,
change_stage: true,
},
inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
};
}
@@ -472,6 +474,8 @@ export function makeViewerPermissions(): RolePermissions {
delete: false,
change_stage: false,
},
inquiries: { view: true, manage: false },
client_groups: { view: true, manage: false },
};
}
@@ -560,6 +564,8 @@ export function makeSalesAgentPermissions(): RolePermissions {
delete: false,
change_stage: false,
},
inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
};
}
@@ -648,6 +654,8 @@ export function makeSalesManagerPermissions(): RolePermissions {
delete: true,
change_stage: true,
},
inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
};
}

View File

@@ -0,0 +1,70 @@
/**
* Bulk-dismiss service: dismissAllForPort must respect the optional rule/
* severity filter and never touch another port's alerts.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { and, eq, isNull } from 'drizzle-orm';
vi.mock('@/lib/socket/server', () => ({
emitToRoom: vi.fn(),
}));
import { db } from '@/lib/db';
import { alerts } from '@/lib/db/schema/insights';
import { user } from '@/lib/db/schema/users';
import { dismissAllForPort } from '@/lib/services/alerts.service';
import { makePort } from '../helpers/factories';
let USER_ID = '';
beforeAll(async () => {
const [u] = await db.select({ id: user.id }).from(user).limit(1);
if (!u) throw new Error('No user available; run pnpm db:seed first');
USER_ID = u.id;
});
async function seedAlert(portId: string, ruleId: string, severity = 'info') {
const [row] = await db
.insert(alerts)
.values({
portId,
ruleId,
severity,
title: `t-${ruleId}`,
link: '/x',
fingerprint: `fp-${Math.random().toString(36).slice(2)}`,
metadata: {},
})
.returning({ id: alerts.id });
return row!.id;
}
async function openCount(portId: string) {
const rows = await db
.select()
.from(alerts)
.where(and(eq(alerts.portId, portId), isNull(alerts.dismissedAt), isNull(alerts.resolvedAt)));
return rows.length;
}
describe('dismissAllForPort', () => {
it('dismisses only the filtered rule, scoped to the port, then all', async () => {
const portA = await makePort();
const portB = await makePort();
await seedAlert(portA.id, 'interest.stale');
await seedAlert(portA.id, 'interest.stale');
await seedAlert(portA.id, 'document.signer_overdue', 'warning');
await seedAlert(portB.id, 'interest.stale');
const filtered = await dismissAllForPort(portA.id, USER_ID, { ruleId: 'interest.stale' });
expect(filtered).toBe(2);
expect(await openCount(portA.id)).toBe(1); // signer_overdue remains
expect(await openCount(portB.id)).toBe(1); // other port untouched
const rest = await dismissAllForPort(portA.id, USER_ID);
expect(rest).toBe(1);
expect(await openCount(portA.id)).toBe(0);
expect(await openCount(portB.id)).toBe(1);
});
});

View File

@@ -18,6 +18,8 @@ import { alerts } from '@/lib/db/schema/insights';
import { interests } from '@/lib/db/schema/interests';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { documents } from '@/lib/db/schema/documents';
import { interestContactLog } from '@/lib/db/schema/operations';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
import { makePort, makeClient, makeBerth, makeYacht } from '../helpers/factories';
@@ -32,6 +34,30 @@ async function listOpenAlerts(portId: string, ruleId: string) {
.where(and(eq(alerts.portId, portId), eq(alerts.ruleId, ruleId), isNull(alerts.resolvedAt)));
}
/** Mark an interest as bulk-imported via the migration ledger. */
async function markImported(interestId: string) {
await db.insert(migrationSourceLinks).values({
sourceSystem: 'nocodb_interests',
sourceId: `legacy-${interestId}`,
targetEntityType: 'interest',
targetEntityId: interestId,
appliedId: 'test-apply',
});
}
/** A genuine in-system follow-up: a logged contact at `occurredAt`. */
async function logContact(portId: string, interestId: string, occurredAt: Date) {
await db.insert(interestContactLog).values({
portId,
interestId,
occurredAt,
channel: 'phone',
direction: 'outbound',
summary: 'Test follow-up',
createdBy: 'seed',
});
}
describe('alert engine', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -149,7 +175,7 @@ describe('alert engine', () => {
expect(allRows[0]!.resolvedAt).not.toBeNull();
});
it('interest.stale fires for old leads in mid-funnel stages', async () => {
it('interest.stale fires for worked leads gone quiet >14d', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const stale = new Date(Date.now() - 30 * 86_400_000);
@@ -164,6 +190,9 @@ describe('alert engine', () => {
updatedAt: stale,
})
.returning();
// A real in-system follow-up 30 days ago → this is a worked-then-quiet lead,
// not an untouched import.
await logContact(port.id, interest!.id, stale);
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
@@ -172,6 +201,60 @@ describe('alert engine', () => {
expect(open).toHaveLength(1);
expect(open[0]!.entityId).toBe(interest!.id);
expect(open[0]!.severity).toBe('info');
// A worked lead must not also fire the new-untouched rule.
expect(await listOpenAlerts(port.id, 'interest.no_activity')).toHaveLength(0);
});
it('interest.stale does NOT fire for imported, never-touched interests', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const migrationTime = new Date(Date.now() - 20 * 86_400_000);
const legacyDate = new Date(Date.now() - 3 * 365 * 86_400_000); // ~3yr back-dated
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
pipelineStage: 'qualified',
dateLastContact: legacyDate, // back-dated by the migration
createdAt: migrationTime,
updatedAt: migrationTime,
})
.returning();
await markImported(interest!.id);
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
// Imported + never touched in-system → neither interest rule should fire.
expect(await listOpenAlerts(port.id, 'interest.stale')).toHaveLength(0);
expect(await listOpenAlerts(port.id, 'interest.no_activity')).toHaveLength(0);
});
it('interest.no_activity fires for new, non-imported, untouched interests >14d old', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const created = new Date(Date.now() - 20 * 86_400_000);
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
pipelineStage: 'enquiry',
dateLastContact: null,
createdAt: created,
updatedAt: created,
})
.returning();
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
const open = await listOpenAlerts(port.id, 'interest.no_activity');
expect(open).toHaveLength(1);
expect(open[0]!.entityId).toBe(interest!.id);
expect(open[0]!.severity).toBe('info');
expect(await listOpenAlerts(port.id, 'interest.stale')).toHaveLength(0);
});
it('interest.high_value_silent fires for hot leads silent >7d', async () => {
@@ -195,6 +278,31 @@ describe('alert engine', () => {
expect(open[0]!.severity).toBe('critical');
});
it('interest.high_value_silent skips imported, never-touched hot leads', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const migrationTime = new Date(Date.now() - 20 * 86_400_000);
const legacyDate = new Date(Date.now() - 3 * 365 * 86_400_000);
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
pipelineStage: 'qualified',
leadCategory: 'hot_lead',
dateLastContact: legacyDate,
createdAt: migrationTime,
updatedAt: migrationTime,
})
.returning();
await markImported(interest!.id);
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
expect(await listOpenAlerts(port.id, 'interest.high_value_silent')).toHaveLength(0);
});
it('engine reports rule errors without crashing the sweep', async () => {
const port = await makePort();
const summary = await runAlertEngineForPorts([port.id]);

View File

@@ -0,0 +1,129 @@
/**
* Integration tests for the bulk berth price-reconcile service (CM-2 Part A).
*
* Uses the real filesystem storage backend (seeded below) + a real spec-sheet
* PDF, so the full upload → store → re-parse → extract path is exercised end to
* end with no storage mock.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { eq } from 'drizzle-orm';
import { beforeEach, describe, expect, it } from 'vitest';
import {
listPriceReconciliation,
applyBulkBerthPrices,
} from '@/lib/services/berth-price-reconcile.service';
import { uploadBerthPdf } from '@/lib/services/berth-pdf.service';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { systemSettings } from '@/lib/db/schema/system';
import { makeBerth, makeFullPermissions, makePort } from '../helpers/factories';
import { makeMockCtx, makeMockRequest } from '../helpers/route-tester';
const A1_PDF = readFileSync(path.join(process.cwd(), 'berth_pdf_example/Berth_Spec_Sheet_A1.pdf'));
beforeEach(async () => {
await db
.insert(systemSettings)
.values({ key: 'storage_backend', value: 'filesystem', portId: null, updatedBy: null })
.onConflictDoNothing();
});
describe('listPriceReconciliation', () => {
it('parses the main price for a berth with a PDF and flags one without', async () => {
const port = await makePort();
const withPdf = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } });
// No-PDF berth — created for its 'no_pdf' row; the value isn't referenced.
await makeBerth({ portId: port.id, overrides: { mooringNumber: 'Z9' } });
await uploadBerthPdf({
berthId: withPdf.id,
portId: port.id,
buffer: A1_PDF,
fileName: 'Berth_Spec_Sheet_A1.pdf',
uploadedBy: 'test-user',
});
const rows = await listPriceReconciliation(port.id);
const w = rows.find((r) => r.mooringNumber === 'A1');
const wo = rows.find((r) => r.mooringNumber === 'Z9');
expect(w?.parsedPrice).toBe(3880800);
expect(w?.parsedCurrency).toBe('USD');
expect(w?.currentPrice).toBeNull();
expect(w?.status).toBe('changed'); // CRM price null → changed
expect(wo?.status).toBe('no_pdf');
});
});
describe('applyBulkBerthPrices', () => {
it('writes only approved, in-port berths and skips cross-port ids', async () => {
const portA = await makePort();
const portB = await makePort();
const berthA = await makeBerth({ portId: portA.id, overrides: { mooringNumber: 'A1' } });
const berthB = await makeBerth({ portId: portB.id, overrides: { mooringNumber: 'A1' } });
const res = await applyBulkBerthPrices(
portA.id,
[
{ berthId: berthA.id, price: 3880800, currency: 'USD' },
{ berthId: berthB.id, price: 999, currency: 'USD' }, // foreign port → skipped
],
'test-user',
);
expect(res.updated).toBe(1);
const [a] = await db.select().from(berths).where(eq(berths.id, berthA.id));
expect(Number(a!.price)).toBe(3880800);
expect(a!.priceCurrency).toBe('USD');
const [b] = await db.select().from(berths).where(eq(berths.id, berthB.id));
expect(b!.price).toBeNull(); // untouched
});
});
describe('price-reconcile route handlers', () => {
it('GET lists rows and POST apply writes the approved price', async () => {
const { getHandler } = await import('@/app/api/v1/berths/price-reconcile/handlers');
const { postHandler } = await import('@/app/api/v1/berths/price-reconcile/apply/handlers');
const port = await makePort();
const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } });
await uploadBerthPdf({
berthId: berth.id,
portId: port.id,
buffer: A1_PDF,
fileName: 'Berth_Spec_Sheet_A1.pdf',
uploadedBy: 'test-user',
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const listRes = await getHandler(
makeMockRequest('GET', 'http://t/api/v1/berths/price-reconcile'),
ctx,
{},
);
const listJson = (await listRes.json()) as {
data: Array<{ mooringNumber: string; parsedPrice: number | null }>;
};
expect(listJson.data.find((r) => r.mooringNumber === 'A1')?.parsedPrice).toBe(3880800);
const applyRes = await postHandler(
makeMockRequest('POST', 'http://t/api/v1/berths/price-reconcile/apply', {
body: { approvals: [{ berthId: berth.id, price: 3880800, currency: 'USD' }] },
}),
ctx,
{},
);
const applyJson = (await applyRes.json()) as { data: { updated: number } };
expect(applyJson.data.updated).toBe(1);
const [b] = await db.select().from(berths).where(eq(berths.id, berth.id));
expect(Number(b!.price)).toBe(3880800);
});
});

View File

@@ -0,0 +1,81 @@
/**
* CM-1: client-groups service — CRUD, wipe-and-rewrite membership, member
* email resolution (for copy-emails), and the port-scope guard.
*/
import { describe, it, expect } from 'vitest';
import { db } from '@/lib/db';
import { clientContacts } from '@/lib/db/schema';
import {
archiveClientGroup,
createClientGroup,
getClientGroupById,
listClientGroups,
listGroupMembers,
setGroupMembers,
updateClientGroup,
} from '@/lib/services/client-groups.service';
import { makeAuditMeta, makeClient, makePort } from '../helpers/factories';
describe('client-groups.service (CM-1)', () => {
it('creates a group and lists it with a zero member count', async () => {
const port = await makePort();
const meta = makeAuditMeta({ portId: port.id });
const group = await createClientGroup(port.id, { name: 'VIP Mailing' }, meta);
expect(group.name).toBe('VIP Mailing');
expect(group.color).toBe('#6B7280');
const list = await listClientGroups(port.id);
expect(list).toHaveLength(1);
expect(list[0]?.memberCount).toBe(0);
});
it('sets members (wipe-and-rewrite) and lists them with primary email', async () => {
const port = await makePort();
const meta = makeAuditMeta({ portId: port.id });
const c1 = await makeClient({ portId: port.id });
const c2 = await makeClient({ portId: port.id });
await db
.insert(clientContacts)
.values({ clientId: c1.id, channel: 'email', value: 'vip@example.com', isPrimary: true });
const group = await createClientGroup(port.id, { name: 'Newsletter' }, meta);
await setGroupMembers(group.id, port.id, [c1.id, c2.id], meta);
const members = await listGroupMembers(group.id, port.id);
expect(members.map((m) => m.clientId).sort()).toEqual([c1.id, c2.id].sort());
expect(members.find((m) => m.clientId === c1.id)?.email).toBe('vip@example.com');
expect(members.find((m) => m.clientId === c2.id)?.email).toBeNull();
const list = await listClientGroups(port.id);
expect(list.find((g) => g.id === group.id)?.memberCount).toBe(2);
// Wipe-and-rewrite: setting to [c2] drops c1.
await setGroupMembers(group.id, port.id, [c2.id], meta);
const after = await listGroupMembers(group.id, port.id);
expect(after.map((m) => m.clientId)).toEqual([c2.id]);
});
it('rejects members from a foreign port', async () => {
const portA = await makePort();
const portB = await makePort();
const meta = makeAuditMeta({ portId: portA.id });
const foreign = await makeClient({ portId: portB.id });
const group = await createClientGroup(portA.id, { name: 'Scoped' }, meta);
await expect(setGroupMembers(group.id, portA.id, [foreign.id], meta)).rejects.toThrow(
/not in this port/,
);
});
it('updates and archives a group', async () => {
const port = await makePort();
const meta = makeAuditMeta({ portId: port.id });
const group = await createClientGroup(port.id, { name: 'Temp' }, meta);
const updated = await updateClientGroup(group.id, port.id, { name: 'Renamed' }, meta);
expect(updated.name).toBe('Renamed');
await archiveClientGroup(group.id, port.id, meta);
await expect(getClientGroupById(group.id, port.id)).rejects.toThrow(/not found/i);
expect(await listClientGroups(port.id)).toHaveLength(0);
});
});

Some files were not shown because too many files have changed in this diff Show More