20 Commits

Author SHA1 Message Date
352b2420b7 fix(ui): mobile cutoff polish — onboarding banner + yacht owner truncate (R1/R2)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m2s
Build & Push Docker Images / build-and-push (push) Successful in 8m28s
Responsive-overflow sweep findings (tests/e2e/matrix/responsive-overflow.spec.ts):

- R1: the onboarding banner's verbose "N of M steps done. Next: <link>" was
  clipped on mobile (extended ~160px past a 390px viewport) and duplicated the
  always-visible "View checklist" button. Now hidden below sm:; mobile shows
  just "Setup X% complete" + the checklist button.
- R2: yacht card owner subtitle used inline-flex + truncate, so a long owner
  name overflowed ~11px on the narrowest widths. Switched to flex min-w-0 so it
  truncates within the card.
- Detector: skip SVG internals (icons / the react-grab dev overlay) and elements
  inside overflow-x scroll containers (data tables scroll on purpose) to drop
  false positives. Sweep now confirms mobile/tablet clean + no real desktop
  overflow (berths wide table is the DataTable's intended horizontal scroll).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:23:56 +02:00
459c68a2c3 feat(rbac): residential-partner route lockdown + role-aware mobile nav
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m0s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
UAT (residential partners must have zero access to anything non-residential;
no marina dashboard). Server-side their permission map already 403s every
marina domain — this locks the client surface to match:

- AppShell: a residential-only user (residential_clients.view && !clients.view,
  non-super-admin) is redirected off ANY non-residential route to
  /residential/clients. Blocks the marina dashboard + every marina page in one
  place; personal surfaces (settings, inbox) stay reachable. (Fixes F4 — they
  no longer land on a marina dashboard of 403-ing empty widgets.)
- Mobile bottom tabs were hardcoded Dashboard/Clients/Berths regardless of role;
  now role-aware — residential-only users get Residential Clients/Interests
  instead of marina tabs they 403 on. (Fixes F5.)
- e2e: stale `#email` login selector → `#identifier` (smoke helper) — a real
  reason the smoke auth specs fail independent of the dev-server OOM.
- New crash-safe `matrix` Playwright project (role×viewport access matrix +
  responsive overflow sweep) — lean alternative to the full suite which
  OOM-crashes next dev locally.

Verified: matrix run shows residential_partner redirected to residential +
residential-scoped mobile tabs; 403s unchanged; tsc + eslint + 42 permission
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:22 +02:00
adc9802361 fix(rbac): sales/operational roles see deal alerts; quiet admin-only onboarding probe
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m3s
Build & Push Docker Images / build-and-push (push) Successful in 8m23s
UAT findings from the Sales-role functional walkthrough:

F1 — The deal-alert feed (stale interest, hot-lead-silent, EOI unsigned,
signer overdue, reservation-needs-agreement, berth stalled, expense dupes)
was gated on admin.view_audit_log, so salespeople got a 403 on the Alerts
inbox. None of the 9 alert rules are audit/security signals — they're all
operational — so re-gate the list route to interests.view (sales, director,
viewer get it; external residential partners don't) and hide the Alerts
section in the inbox for users without it instead of letting the query 403.

F2 — Non-admins triggered /api/v1/admin/onboarding/status (admin-only) and
ate a 403 in the console. Make useOnboardingStatus strictly opt-in
(enabled: opts.enabled === true) so a transient/stale isSuperAdmin during
permission hydration can't fire the privileged request.

1664 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:49:12 +02:00
d8f739a7c2 feat(rbac): GDPR export becomes a toggleable clients.gdpr_export permission
Previously the GDPR export trigger + download routes were gated by
admin.manage_settings, so sales roles couldn't run a client data export.
Per request, make it a dedicated, toggleable permission that's on by
default for sales-capable roles and hides the button when withheld.

- New RolePermissions leaf clients.gdpr_export (+ PERMISSION_CATALOG entry);
  strict type forces every role map + fixture to declare it.
- Granted true for super_admin / director / sales_manager / sales_agent;
  false for viewer / residential_partner.
- GDPR export POST (trigger) and [exportId] GET (download) re-gated from
  admin.manage_settings -> clients.gdpr_export.
- GdprExportButton visibility now keys off clients.gdpr_export, so toggling
  it off per-user hides the function entirely.
- Migration 0098 backfills the key onto existing role rows (idempotent).

Verified end-to-end as a Sales user: trigger (202) -> worker build (ready)
-> list (200) -> download (200). 1664 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:20:31 +02:00
93989b1e1d feat(admin): single Sales role, welcome-email password setup, Director=sales
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m24s
- Collapse the two sales roles in the create-user dropdown to one "Sales"
  (sales_manager relabelled). Hide super_admin + sales_agent from selection
  via NON_ASSIGNABLE_ROLE_NAMES; the form keeps a user's *current* role even
  if hidden so existing assignments stay editable.
- Director becomes a senior-title twin of Sales: DIRECTOR_PERMISSIONS now
  equals SALES_MANAGER_PERMISSIONS (no admin/settings — Super-Admin only).
  Migration 0097 updates the existing global director row (idempotent,
  data-only; 0 users assigned on prod, so no blast radius).
- Admin create-user defaults to emailing a set-password link instead of an
  inline password (manual entry still available via a toggle). createUserSchema:
  password optional + sendSetupEmail; createUser provisions with a throwaway
  password then triggers the set-password email.
- New users get a dedicated, unique WELCOME email (crmWelcomeEmail), not the
  self-service "reset your password" email. A pending-welcome flag routes the
  shared better-auth sendResetPassword callback via account-setup-email.ts.
- Phone confirmed already optional for staff accounts (no change needed).

Tests: +welcome-routing, +create-user-setup; permission-matrix director block
realigned to no-admin. 1662 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:40:55 +02:00
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
102 changed files with 4310 additions and 441 deletions

View File

@@ -24,6 +24,28 @@ export default defineConfig({
name: 'setup', name: 'setup',
testMatch: /smoke\/global-setup\.ts/, testMatch: /smoke\/global-setup\.ts/,
}, },
{
// Permission-matrix UX sweep. Users + roles are seeded separately via
// `pnpm tsx tests/e2e/permissions/seed-permission-matrix.ts` (no global
// setup dependency — relies on the already-seeded dev DB).
name: 'permissions',
testMatch: /permissions\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Lean role × viewport access matrix. Users pre-seeded (admin/director/
// sales/viewer/residential_partner) — no global-setup dependency. Few
// route compilations, so it stays under the dev-server OOM threshold.
name: 'matrix',
testMatch: /matrix\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{ {
name: 'smoke', name: 'smoke',
testMatch: /smoke\/\d{2}-.*\.spec\.ts/, testMatch: /smoke\/\d{2}-.*\.spec\.ts/,

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Route } from 'next'; 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 { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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.", "Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch, 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; ] as const;
return ( 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 { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 // All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global // `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." 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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">

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

@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config'; import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Scan receipt', title: 'Scan receipt',
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
const { portSlug } = await params; const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) }); const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null; 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

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

View File

@@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
type AlertStatus = 'open' | 'dismissed' | 'resolved'; type AlertStatus = 'open' | 'dismissed' | 'resolved';
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent // The alert feed is entirely operational/deal signals (stale interest, hot lead
// signals. Gated on admin.view_audit_log - same permission the audit log // silent, EOI unsigned, signer overdue, reservation needs agreement, berth
// page uses. // stalled, duplicate/unscanned expense) — there are no audit/security alert
// rules. Gated on interests.view so the operational roles that act on these
// (sales, director, viewer) see them; external residential partners don't.
export const GET = withAuth( export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => { withPermission('interests', 'view', async (req: NextRequest, ctx) => {
const url = new URL(req.url); const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus; const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;

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

@@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit';
*/ */
export const GET = withAuth( export const GET = withAuth(
withPermission( withPermission(
'admin', 'clients',
'manage_settings', 'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => { withRateLimit('exports', async (req, ctx, params) => {
try { try {
const url = await getExportDownloadUrl(params.exportId!, ctx.portId); const url = await getExportDownloadUrl(params.exportId!, ctx.portId);

View File

@@ -26,8 +26,8 @@ export const GET = withAuth(
export const POST = withAuth( export const POST = withAuth(
withPermission( withPermission(
'admin', 'clients',
'manage_settings', 'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => { withRateLimit('exports', async (req, ctx, params) => {
try { try {
const body = await parseBody(req, requestSchema); const body = await parseBody(req, requestSchema);

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); 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 // Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on // an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its // and (b) a key resolves. Otherwise the client falls back to its

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; hasApiKey: boolean;
useGlobal: boolean; useGlobal: boolean;
aiEnabled: boolean; aiEnabled: boolean;
manualEntry: boolean;
}; };
models: Record<Provider, string[]>; models: Record<Provider, string[]>;
} }
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
// Key the body on the loaded payload so useState initializers seed // Key the body on the loaded payload so useState initializers seed
// from server values cleanly. // from server values cleanly.
const sig = data?.data 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'; : 'loading';
return ( return (
<SettingsBlockBody <SettingsBlockBody
@@ -89,6 +90,7 @@ function SettingsBlockBody({
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false); const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? 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 }>( const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null, null,
); );
@@ -105,6 +107,7 @@ function SettingsBlockBody({
clearApiKey: Boolean(clearApiKey), clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal, useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled, aiEnabled: scope === 'global' ? false : aiEnabled,
manualEntry: scope === 'global' ? false : manualEntry,
}, },
}), }),
onSuccess: () => { onSuccess: () => {
@@ -190,6 +193,25 @@ function SettingsBlockBody({
</div> </div>
) : null} ) : 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="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label> <Label htmlFor={`provider-${scope}`}>Provider</Label>

View File

@@ -50,20 +50,25 @@ export function OnboardingBanner() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Sparkles className="size-4 shrink-0" aria-hidden /> <Sparkles className="size-4 shrink-0" aria-hidden />
<span className="truncate"> <span className="truncate">
<strong>Setup is {data.percent}% complete</strong>. {data.completed} of {data.total} steps <strong>Setup is {data.percent}% complete</strong>
done.{' '} {/* Verbose progress + the "Next:" deep-link are hidden on mobile,
{next ? ( where they get clipped (R1) and duplicate the always-visible
<> "View checklist" button. Shown from sm: up. */}
Next:{' '} <span className="hidden sm:inline">
<Link . {data.completed} of {data.total} steps done.{' '}
// eslint-disable-next-line @typescript-eslint/no-explicit-any {next ? (
href={`/${portSlug}/admin/${next.href}` as any} <>
className="font-medium underline-offset-2 hover:underline" Next:{' '}
> <Link
{next.label} // eslint-disable-next-line @typescript-eslint/no-explicit-any
</Link> href={`/${portSlug}/admin/${next.href}` as any}
</> className="font-medium underline-offset-2 hover:underline"
) : null} >
{next.label}
</Link>
</>
) : null}
</span>
</span> </span>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, 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', key: 'tenancies_module_enabled',
label: 'Tenancies Module', label: 'Tenancies Module',

View File

@@ -29,7 +29,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants'; import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
interface Role { interface Role {
id: string; id: string;
@@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
enabled: open, enabled: open,
}); });
const roles = rolesQuery.data?.data ?? []; const roles = rolesQuery.data?.data ?? [];
// Hide retired/owner-only system roles from the picker, but always keep the
// role the user being edited already holds so their record stays editable.
const selectableRoles = roles.filter(
(r) => !NON_ASSIGNABLE_ROLE_NAMES.has(r.name) || r.id === user?.role.id,
);
const [firstName, setFirstName] = useState(initialNames.first); const [firstName, setFirstName] = useState(initialNames.first);
const [lastName, setLastName] = useState(initialNames.last); const [lastName, setLastName] = useState(initialNames.last);
const [email, setEmail] = useState(user?.email ?? ''); const [email, setEmail] = useState(user?.email ?? '');
const [originalEmail] = useState(user?.email ?? ''); const [originalEmail] = useState(user?.email ?? '');
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false); const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
// New users: email them a set-password link by default rather than typing a
// password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>( const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
user?.phone ? { e164: user.phone, country: 'US' } : null, user?.phone ? { e164: user.phone, country: 'US' } : null,
@@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
firstName: firstName || null, firstName: firstName || null,
lastName: lastName || null, lastName: lastName || null,
email, email,
password, // Email mode omits the password entirely; manual mode sends it.
password: sendSetupEmail ? undefined : password,
sendSetupEmail,
displayName, displayName,
phone: phoneE164 ?? undefined, phone: phoneE164 ?? undefined,
roleId, roleId,
@@ -250,18 +260,37 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
</div> </div>
{!isEdit && ( {!isEdit && (
<div className="space-y-2"> <>
<Label htmlFor="user-password">Password</Label> <div className="flex items-center justify-between rounded-lg border p-3">
<Input <div>
id="user-password" <Label htmlFor="user-setup-email">Email a set-password link</Label>
type="password" <p className="text-xs text-muted-foreground">
value={password} The user gets an email to choose their own password. Turn off to set one
onChange={(e) => setPassword(e.target.value)} here instead.
placeholder="Min 12 characters" </p>
minLength={12} </div>
required <Switch
/> id="user-setup-email"
</div> checked={sendSetupEmail}
onCheckedChange={setSendSetupEmail}
/>
</div>
{!sendSetupEmail && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
)}
</>
)} )}
<div className="space-y-2"> <div className="space-y-2">
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
<SelectValue placeholder="Select a role" /> <SelectValue placeholder="Select a role" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{roles.map((r) => ( {selectableRoles.map((r) => (
<SelectItem key={r.id} value={r.id}> <SelectItem key={r.id} value={r.id}>
{formatRole(r.name)} {formatRole(r.name)}
</SelectItem> </SelectItem>

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 { useParams, useRouter } from 'next/navigation';
import type { Route } from 'next'; import type { Route } from 'next';
import { useState } from 'react'; import { useState } from 'react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react'; import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const primaryEmail = const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.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 country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt const addedLabel = client.createdAt
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</p> </p>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1"> {/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
{primaryEmail ? ( request. GDPR export moved to the top-right action cluster.
<Button Portal-invite stays as the one primary CTA here. */}
asChild {!isArchived && client.clientPortalEnabled === true ? (
variant="outline" <div className="flex flex-wrap items-center gap-1.5 pt-1">
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 ? (
<div className="hidden sm:inline-flex"> <div className="hidden sm:inline-flex">
<PortalInviteButton <PortalInviteButton
clientId={client.id} clientId={client.id}
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail} defaultEmail={primaryEmail}
/> />
</div> </div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div> </div>
</div> ) : null}
{client.tags && client.tags.length > 0 && ( {client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
right perm) permanently-delete. Destructive actions sit out right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */} of the primary action flow. */}
<div className="flex items-start gap-1"> <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 && ( {isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients"> <PermissionGate resource="admin" action="permanently_delete_clients">
<button <button

View File

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

View File

@@ -48,14 +48,22 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
failed: 'destructive', 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 { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [emailToClient, setEmailToClient] = useState(false); const [emailToClient, setEmailToClient] = useState(false);
const [emailOverride, setEmailOverride] = useState(''); const [emailOverride, setEmailOverride] = useState('');
const allowed = isSuperAdmin || can('admin', 'manage_settings'); const allowed = isSuperAdmin || can('clients', 'gdpr_export');
const queryKey = ['gdpr-exports', clientId]; const queryKey = ['gdpr-exports', clientId];
const { data, isLoading } = useQuery<ListResp>({ const { data, isLoading } = useQuery<ListResp>({
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8"> {variant === 'icon' ? (
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden /> <button
GDPR export type="button"
</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> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>

View File

@@ -104,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The // useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set. // 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], queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`), queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId, enabled: open && !!fileId,
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading; const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null; 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell'; import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
import { ReminderList } from '@/components/reminders/reminder-list'; import { ReminderList } from '@/components/reminders/reminder-list';
import { useAlertCount } from '@/components/alerts/use-alerts'; import { useAlertCount } from '@/components/alerts/use-alerts';
import { usePermissions } from '@/hooks/use-permissions';
/** /**
* Merged "Inbox" surface - replaces the previously-separate /alerts and * Merged "Inbox" surface - replaces the previously-separate /alerts and
@@ -29,6 +30,11 @@ export function InboxPageShell() {
const [alertsOpen, setAlertsOpen] = useState(true); const [alertsOpen, setAlertsOpen] = useState(true);
const [remindersOpen, setRemindersOpen] = useState(true); const [remindersOpen, setRemindersOpen] = useState(true);
const { data: alertCount } = useAlertCount(); const { data: alertCount } = useAlertCount();
// The deal-alert feed (stale interests, overdue signers, …) is gated on
// interests.view — operational roles see it; external residential partners
// don't. Hide the whole section rather than letting its query 403.
const { can } = usePermissions();
const canSeeAlerts = can('interests', 'view');
// localStorage hydration on mount - canonical "read from external // localStorage hydration on mount - canonical "read from external
// store" pattern. setState in effect is intentional. // store" pattern. setState in effect is intentional.
@@ -95,20 +101,22 @@ export function InboxPageShell() {
) : null} ) : null}
</section> </section>
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs"> {canSeeAlerts ? (
<SectionHeader <section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />} <SectionHeader
label="Alerts" icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
count={activeAlerts} label="Alerts"
open={alertsOpen} count={activeAlerts}
onToggle={toggleAlerts} open={alertsOpen}
/> onToggle={toggleAlerts}
{alertsOpen ? ( />
<div className="border-t px-4 pb-4 pt-3"> {alertsOpen ? (
<AlertsPageShell embedded /> <div className="border-t px-4 pb-4 pt-3">
</div> <AlertsPageShell embedded />
) : null} </div>
</section> ) : null}
</section>
) : null}
</div> </div>
); );
} }

View File

@@ -49,6 +49,13 @@ export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
dismissed: 'bg-slate-100 text-slate-600', 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 }> = [ export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'contactEmail', label: 'Email' }, { id: 'contactEmail', label: 'Email' },
{ id: 'kind', label: 'Type' }, { id: 'kind', label: 'Type' },
@@ -114,7 +121,7 @@ export function getInquiryColumns({
const state = row.original.triageState; const state = row.original.triageState;
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Badge className={TRIAGE_TONE[state]}>{state}</Badge> <Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
{row.original.convertedInterestId ? ( {row.original.convertedInterestId ? (
<Link <Link
href={`/${portSlug}/interests/${row.original.convertedInterestId}`} href={`/${portSlug}/interests/${row.original.convertedInterestId}`}

View File

@@ -8,8 +8,10 @@ import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout'
import { DetailNotFound } from '@/components/shared/detail-not-found'; import { DetailNotFound } from '@/components/shared/detail-not-found';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import { import {
KIND_LABELS, KIND_LABELS,
TRIAGE_LABELS,
TRIAGE_TONE, TRIAGE_TONE,
type InquiryKind, type InquiryKind,
type InquiryTriageState, type InquiryTriageState,
@@ -49,6 +51,7 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export function InquiryDetail({ id }: { id: string }) { export function InquiryDetail({ id }: { id: string }) {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const { isSuperAdmin } = usePermissions();
const { data, isLoading, error } = useQuery<InquiryDetailData>({ const { data, isLoading, error } = useQuery<InquiryDetailData>({
queryKey: ['inquiries', id], queryKey: ['inquiries', id],
@@ -74,6 +77,10 @@ export function InquiryDetail({ id }: { id: string }) {
const p = (data?.payload ?? {}) as Record<string, unknown>; const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : ''); 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[] = [ const tabs: DetailTab[] = [
{ {
@@ -88,7 +95,9 @@ export function InquiryDetail({ id }: { id: string }) {
<Row label="Place of residence" value={str('address')} /> <Row label="Place of residence" value={str('address')} />
) : null} ) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null} {data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{data?.kind === 'contact_form' ? <Row label="Comments" value={str('comments')} /> : 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="Type" value={data ? KIND_LABELS[data.kind] : ''} />
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} /> <Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
<Row label="Source IP" value={data?.sourceIp} /> <Row label="Source IP" value={data?.sourceIp} />
@@ -107,7 +116,9 @@ export function InquiryDetail({ id }: { id: string }) {
label="Status" label="Status"
value={ value={
data ? ( data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge> <Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : ( ) : (
'' ''
) )
@@ -155,7 +166,7 @@ export function InquiryDetail({ id }: { id: string }) {
</pre> </pre>
), ),
}, },
]; ].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
return ( return (
<DetailLayout <DetailLayout
@@ -166,7 +177,9 @@ export function InquiryDetail({ id }: { id: string }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1> <h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
{data ? ( {data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge> <Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : null} ) : null}
</div> </div>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">

View File

@@ -11,14 +11,11 @@ import {
Trophy, Trophy,
XCircle, XCircle,
RefreshCcw, RefreshCcw,
Mail,
MessageSquarePlus, MessageSquarePlus,
Phone,
AlarmClock, AlarmClock,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab'; import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; 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 { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip'; import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { formatOutcome } from '@/lib/constants'; import { formatOutcome } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label'; import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
id: string; id: string;
clientId: string; clientId: string;
clientName: string | null; clientName: string | null;
/** Primary contact channels resolved from the linked client. The header /** Primary contact channels resolved from the linked client. The
* uses these to render Email / Call / WhatsApp buttons so the rep * Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
* doesn't have to navigate to the client page just to reach out. */ * for downstream reuse (e.g. proxy comms routing, CM-9). */
clientPrimaryEmail?: string | null; clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null; clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null; clientPrimaryPhoneE164?: string | null;
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [logContactOpen, setLogContactOpen] = useState(false); const [logContactOpen, setLogContactOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false); const [reminderOpen, setReminderOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.) // (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 isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome); const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!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({ const reopenMutation = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }), apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount} {interest.activeReminderCount}
</span> </span>
) : null} ) : null}
<PermissionGate resource="interests" action="edit"> {assignmentEnabled ? (
<AssignedToChip <PermissionGate resource="interests" action="edit">
interestId={interest.id} <AssignedToChip
currentAssignedTo={interest.assignedTo ?? null} interestId={interest.id}
currentAssignedToName={interest.assignedToName ?? null} currentAssignedTo={interest.assignedTo ?? null}
/> currentAssignedToName={interest.assignedToName ?? null}
</PermissionGate> />
</PermissionGate>
) : null}
<MultiEoiChip interestId={interest.id} /> <MultiEoiChip interestId={interest.id} />
<DealPulseChip <DealPulseChip
interest={{ interest={{
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div> </div>
)} )}
{/* Contact deep-links - let the rep email / call / WhatsApp the {/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
client without leaving the interest workspace. Resolved from Client-page link + Log-contact action stay - the rep can still
the linked client's primary contact channels (server-side jump to the client and record outreach without leaving here. */}
fetch in getInterestById). */} <div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientPrimaryEmail || {interest.clientId ? (
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}
<Button <Button
asChild
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5" 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 /> <Link
Log contact // 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> </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> </div>
{/* Top-right actions. Won/Lost are sales-critical and read as text {/* Top-right actions. Won/Lost are sales-critical and read as text

View File

@@ -19,6 +19,7 @@ import {
AccordionTrigger, AccordionTrigger,
} from '@/components/ui/accordion'; } from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history'; import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor'; import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
@@ -848,7 +849,18 @@ function OverviewTab({
deposit_paid: 'deposit', deposit_paid: 'deposit',
contract: 'contract', 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 stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
const phaseFor = (k: (typeof order)[number]): Phase => { const phaseFor = (k: (typeof order)[number]): Phase => {
// Stage owns this milestone → always current, never collapsed. // Stage owns this milestone → always current, never collapsed.
@@ -1122,6 +1134,9 @@ function OverviewTab({
archivedAt={null} 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 {/* Qualification checklist - surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */} of 'enquiry'. Hidden when the port has no enabled criteria. */}

View File

@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
addedBy: string | null; addedBy: string | null;
addedAt: string; addedAt: string;
notes: string | null; notes: string | null;
priceOverride: string | null;
priceOverrideCurrency: string | null;
mooringNumber: string | null; mooringNumber: string | null;
area: string | null; area: string | null;
status: string; 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 ────────────────────────────────────────────────────────── // ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps { interface BypassDialogProps {
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
}: RowProps) { }: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false); const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = 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 dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed'; 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 ( return (
<div <div
className={cn( className={cn(
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
</div> </div>
</TooltipProvider> </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 ? ( {showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description // Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't // on the left, action button inline with the description so it doesn't

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'; import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePermissions } from '@/hooks/use-permissions';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar'; import { Topbar } from '@/components/layout/topbar';
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker'; import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
@@ -112,6 +114,30 @@ export function AppShell({
const currentPortId = useUIStore((s) => s.currentPortId); const currentPortId = useUIStore((s) => s.currentPortId);
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null; const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
// Residential lockdown: a residential-only user (residential access, no
// marina `clients.view`) must never see marina pages — including the marina
// dashboard. The API already 403s their data; this guard blocks the *routes*,
// redirecting any non-residential path to their residential home. Personal
// surfaces (settings, inbox) stay reachable.
const pathname = usePathname();
const router = useRouter();
const { can } = usePermissions();
const residentialOnly =
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
useEffect(() => {
if (!residentialOnly || !pathname) return;
const [portSeg, ...rest] = pathname.split('/').filter(Boolean);
const sub = rest.join('/');
const allowed =
sub === '' ||
sub.startsWith('residential') ||
sub.startsWith('settings') ||
sub.startsWith('inbox');
if (!allowed && portSeg) {
router.replace(`/${portSeg}/residential/clients`);
}
}, [residentialOnly, pathname, router]);
useEffect(() => { useEffect(() => {
const mqMobile = window.matchMedia(MOBILE_QUERY); const mqMobile = window.matchMedia(MOBILE_QUERY);
const mqTablet = window.matchMedia(TABLET_QUERY); const mqTablet = window.matchMedia(TABLET_QUERY);

View File

@@ -2,9 +2,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react'; import { Anchor, ClipboardList, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePermissions } from '@/hooks/use-permissions';
type TabSpec = { type TabSpec = {
label: string; label: string;
@@ -12,16 +13,21 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/ segment: string; // route segment after /[portSlug]/
}; };
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More. // Marina users: Dashboard, Clients | Berths. Search center, More right.
// Search occupies the center slot. Documents demoted to the MoreSheet - const MARINA_TABS_LEFT: TabSpec[] = [
// reps reach docs less often than berths during a walking inventory check,
// and pinned-to-client documents are accessed via the client detail anyway.
const TABS_LEFT: TabSpec[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
{ label: 'Clients', icon: Users, segment: 'clients' }, { label: 'Clients', icon: Users, segment: 'clients' },
]; ];
const MARINA_TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
const TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }]; // Residential-only users (e.g. residential partners) never have marina access,
// so the bottom tabs mirror their residential-only sidebar instead of showing
// Clients/Berths they 403 on (matches the AppShell route lockdown).
const RESIDENTIAL_TABS_LEFT: TabSpec[] = [
{ label: 'Clients', icon: Users, segment: 'residential/clients' },
{ label: 'Interests', icon: ClipboardList, segment: 'residential/interests' },
];
const RESIDENTIAL_TABS_RIGHT: TabSpec[] = [];
interface MobileBottomTabsProps { interface MobileBottomTabsProps {
onMoreClick: () => void; onMoreClick: () => void;
@@ -31,6 +37,11 @@ interface MobileBottomTabsProps {
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) { export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
const pathname = usePathname(); const pathname = usePathname();
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
const { can, isSuperAdmin } = usePermissions();
const residentialOnly =
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
const tabsLeft = residentialOnly ? RESIDENTIAL_TABS_LEFT : MARINA_TABS_LEFT;
const tabsRight = residentialOnly ? RESIDENTIAL_TABS_RIGHT : MARINA_TABS_RIGHT;
function isActive(segment: string): boolean { function isActive(segment: string): boolean {
return pathname.startsWith(`/${portSlug}/${segment}`); return pathname.startsWith(`/${portSlug}/${segment}`);
@@ -46,7 +57,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
'flex items-end', 'flex items-end',
)} )}
> >
{TABS_LEFT.map((tab) => ( {tabsLeft.map((tab) => (
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} /> <NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))} ))}
@@ -60,7 +71,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
<span className="relative font-medium">Search</span> <span className="relative font-medium">Search</span>
</button> </button>
{TABS_RIGHT.map((tab) => ( {tabsRight.map((tab) => (
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} /> <NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))} ))}

View File

@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
/** /**
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium: * Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
* deep-navy gradient surface with white type, the brand "PN" mark on the * deep-navy gradient surface with white type, a back arrow on the left when
* left when there's no back affordance, and a soft glow shadow underneath * there's a back affordance (otherwise a balancing spacer), and a soft glow
* for depth instead of a hard divider line. * shadow underneath for depth instead of a hard divider line.
* *
* Slots: title (auto-truncating), back arrow, primary action - all driven by * Slots: title (auto-truncating), back arrow, primary action - all driven by
* `useMobileChrome()` from the active page. When no page has set a title the * `useMobileChrome()` from the active page. When no page has set a title the
@@ -47,17 +47,6 @@ export function MobileTopbar() {
portTitle || portTitle ||
'CRM'; '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 ( return (
<header <header
className={cn( className={cn(
@@ -71,15 +60,10 @@ export function MobileTopbar() {
{backTarget ? ( {backTarget ? (
<BackButton variant="mobile" /> <BackButton variant="mobile" />
) : ( ) : (
<div // No back affordance on top-level pages. Render an empty spacer the
aria-label={portTitle || 'Home'} // same width as the right-hand action slot so the centered title
className={cn( // stays optically centered (the brand "PN" mark was removed here).
'size-9 shrink-0 rounded-lg flex items-center justify-center', <div className="size-11 shrink-0" aria-hidden />
'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>
)} )}
<h1 <h1

View File

@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
UsersRound,
Bookmark, Bookmark,
Anchor, Anchor,
KeyRound, KeyRound,
@@ -113,6 +114,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
items: [ items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard }, { href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users }, { 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}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 }, { href: `${base}/companies`, label: 'Companies', icon: Building2 },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark }, { href: `${base}/interests`, label: 'Interests', icon: Bookmark },

View File

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

View File

@@ -320,9 +320,11 @@ interface ScanShellProps {
* imagery. */ * imagery. */
logoUrl?: string | null; logoUrl?: string | null;
portName?: 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 router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug); const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
if (imagePreview) URL.revokeObjectURL(imagePreview); if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file)); setImagePreview(URL.createObjectURL(file));
setCurrentFile(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' }); setState({ kind: 'processing', engine: 'tesseract' });
// Always run Tesseract first - it's free, on-device, and gives us a // 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

@@ -102,9 +102,11 @@ export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
{/* Owner subtitle */} {/* Owner subtitle. `flex min-w-0` (not inline-flex) so a long owner
name truncates within the card instead of overflowing ~11px on
the narrowest mobile widths (R2). */}
{yacht.currentOwnerName ? ( {yacht.currentOwnerName ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground"> <p className="mt-0.5 flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden /> <OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{yacht.currentOwnerName}</span> <span className="truncate">{yacht.currentOwnerName}</span>
</p> </p>

View File

@@ -12,6 +12,7 @@ import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialo
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history'; import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { ProxyCard } from '@/components/shared/proxy-card';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list'; import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
@@ -176,6 +177,10 @@ function OverviewTab({
return ( return (
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}> <FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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 */} {/* Identity */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3> <h3 className="text-sm font-medium mb-2">Identity</h3>

View File

@@ -27,8 +27,11 @@ export interface OnboardingStatusPayload {
* and the admin checklist summary. Cached for 60s so all three surfaces * and the admin checklist summary. Cached for 60s so all three surfaces
* share a single fetch on first paint. * share a single fetch on first paint.
* *
* Pass `enabled=false` to skip the network call (e.g. when the current * Defaults to OFF: the endpoint is admin-only (admin.manage_settings), so
* user isn't a super_admin and the surface won't render anyway). * callers must opt in with `enabled: true` once they've confirmed the user is
* a super_admin. This prevents a transient 403 (e.g. a stale `isSuperAdmin`
* during permission hydration) from firing the privileged request for
* non-admins.
*/ */
export function useOnboardingStatus(opts: { enabled?: boolean } = {}) { export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
return useQuery<OnboardingStatusPayload>({ return useQuery<OnboardingStatusPayload>({
@@ -38,7 +41,7 @@ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
(r) => r.data, (r) => r.data,
), ),
staleTime: 60_000, staleTime: 60_000,
enabled: opts.enabled ?? true, enabled: opts.enabled === true,
retry: false, retry: false,
}); });
} }

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

@@ -0,0 +1,62 @@
import type { BrandingShell } from '@/lib/email/shell';
import { consumePendingWelcome } from './pending-welcome';
interface AuthBranding {
appName?: string | null;
logoUrl?: string | null;
backgroundUrl?: string | null;
}
/**
* Builds the email body for better-auth's `sendResetPassword` callback,
* choosing between two framings of the same set-password link:
*
* - a unique **welcome** email when the recipient was flagged by the admin
* "create user" flow (a brand-new user has nothing to reset), or
* - the standard **password-reset** email for genuine self-service resets.
*
* Pure aside from rendering — no SMTP, no DB — so the welcome-vs-reset routing
* is directly unit-testable.
*/
export async function buildAccountPasswordEmail(opts: {
email: string;
name?: string | null;
url: string;
appName: string;
authBranding: AuthBranding | null;
}): Promise<{ subject: string; html: string; text: string }> {
const emailBranding: BrandingShell | null = opts.authBranding
? {
logoUrl: opts.authBranding.logoUrl ?? null,
backgroundUrl: opts.authBranding.backgroundUrl ?? null,
primaryColor: null,
emailHeaderHtml: null,
emailFooterHtml: null,
}
: null;
if (consumePendingWelcome(opts.email)) {
const { crmWelcomeEmail } = await import('@/lib/email/templates/crm-welcome');
return crmWelcomeEmail(
{ link: opts.url, recipientName: opts.name ?? undefined, appName: opts.appName },
{ branding: emailBranding },
);
}
const { renderShell, safeUrl } = await import('@/lib/email/shell');
const subject = `Reset your ${opts.appName} password`;
const safeName = (opts.name || 'there').replace(/[<>&]/g, '');
const body = `
<p style="margin-bottom:16px;">Hi ${safeName},</p>
<p style="margin-bottom:16px;">You requested a password reset for your ${opts.appName} account.</p>
<p style="margin-bottom:16px;">
<a href="${safeUrl(opts.url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
- the link expires in 1 hour.
</p>
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
`;
const html = renderShell({ title: subject, body, branding: emailBranding });
const text = `Reset your password: ${opts.url}`;
return { subject, html, text };
}

View File

@@ -77,42 +77,28 @@ function buildAuth() {
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it // through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
// in dev. // in dev.
sendResetPassword: async ({ user, url }) => { sendResetPassword: async ({ user, url }) => {
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] = const [{ sendEmail }, { resolveAuthShellBranding }, { buildAccountPasswordEmail }] =
await Promise.all([ await Promise.all([
import('@/lib/email'), import('@/lib/email'),
import('@/lib/email/shell'),
import('@/lib/email/auth-shell-branding'), import('@/lib/email/auth-shell-branding'),
import('@/lib/auth/account-setup-email'),
]); ]);
const branding = await resolveAuthShellBranding(); const branding = await resolveAuthShellBranding();
const appName = branding?.appName ?? 'CRM'; const appName = branding?.appName ?? 'CRM';
const subject = `Reset your ${appName} password`;
const safeName = (user.name || 'there').replace(/[<>&]/g, '');
const body = `
<p style="margin-bottom:16px;">Hi ${safeName},</p>
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
<p style="margin-bottom:16px;">
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
- the link expires in 1 hour.
</p>
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
`;
const html = renderShell({ // Admin-created users ride the same reset-token machinery but should
title: subject, // receive a welcome email, not a "you requested a reset" one — the
body, // create-user service marks them just before triggering this. The
branding: branding // builder picks welcome-vs-reset and renders accordingly.
? { const mail = await buildAccountPasswordEmail({
logoUrl: branding.logoUrl, email: user.email,
backgroundUrl: branding.backgroundUrl, name: user.name,
primaryColor: null, url,
emailHeaderHtml: null, appName,
emailFooterHtml: null, authBranding: branding,
}
: null,
}); });
const text = `Reset your password: ${url}`; await sendEmail(user.email, mail.subject, mail.html, undefined, mail.text);
await sendEmail(user.email, subject, html, undefined, text);
}, },
}, },

View File

@@ -0,0 +1,29 @@
/**
* Bridges the admin "create user" flow to better-auth's single
* `sendResetPassword` callback.
*
* A brand-new admin-created user is provisioned with a throwaway password and
* then sent a set-password link via better-auth's password-reset machinery — but
* the *email* should read as a welcome, not a "you requested a reset". better-auth
* exposes only one `sendResetPassword` callback (no per-call context), so the
* create-user service marks the recipient here immediately before triggering the
* reset; the callback consumes the mark and renders the welcome email instead.
*
* Mark and consume happen in the same process within a single synchronous
* request flow (`createUser` awaits `requestPasswordReset`, which awaits the
* callback), so this module-level set is safe — it is never read across requests.
* Keyed by lowercased email; distinct recipients never collide.
*/
const pendingWelcome = new Set<string>();
export function markPendingWelcome(email: string): void {
pendingWelcome.add(email.toLowerCase());
}
/** Returns true (and clears the mark) when this email was flagged as a welcome. */
export function consumePendingWelcome(email: string): boolean {
const key = email.toLowerCase();
const had = pendingWelcome.has(key);
pendingWelcome.delete(key);
return had;
}

View File

@@ -21,7 +21,7 @@ export type PermissionAction<R extends PermissionResource> = keyof RolePermissio
* (audit finding L23). * (audit finding L23).
*/ */
export const PERMISSION_CATALOG = { export const PERMISSION_CATALOG = {
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'], clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'],
interests: [ interests: [
'view', 'view',
'create', 'create',
@@ -70,6 +70,7 @@ export const PERMISSION_CATALOG = {
residential_clients: ['view', 'create', 'edit', 'delete'], residential_clients: ['view', 'create', 'edit', 'delete'],
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'], residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
inquiries: ['view', 'manage'], inquiries: ['view', 'manage'],
client_groups: ['view', 'manage'],
} as const satisfies { } as const satisfies {
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>; [R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
}; };

View File

@@ -332,13 +332,27 @@ export function formatSource(source: string | null | undefined): string | null {
export const ROLE_LABELS: Record<string, string> = { export const ROLE_LABELS: Record<string, string> = {
super_admin: 'Super Admin', super_admin: 'Super Admin',
director: 'Director', director: 'Director',
sales_manager: 'Sales Manager', // Single sales role for the deployment — `sales_manager` is the full-access
// sales map, surfaced to users simply as "Sales". `sales_agent` is retired
// from selection (see NON_ASSIGNABLE_ROLE_NAMES) but keeps its label for any
// legacy assignment still rendered.
sales_manager: 'Sales',
sales_agent: 'Sales Agent', sales_agent: 'Sales Agent',
finance_manager: 'Finance Manager', finance_manager: 'Finance Manager',
viewer: 'Viewer', viewer: 'Viewer',
residential_partner: 'Residential Partner', residential_partner: 'Residential Partner',
}; };
/**
* System roles that must not appear as choices when assigning a role to a
* user. `super_admin` is platform-owner-only (minted via the invitation
* flow's isSuperAdmin gate, never the role dropdown); `sales_agent` is
* superseded by the single "Sales" role. The create/edit user form still
* surfaces a user's *current* role even if it's listed here, so existing
* assignments stay editable.
*/
export const NON_ASSIGNABLE_ROLE_NAMES = new Set(['super_admin', 'sales_agent']);
/** Returns the human label for a stored role name. Falls back to a /** Returns the human label for a stored role name. Falls back to a
* Title-Case rendering for legacy / custom roles. */ * Title-Case rendering for legacy / custom roles. */
export function formatRole(role: string | null | undefined): string { export function formatRole(role: string | null | undefined): string {

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,14 @@
-- Director becomes a senior-title twin of the single "Sales" role: identical
-- capabilities, no admin/settings access (admin stays Super-Admin-only).
--
-- The bootstrap seed inserts system roles with ON CONFLICT DO NOTHING, so
-- editing DIRECTOR_PERMISSIONS in seed-permissions.ts only affects fresh seeds.
-- Existing deployments need this data update to bring the stored `director`
-- row in line. Idempotent: re-running simply re-copies the sales map.
UPDATE roles AS d
SET permissions = sm.permissions,
description = 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
updated_at = now()
FROM roles AS sm
WHERE d.name = 'director'
AND sm.name = 'sales_manager';

View File

@@ -0,0 +1,23 @@
-- New toggleable permission: clients.gdpr_export (trigger + download a client's
-- GDPR data export). Previously the export routes were gated by
-- admin.manage_settings, which sales roles lack. This grants it to the
-- sales-capable system roles by default and makes it an explicit (off) toggle
-- everywhere else, so admins can withhold it per-user (which hides the button).
--
-- Existing role rows store permissions as jsonb, so editing the seed/role maps
-- alone won't reach them — this backfills the key. Idempotent.
-- Sales-capable system roles get it ON by default.
UPDATE roles
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'true'::jsonb, true),
updated_at = now()
WHERE name IN ('super_admin', 'director', 'sales_manager', 'sales_agent')
AND permissions ? 'clients';
-- Every other role that has a clients block but not the key yet defaults to OFF,
-- so the permission surfaces as an explicit toggle in the matrix.
UPDATE roles
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'false'::jsonb, true),
updated_at = now()
WHERE permissions ? 'clients'
AND NOT (permissions -> 'clients' ? 'gdpr_export');

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 // Clients
export * from './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 // Companies
export * from './companies'; export * from './companies';

View File

@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
addedBy: text('added_by'), addedBy: text('added_by'),
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(), addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
notes: text('notes'), 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) => [ (table) => [
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId), 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

@@ -11,6 +11,9 @@ export type RolePermissions = {
delete: boolean; delete: boolean;
merge: boolean; merge: boolean;
export: boolean; export: boolean;
/** Trigger + download a GDPR data export for a client. Toggleable so it
* can be hidden from a user (e.g. a sales rep) when withheld. */
gdpr_export: boolean;
}; };
interests: { interests: {
view: boolean; view: boolean;
@@ -166,6 +169,10 @@ export type RolePermissions = {
view: boolean; view: boolean;
manage: boolean; manage: boolean;
}; };
client_groups: {
view: boolean;
manage: boolean;
};
}; };
/** /**

View File

@@ -153,7 +153,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: 'director', name: 'director',
description: 'Operational admin within assigned port(s). Can manage users and settings.', description: 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
permissions: DIRECTOR_PERMISSIONS, permissions: DIRECTOR_PERMISSIONS,
isGlobal: true, isGlobal: true,
isSystem: true, isSystem: true,

View File

@@ -12,7 +12,15 @@
import type { RolePermissions } from './schema/users'; import type { RolePermissions } from './schema/users';
export const ALL_PERMISSIONS: RolePermissions = { export const ALL_PERMISSIONS: RolePermissions = {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: true,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -92,93 +100,27 @@ export const ALL_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: true, manage: true,
}, },
}; client_groups: {
export const DIRECTOR_PERMISSIONS: RolePermissions = {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
override_stage: true,
generate_eoi: true,
export: true,
},
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: true,
manage_folders: true,
},
expenses: {
view: true,
create: true,
edit: true,
delete: true,
export: true,
scan_receipt: true,
},
invoices: {
view: true,
create: true,
edit: true,
delete: true,
send: true,
record_payment: true,
export: true,
},
payments: { view: true, record: true, delete: true },
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
view_own: true,
view_all: true,
create: true,
edit_own: true,
edit_all: true,
assign_others: true,
},
calendar: { connect: true, view_events: true },
reports: { view_dashboard: true, view_analytics: true, export: true },
document_templates: { view: true, generate: true, manage: true },
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
companies: { view: true, create: true, edit: true, delete: true },
memberships: { view: true, manage: true },
tenancies: { view: true, manage: true, cancel: true },
admin: {
manage_users: true,
view_audit_log: true,
manage_settings: true,
manage_webhooks: true,
manage_reports: true,
manage_custom_fields: true,
manage_forms: true,
manage_tags: true,
system_backup: false,
permanently_delete_clients: false,
},
residential_clients: { view: true, create: true, edit: true, delete: true },
residential_interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
},
inquiries: {
view: true, view: true,
manage: true, manage: true,
}, },
}; };
// DIRECTOR_PERMISSIONS is defined just below SALES_MANAGER_PERMISSIONS — it is a
// senior-title twin of the single "Sales" role with identical capabilities and
// no admin/settings access (reserved for Super Admin). Kept there so it can
// reference the sales map directly.
export const SALES_MANAGER_PERMISSIONS: RolePermissions = { export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: false,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -258,10 +200,27 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: true, manage: true,
}, },
client_groups: {
view: true,
manage: true,
},
}; };
// Director is now a senior-title twin of the single "Sales" role: identical
// capabilities, no admin/settings access (admin stays Super-Admin-only). It
// remains a distinct, selectable role purely so the title can differ.
export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS;
export const SALES_AGENT_PERMISSIONS: RolePermissions = { export const SALES_AGENT_PERMISSIONS: RolePermissions = {
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: false,
merge: false,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -341,10 +300,22 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: true, manage: true,
}, },
client_groups: {
view: true,
manage: true,
},
}; };
export const VIEWER_PERMISSIONS: RolePermissions = { export const VIEWER_PERMISSIONS: RolePermissions = {
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, clients: {
view: true,
create: false,
edit: false,
delete: false,
merge: false,
export: false,
gdpr_export: false,
},
interests: { interests: {
view: true, view: true,
create: false, create: false,
@@ -430,13 +401,25 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: false, manage: false,
}, },
client_groups: {
view: true,
manage: false,
},
}; };
// Residential Partner - for an outside party who handles residential // Residential Partner - for an outside party who handles residential
// inquiries on the marina's behalf. Sees only the residential pages and // inquiries on the marina's behalf. Sees only the residential pages and
// nothing else; can't see marina clients, yachts, berths, EOIs, etc. // nothing else; can't see marina clients, yachts, berths, EOIs, etc.
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = { export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false }, clients: {
view: false,
create: false,
edit: false,
delete: false,
merge: false,
export: false,
gdpr_export: false,
},
interests: { interests: {
view: false, view: false,
create: false, create: false,
@@ -522,4 +505,8 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
view: false, view: false,
manage: false, manage: false,
}, },
client_groups: {
view: false,
manage: false,
},
}; };

View File

@@ -0,0 +1,125 @@
import { Button, Hr, Link, Text, render } from '@react-email/components';
import * as React from 'react';
import {
brandingPrimaryColor,
emailStyle,
renderShell,
safeUrl,
type BrandingShell,
} from '@/lib/email/shell';
interface WelcomeData {
/** The set-password link (better-auth reset URL landing on /set-password). */
link: string;
recipientName?: string;
/** Human label of the role the account was created with (e.g. "Sales"). */
roleName?: string;
/** Full product / app name as branded — e.g. "Port Nimara CRM". Falls back
* to "Port Nimara CRM". Used verbatim (no " CRM" is appended). */
appName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
subject?: string | null;
}
function WelcomeBody({
appName,
link,
recipientName,
roleName,
accent,
}: {
appName: string;
link: string;
recipientName?: string;
roleName?: string;
accent: string;
}) {
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
const roleClause = roleName ? ` with the ${roleName} role` : '';
return (
<>
<Text style={emailStyle.title(accent)}>Welcome to {appName}</Text>
<Text style={emailStyle.paragraph}>{greeting}</Text>
<Text style={emailStyle.paragraph}>
An account has been created for you in {appName}
{roleClause}. To get started, set your password using the button below then sign in with
this email address.
</Text>
<div style={emailStyle.buttonRow}>
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
Set your password
</Button>
</div>
<Text style={emailStyle.signoff}>
See you inside,
<br />
<strong>The {appName} Team</strong>
</Text>
<Hr style={emailStyle.divider} />
<Text style={emailStyle.finePrint}>
For security this link will expire after a short while. If it&apos;s no longer valid, ask
your administrator to send you a new one.
<br />
<br />
If the button doesn&apos;t work, paste this link into your browser:
<br />
<Link
href={safeUrl(link)}
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
>
{link}
</Link>
</Text>
</>
);
}
/**
* Welcome / set-your-password email for an admin-created CRM user. Distinct from
* the self-service "Reset your password" email — a brand-new user has nothing to
* reset. Wraps the same better-auth reset link that the /set-password page
* consumes, but in onboarding framing.
*/
export async function crmWelcomeEmail(
data: WelcomeData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const appName = data.appName ?? 'Port Nimara CRM';
const subject = overrides?.subject?.trim()
? overrides.subject
: `Welcome to ${appName} — set your password`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<WelcomeBody
appName={appName}
link={data.link}
recipientName={data.recipientName}
roleName={data.roleName}
accent={accent}
/>,
{ pretty: false },
);
const text = [
`Welcome to ${appName}`,
'',
`An account has been created for you${data.roleName ? ` with the ${data.roleName} role` : ''}.`,
`Set your password to get started: ${data.link}`,
'',
`For security this link will expire after a short while — if it's no longer valid, ask your administrator to send a new one.`,
'',
`See you inside,`,
`The ${appName} Team`,
].join('\n');
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -357,10 +357,14 @@ export function extractFromOcrText(rawText: string): {
}; };
} }
// Purchase price: "PURCHASE PRICE:\nFEE SIMPLE OR STRATA LOT\n3,880,800 USD" // Purchase price: the single clean comma-grouped currency figure. The rates
const priceMatch = text.match(/PURCHASE\s+PRICE[\s\S]{0,80}?([0-9][0-9,]+)\s*USD/i); // on the same sheet are letter-spaced (garble) and below the floor, so they
if (priceMatch) { // never collide with the main price. See extractPurchasePrice().
out.price = { value: Number(priceMatch[1]!.replace(/,/g, '')), confidence: 0.7, engine: 'ocr' }; 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" // 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; 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". */ /** Parse a human date like "September 15 2025" → "2025-09-15". */
export function parseHumanDate(raw: string): string | null { export function parseHumanDate(raw: string): string | null {
const cleaned = raw.replace(/(\d+)(st|nd|rd|th)/i, '$1').trim(); 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 today = now.toISOString().slice(0, 10);
const year = String(now.getFullYear()); 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 { return {
client: { client: {
id: client.id, id: client.id,
@@ -337,8 +359,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
mooringNumber: berth.mooringNumber, mooringNumber: berth.mooringNumber,
area: berth.area, area: berth.area,
lengthFt: berth.lengthFt, lengthFt: berth.lengthFt,
price: berth.price, price: resolvedBerthPrice,
priceCurrency: berth.priceCurrency, priceCurrency: resolvedBerthCurrency,
tenureType: berth.tenureType, tenureType: berth.tenureType,
} }
: null, : null,

View File

@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
addedBy: interestBerths.addedBy, addedBy: interestBerths.addedBy,
addedAt: interestBerths.addedAt, addedAt: interestBerths.addedAt,
notes: interestBerths.notes, notes: interestBerths.notes,
priceOverride: interestBerths.priceOverride,
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
mooringNumber: berths.mooringNumber, mooringNumber: berths.mooringNumber,
area: berths.area, area: berths.area,
status: berths.status, status: berths.status,
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
.delete(interestBerths) .delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))); .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 // every new lead. Falls back to null (Unassigned) when none of
// the above resolve. // the above resolve.
let resolvedAssignedTo = interestData.assignedTo ?? null; 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 defaultOwner = await getSetting('default_new_interest_owner', portId);
const v = defaultOwner?.value as { userId?: string } | null | undefined; const v = defaultOwner?.value as { userId?: string } | null | undefined;
if (v?.userId) { 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. * provider is never called even if a key is configured.
*/ */
aiEnabled: boolean; 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. */ /** Internal shape including the decrypted key - server-side only. */
@@ -52,6 +58,7 @@ interface StoredOcrConfig {
apiKeyEncrypted: string | null; apiKeyEncrypted: string | null;
useGlobal: boolean; useGlobal: boolean;
aiEnabled?: boolean; aiEnabled?: boolean;
manualEntry?: boolean;
} }
const KEY = 'ocr.config'; const KEY = 'ocr.config';
@@ -106,12 +113,14 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: false, hasApiKey: false,
useGlobal: portRow?.useGlobal === true, useGlobal: portRow?.useGlobal === true,
aiEnabled: false, aiEnabled: false,
manualEntry: portRow?.manualEntry === true,
source: 'none', source: 'none',
}; };
} }
// The aiEnabled flag is per-port: even if the port falls back to a global // The aiEnabled / manualEntry flags are per-port: even if the port falls back
// key, the port admin still has to flip the switch on this port. // to a global key, the port admin still has to flip these on this port.
const aiEnabled = portRow?.aiEnabled === true; const aiEnabled = portRow?.aiEnabled === true;
const manualEntry = portRow?.manualEntry === true;
return { return {
provider: sourceRow.provider, provider: sourceRow.provider,
model: sourceRow.model, model: sourceRow.model,
@@ -119,6 +128,7 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: Boolean(sourceRow.apiKeyEncrypted), hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
useGlobal: portRow?.useGlobal === true, useGlobal: portRow?.useGlobal === true,
aiEnabled, aiEnabled,
manualEntry,
source: useGlobal ? 'global' : 'port', source: useGlobal ? 'global' : 'port',
}; };
} }
@@ -133,6 +143,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: false, hasApiKey: false,
useGlobal: false, useGlobal: false,
aiEnabled: false, aiEnabled: false,
manualEntry: false,
}; };
} }
return { return {
@@ -141,6 +152,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: Boolean(row.apiKeyEncrypted), hasApiKey: Boolean(row.apiKeyEncrypted),
useGlobal: row.useGlobal, useGlobal: row.useGlobal,
aiEnabled: row.aiEnabled === true, aiEnabled: row.aiEnabled === true,
manualEntry: row.manualEntry === true,
}; };
} }
@@ -154,6 +166,8 @@ export interface SaveOcrConfigInput {
useGlobal?: boolean; useGlobal?: boolean;
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */ /** Per-port toggle: enable AI receipt parsing. Defaults to false. */
aiEnabled?: boolean; aiEnabled?: boolean;
/** Per-port toggle: manual entry (skip all parsing). Defaults to false. */
manualEntry?: boolean;
} }
export async function saveOcrConfig( 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 // 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). // caller didn't pass one (so toggling provider/model doesn't re-disable AI).
const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false); 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( await writeRow(
portId, portId,
{ {
@@ -179,6 +196,7 @@ export async function saveOcrConfig(
apiKeyEncrypted, apiKeyEncrypted,
useGlobal: portId === null ? false : Boolean(input.useGlobal), useGlobal: portId === null ? false : Boolean(input.useGlobal),
aiEnabled, aiEnabled,
manualEntry,
}, },
userId, 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

@@ -153,11 +153,22 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
}); });
if (!role) throw new ValidationError('Invalid role ID'); if (!role) throw new ValidationError('Invalid role ID');
// Two onboarding modes:
// - setup-email (default when no password is supplied): provision the
// account with a throwaway random password the admin never sees, then
// email the user a link to set their own. The /set-password page
// consumes the better-auth reset token.
// - manual: the admin typed a password inline; use it verbatim.
const useSetupEmail = data.sendSetupEmail ?? !data.password;
const initialPassword = useSetupEmail
? `${crypto.randomUUID()}${crypto.randomUUID()}`
: data.password!;
// Create Better Auth user // Create Better Auth user
const authResult = await auth.api.signUpEmail({ const authResult = await auth.api.signUpEmail({
body: { body: {
email: data.email.toLowerCase(), email: data.email.toLowerCase(),
password: data.password, password: initialPassword,
name: data.name, name: data.name,
}, },
}); });
@@ -199,6 +210,32 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
severity: 'info', severity: 'info',
}); });
// Setup-email mode: dispatch a "set your password" link. Reuses better-auth's
// password-reset token, which the existing /set-password page consumes. Done
// after the role assignment so the user is fully provisioned the moment they
// set their password. A send failure must not roll back the created account —
// the admin can resend, or fall back to setting a password manually.
if (useSetupEmail) {
// Flag this recipient so the shared sendResetPassword callback renders the
// welcome email rather than the "you requested a reset" copy.
const { markPendingWelcome, consumePendingWelcome } =
await import('@/lib/auth/pending-welcome');
markPendingWelcome(data.email);
try {
await auth.api.requestPasswordReset({
body: { email: data.email.toLowerCase(), redirectTo: '/set-password' },
});
} catch (err) {
// Clear the flag if the reset never dispatched, so a later self-service
// reset for this address isn't mistaken for a welcome.
consumePendingWelcome(data.email);
logger.error(
{ err, userId: newUserId },
'createUser: failed to send welcome / set-password email (account was still created)',
);
}
}
return getUser(newUserId, portId); return getUser(newUserId, portId);
} }

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

@@ -1,16 +1,28 @@
import { z } from 'zod'; import { z } from 'zod';
export const createUserSchema = z.object({ export const createUserSchema = z
email: z.string().email(), .object({
name: z.string().min(1).max(200), email: z.string().email(),
password: z.string().min(12), name: z.string().min(1).max(200),
displayName: z.string().min(1).max(200), /** Optional at creation: omit it to email the user a set-password link
firstName: z.string().min(1).max(200).nullable().optional(), * instead (see `sendSetupEmail`). Required when `sendSetupEmail` is
lastName: z.string().min(1).max(200).nullable().optional(), * explicitly false. */
phone: z.string().optional(), password: z.string().min(12).optional(),
roleId: z.string().uuid(), /** When true (the default when no password is supplied), the account is
residentialAccess: z.boolean().optional().default(false), * provisioned and the new user is emailed a link to set their own
}); * password. When false, `password` must be supplied inline. */
sendSetupEmail: z.boolean().optional(),
displayName: z.string().min(1).max(200),
firstName: z.string().min(1).max(200).nullable().optional(),
lastName: z.string().min(1).max(200).nullable().optional(),
phone: z.string().optional(),
roleId: z.string().uuid(),
residentialAccess: z.boolean().optional().default(false),
})
.refine((d) => d.sendSetupEmail !== false || (d.password?.length ?? 0) >= 12, {
message: 'A password (min 12 characters) is required when not sending a setup email',
path: ['password'],
});
export type CreateUserInput = z.infer<typeof createUserSchema>; export type CreateUserInput = z.infer<typeof createUserSchema>;

View File

@@ -0,0 +1,155 @@
/**
* Responsive overflow / cutoff sweep.
*
* Walks the key pages at desktop / tablet / mobile / small-mobile viewports and
* programmatically flags layout bugs the eye looks for on small screens:
* - horizontal overflow (document wider than the viewport → off-screen content,
* a horizontal scrollbar),
* - individual elements whose right edge runs past the viewport (clipped /
* off-screen buttons + text),
* - elements overflowing the BOTTOM of their own box (cut-off text).
* Captures a full-page screenshot per page/viewport for eyeball QC.
*
* Runs as `admin` (sees every page). Layout is role-independent, so one broad
* role surfaces the responsive issues; role-specific nav scoping is covered by
* role-access.spec.ts.
*/
import { test, expect } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
const PORT = 'port-nimara';
const OUT = join(process.cwd(), '.audit', 'responsive');
const ADMIN = { email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' };
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 820, height: 1180 },
{ name: 'mobile', width: 390, height: 844 },
{ name: 'small', width: 360, height: 740 },
] as const;
const PAGES = [
'dashboard',
'clients',
'interests',
'inquiries',
'berths',
'yachts',
'companies',
'reports',
'reports/financial',
'documents',
'expenses',
'inbox',
'settings',
'admin',
'admin/users',
];
test.describe('Responsive overflow sweep', () => {
test('admin — every key page at every viewport, flag overflow + cutoff', async ({ page }) => {
test.setTimeout(600_000);
mkdirSync(OUT, { recursive: true });
const res = await page.request.post('/api/auth/sign-in/email', {
data: { email: ADMIN.email, password: ADMIN.pw },
headers: { 'content-type': 'application/json' },
});
expect(res.ok()).toBeTruthy();
const findings: string[] = [];
for (const vp of VIEWPORTS) {
await page.setViewportSize({ width: vp.width, height: vp.height });
for (const p of PAGES) {
const url = `/${PORT}/${p}`;
const slug = p.replace(/\//g, '_');
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
} catch {
findings.push(`NAV-FAIL ${vp.name.padEnd(7)} ${p}`);
continue;
}
// let layout settle + data paint
await page.waitForTimeout(1800);
const report = await page.evaluate((vpWidth) => {
const docW = document.documentElement.scrollWidth;
const innerW = window.innerWidth;
const horizOverflow = docW - innerW;
// Elements whose right edge runs past the viewport by > 2px and are
// actually visible (have size, not display:none).
const offscreen: { tag: string; cls: string; right: number; text: string }[] = [];
const SVG_INTERNAL = new Set([
'svg',
'g',
'ellipse',
'path',
'circle',
'rect',
'line',
'polyline',
'polygon',
]);
const els = document.querySelectorAll('body *');
for (const el of els) {
const tag = el.tagName.toLowerCase();
// Skip SVG internals (icons, the react-grab dev overlay, chart guts)
// — not layout-cutoff signal.
if (SVG_INTERNAL.has(tag)) continue;
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > vpWidth + 2 && r.left < vpWidth) {
// Skip elements inside a horizontal-scroll container (data tables
// etc. scroll on purpose) — that's intended, not a clip.
let p: HTMLElement | null = el.parentElement;
let inScroll = false;
while (p) {
const ox = getComputedStyle(p).overflowX;
if (ox === 'auto' || ox === 'scroll') {
inScroll = true;
break;
}
p = p.parentElement;
}
if (inScroll) continue;
const cls = ((el as HTMLElement).className || '').toString().slice(0, 40);
const text = (el.textContent || '').trim().slice(0, 30);
offscreen.push({ tag, cls, right: Math.round(r.right), text });
}
}
// de-dupe by tag+text, cap
const seen = new Set<string>();
const uniq = offscreen
.filter((o) => {
const k = `${o.tag}:${o.text}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.slice(0, 6);
return { horizOverflow, docW, innerW, offscreen: uniq };
}, vp.width);
await page
.screenshot({ path: join(OUT, `admin-${vp.name}-${slug}.png`), fullPage: true })
.catch(() => {});
const flagged = report.horizOverflow > 3 || report.offscreen.length > 0;
const line = `${flagged ? 'OVERFLOW' : 'ok '} ${vp.name.padEnd(7)} ${p.padEnd(18)} hScroll=${report.horizOverflow}px doc=${report.docW}/${report.innerW}`;
console.log(line);
if (report.offscreen.length) {
for (const o of report.offscreen) {
console.log(` ↳ off-right ${o.tag} right=${o.right} "${o.text}" .${o.cls}`);
}
}
if (flagged) findings.push(line);
}
}
console.log(`\n=== OVERFLOW FINDINGS (${findings.length}) ===`);
for (const f of findings) console.log(f);
});
});

View File

@@ -0,0 +1,138 @@
/**
* Role × viewport access matrix.
*
* A LEAN, crash-safe alternative to running the full 162-test smoke suite
* (which OOM-crashes `next dev` locally). For each of the 5 core roles it:
* - logs in (UI),
* - probes a fixed set of API endpoints in the authenticated session and
* records the HTTP status (the read/permission matrix),
* - records which sidebar nav sections are visible,
* - screenshots the dashboard at desktop / tablet / mobile viewports.
*
* Few route compilations per run, so the dev server stays up. Users are
* pre-seeded (admin/director/sales/viewer/residential_partner); no global
* setup dependency.
*/
import { test, expect } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
const PORT = 'port-nimara';
const OUT = join(process.cwd(), '.audit', 'matrix');
const ROLES = [
{ key: 'super_admin', email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' },
{ key: 'director', email: 'director@portnimara.test', pw: 'DirectorUser12345!' },
{ key: 'sales', email: 'mpciaccio13@verizon.net', pw: 'SallySales12345!' },
{ key: 'viewer', email: 'viewer@portnimara.test', pw: 'ViewerUser12345!' },
{ key: 'residential_partner', email: 'respartner@portnimara.test', pw: 'ResPartner12345!' },
] as const;
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 820, height: 1180 },
{ name: 'mobile', width: 390, height: 844 },
] as const;
// GET probes — expected status varies by role; we just record what we get.
const PROBES: { label: string; path: string }[] = [
{ label: 'clients.view', path: '/api/v1/clients?limit=1' },
{ label: 'interests.view', path: '/api/v1/interests?limit=1' },
{ label: 'yachts.view', path: '/api/v1/yachts?limit=1' },
{ label: 'reports.financial', path: '/api/v1/reports/financial' },
{ label: 'alerts(interests.view)', path: '/api/v1/alerts?status=open' },
{ label: 'residential.clients', path: '/api/v1/residential/clients?limit=1' },
{ label: 'admin.users', path: '/api/v1/admin/users' },
{ label: 'admin.audit', path: '/api/v1/admin/audit?limit=1' },
{ label: 'admin.onboarding', path: '/api/v1/admin/onboarding/status' },
];
async function login(page: import('@playwright/test').Page, email: string, pw: string) {
// Authenticate via the API (better-auth sign-in) rather than the UI: the
// dev-mode login page hydrates slowly and a pre-hydration click submits the
// form as a native GET. page.request shares the cookie jar with the page
// context, so after this the page's navigations + fetches are authenticated.
const res = await page.request.post('/api/auth/sign-in/email', {
data: { email, password: pw },
headers: { 'content-type': 'application/json' },
});
if (!res.ok()) {
throw new Error(`API login failed for ${email}: ${res.status()} ${await res.text()}`);
}
}
test.describe('Role × viewport access matrix', () => {
// Independent tests — a flake in one role must not skip the others.
for (const role of ROLES) {
test(`${role.key} — access matrix + nav + viewport renders`, async ({ page }) => {
mkdirSync(OUT, { recursive: true });
test.setTimeout(120_000);
await login(page, role.email, role.pw);
// Land on the app (authenticated via the shared cookie) so in-page
// fetch() has the right origin + the sidebar nav is present.
await page.goto(`/${PORT}/dashboard`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
// 1. API access matrix (authenticated fetch in-page). Non-super-admins
// need the X-Port-Id header (apiFetch adds it) or every route 400s on
// "Port context required" — resolve it via /me/ports first.
const matrix = await page.evaluate(async (probes) => {
let portId = '';
try {
const pr = await fetch('/api/v1/me/ports', { headers: { accept: 'application/json' } });
const pj = (await pr.json()) as { data?: { id: string; slug: string }[] };
portId =
(pj.data ?? []).find((p) => p.slug === 'port-nimara')?.id ??
(pj.data ?? [])[0]?.id ??
'';
} catch {
/* leave empty */
}
const out: Record<string, number | string> = { _port: portId ? 'ok' : 'MISSING' };
for (const p of probes) {
try {
const r = await fetch(p.path, {
headers: { accept: 'application/json', 'X-Port-Id': portId },
});
out[p.label] = r.status;
} catch {
out[p.label] = -1;
}
}
return out;
}, PROBES);
// 2. Visible nav sections
const nav = await page.evaluate(() =>
[...document.querySelectorAll('nav a')].map((a) => a.getAttribute('href')).filter(Boolean),
);
const hasAdminNav = nav.some((h) => h?.includes('/admin'));
const hasResidentialNav = nav.some((h) => h?.includes('/residential'));
console.log(`\n=== ROLE: ${role.key} ===`);
console.log(' access:', JSON.stringify(matrix));
console.log(
` nav: adminSection=${hasAdminNav} residentialSection=${hasResidentialNav} count=${nav.length}`,
);
// 3. Viewport renders — dashboard + clients at each size
for (const vp of VIEWPORTS) {
await page.setViewportSize({ width: vp.width, height: vp.height });
for (const path of [`/${PORT}/dashboard`, `/${PORT}/clients`]) {
const slug = path.split('/').pop();
const resp = await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
await page
.screenshot({ path: join(OUT, `${role.key}-${vp.name}-${slug}.png`), fullPage: false })
.catch(() => {});
console.log(` render ${vp.name} ${slug}: http=${resp?.status()}`);
}
}
// Sanity: every role can at least reach its landing without a hard error.
expect(matrix['clients.view'] === 200 || matrix['residential.clients'] === 200).toBeTruthy();
});
}
});

View File

@@ -25,9 +25,10 @@ export async function login(page: Page, role: keyof typeof USERS = 'super_admin'
const user = USERS[role]; const user = USERS[role];
await page.goto('/login'); await page.goto('/login');
await page.waitForSelector('#email', { state: 'visible' }); // The email/username field id is `identifier` (accepts either).
await page.waitForSelector('#identifier', { state: 'visible' });
await page.fill('#email', user.email); await page.fill('#identifier', user.email);
await page.fill('#password', user.password); await page.fill('#password', user.password);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');

View File

@@ -302,7 +302,15 @@ import type { RolePermissions } from '@/lib/db/schema/users';
/** Full permissions - every action allowed. */ /** Full permissions - every action allowed. */
export function makeFullPermissions(): RolePermissions { export function makeFullPermissions(): RolePermissions {
return { return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: true,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -385,13 +393,22 @@ export function makeFullPermissions(): RolePermissions {
change_stage: true, change_stage: true,
}, },
inquiries: { view: true, manage: true }, inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
}; };
} }
/** Read-only viewer permissions - no create/update/delete. */ /** Read-only viewer permissions - no create/update/delete. */
export function makeViewerPermissions(): RolePermissions { export function makeViewerPermissions(): RolePermissions {
return { return {
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, clients: {
view: true,
create: false,
edit: false,
delete: false,
merge: false,
export: false,
gdpr_export: false,
},
interests: { interests: {
view: true, view: true,
create: false, create: false,
@@ -474,13 +491,22 @@ export function makeViewerPermissions(): RolePermissions {
change_stage: false, change_stage: false,
}, },
inquiries: { view: true, manage: false }, inquiries: { view: true, manage: false },
client_groups: { view: true, manage: false },
}; };
} }
/** Sales agent permissions - own clients/interests, no admin. */ /** Sales agent permissions - own clients/interests, no admin. */
export function makeSalesAgentPermissions(): RolePermissions { export function makeSalesAgentPermissions(): RolePermissions {
return { return {
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false }, clients: {
view: true,
create: true,
edit: true,
delete: false,
merge: false,
export: false,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -563,13 +589,22 @@ export function makeSalesAgentPermissions(): RolePermissions {
change_stage: false, change_stage: false,
}, },
inquiries: { view: true, manage: true }, inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
}; };
} }
/** Sales manager - can do most things, limited admin. */ /** Sales manager - can do most things, limited admin. */
export function makeSalesManagerPermissions(): RolePermissions { export function makeSalesManagerPermissions(): RolePermissions {
return { return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: true,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -652,19 +687,18 @@ export function makeSalesManagerPermissions(): RolePermissions {
change_stage: true, change_stage: true,
}, },
inquiries: { view: true, manage: true }, inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
}; };
} }
/** Director - everything except system backup. */ /** Director - everything except system backup. */
/**
* Director is a senior-title twin of the single "Sales" role: identical
* capabilities, no admin/settings access (admin stays Super-Admin-only). Mirror
* the sales-manager map so the fixture tracks the real seeded role.
*/
export function makeDirectorPermissions(): RolePermissions { export function makeDirectorPermissions(): RolePermissions {
return { return makeSalesManagerPermissions();
...makeFullPermissions(),
admin: {
...makeFullPermissions().admin,
system_backup: false,
permanently_delete_clients: false,
},
};
} }
// ─── Minimal valid CreateClientInput ───────────────────────────────────────── // ─── Minimal valid CreateClientInput ─────────────────────────────────────────

View File

@@ -0,0 +1,102 @@
/**
* CM: admin user creation can defer the password to the new user.
*
* Two modes:
* - setup-email mode (default): no password is supplied at creation. The
* account is provisioned (profile + port role) and a set-password link is
* dispatched via better-auth. (The welcome-vs-reset framing of that email
* is covered by tests/unit/email/account-setup-email-routing.test.ts.)
* - manual mode: the admin supplies a password inline; no email is sent.
*/
import { afterAll, describe, expect, it, vi } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { account, roles, user, userProfiles } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createUser } from '@/lib/services/users.service';
import { makePort, makeAuditMeta } from '../helpers/factories';
describe('createUser - set-password email flow', () => {
const createdUserIds: string[] = [];
afterAll(async () => {
// user_port_roles are port-scoped and cleaned by global teardown; the
// auth user + profile + account rows are global, so purge them here.
for (const id of createdUserIds) {
await db.delete(account).where(eq(account.userId, id));
await db.delete(userProfiles).where(eq(userProfiles.userId, id));
await db.delete(user).where(eq(user.id, id));
}
});
async function salesRoleId(): Promise<string> {
const r = await db.query.roles.findFirst({ where: eq(roles.name, 'sales_manager') });
if (!r) throw new Error('sales_manager role not seeded — run pnpm db:seed');
return r.id;
}
it('provisions the user without a password and emails a set-password link', async () => {
const port = await makePort();
const roleId = await salesRoleId();
const resetSpy = vi
.spyOn(auth.api, 'requestPasswordReset')
.mockResolvedValue({ status: true } as never);
try {
const email = `setup-test-${Date.now()}-a@example.test`;
const result = await createUser(
port.id,
{
email,
name: 'Jane Doe',
displayName: 'Jane Doe',
roleId,
sendSetupEmail: true,
residentialAccess: false,
},
makeAuditMeta(),
);
createdUserIds.push(result.userId);
// Provisioned with the assigned role, ready to sign in once they set a password.
expect(result.role.name).toBe('sales_manager');
// A set-password email was dispatched to their address.
expect(resetSpy).toHaveBeenCalledTimes(1);
expect(resetSpy.mock.calls[0]?.[0]?.body?.email).toBe(email);
} finally {
resetSpy.mockRestore();
}
});
it('uses the supplied password and sends no email in manual mode', async () => {
const port = await makePort();
const roleId = await salesRoleId();
const resetSpy = vi
.spyOn(auth.api, 'requestPasswordReset')
.mockResolvedValue({ status: true } as never);
try {
const email = `setup-test-${Date.now()}-b@example.test`;
const result = await createUser(
port.id,
{
email,
name: 'John Roe',
displayName: 'John Roe',
roleId,
password: 'manual-secret-1234',
sendSetupEmail: false,
residentialAccess: false,
},
makeAuditMeta(),
);
createdUserIds.push(result.userId);
expect(resetSpy).not.toHaveBeenCalled();
} finally {
resetSpy.mockRestore();
}
});
});

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);
});
});

View File

@@ -0,0 +1,94 @@
/**
* CM-5: interest assignment is gated behind the per-port `assignment_enabled`
* setting. When off (the default), createInterest must NOT auto-assign an owner
* even when a `default_new_interest_owner` is configured. When on, the existing
* tier-2 (port default-owner) auto-assign fires. An explicit `assignedTo` from
* the caller (tier 1) is always honored regardless of the toggle.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
// interests.assigned_to FKs to user_profiles(user_id); the owner must exist.
const OWNER = 'cm5-default-owner';
describe('interests.service - assignment_enabled gate (CM-5)', () => {
let createInterest: typeof import('@/lib/services/interests.service').createInterest;
let makePort: typeof import('../helpers/factories').makePort;
let makeClient: typeof import('../helpers/factories').makeClient;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
beforeAll(async () => {
const svc = await import('@/lib/services/interests.service');
createInterest = svc.createInterest;
const factories = await import('../helpers/factories');
makePort = factories.makePort;
makeClient = factories.makeClient;
makeAuditMeta = factories.makeAuditMeta;
// Idempotent owner profile - left in place (created interests reference it,
// so we never delete it in teardown).
await db
.insert(userProfiles)
.values({ userId: OWNER, displayName: 'CM5 Default Owner' })
.onConflictDoNothing();
});
afterEach(async () => {
await db.delete(systemSettings).where(eq(systemSettings.key, 'assignment_enabled'));
await db.delete(systemSettings).where(eq(systemSettings.key, 'default_new_interest_owner'));
});
async function setSetting(portId: string, key: string, value: unknown) {
await db.insert(systemSettings).values({ key, portId, value: value as never });
}
it('does NOT auto-assign the port default owner when assignment is disabled (default)', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// A default owner IS configured, but the feature is OFF - the guard must
// skip tier-2 entirely and leave the interest unassigned.
await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBeNull();
});
it('auto-assigns the port default owner when assignment is enabled', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await setSetting(port.id, 'assignment_enabled', true);
await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBe(OWNER);
});
it('always honors an explicit assignedTo regardless of the toggle', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Feature off, but the caller explicitly picked an owner - tier 1 wins.
const interest = await createInterest(
port.id,
{
clientId: client.id,
assignedTo: OWNER,
pipelineStage: 'enquiry',
tagIds: [],
reminderEnabled: false,
},
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBe(OWNER);
});
});

View File

@@ -147,6 +147,60 @@ describe('OCR config', () => {
expect(resolved.aiEnabled).toBe(false); expect(resolved.aiEnabled).toBe(false);
}); });
// CM-6: manual-entry mode (skip all parsing) - mirrors the aiEnabled contract.
it('manualEntry defaults to false and round-trips when toggled', async () => {
const port = await makePort();
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-y' },
'user-1',
);
let resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(false);
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', manualEntry: true },
'user-1',
);
resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(true);
expect(resolved.apiKey).toBe('sk-y'); // toggling the mode never wipes the key
});
it('manualEntry is preserved when other fields change', async () => {
const port = await makePort();
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-z', manualEntry: true },
'user-1',
);
// Update the model only - manualEntry must survive (mirrors aiEnabled).
await saveOcrConfig(port.id, { provider: 'openai', model: 'gpt-4o' }, 'user-1');
const resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(true);
expect(resolved.model).toBe('gpt-4o');
});
it('manualEntry shows on the public view and is forced false at global scope', async () => {
await saveOcrConfig(
null,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'g', manualEntry: true },
'user-1',
);
const port = await makePort();
const resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(false); // per-port, never inherited from global
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', manualEntry: true },
'user-1',
);
const pub = await getPublicOcrConfig(port.id);
expect(pub.manualEntry).toBe(true);
});
it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => { it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => {
await saveOcrConfig( await saveOcrConfig(
null, null,

View File

@@ -9,7 +9,7 @@
* - viewer can read but not write * - viewer can read but not write
* - sales_agent can manage own clients/interests but not admin features * - sales_agent can manage own clients/interests but not admin features
* - sales_manager has elevated but non-admin access * - sales_manager has elevated but non-admin access
* - director has near-full access * - director mirrors sales (full sales access, no admin)
* - deepMerge correctly applies port-level overrides * - deepMerge correctly applies port-level overrides
*/ */
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
@@ -99,6 +99,10 @@ describe('Permission Matrix - viewer', () => {
expect(await checkPermission(ctx, 'clients', 'create')).toBe(403); expect(await checkPermission(ctx, 'clients', 'create')).toBe(403);
}); });
it('cannot run a GDPR export', async () => {
expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(403);
});
it('cannot update clients', async () => { it('cannot update clients', async () => {
expect(await checkPermission(ctx, 'clients', 'edit')).toBe(403); expect(await checkPermission(ctx, 'clients', 'edit')).toBe(403);
}); });
@@ -177,6 +181,10 @@ describe('Permission Matrix - sales_manager', () => {
} }
}); });
it('can run a GDPR export (clients.gdpr_export)', async () => {
expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(200);
});
it('can view audit log', async () => { it('can view audit log', async () => {
expect(await checkPermission(ctx, 'admin', 'view_audit_log')).toBe(200); expect(await checkPermission(ctx, 'admin', 'view_audit_log')).toBe(200);
}); });
@@ -190,17 +198,25 @@ describe('Permission Matrix - sales_manager', () => {
}); });
}); });
// ─── director ───────────────────────────────────────────────────────────────── // ─── director (senior-title twin of Sales: full sales, no admin) ──────────────
describe('Permission Matrix - director', () => { describe('Permission Matrix - director', () => {
const ctx = makeCtx({ permissions: makeDirectorPermissions() }); const ctx = makeCtx({ permissions: makeDirectorPermissions() });
it('can manage webhooks', async () => { it('has full sales access (create clients)', async () => {
expect(await checkPermission(ctx, 'admin', 'manage_webhooks')).toBe(200); expect(await checkPermission(ctx, 'clients', 'create')).toBe(200);
}); });
it('can manage users', async () => { it('can manage tags', async () => {
expect(await checkPermission(ctx, 'admin', 'manage_users')).toBe(200); expect(await checkPermission(ctx, 'admin', 'manage_tags')).toBe(200);
});
it('cannot manage users', async () => {
expect(await checkPermission(ctx, 'admin', 'manage_users')).toBe(403);
});
it('cannot manage settings', async () => {
expect(await checkPermission(ctx, 'admin', 'manage_settings')).toBe(403);
}); });
it('cannot perform system_backup', async () => { it('cannot perform system_backup', async () => {

View File

@@ -0,0 +1,94 @@
/**
* CM-9: proxy service — per-entity CRUD/upsert, tenant guard, and the
* yacht → interest → client resolution precedence.
*/
import { describe, it, expect } from 'vitest';
import { createInterest } from '@/lib/services/interests.service';
import {
clearProxy,
getProxy,
resolveEffectiveProxy,
setProxy,
} from '@/lib/services/proxies.service';
import { makeAuditMeta, makeClient, makePort, makeYacht } from '../helpers/factories';
describe('proxies.service (CM-9)', () => {
it('sets, reads, upserts and clears a client proxy', async () => {
const port = await makePort();
const meta = makeAuditMeta({ portId: port.id });
const client = await makeClient({ portId: port.id });
expect(await getProxy(port.id, 'client', client.id)).toBeNull();
const p = await setProxy(
port.id,
'client',
client.id,
{ name: 'Broker Bob', email: 'bob@example.com' },
meta,
);
expect(p.name).toBe('Broker Bob');
expect(p.email).toBe('bob@example.com');
// Upsert: one proxy per entity — setting again updates the same row.
const p2 = await setProxy(
port.id,
'client',
client.id,
{ name: 'Broker Bob', email: '', phone: '+100' },
meta,
);
expect(p2.id).toBe(p.id);
expect(p2.email).toBeNull(); // empty string normalised to null
expect(p2.phone).toBe('+100');
await clearProxy(port.id, 'client', client.id, meta);
expect(await getProxy(port.id, 'client', client.id)).toBeNull();
});
it('rejects an entity 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 });
await expect(setProxy(portA.id, 'client', foreign.id, { name: 'X' }, meta)).rejects.toThrow(
/not found in this port/,
);
});
it('resolves the most specific proxy: yacht → interest → client', async () => {
const port = await makePort();
const meta = makeAuditMeta({ portId: port.id });
const client = await makeClient({ portId: port.id });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
meta,
);
const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id });
await setProxy(port.id, 'client', client.id, { name: 'Client PoC' }, meta);
await setProxy(port.id, 'interest', interest.id, { name: 'Deal PoC' }, meta);
await setProxy(port.id, 'yacht', yacht.id, { name: 'Vessel PoC' }, meta);
const ctx = {
portId: port.id,
clientId: client.id,
interestId: interest.id,
yachtId: yacht.id,
};
expect((await resolveEffectiveProxy(ctx))?.source).toBe('yacht');
await clearProxy(port.id, 'yacht', yacht.id, meta);
expect((await resolveEffectiveProxy(ctx))?.source).toBe('interest');
await clearProxy(port.id, 'interest', interest.id, meta);
const eff = await resolveEffectiveProxy(ctx);
expect(eff?.source).toBe('client');
expect(eff?.proxy.name).toBe('Client PoC');
await clearProxy(port.id, 'client', client.id, meta);
expect(await resolveEffectiveProxy(ctx)).toBeNull();
});
});

View File

@@ -0,0 +1,64 @@
/**
* The shared better-auth sendResetPassword callback must send a unique WELCOME
* email to admin-created users (flagged via pending-welcome) and the standard
* RESET email to everyone else — same link, different framing.
*/
import { describe, it, expect } from 'vitest';
import { buildAccountPasswordEmail } from '@/lib/auth/account-setup-email';
import { markPendingWelcome } from '@/lib/auth/pending-welcome';
const url = 'https://crm.example.com/set-password#token=tok';
describe('buildAccountPasswordEmail routing', () => {
it('sends a welcome email when the recipient was flagged by create-user', async () => {
const email = 'new-hire@example.test';
markPendingWelcome(email);
const mail = await buildAccountPasswordEmail({
email,
name: 'New Hire',
url,
appName: 'Port Nimara CRM',
authBranding: null,
});
expect(mail.subject.toLowerCase()).toContain('welcome');
expect(mail.subject.toLowerCase()).not.toContain('reset');
expect(mail.html).toContain('tok');
});
it('sends the standard reset email for an unflagged self-service reset', async () => {
const mail = await buildAccountPasswordEmail({
email: 'existing@example.test',
name: 'Existing User',
url,
appName: 'Port Nimara CRM',
authBranding: null,
});
expect(mail.subject.toLowerCase()).toContain('reset');
expect(mail.subject.toLowerCase()).not.toContain('welcome');
});
it('consumes the welcome flag (a second build for the same email is a reset)', async () => {
const email = 'once@example.test';
markPendingWelcome(email);
const first = await buildAccountPasswordEmail({
email,
url,
appName: 'Port Nimara CRM',
authBranding: null,
});
const second = await buildAccountPasswordEmail({
email,
url,
appName: 'Port Nimara CRM',
authBranding: null,
});
expect(first.subject.toLowerCase()).toContain('welcome');
expect(second.subject.toLowerCase()).toContain('reset');
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { crmWelcomeEmail } from '@/lib/email/templates/crm-welcome';
describe('crmWelcomeEmail', () => {
it('is a unique welcome email (not a password-reset) carrying the set-password link', async () => {
const link = 'https://crm.example.com/set-password#token=abc123';
const { subject, html, text } = await crmWelcomeEmail({
link,
recipientName: 'Jane Doe',
appName: 'Port Nimara CRM',
});
// Distinct welcome framing, not the reset-password copy.
expect(subject.toLowerCase()).toContain('welcome');
expect(subject.toLowerCase()).not.toContain('reset');
// No accidental double "CRM CRM" when the app name already carries it.
expect(subject).not.toContain('CRM CRM');
// Greets the recipient and drives them to set their password.
expect(html).toContain('Jane Doe');
expect(html).toContain('set-password');
expect(html).toContain('abc123');
expect(text).toContain(link);
});
it('falls back to a generic greeting when no name is given', async () => {
const { html } = await crmWelcomeEmail({
link: 'https://crm.example.com/set-password#token=xyz',
appName: 'Port Nimara CRM',
});
expect(html.toLowerCase()).toContain('welcome');
});
});

View File

@@ -13,9 +13,11 @@ import { describe, expect, it } from 'vitest';
import { import {
extractFromOcrText, extractFromOcrText,
extractPurchasePrice,
isPdfMagic, isPdfMagic,
parseFeetInches, parseFeetInches,
parseHumanDate, parseHumanDate,
PURCHASE_PRICE_FLOOR,
shouldOfferAiTier, shouldOfferAiTier,
} from '@/lib/services/berth-pdf-parser'; } from '@/lib/services/berth-pdf-parser';
@@ -191,3 +193,43 @@ describe('shouldOfferAiTier', () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe('extractPurchasePrice', () => {
it('isolates the single clean main price among letter-spaced rate garble', () => {
// Real-sheet shape: rates are letter-spaced (so they never match the strict
// token); the main price renders clean.
const text =
'W E E K H I G H / LO W : 1 1 , 3 4 1 U S D / 8 , 1 0 0 U S D 3,880,800 USD ' +
'DAY H I G H / LO W : 1 , 8 9 0 U S D / 1 , 3 5 0 U S D';
const r = extractPurchasePrice(text);
expect(r.value).toBe(3880800);
expect(r.currency).toBe('USD');
expect(r.confidence).toBeGreaterThanOrEqual(0.9);
});
it('excludes clean rate tokens below the floor (synthetic clean sheet)', () => {
const text = '3,880,800 USD WEEK HIGH / LOW: 11,341 USD / 8,100 USD';
expect(extractPurchasePrice(text).value).toBe(3880800);
});
it('returns null + warning when no price-magnitude token is present', () => {
const r = extractPurchasePrice('no prices here, just 12 USD of nothing');
expect(r.value).toBeNull();
expect(r.warning).toMatch(/no purchase-price/i);
});
it('flags ambiguity when two DISTINCT above-floor tokens appear', () => {
const r = extractPurchasePrice('3,880,800 USD and also 1,247,400 USD');
expect(r.value).toBeNull();
expect(r.warning).toMatch(/multiple/i);
});
it('treats a repeated identical price as unambiguous', () => {
const r = extractPurchasePrice('720,720 USD ... header ... 720,720 USD');
expect(r.value).toBe(720720);
});
it('exposes the floor constant', () => {
expect(PURCHASE_PRICE_FLOOR).toBe(50_000);
});
});

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