28 Commits

Author SHA1 Message Date
866930c943 feat(intake): residence-type capture + CRM-owned inquiry emails for website cutover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m17s
Website register-interest form now offers the 3 residence types as a
multi-select; the choice + preferred-contact flow into the CRM inquiry
payload, the inbox detail, and the residential emails.

- inquiry inbox detail surfaces residence type(s), preferred contact,
  type-of-interest, comments (full data capture)
- residential-inquiry emails: client confirmation names the chosen
  villa(s); sales alert converted to the canonical detail-line format
  (uniform with berth/contact) + residence type(s)/preferred contact +
  plain-text part
- website-intake-fields parses residence_types[] + method_of_contact
- contact_form alerts split to their own recipient key
  (contact_notification_recipients)
- Residential Interests section: new residence_type field (schema +
  migration 0099, validators, inline select on the detail)
- contact-form-alert email refactor shipped (interest-alert style)

Tests: website-intake-fields, residential-inquiry templates,
contact-form-alert, residential-interest validators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 20:58:53 +02:00
64a488dc15 fix(signing): branded embed URL for developer + Copy link
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m6s
Two bugs in the marketing-site embed-URL rewrite, both surfaced by
testing through the actual invitation emails:

1. Developer invite linked to `…/sign/undefined/<token>`. document_signers
   persists Documenso's normalized role, so the order-2 EOI developer
   arrives as 'signer', which ROLE_TO_URL_SEGMENT didn't have — the lookup
   returned undefined. Add a 'signer' → 'developer' alias and a 'cc'
   fallback so an unknown role can never emit `/sign/undefined/`.

2. "Copy link" copied the bare Documenso URL, not the branded embed URL.
   listDocumentSigners (feeds the EOI tab signers + Copy link) now runs
   each signing_url through transformSigningUrl, so the copied link
   matches what the invitation email sends. Send-invitation/automation
   read the raw rows directly, so they're unaffected.

Regression tests pin 'signer' → /sign/developer and the unknown-role
fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:14:28 +02:00
2bc2cfac6f fix(eoi): render signed-PDF preview inline (preview endpoint + font-src)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m5s
After adding frame-src the preview iframe stopped showing the broken-file
icon but went blank: it pointed at /api/v1/files/[id]/download, which
presigns with the filename so S3 returns Content-Disposition: attachment
— the browser downloaded the PDF instead of rendering it. Point the
SignedPdfPreview iframe at the existing /preview endpoint, which presigns
WITHOUT a filename (inline disposition) so the native PDF viewer renders.

Also widen font-src to include https: so react-pdf/pdf.js can load its
standard-font pack (LiberationSans*) — previously blocked by
font-src 'self' data:, breaking the pdf.js-based viewers' glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:45:34 +02:00
3f6f845c02 fix(csp): add frame-src so signed-PDF previews + embedded signing load
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
The CSP set default-src 'self' with no frame-src, so it fell back to
'self' for frames. Inline signed-EOI previews iframe a presigned
s3.portnimara.com URL and the admin embedded-signing card iframes the
Documenso host — both were blocked, rendering a broken-file placeholder
("Framing 'https://s3.portnimara.com/' violates ... default-src 'self'").

Add `frame-src 'self' blob: https:` to both CSP definitions (proxy.ts
middleware + next.config.ts), matching the existing https: posture of
img-src/connect-src. frame-ancestors 'none' is unchanged, so we still
can't be embedded by third parties.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:26:38 +02:00
fc994cd88b fix(eoi): silence Documenso's own lifecycle emails on createDocument
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m53s
The local-fill EOI pathway creates fresh Documenso envelopes via
createDocument, which (unlike the template pathway that inherits
template 8's all-false emailSettings) used Documenso's defaults — every
email event defaults to true on both the v1 and v2.13 APIs. So Documenso
fired its OWN unbranded "Waiting for others to complete signing." and
"Signing Complete!" emails (signed PDF attached, reply-to sales@),
bypassing EMAIL_REDIRECT_TO and duplicating the CRM's branded sends.

Force emailSettings to all-false (DOCUMENSO_SILENT_EMAIL_SETTINGS) on
every createDocument call (v1 JSON + v2 multipart). The CRM stays the
sole sender of signing comms. Verified against the live v2.13 OpenAPI +
template 8's stored meta.

Also stop the EMAIL_REDIRECT_TO gate from appending "(was: <email>)" to
the recipient NAME: a "Name" field auto-fills from it into the signed
PDF, so the annotation overlapped the signature. Redirect the email
only; the original is still logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:20:55 +02:00
e17476f3e3 fix(eoi): v1 field placement uses percent coords, not absolute points
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m50s
Build & Push Docker Images / build-and-push (push) Successful in 8m38s
Documenso 2.x's v1-compat POST /documents/{id}/fields expects percent
coordinates (0-100), same as v2 — but placeFields multiplied by a hardcoded
A4 page size, so 39.6% became ~237 and was read as 237%, placing the EOI
signature fields far off-page. Send percent straight through (matches
template-native fields, which render correctly). Drop the now-dead
getPageDimensions/DEFAULT_PAGE_DIMENSIONS A4 fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:05:42 +02:00
f4cfc5600f fix(eoi): uppercase Documenso recipient roles at the API boundary
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m9s
The local-fill EOI pathway passed lowercase roles ('signer'/'approver') to
createDocument; Documenso's API requires UPPERCASE (CC|SIGNER|VIEWER|APPROVER|
ASSISTANT) and rejected them with a 400 "Invalid enum value", surfacing as a
502 DOCUMENSO_UPSTREAM_ERROR on generate. Normalize role to uppercase where
safeRecipients is built so both v1 + v2 paths and all callers are covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 03:37:14 +02:00
0ca9b2c3b5 feat(eoi): toggleable local-fill pathway — clean detail render + address wrapping
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m34s
EOI detail fields (address, name, yacht, berth) rendered oversized and
top-clipped because Documenso auto-sizes AcroForm text when *it* fills the
template (ignores the PDF's 12pt font; a taller box → bigger font → more clip,
and a 2-line address box renders huge). Proven: filling the same source PDF
locally at 12pt renders cleanly and wraps long addresses to a 2nd line.

Add a per-port `eoi_fill_method` setting (default `local`), toggleable in
admin → Documenso → Templates & signing pathway:
- local:     CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
             multiline address wrap), uploads the flattened PDF to Documenso,
             and places ONLY the 6 page-3 signature fields. Documenso never
             re-renders the body text → no clipping.
- documenso: legacy template AcroForm fill (auto-sizes/clips) — fallback only.

Both still flow through Documenso for signing, so branded invites, embedded
signing, webhooks, signer rows, and the EOI milestone are unchanged.

- computeEoiSignatureLayout(): 6 page-3 fields at template-8 coords (unit-tested)
- createDocument (v1): PUT bytes to Documenso's presigned uploadUrl (2.x v1-compat
  ignores the base64 field) so the uploaded document actually has content
- placeFields (v1): pass fieldMeta through so the Place-of-Signing TEXT field
  keeps its label/required

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:48:11 +02:00
af05bb18dc fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 8m39s
Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns JSON
`{ downloadUrl }` (a presigned S3 URL), not raw PDF bytes. `downloadSignedPdf`
was doing `res.arrayBuffer()` directly, so it stored the ~500-byte JSON as the
"signed PDF" — every signer got a corrupt attachment ("Adobe could not open …
damaged") and the corrupt file was filed against the deal in the CRM.

Fix: magic-byte detection — if the v1 /download body isn't a `%PDF-`, parse the
JSON and follow `downloadUrl` to fetch the actual file; validate the result is a
real PDF before returning. Backward-compatible with older v1 servers that return
the PDF directly. This also protects the CRM deposit + email fan-out, since both
consume downloadSignedPdf's buffer — a non-PDF now throws instead of depositing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 23:55:16 +02:00
1c91d76c52 feat(signing): internal "who signed" + completion email alerts to configurable recipients
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m56s
Adds a per-port `signing_notification_recipients` setting (users / roles /
explicit emails via the existing RecipientPicker) and fires a branded
internal email on (a) each party signing and (b) full completion —
replicating the legacy "Document Signed" / "EOI Complete Update Status"
Activepieces flows that staff relied on.

- New branded template `signing-status-notification.tsx` (per-signer
  progress + completion variants, deep-links into the CRM).
- New `sendSigningStatusNotification` resolver in document-signing-emails:
  resolves recipients, falls back to the port reply-to (sales@) when the
  list is empty so alerts are never silently dropped, per-recipient send.
- Wired into `handleRecipientSigned` (first signed transition) and
  `handleDocumentCompleted` (idempotent, fires once) — reached by both the
  Documenso webhook and the 5-min poll. Fully guarded so a notification
  failure never undoes a signing side effect. Respects EMAIL_REDIRECT_TO.
- Admin UI: `Document signing alerts` recipients card in settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 21:38:22 +02:00
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
117 changed files with 5574 additions and 468 deletions

View File

@@ -51,8 +51,13 @@ const csp = [
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`, `script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"font-src 'self' data:", // https: so react-pdf/pdf.js can load its standard-font pack + branding fonts.
"font-src 'self' data: https:",
`connect-src 'self' ws: wss: https:${devConnectHosts}`, `connect-src 'self' ws: wss: https:${devConnectHosts}`,
// PDF previews iframe a presigned storage URL; embedded-signing iframes the
// Documenso host. Both are per-port/per-env, so allow https: (matching
// img-src). frame-ancestors 'none' still blocks others from embedding us.
"frame-src 'self' blob: https:",
"frame-ancestors 'none'", "frame-ancestors 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",

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

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

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

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

@@ -149,9 +149,17 @@ const KNOWN_SETTINGS: Array<{
}, },
{ {
key: 'inquiry_notification_recipients', key: 'inquiry_notification_recipients',
label: 'Berth & contact inquiry alerts', label: 'Berth inquiry alerts',
description: description:
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.', 'Who receives staff alerts for new berth inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
type: 'recipients',
defaultValue: [],
},
{
key: 'contact_notification_recipients',
label: 'Contact-form alerts',
description:
'Who receives staff alerts for new website contact-form submissions: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Falls back to Inquiry Contact Email when empty.',
type: 'recipients', type: 'recipients',
defaultValue: [], defaultValue: [],
}, },
@@ -163,6 +171,14 @@ const KNOWN_SETTINGS: Array<{
type: 'recipients', type: 'recipients',
defaultValue: [], defaultValue: [],
}, },
{
key: 'signing_notification_recipients',
label: 'Document signing alerts',
description:
'Who gets emailed each time a party signs an EOI / contract and when a document is fully signed: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Add yourself and sales@ here. Falls back to the Reply-To address when empty.',
type: 'recipients',
defaultValue: [],
},
{ {
key: 'eoi_signers', key: 'eoi_signers',
label: 'EOI Signers', label: 'EOI Signers',

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

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

@@ -63,7 +63,7 @@ export function GdprExportButton({
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>({

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

@@ -77,10 +77,29 @@ 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) : '');
// Read a payload value that may be a string[] (e.g. residence_types, the
// contact form's interest[]) OR a lone string, and present it comma-joined.
const list = (k: string): string => {
const v = p[k];
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string').join(', ');
return typeof v === 'string' ? v : '';
};
// The free-text message a lead left. Website forms use different keys // The free-text message a lead left. Website forms use different keys
// (contact form -> `comments`; others -> `message`/`comment`), so probe the // (contact form -> `comments`; others -> `message`/`comment`), so probe the
// common ones and surface it for every inquiry kind. // common ones and surface it for every inquiry kind.
const comment = str('comments') || str('message') || str('comment') || str('notes'); const comment = str('comments') || str('message') || str('comment') || str('notes');
// Preferred method of contact (register form: 'email' | 'phone'). Surfaced so
// reps honour the lead's stated contact request.
const preferredContactRaw = str('method_of_contact').toLowerCase();
const preferredContact =
preferredContactRaw === 'email'
? 'Email'
: preferredContactRaw === 'phone'
? 'Phone call back'
: '';
const residenceTypes = list('residence_types');
// Contact-form "type of interest" (owner/broker/investor/…), stored as an array.
const contactInterest = list('interest');
const tabs: DetailTab[] = [ const tabs: DetailTab[] = [
{ {
@@ -91,10 +110,17 @@ export function InquiryDetail({ id }: { id: string }) {
<Row label="Name" value={data?.contactName} /> <Row label="Name" value={data?.contactName} />
<Row label="Email" value={data?.contactEmail} /> <Row label="Email" value={data?.contactEmail} />
<Row label="Phone" value={str('phone')} /> <Row label="Phone" value={str('phone')} />
{data?.kind === 'residence_inquiry' ? (
<Row label="Residence type(s)" value={residenceTypes} />
) : null}
{data?.kind === 'residence_inquiry' ? ( {data?.kind === 'residence_inquiry' ? (
<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' && contactInterest ? (
<Row label="Type of interest" value={contactInterest} />
) : null}
{preferredContact ? <Row label="Preferred contact" value={preferredContact} /> : null}
{comment ? ( {comment ? (
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} /> <Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
) : null} ) : null}

View File

@@ -868,10 +868,15 @@ function SignedEoiCard({
* the file in a new tab via the alongside View button for full-screen. * the file in a new tab via the alongside View button for full-screen.
*/ */
function SignedPdfPreview({ fileId }: { fileId: string }) { function SignedPdfPreview({ fileId }: { fileId: string }) {
const { data, isLoading, isError } = useQuery<{ data: { url: string; filename: string } }>({ // Use the PREVIEW endpoint, not /download: /download presigns with the
queryKey: ['files', fileId, 'download-url'], // filename so S3 returns `Content-Disposition: attachment`, which makes the
// iframe trigger a file download (blank preview) instead of rendering. The
// preview endpoint presigns WITHOUT a filename → inline disposition → the
// browser's native PDF viewer renders it in the card.
const { data, isLoading, isError } = useQuery<{ data: { url: string; mimeType: string } }>({
queryKey: ['files', fileId, 'preview-url'],
queryFn: () => queryFn: () =>
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`), apiFetch<{ data: { url: string; mimeType: string } }>(`/api/v1/files/${fileId}/preview`),
// Presigned URL TTLs vary per backend - refresh well before they // Presigned URL TTLs vary per backend - refresh well before they
// expire so a long-open card doesn't suddenly 403. 4 minutes is // expire so a long-open card doesn't suddenly 403. 4 minutes is
// comfortably below the 5-minute MinIO default. // comfortably below the 5-minute MinIO default.

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

@@ -20,6 +20,7 @@ interface ResidentialInterest {
source: string | null; source: string | null;
notes: string | null; notes: string | null;
preferences: string | null; preferences: string | null;
residenceType: string | null;
assignedTo: string | null; assignedTo: string | null;
client: { id: string; fullName: string } | null; client: { id: string; fullName: string } | null;
} }

View File

@@ -9,6 +9,7 @@ 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 { useFeatureFlag } from '@/hooks/use-feature-flag';
import { SOURCES } from '@/lib/constants'; import { SOURCES } from '@/lib/constants';
import { RESIDENCE_TYPES } from '@/lib/validators/residential';
interface ResidentialInterest { interface ResidentialInterest {
id: string; id: string;
@@ -17,6 +18,7 @@ interface ResidentialInterest {
source: string | null; source: string | null;
notes: string | null; notes: string | null;
preferences: string | null; preferences: string | null;
residenceType: string | null;
assignedTo: string | null; assignedTo: string | null;
} }
@@ -28,6 +30,7 @@ interface Args {
} }
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label })); const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
const RESIDENCE_TYPE_OPTIONS = RESIDENCE_TYPES.map((t) => ({ value: t, label: t }));
function Row({ label, children }: { label: string; children: React.ReactNode }) { function Row({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
@@ -151,6 +154,15 @@ function OverviewTab({
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Details</h3> <h3 className="text-sm font-medium mb-2">Details</h3>
<Row label="Residence type">
<InlineEditableField
variant="select"
options={RESIDENCE_TYPE_OPTIONS}
value={interest.residenceType}
onSave={save('residenceType')}
placeholder="Not set"
/>
</Row>
<Row label="Preferences"> <Row label="Preferences">
<InlineEditableField <InlineEditableField
variant="textarea" variant="textarea"

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,5 @@
-- Residential interests: structured residence unit type the lead is pursuing
-- (e.g. "Two Bedroom Marina Villa"). Mirrors the multi-select on the website's
-- register-interest form. Nullable; additive — safe to apply online.
ALTER TABLE residential_interests
ADD COLUMN IF NOT EXISTS residence_type text;

View File

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

View File

@@ -7,6 +7,12 @@ export * from './users';
// Clients // 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

@@ -97,6 +97,13 @@ export const residentialInterests = pgTable(
* heavily. Schema can grow into structured columns later if needed. * heavily. Schema can grow into structured columns later if needed.
*/ */
preferences: text('preferences'), preferences: text('preferences'),
/**
* Structured residence unit type the lead is pursuing (e.g. "Two Bedroom
* Marina Villa"). Mirrors the multi-select on the website's register-interest
* form; on a structured interest it captures the single unit type being
* worked. Nullable - older rows + manual entries may leave it unset.
*/
residenceType: text('residence_type'),
/** /**
* better-auth user id of the residential team member working this lead. * better-auth user id of the residential team member working this lead.
*/ */

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

@@ -1,4 +1,4 @@
import { Button, Text, render } from '@react-email/components'; import { Link, Text, render } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
@@ -17,6 +17,9 @@ export interface ContactFormSalesAlertData {
portName?: string; portName?: string;
} }
// Mirrors the interest-registration alert (inquiry-sales-notification.tsx):
// friendly intro, `**Label:** value` detail lines, inline CRM follow-up link,
// and a plain-text part — so contact-form alerts read identically to interest ones.
function SalesAlertBody({ function SalesAlertBody({
portName, portName,
data, data,
@@ -26,61 +29,42 @@ function SalesAlertBody({
data: ContactFormSalesAlertData; data: ContactFormSalesAlertData;
accent: string; accent: string;
}) { }) {
const labelCell = { color: '#666', width: '140px' } as const; const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
const comments = data.comments?.trim() ? data.comments : '(none provided)';
return ( return (
<> <>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
New contact form submission <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
A new contact-form enquiry has come in for <strong>{portName}</strong>. {data.fullName} got
in touch via the website contact page - full details below:
</Text> </Text>
<table <Text style={detailStyle}>
role="presentation" <strong>Name:</strong> {data.fullName}
width="100%" </Text>
cellPadding={6} <Text style={detailStyle}>
cellSpacing={0} <strong>Email:</strong> {data.email}
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }} </Text>
> {data.interestType ? (
<tbody> <Text style={detailStyle}>
<tr> <strong>Interest:</strong> {data.interestType}
<td style={labelCell}>Name</td> </Text>
<td>{data.fullName}</td>
</tr>
<tr>
<td style={labelCell}>Email</td>
<td>{data.email}</td>
</tr>
{data.interestType ? (
<tr>
<td style={labelCell}>Interest</td>
<td>{data.interestType}</td>
</tr>
) : null}
{data.comments ? (
<tr>
<td style={labelCell}>Comments</td>
<td>{data.comments}</td>
</tr>
) : null}
</tbody>
</table>
{data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<Button
href={safeUrl(data.crmDeepLink)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
>
Open in CRM
</Button>
</div>
) : null} ) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text> <Text style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
<strong>Comments:</strong> {comments}
</Text>
{data.crmDeepLink ? (
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Open the{' '}
<Link
href={safeUrl(data.crmDeepLink)}
style={{ color: accent, textDecoration: 'underline' }}
>
{portName} CRM
</Link>{' '}
to follow up.
</Text>
) : null}
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
</> </>
); );
} }
@@ -89,7 +73,7 @@ export async function contactFormSalesAlert(
data: ContactFormSalesAlertData, data: ContactFormSalesAlertData,
overrides?: RenderOpts, overrides?: RenderOpts,
) { ) {
const portName = data.portName ?? 'our team'; const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: `New contact form submission - ${data.fullName}`; : `New contact form submission - ${data.fullName}`;
@@ -97,8 +81,27 @@ export async function contactFormSalesAlert(
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, { const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false, pretty: false,
}); });
const comments = data.comments?.trim() ? data.comments : '(none provided)';
const text = [
'Hello,',
'',
`A new contact-form enquiry has come in for ${portName}. ${data.fullName} got in touch via the website contact page - full details below:`,
'',
`Name: ${data.fullName}`,
`Email: ${data.email}`,
...(data.interestType ? [`Interest: ${data.interestType}`] : []),
`Comments: ${comments}`,
'',
...(data.crmDeepLink
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
: []),
`- ${portName} CRM`,
].join('\n');
return { return {
subject, subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }), html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
}; };
} }

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

@@ -1,4 +1,4 @@
import { Button, Link, Text, render } from '@react-email/components'; import { Link, Text, render } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
@@ -11,18 +11,35 @@ interface RenderOpts {
export interface ResidentialClientConfirmationData { export interface ResidentialClientConfirmationData {
firstName: string; firstName: string;
contactEmail: string; contactEmail: string;
residenceTypes?: string[];
portName?: string; portName?: string;
} }
/**
* Human-readable list of the residence types a lead selected, e.g.
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
* Falls back to a generic phrase when nothing was selected so the copy
* always reads naturally.
*/
function residencePhrase(portName: string, types: string[] | undefined): string {
const list = (types ?? []).filter(Boolean);
if (list.length === 0) return `the residences at ${portName}`;
if (list.length === 1) return `the ${list[0]}`;
if (list.length === 2) return `the ${list[0]} and the ${list[1]}`;
return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`;
}
function ClientConfirmationBody({ function ClientConfirmationBody({
portName, portName,
firstName, firstName,
contactEmail, contactEmail,
residencePhraseText,
accent, accent,
}: { }: {
portName: string; portName: string;
firstName: string; firstName: string;
contactEmail: string; contactEmail: string;
residencePhraseText: string;
accent: string; accent: string;
}) { }) {
return ( return (
@@ -34,7 +51,7 @@ function ClientConfirmationBody({
Dear {firstName}, Dear {firstName},
</Text> </Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}> <Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
Thank you for your interest in the residences at {portName}. Our residential sales team has Thank you for your interest in {residencePhraseText}. Our residential sales team has
received your enquiry, and a member of the team will be in touch shortly with the details received your enquiry, and a member of the team will be in touch shortly with the details
you&apos;ve requested. you&apos;ve requested.
</Text> </Text>
@@ -66,18 +83,31 @@ export async function residentialClientConfirmation(
? overrides.subject ? overrides.subject
: `Thank you for your interest in ${portName} Residences`; : `Thank you for your interest in ${portName} Residences`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const residencePhraseText = residencePhrase(portName, data.residenceTypes);
const body = await render( const body = await render(
<ClientConfirmationBody <ClientConfirmationBody
portName={portName} portName={portName}
firstName={data.firstName} firstName={data.firstName}
contactEmail={data.contactEmail} contactEmail={data.contactEmail}
residencePhraseText={residencePhraseText}
accent={accent} accent={accent}
/>, />,
{ pretty: false }, { pretty: false },
); );
const text = [
`Dear ${data.firstName},`,
'',
`Thank you for your interest in ${residencePhraseText}. Our residential sales team has received your enquiry, and a member of the team will be in touch shortly with the details you've requested.`,
'',
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${data.contactEmail}.`,
'',
'With warm regards,',
`The ${portName} Residential Team`,
].join('\n');
return { return {
subject, subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }), html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
}; };
} }
@@ -85,6 +115,7 @@ export interface ResidentialSalesAlertData {
fullName: string; fullName: string;
email: string; email: string;
phone: string; phone: string;
residenceTypes?: string[];
placeOfResidence?: string; placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone'; preferredContactMethod?: 'email' | 'phone';
notes?: string; notes?: string;
@@ -93,6 +124,12 @@ export interface ResidentialSalesAlertData {
portName?: string; portName?: string;
} }
function formatPreferredContact(method: 'email' | 'phone' | undefined): string | undefined {
if (method === 'email') return 'Email';
if (method === 'phone') return 'Phone call back';
return undefined;
}
function SalesAlertBody({ function SalesAlertBody({
portName, portName,
data, data,
@@ -102,77 +139,65 @@ function SalesAlertBody({
data: ResidentialSalesAlertData; data: ResidentialSalesAlertData;
accent: string; accent: string;
}) { }) {
const labelCell = { color: '#666', width: '140px' } as const; const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
const preferredContact = formatPreferredContact(data.preferredContactMethod);
return ( return (
<> <>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
New residential inquiry <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
A new residential enquiry has come in for <strong>{portName}</strong>. {data.fullName} has
asked us to be in touch - full details below:
</Text> </Text>
<table <div style={{ marginBottom: '16px' }}>
role="presentation" <Text style={detailStyle}>
width="100%" <strong>Name:</strong> {data.fullName}
cellPadding={6} </Text>
cellSpacing={0} <Text style={detailStyle}>
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }} <strong>Email:</strong> {data.email}
> </Text>
<tbody> <Text style={detailStyle}>
<tr> <strong>Telephone:</strong> {data.phone}
<td style={labelCell}>Name</td> </Text>
<td>{data.fullName}</td> {residenceTypes.length > 0 ? (
</tr> <Text style={detailStyle}>
<tr> <strong>Residence type(s):</strong> {residenceTypes.join(', ')}
<td style={labelCell}>Email</td> </Text>
<td>{data.email}</td> ) : null}
</tr> {preferredContact ? (
<tr> <Text style={detailStyle}>
<td style={labelCell}>Phone</td> <strong>Preferred contact:</strong> {preferredContact}
<td>{data.phone}</td> </Text>
</tr> ) : null}
{data.placeOfResidence ? ( {data.placeOfResidence ? (
<tr> <Text style={detailStyle}>
<td style={labelCell}>Residence</td> <strong>Place of residence:</strong> {data.placeOfResidence}
<td>{data.placeOfResidence}</td> </Text>
</tr> ) : null}
) : null} {data.preferences ? (
{data.preferredContactMethod ? ( <Text style={detailStyle}>
<tr> <strong>Preferences:</strong> {data.preferences}
<td style={labelCell}>Prefers</td> </Text>
<td>{data.preferredContactMethod}</td> ) : null}
</tr> {data.notes ? (
) : null} <Text style={detailStyle}>
{data.preferences ? ( <strong>Comments:</strong> {data.notes}
<tr> </Text>
<td style={labelCell}>Preferences</td> ) : null}
<td>{data.preferences}</td> </div>
</tr>
) : null}
{data.notes ? (
<tr>
<td style={labelCell}>Notes</td>
<td>{data.notes}</td>
</tr>
) : null}
</tbody>
</table>
{data.crmDeepLink ? ( {data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
<Button Open the{' '}
<Link
href={safeUrl(data.crmDeepLink)} href={safeUrl(data.crmDeepLink)}
style={{ style={{ color: accent, textDecoration: 'underline' }}
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
> >
Open in CRM {portName} CRM
</Button> </Link>{' '}
</div> to follow up.
</Text>
) : null} ) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text> <Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
</> </>
); );
} }
@@ -189,8 +214,32 @@ export async function residentialSalesAlert(
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, { const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false, pretty: false,
}); });
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
const preferredContact = formatPreferredContact(data.preferredContactMethod);
const text = [
'Hello,',
'',
`A new residential enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch - full details below:`,
'',
`Name: ${data.fullName}`,
`Email: ${data.email}`,
`Telephone: ${data.phone}`,
...(residenceTypes.length > 0 ? [`Residence type(s): ${residenceTypes.join(', ')}`] : []),
...(preferredContact ? [`Preferred contact: ${preferredContact}`] : []),
...(data.placeOfResidence ? [`Place of residence: ${data.placeOfResidence}`] : []),
...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
...(data.notes ? [`Comments: ${data.notes}`] : []),
'',
...(data.crmDeepLink
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
: []),
`- ${portName} CRM`,
].join('\n');
return { return {
subject, subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }), html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
}; };
} }

View File

@@ -0,0 +1,152 @@
/**
* Internal "signing progress" alert — sent to the port's configured
* signing-notification recipients (e.g. the admin + sales@) so staff get
* a heads-up every time a party signs and again when a document is fully
* signed. This is the CRM equivalent of the legacy "Document Signed" /
* "EOI Complete Update Status" Activepieces flows.
*
* Two events:
* - `signed` — a single party just signed (carries who + progress).
* - `completed` — all parties have signed.
*
* Unlike the signer-facing templates, this one links back into the CRM
* (deep link to the document) rather than to a signing page — the
* recipients are staff, not signers.
*/
import { Button, Hr, Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
subject?: string | null;
branding?: BrandingShell | null;
}
export interface StatusNotificationData {
event: 'signed' | 'completed';
documentLabel: string;
/** Deal / client name for the salutation + subject context. */
clientName: string;
portName: string;
/** Deep link into the CRM document detail page. */
crmUrl: string;
/** For `signed`: who just signed. */
signerName?: string | null;
signerRole?: string | null;
/** For `signed`: progress within the signing order. */
signedCount?: number;
totalCount?: number;
}
function roleLabel(role?: string | null): string {
switch (role) {
case 'client':
return 'the client';
case 'developer':
return 'the developer';
case 'approver':
return 'the approver';
case 'witness':
return 'a witness';
default:
return 'a signer';
}
}
function StatusBody({ data, accent }: { data: StatusNotificationData; accent: string }) {
const isCompleted = data.event === 'completed';
const progress =
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
? `${data.signedCount} of ${data.totalCount}`
: null;
const heading = isCompleted
? `${data.documentLabel} fully signed`
: `${data.signerName ?? 'A signer'} has signed`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
{heading}
</Text>
{isCompleted ? (
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The {data.documentLabel} for <strong>{data.clientName}</strong> has now been signed by all
parties. The fully signed PDF has been filed against the deal in the {data.portName} CRM.
</Text>
) : (
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
<strong>{data.signerName ?? 'A signer'}</strong> ({roleLabel(data.signerRole)}) has signed
the {data.documentLabel} for <strong>{data.clientName}</strong>
{progress ? `${progress} signatures collected so far.` : '.'}
</Text>
)}
<div style={{ textAlign: 'center', margin: '28px 0' }}>
<Button
href={safeUrl(data.crmUrl)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 36px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
View in CRM
</Button>
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
Open the deal:{' '}
<Link
href={safeUrl(data.crmUrl)}
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
>
{data.crmUrl}
</Link>
</Text>
<Text style={{ fontSize: '13px', color: '#999', lineHeight: '1.5', marginTop: '14px' }}>
You&apos;re receiving this because you&apos;re on the signing-notification list for{' '}
{data.portName}. An administrator can change who gets these alerts in CRM settings.
</Text>
</>
);
}
export async function signingStatusNotificationEmail(
data: StatusNotificationData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const isCompleted = data.event === 'completed';
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{clientName\}\}/g, data.clientName)
.replace(/\{\{portName\}\}/g, data.portName)
: isCompleted
? `${data.documentLabel} fully signed — ${data.clientName}`
: `${data.signerName ?? 'A signer'} signed the ${data.documentLabel}${data.clientName}`;
const body = await render(<StatusBody data={data} accent={accent} />, { pretty: false });
const progress =
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
? ` (${data.signedCount} of ${data.totalCount} signed)`
: '';
const text = isCompleted
? `The ${data.documentLabel} for ${data.clientName} has been signed by all parties and filed in the ${data.portName} CRM.\n\nView in CRM: ${data.crmUrl}`
: `${data.signerName ?? 'A signer'} (${roleLabel(data.signerRole)}) has signed the ${data.documentLabel} for ${data.clientName}${progress}.\n\nView in CRM: ${data.crmUrl}`;
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

@@ -243,16 +243,19 @@ export interface DocumensoDocument {
/** /**
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient * When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
* email so Documenso doesn't accidentally email real clients during a * email so Documenso doesn't accidentally email real clients during a
* data import / migration dry-run. Names are prefixed with the original * data import / migration dry-run.
* email so the recipient (you) can tell who would have received the doc.
* *
* In production this env var is unset and recipients flow through unchanged. * The NAME is left untouched: a "Name" signature field auto-fills from the
* recipient name and renders into the signed PDF, so any annotation here
* (we used to append "(was: <email>)") leaks into the document and overlaps
* the signature. The original email is captured in the createDocument log
* line instead. In production this env var is unset and recipients flow
* through unchanged.
*/ */
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] { function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
if (!env.EMAIL_REDIRECT_TO) return recipients; if (!env.EMAIL_REDIRECT_TO) return recipients;
return recipients.map((r) => ({ return recipients.map((r) => ({
...r, ...r,
name: `${r.name} (was: ${r.email})`,
email: env.EMAIL_REDIRECT_TO!, email: env.EMAIL_REDIRECT_TO!,
})); }));
} }
@@ -265,11 +268,11 @@ function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoReci
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> { function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
if (!env.EMAIL_REDIRECT_TO) return payload; if (!env.EMAIL_REDIRECT_TO) return payload;
const out: Record<string, unknown> = { ...payload }; const out: Record<string, unknown> = { ...payload };
// 2.x recipient shape // 2.x recipient shape — redirect the email only, keep the name clean (it
// renders into the signed PDF's Name field). See applyRecipientRedirect.
if (Array.isArray(out.recipients)) { if (Array.isArray(out.recipients)) {
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({ out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
...r, ...r,
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
email: env.EMAIL_REDIRECT_TO, email: env.EMAIL_REDIRECT_TO,
})); }));
} }
@@ -288,11 +291,41 @@ function applyPayloadRedirect(payload: Record<string, unknown>): Record<string,
return out; return out;
} }
/**
* Documenso fires its OWN lifecycle emails for every envelope: each event
* below defaults to `true` (verified against the v2.13 OpenAPI + the EOI
* Documenso template's stored meta). The CRM is the SOLE sender of signing
* comms — branded invitations via `sendSigningInvitation`, plus the
* completion / "who signed" alert emails — so we disable ALL of Documenso's
* events at creation time.
*
* Without this, the local-fill pathway (which creates fresh envelopes via
* `createDocument`, unlike the template pathway that inherits the template's
* all-false `emailSettings`) leaks unbranded "Waiting for others" /
* "Signing Complete!" emails — sent with the signed PDF attached from the
* Documenso instance's own account (reply-to sales@) — duplicating ours.
*
* The v2 schema marks every key `required` when the object is present, so
* all nine are listed explicitly.
*/
export const DOCUMENSO_SILENT_EMAIL_SETTINGS = {
recipientSigningRequest: false,
recipientRemoved: false,
recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: false,
ownerRecipientExpired: false,
ownerDocumentCreated: false,
} as const;
/** /**
* Optional metadata applied to the document on creation. v1 accepts * Optional metadata applied to the document on creation. v1 accepts
* `redirectUrl` and `subject`/`message` on its `/documents` endpoint. * `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
* v2's `/envelope/create` accepts the same plus `signingOrder` for * v2's `/envelope/create` accepts the same plus `signingOrder` for
* PARALLEL-vs-SEQUENTIAL signing enforcement. * PARALLEL-vs-SEQUENTIAL signing enforcement. `emailSettings` is always
* forced to `DOCUMENSO_SILENT_EMAIL_SETTINGS` inside `createDocument`.
*/ */
export interface CreateDocumentMeta { export interface CreateDocumentMeta {
subject?: string; subject?: string;
@@ -309,7 +342,14 @@ export async function createDocument(
portId?: string, portId?: string,
meta?: CreateDocumentMeta, meta?: CreateDocumentMeta,
): Promise<DocumensoDocument> { ): Promise<DocumensoDocument> {
const safeRecipients = applyRecipientRedirect(recipients); // Documenso's API requires UPPERCASE recipient roles
// (CC | SIGNER | VIEWER | APPROVER | ASSISTANT). The CRM uses lowercase
// role strings internally ('signer' / 'approver'), so normalize here at the
// API boundary — otherwise create fails with a 400 "Invalid enum value".
const safeRecipients = applyRecipientRedirect(recipients).map((r) => ({
...r,
role: typeof r.role === 'string' ? r.role.toUpperCase() : r.role,
}));
if (env.EMAIL_REDIRECT_TO) { if (env.EMAIL_REDIRECT_TO) {
logger.info( logger.info(
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) }, { redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
@@ -335,16 +375,14 @@ export async function createDocument(
role: r.role, role: r.role,
signingOrder: r.signingOrder || i + 1, signingOrder: r.signingOrder || i + 1,
})), })),
...(meta meta: {
? { // CRM is the sole email sender — Documenso stays silent.
meta: { emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
...(meta.subject ? { subject: meta.subject } : {}), ...(meta?.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}), ...(meta?.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}), ...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}), ...(meta?.signingOrder ? { signingOrder: meta.signingOrder } : {}),
}, },
}
: {}),
}; };
form.append('payload', JSON.stringify(payload)); form.append('payload', JSON.stringify(payload));
form.append( form.append(
@@ -391,8 +429,13 @@ export async function createDocument(
return getDocument(envelopeId, portId); return getDocument(envelopeId, portId);
} }
// v1: existing path. Meta keys are accepted at the top level. // v1: existing path. Meta keys are accepted at the top level. We still send
return documensoFetch( // `document` (base64) for older Documenso servers that store it inline, but
// Documenso 2.x's v1-compat endpoint instead returns a presigned `uploadUrl`
// and expects the PDF bytes to be PUT there (the base64 is ignored). So when
// the create response carries an `uploadUrl`, upload the bytes to it — without
// this the document is created with NO content (signers see a blank PDF).
const raw = (await documensoFetch(
'/api/v1/documents', '/api/v1/documents',
{ {
method: 'POST', method: 'POST',
@@ -400,19 +443,49 @@ export async function createDocument(
title, title,
document: pdfBase64, document: pdfBase64,
recipients: safeRecipients, recipients: safeRecipients,
...(meta?.subject || meta?.message || meta?.redirectUrl meta: {
? { // CRM is the sole email sender — Documenso stays silent.
meta: { emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
...(meta.subject ? { subject: meta.subject } : {}), ...(meta?.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}), ...(meta?.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}), ...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
}, },
}
: {}),
}), }),
}, },
portId, portId,
).then(normalizeDocument); )) as Record<string, unknown>;
const uploadUrl = typeof raw.uploadUrl === 'string' ? raw.uploadUrl : null;
if (uploadUrl) {
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
let putRes: Response;
try {
putRes = await fetchWithTimeout(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/pdf' },
body: pdfBuffer,
});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `v1 createDocument uploadUrl PUT timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!putRes.ok) {
const errText = await putRes.text().catch(() => '');
logger.error(
{ status: putRes.status, err: errText, portId },
'Documenso v1 createDocument uploadUrl PUT failed - document has no content',
);
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `v1 createDocument uploadUrl PUT → ${putRes.status}: ${errText}`,
});
}
}
return normalizeDocument(raw);
} }
export async function generateDocumentFromTemplate( export async function generateDocumentFromTemplate(
@@ -1060,8 +1133,52 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
}); });
} }
const arrayBuffer = await res.arrayBuffer(); // Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }`
return Buffer.from(arrayBuffer); // (a presigned S3 URL), NOT the raw PDF. Older v1 returned the PDF bytes
// directly. Detect by magic bytes: a real PDF starts with `%PDF-`. When it
// doesn't, parse the JSON and follow `downloadUrl` to fetch the actual file.
// Saving the JSON body as the "signed PDF" produced a ~500-byte corrupt file
// that got emailed to every signer + filed in the CRM (audit 2026-06-24).
const firstBuf = Buffer.from(await res.arrayBuffer());
if (firstBuf.subarray(0, 5).toString('latin1') === '%PDF-') {
return firstBuf;
}
let downloadUrl: string | undefined;
try {
downloadUrl = (JSON.parse(firstBuf.toString('utf8')) as { downloadUrl?: string }).downloadUrl;
} catch {
/* body was neither a PDF nor JSON */
}
if (!downloadUrl) {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `${path} returned a ${firstBuf.length}-byte non-PDF body with no downloadUrl`,
});
}
let pdfRes: Response;
try {
pdfRes = await fetchWithTimeout(downloadUrl, {});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `signed-PDF presigned download timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!pdfRes.ok) {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `signed-PDF presigned URL → ${pdfRes.status}`,
});
}
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
if (pdfBuf.subarray(0, 5).toString('latin1') !== '%PDF-') {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `signed-PDF presigned URL returned a ${pdfBuf.length}-byte non-PDF`,
});
}
return pdfBuf;
} }
/** Convenience health-check used by the admin "Test connection" button. /** Convenience health-check used by the admin "Test connection" button.
@@ -1203,8 +1320,6 @@ export interface DocumensoPageDimensions {
height: number; height: number;
} }
const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt
const pageDimensionCache = new Map<string, DocumensoPageDimensions>(); const pageDimensionCache = new Map<string, DocumensoPageDimensions>();
/** Test seam - clears the page-dimension memoization. */ /** Test seam - clears the page-dimension memoization. */
@@ -1212,18 +1327,6 @@ export function __resetDocumensoCachesForTests(): void {
pageDimensionCache.clear(); pageDimensionCache.clear();
} }
async function getPageDimensions(docId: string, portId?: string): Promise<DocumensoPageDimensions> {
const cached = pageDimensionCache.get(docId);
if (cached) return cached;
// v1 doesn't expose page dimensions cleanly via the public API; the auto-
// placement use case is footer-anchored signature fields, where a default A4
// page rendered by Documenso is a safe assumption. Real page dims can be
// wired in a follow-up by parsing the document/document-data endpoints.
void portId;
pageDimensionCache.set(docId, DEFAULT_PAGE_DIMENSIONS);
return DEFAULT_PAGE_DIMENSIONS;
}
/** /**
* Place one or more fields on a Documenso document. Coordinates are PERCENT * Place one or more fields on a Documenso document. Coordinates are PERCENT
* (0-100) and converted to pixels for v1 internally. * (0-100) and converted to pixels for v1 internally.
@@ -1286,16 +1389,23 @@ export async function placeFields(
return; return;
} }
const dims = await getPageDimensions(docId, portId);
for (const f of fields) { for (const f of fields) {
const body = { const body = {
recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId, recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId,
type: f.type, type: f.type,
pageNumber: f.pageNumber, pageNumber: f.pageNumber,
pageX: Math.round((f.pageX / 100) * dims.width), // Documenso 2.x's v1-compat /fields endpoint expects PERCENT coords
pageY: Math.round((f.pageY / 100) * dims.height), // (0-100), the same as v2 — NOT absolute points. (Confirmed live:
pageWidth: Math.round((f.pageWidth / 100) * dims.width), // absolute values like 237 were read as 237% and placed fields far
pageHeight: Math.round((f.pageHeight / 100) * dims.height), // off-page.) Send the percent values straight through.
pageX: f.pageX,
pageY: f.pageY,
pageWidth: f.pageWidth,
pageHeight: f.pageHeight,
// Pass fieldMeta through on v1 too (Documenso 2.x's v1-compat endpoint
// accepts it) so TEXT fields like "Place of Signing" keep their label /
// required / placeholder. Older v1 servers ignore unknown keys.
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
}; };
// Retry transient failures so one flaky 5xx mid-loop doesn't leave // Retry transient failures so one flaky 5xx mid-loop doesn't leave
// the document with a partial field set. 3 attempts at 250 / 500 / // the document with a partial field set. 3 attempts at 250 / 500 /
@@ -1381,6 +1491,93 @@ export function computeDefaultSignatureLayout(
})); }));
} }
/**
* EOI page-3 signature-block layout — the six fields template 8 carries, so
* the in-app pathway (local pdf-lib fill + flatten → upload as a Documenso
* document) produces a signed EOI that matches the legacy template output
* exactly. Coordinates are percent of page, captured verbatim from template 8.
*
* Client (signer 1) gets Signature + Name + Place-of-Signing (TEXT) + Date.
* Developer (signer 2) gets Name + Signature. The approver (signer 3) carries
* no fields. `fieldMeta` is passed through to Documenso (v1 + v2) so the
* Place-of-Signing field keeps its label / required / placeholder.
*/
export function computeEoiSignatureLayout(
clientRecipientId: number | string,
developerRecipientId: number | string,
): DocumensoFieldPlacement[] {
return [
{
recipientId: clientRecipientId,
type: 'SIGNATURE',
pageNumber: 3,
pageX: 39.64497370960451,
pageY: 64.81957098456644,
pageWidth: 21.21662173851308,
pageHeight: 4.303685358613111,
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
},
{
recipientId: clientRecipientId,
type: 'NAME',
pageNumber: 3,
pageX: 14.34911393977768,
pageY: 64.81957098456644,
pageWidth: 24.33234194973456,
pageHeight: 4.303685358613111,
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
},
{
recipientId: clientRecipientId,
type: 'TEXT',
pageNumber: 3,
pageX: 14.49704042881816,
pageY: 57.4932908677896,
pageWidth: 24.4807121661721,
pageHeight: 4.40865329418904,
fieldMeta: {
type: 'text',
label: 'Place of Signing',
readOnly: false,
required: true,
textAlign: 'left',
placeholder: 'Anguilla, AI',
characterLimit: 0,
},
},
{
recipientId: clientRecipientId,
type: 'DATE',
pageNumber: 3,
pageX: 39.79290246256028,
pageY: 57.4932908677896,
pageWidth: 21.06824925816024,
pageHeight: 4.40865329418904,
fieldMeta: { type: 'date', fontSize: 10, overflow: 'auto', textAlign: 'left' },
},
{
recipientId: developerRecipientId,
type: 'NAME',
pageNumber: 3,
pageX: 14.34911393977768,
pageY: 72.56877244919716,
pageWidth: 24.33234194973456,
pageHeight: 3.988781551885322,
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
},
{
recipientId: developerRecipientId,
type: 'SIGNATURE',
pageNumber: 3,
pageX: 39.64497370960451,
pageY: 72.56877244919716,
pageWidth: 21.21662173851308,
pageHeight: 3.988781551885322,
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
},
];
}
/** /**
* Void/cancel a Documenso document. * Void/cancel a Documenso document.
* *

View File

@@ -38,8 +38,10 @@ import {
signingInvitationEmail, signingInvitationEmail,
signingReminderEmail, signingReminderEmail,
} from '@/lib/email/templates/document-signing'; } from '@/lib/email/templates/document-signing';
import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { extractSigningToken } from '@/lib/services/documenso-signers'; import { extractSigningToken } from '@/lib/services/documenso-signers';
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -129,9 +131,14 @@ export interface SigningCompletedArgs {
* Risk #5 - fixing this mapping prevents an `approver` invite from * Risk #5 - fixing this mapping prevents an `approver` invite from
* landing on `/sign/error`. * landing on `/sign/error`.
*/ */
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer' | 'witness'> = { const ROLE_TO_URL_SEGMENT: Record<string, 'client' | 'cc' | 'developer' | 'witness'> = {
client: 'client', client: 'client',
developer: 'developer', developer: 'developer',
// `document_signers.signer_role` persists Documenso's normalized role, so
// the order-2 EOI developer arrives here as 'signer' (not 'developer').
// Without this alias the lookup returned `undefined` and the branded link
// became `…/sign/undefined/<token>` (dead). Map it to the developer page.
signer: 'developer',
approver: 'cc', approver: 'cc',
witness: 'witness', witness: 'witness',
other: 'cc', other: 'cc',
@@ -153,7 +160,9 @@ export function transformSigningUrl(
// Trim trailing slashes off the host so we always produce a clean // Trim trailing slashes off the host so we always produce a clean
// single `/` between segments. // single `/` between segments.
const host = embeddedSigningHost.replace(/\/+$/, ''); const host = embeddedSigningHost.replace(/\/+$/, '');
const urlRole = ROLE_TO_URL_SEGMENT[signerRole]; // Fall back to the passive `cc` page for any unrecognised role rather than
// ever emitting `…/sign/undefined/<token>`.
const urlRole = ROLE_TO_URL_SEGMENT[signerRole] ?? 'cc';
return `${host}/sign/${urlRole}/${token}`; return `${host}/sign/${urlRole}/${token}`;
} }
@@ -329,3 +338,78 @@ export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<
), ),
); );
} }
// ─── Internal status notifications (staff "who signed" alerts) ────────────────
export interface SigningStatusNotificationArgs {
portId: string;
portName: string;
/** `signed` = one party just signed; `completed` = all parties done. */
event: 'signed' | 'completed';
documentLabel: string;
/** Deal / client name for context in the subject + body. */
clientName: string;
/** Deep link into the CRM document detail page. */
crmUrl: string;
/** For `signed`: who just signed + their role + running progress. */
signerName?: string | null;
signerRole?: SignerRole | null;
signedCount?: number;
totalCount?: number;
}
/**
* Notify the port's configured signing-notification recipients (the admin
* + sales@, plus any extras) that a party signed or that a document is
* fully signed. CRM equivalent of the legacy "Document Signed" /
* "EOI Complete Update Status" Activepieces flows.
*
* Recipients come from the `signing_notification_recipients` setting
* (users / roles / emails), falling back to the port's reply-to address
* (`email_reply_to`) so the alert is never silently dropped. No-op when
* nothing resolves. Per-recipient send so the internal list isn't exposed
* across recipients; failures are logged, never thrown (the webhook /
* completion path must not be undone by an email hiccup).
*/
export async function sendSigningStatusNotification(
args: SigningStatusNotificationArgs,
): Promise<void> {
const recipients = await resolveNotificationRecipients(
args.portId,
'signing_notification_recipients',
'email_reply_to',
);
if (recipients.length === 0) return;
const branding = await getBrandingShell(args.portId);
const { subject, html, text } = await signingStatusNotificationEmail(
{
event: args.event,
documentLabel: args.documentLabel,
clientName: args.clientName,
portName: args.portName,
crmUrl: args.crmUrl,
signerName: args.signerName ?? null,
signerRole: args.signerRole ?? null,
signedCount: args.signedCount,
totalCount: args.totalCount,
},
{ branding },
);
const sendLimit = pLimit(3);
await Promise.all(
recipients.map((to) =>
sendLimit(async () => {
try {
await sendEmail(to, subject, html, undefined, text, args.portId);
} catch (err) {
logger.error(
{ err, portId: args.portId, recipient: to, event: args.event },
'Signing status notification send failed',
);
}
}),
),
);
}

View File

@@ -17,11 +17,14 @@ import { emitToRoom } from '@/lib/socket/server';
import { buildStoragePath } from '@/lib/minio'; import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage'; import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getCountryName } from '@/lib/i18n/countries'; import { getCountryName } from '@/lib/i18n/countries';
import { import {
createDocument as documensoCreate, createDocument as documensoCreate,
sendDocument as documensoSend, sendDocument as documensoSend,
generateDocumentFromTemplate as documensoGenerateFromTemplate, generateDocumentFromTemplate as documensoGenerateFromTemplate,
placeFields as documensoPlaceFields,
computeEoiSignatureLayout,
} from '@/lib/services/documenso-client'; } from '@/lib/services/documenso-client';
import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload'; import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { getPortDocumensoConfig } from '@/lib/services/port-config';
@@ -714,7 +717,15 @@ async function generateAndSignViaInApp(
} }
const pdfBase64 = Buffer.concat(chunks).toString('base64'); const pdfBase64 = Buffer.concat(chunks).toString('base64');
// Create Documenso document // Per-port Documenso config for the post-signing redirect + signing order
// (parity with the documenso-template pathway).
const docCfg = await getPortDocumensoConfig(portId);
// Create the Documenso document from the locally-filled + flattened PDF.
// Because the detail fields are flattened by pdf-lib (clean 12pt + multiline
// address wrapping), Documenso never re-renders them — it only collects
// signatures. This is what fixes the auto-sized/clipped detail text the
// Documenso template-fill pathway produced.
const documensoDoc = await documensoCreate( const documensoDoc = await documensoCreate(
template.name, template.name,
pdfBase64, pdfBase64,
@@ -724,10 +735,38 @@ async function generateAndSignViaInApp(
role: s.role, role: s.role,
signingOrder: s.signingOrder, signingOrder: s.signingOrder,
})), })),
portId,
{
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
},
); );
// Place the EOI page-3 signature block. The flattened PDF carries no
// Documenso fields, so place the six fields (client Signature/Name/
// Place-of-Signing/Date, developer Name/Signature) at template 8's
// coordinates, mapped by signing order (1 = client, 2 = developer; the
// approver signs no fields).
if (template.templateType === 'eoi') {
const byOrder = new Map(documensoDoc.recipients.map((r) => [r.signingOrder, r.id]));
const clientRecipientId = byOrder.get(1);
const developerRecipientId = byOrder.get(2);
if (clientRecipientId && developerRecipientId) {
await documensoPlaceFields(
documensoDoc.id,
computeEoiSignatureLayout(clientRecipientId, developerRecipientId),
portId,
);
} else {
logger.warn(
{ docId: documensoDoc.id, recipients: documensoDoc.recipients.length },
'EOI in-app pathway: could not resolve client/developer recipients for signature-field placement',
);
}
}
// Send document for signing // Send document for signing
await documensoSend(documensoDoc.id); await documensoSend(documensoDoc.id, portId);
// Update our document record with Documenso ID and status // Update our document record with Documenso ID and status
await db await db
@@ -740,6 +779,46 @@ async function generateAndSignViaInApp(
}) })
.where(eq(documents.id, documentRecord.id)); .where(eq(documents.id, documentRecord.id));
// Persist per-recipient signer rows so the EOI tab's signing-progress panel
// and the webhook handler (which matches by token / email) work — parity
// with the documenso-template pathway. Strip the `(was: …)` /
// `(placeholder)` suffixes EMAIL_REDIRECT_TO bakes into names.
if (documensoDoc.recipients.length > 0) {
await db.insert(documentSigners).values(
documensoDoc.recipients.map((r) => {
const cleanName = (r.name || r.email)
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
const role =
r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1
? 'client'
: normalizeSignerRole(r.role);
return {
documentId: documentRecord.id,
signerName: cleanName || r.email,
signerEmail: r.email,
signerRole: role,
signingOrder: r.signingOrder,
status: 'pending' as const,
signingUrl: r.signingUrl ?? null,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
invitedAt: null,
};
}),
);
}
// Stamp the interest's EOI milestone so the Overview tab flips to
// "EOI sent / awaiting signatures" — parity with the template pathway.
if (context.interestId) {
await db
.update(interests)
.set({ eoiDocStatus: 'sent', dateEoiSent: new Date(), updatedAt: new Date() })
.where(eq(interests.id, context.interestId));
}
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,
portId, portId,
@@ -794,48 +873,152 @@ async function generateAndSignViaDocumensoTemplate(
// platform to one Documenso instance per CRM process. // platform to one Documenso instance per CRM process.
const docCfg = await getPortDocumensoConfig(portId); const docCfg = await getPortDocumensoConfig(portId);
// v2 prefillFields-by-ID emission requires a field-name → field-ID map // Pick which side of the yacht's stored dimensions ships to the PDF.
// populated by the admin "Sync from Documenso" button. Absent (or partial)
// map → payload skips prefillFields and v2 accepts the legacy formValues
// shape via backward compat.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
// Pick which side of the yacht's stored dimensions ships to Documenso.
// The drawer's toggle drives this; if the caller omitted it, default to // The drawer's toggle drives this; if the caller omitted it, default to
// whichever unit the rep originally typed in (yacht.lengthUnit). Legacy // whichever unit the rep originally typed in (yacht.lengthUnit). Legacy
// yachts without a unit column default to 'ft'. // yachts without a unit column default to 'ft'.
const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft'; const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft';
const payload = buildDocumensoPayload( // Document title used by both fill methods + the documents row.
eoiContext, const docTitle = `Expression of Interest ${eoiContext.client.fullName}`;
let documensoDoc;
let localFileId: string | null = null;
if (docCfg.eoiFillMethod === 'local') {
// LOCAL-FILL (default): fill + flatten the source PDF ourselves (pdf-lib,
// fixed 12pt + multiline address wrapping), upload the flattened PDF to
// Documenso as a document, and place ONLY the page-3 signature fields.
// Documenso never renders the body text, so it can't auto-size/clip it —
// this is the fix for the oversized/clipped detail fields the Documenso
// template-fill produced. Still flows through Documenso for signing, so
// branded invites, embedded signing, webhooks, and emails are unchanged.
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, { dimensionUnit });
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(
port?.slug ?? portId,
'eoi',
context.interestId,
fileId,
'pdf',
);
{ {
interestId: context.interestId, const buffer = Buffer.from(pdfBytes);
clientRecipientId: docCfg.clientRecipientId, const backend = await getStorageBackend();
developerRecipientId: docCfg.developerRecipientId, await backend.put(storagePath, buffer, {
approvalRecipientId: docCfg.approvalRecipientId, contentType: 'application/pdf',
developerName: signers.developer.name, sizeBytes: buffer.length,
developerEmail: signers.developer.email, });
approverName: signers.approver.name, }
approverEmail: signers.approver.email, const [fileRecord] = await db
// Prefer per-port post-signing redirect (typically marketing-site .insert(files)
// /sign/success on v2). Falls back to APP_URL on v1 / when unset. .values({
redirectUrl: docCfg.redirectUrl ?? env.APP_URL, portId,
// v2-only signing-order enforcement. v1 instances ignore this key. clientId: context.clientId ?? null,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), filename: 'expression-of-interest.pdf',
dimensionUnit, originalName: 'Expression of Interest.pdf',
}, mimeType: 'application/pdf',
fieldMap, sizeBytes: String(pdfBytes.byteLength),
); storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
localFileId = fileRecord!.id;
const documensoDoc = await documensoGenerateFromTemplate( const created = await documensoCreate(
docCfg.eoiTemplateId, docTitle,
payload as unknown as Record<string, unknown>, Buffer.from(pdfBytes).toString('base64'),
portId, [
); {
name: eoiContext.client.fullName,
email: eoiContext.client.primaryEmail ?? '',
role: 'signer',
signingOrder: 1,
},
{
name: signers.developer.name,
email: signers.developer.email,
role: 'signer',
signingOrder: 2,
},
{
name: signers.approver.name,
email: signers.approver.email,
role: 'approver',
signingOrder: 3,
},
],
portId,
{
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
},
);
// Record a documents row referencing the Documenso document. No local file - // Place the six page-3 signature fields at template-8 coordinates, mapped
// Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere). // by signing order (1 = client, 2 = developer; approver signs no fields).
const byOrder = new Map(created.recipients.map((r) => [r.signingOrder, r.id]));
const clientRid = byOrder.get(1);
const developerRid = byOrder.get(2);
if (clientRid && developerRid) {
await documensoPlaceFields(
created.id,
computeEoiSignatureLayout(clientRid, developerRid),
portId,
);
} else {
logger.warn(
{ docId: created.id, recipients: created.recipients.length },
'EOI local-fill: could not resolve client/developer recipients for field placement',
);
}
// v2 envelopes don't return signing URLs until distribute; v1 returns them
// on create. Distribute (suppressing Documenso's own emails via
// distributionMethod:NONE on v2 / DRAFT-stays-quiet on v1) only when
// they're missing, so document_signers.signing_url is populated for the
// branded "Send invitation" flow regardless of API version.
const needsDistribute = created.recipients.some((r) => !r.signingUrl);
documensoDoc = needsDistribute ? await documensoSend(created.id, portId) : created;
} else {
// DOCUMENSO TEMPLATE FILL (legacy fallback, eoi_fill_method='documenso'):
// Documenso fills the template's AcroForm fields from the payload. Note it
// auto-sizes/clips long values — kept only as a per-port escape hatch.
// v2 prefillFields-by-ID needs a field-name → field-ID map from the admin
// "Sync from Documenso" button; absent it, v2 ignores the legacy formValues.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
const payload = buildDocumensoPayload(
eoiContext,
{
interestId: context.interestId,
clientRecipientId: docCfg.clientRecipientId,
developerRecipientId: docCfg.developerRecipientId,
approvalRecipientId: docCfg.approvalRecipientId,
developerName: signers.developer.name,
developerEmail: signers.developer.email,
approverName: signers.approver.name,
approverEmail: signers.approver.email,
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
dimensionUnit,
},
fieldMap,
);
documensoDoc = await documensoGenerateFromTemplate(
docCfg.eoiTemplateId,
payload as unknown as Record<string, unknown>,
portId,
);
}
// Record a documents row referencing the Documenso document. Local-fill
// attaches the flattened PDF we stored; template-fill has no local file
// (Documenso owns the PDF; signed copy arrives via webhook).
const [documentRecord] = await db const [documentRecord] = await db
.insert(documents) .insert(documents)
.values({ .values({
@@ -843,8 +1026,9 @@ async function generateAndSignViaDocumensoTemplate(
clientId: context.clientId ?? null, clientId: context.clientId ?? null,
interestId: context.interestId, interestId: context.interestId,
documentType: 'eoi', documentType: 'eoi',
title: payload.title, title: docTitle,
status: 'sent', status: 'sent',
fileId: localFileId,
documensoId: documensoDoc.id, documensoId: documensoDoc.id,
documensoNumericId: documensoDoc.numericId, documensoNumericId: documensoDoc.numericId,
isManualUpload: false, isManualUpload: false,

View File

@@ -45,6 +45,8 @@ import {
import { import {
sendSigningInvitation, sendSigningInvitation,
sendSigningCompleted, sendSigningCompleted,
sendSigningStatusNotification,
transformSigningUrl,
type SignerRole, type SignerRole,
} from '@/lib/services/document-signing-emails.service'; } from '@/lib/services/document-signing-emails.service';
import { import {
@@ -1062,10 +1064,23 @@ export async function uploadSignedManually(
export async function listDocumentSigners(documentId: string, portId: string) { export async function listDocumentSigners(documentId: string, portId: string) {
await getDocumentById(documentId, portId); // verify access await getDocumentById(documentId, portId); // verify access
return db.query.documentSigners.findMany({ const rows = await db.query.documentSigners.findMany({
where: eq(documentSigners.documentId, documentId), where: eq(documentSigners.documentId, documentId),
orderBy: (ds, { asc }) => [asc(ds.signingOrder)], orderBy: (ds, { asc }) => [asc(ds.signingOrder)],
}); });
// Surface the BRANDED marketing-site embed URL (the same wrap the
// invitation email applies) rather than the bare Documenso link, so the
// EOI tab's "Copy link" shares the on-brand signing page. No-op when the
// port has no embeddedSigningHost configured (transformSigningUrl returns
// the raw URL unchanged).
const { embeddedSigningHost } = await getPortDocumensoConfig(portId);
return rows.map((r) => ({
...r,
signingUrl: r.signingUrl
? transformSigningUrl(r.signingUrl, embeddedSigningHost, r.signerRole as SignerRole)
: r.signingUrl,
}));
} }
// ─── List Events ────────────────────────────────────────────────────────────── // ─── List Events ──────────────────────────────────────────────────────────────
@@ -1248,6 +1263,14 @@ export async function handleRecipientSigned(eventData: {
'cascading "your turn" invite failed after recipient signed', 'cascading "your turn" invite failed after recipient signed',
); );
}); });
// Internal "who signed" alert to the port's signing-notification
// recipients (admin + sales@). Fire-and-forget + fully guarded inside
// the helper so it can't undo the signing that just succeeded.
void notifySigningStatus(doc, 'signed', {
name: signer.signerName,
role: signer.signerRole,
});
} }
} }
@@ -1345,6 +1368,70 @@ async function sendCascadingInviteForNextSigner(doc: {
} }
} }
/**
* Fire the internal "signing progress" alert to the port's configured
* signing-notification recipients (admin + sales@, etc). Self-contained
* and fully guarded — a notification failure must never undo a signing /
* completion side effect, so all errors are swallowed + logged. Resolves
* the deal client name + a deep CRM link, and (for `signed`) the running
* signed/total progress.
*/
async function notifySigningStatus(
doc: Parameters<typeof resolveDocumentOwner>[1] & {
id: string;
portId: string;
documentType: string;
title: string;
},
event: 'signed' | 'completed',
signer?: { name: string; role: string | null } | null,
): Promise<void> {
try {
const port = await db.query.ports.findFirst({
where: eq(ports.id, doc.portId),
columns: { name: true, slug: true },
});
let clientName = doc.title;
const owner = await resolveDocumentOwner(doc.portId, doc);
if (owner?.entityType === 'client') {
const client = await db.query.clients.findFirst({
where: eq(clients.id, owner.entityId),
columns: { fullName: true },
});
if (client?.fullName) clientName = client.fullName;
}
let signedCount: number | undefined;
let totalCount: number | undefined;
if (event === 'signed') {
const all = await db
.select({ status: documentSigners.status })
.from(documentSigners)
.where(eq(documentSigners.documentId, doc.id));
totalCount = all.length;
signedCount = all.filter((s) => s.status === 'signed').length;
}
const crmUrl = `${env.APP_URL ?? ''}/${port?.slug ?? ''}/documents/${doc.id}`;
await sendSigningStatusNotification({
portId: doc.portId,
portName: port?.name ?? 'Port Nimara',
event,
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
clientName,
crmUrl,
signerName: signer?.name ?? null,
signerRole: (signer?.role as SignerRole) ?? null,
signedCount,
totalCount,
});
} catch (err) {
logger.error({ err, documentId: doc.id, event }, 'signing status notification failed');
}
}
/** /**
* Manually (re)send the finalized signed PDF of a completed document to the * Manually (re)send the finalized signed PDF of a completed document to the
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted) * deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
@@ -1924,6 +2011,11 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
}), }),
); );
} }
// Internal "fully signed" alert to the port's signing-notification
// recipients (admin + sales@). handleDocumentCompleted is idempotent
// (early-returns on re-delivery), so this fires exactly once per doc.
void notifySigningStatus(doc, 'completed');
} }
export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) { export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) {

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

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

@@ -49,6 +49,11 @@ export const SETTING_KEYS = {
// timing-safe comparison. // timing-safe comparison.
documensoWebhookSecret: 'documenso_webhook_secret', documensoWebhookSecret: 'documenso_webhook_secret',
eoiDefaultPathway: 'eoi_default_pathway', eoiDefaultPathway: 'eoi_default_pathway',
// EOI body-text fill method: 'local' (CRM fills + flattens the PDF, clean
// 12pt + multiline address wrap, Documenso signs only) vs 'documenso'
// (legacy: Documenso fills the template AcroForm fields and auto-sizes /
// clips them). Toggleable per-port in admin → Documenso.
eoiFillMethod: 'eoi_fill_method',
// Identity of the developer + approver that the template's static // Identity of the developer + approver that the template's static
// recipient slots get filled with. Old system hardcoded these // recipient slots get filled with. Old system hardcoded these
// (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys // (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys
@@ -316,6 +321,16 @@ export interface PortDocumensoConfig {
apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none'; apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none';
eoiTemplateId: number; eoiTemplateId: number;
defaultPathway: EoiPathway; defaultPathway: EoiPathway;
/**
* EOI body-text fill method:
* - 'local' : CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
* multiline address wrapping), then uploads the flattened PDF
* to Documenso for signature placement only. Renders cleanly.
* - 'documenso': legacy — Documenso fills the template's AcroForm fields via
* the template-generate API (auto-sizes the text → clips it).
* Toggleable per-port in admin → Documenso. Defaults to 'local'.
*/
eoiFillMethod: 'local' | 'documenso';
/** Documenso template recipient slot IDs (per-instance numeric). */ /** Documenso template recipient slot IDs (per-instance numeric). */
clientRecipientId: number; clientRecipientId: number;
developerRecipientId: number; developerRecipientId: number;
@@ -387,6 +402,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
developerRecipientId, developerRecipientId,
approvalRecipientId, approvalRecipientId,
defaultPathway, defaultPathway,
eoiFillMethod,
developerName, developerName,
developerEmail, developerEmail,
approverName, approverName,
@@ -411,6 +427,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId), readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId),
readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId), readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId),
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId), readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
readSetting<'local' | 'documenso'>(SETTING_KEYS.eoiFillMethod, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId), readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId), readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
readSetting<string>(SETTING_KEYS.documensoApproverName, portId), readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
@@ -464,6 +481,9 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approvalRecipientId: approvalRecipientId:
toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0, toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0,
defaultPathway: defaultPathway ?? 'documenso-template', defaultPathway: defaultPathway ?? 'documenso-template',
// Default to the local-fill method (clean render + address wrapping). Set
// to 'documenso' per-port to fall back to Documenso's template AcroForm fill.
eoiFillMethod: eoiFillMethod === 'documenso' ? 'documenso' : 'local',
developerName: developerName ?? '', developerName: developerName ?? '',
developerEmail: developerEmail ?? '', developerEmail: developerEmail ?? '',
approverName: approverName ?? '', approverName: approverName ?? '',

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

@@ -151,7 +151,12 @@ export async function sendWebsiteSubmissionEmails(
if (kind === 'residence_inquiry') { if (kind === 'residence_inquiry') {
if (fields.email) { if (fields.email) {
const confirmation = await residentialClientConfirmation( const confirmation = await residentialClientConfirmation(
{ firstName: fields.firstName, contactEmail, portName }, {
firstName: fields.firstName,
contactEmail,
residenceTypes: fields.residenceTypes,
portName,
},
{ branding }, { branding },
); );
const subject = await resolveSubject({ const subject = await resolveSubject({
@@ -160,7 +165,14 @@ export async function sendWebsiteSubmissionEmails(
fallback: confirmation.subject, fallback: confirmation.subject,
tokens: { portName, recipientName: fields.firstName }, tokens: { portName, recipientName: fields.firstName },
}); });
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId); await sendEmail(
fields.email,
subject,
confirmation.html,
undefined,
confirmation.text,
portId,
);
} }
const recipients = await resolveRecipients(portId, 'residential_notification_recipients'); const recipients = await resolveRecipients(portId, 'residential_notification_recipients');
@@ -170,6 +182,8 @@ export async function sendWebsiteSubmissionEmails(
fullName: fields.fullName, fullName: fields.fullName,
email: fields.email, email: fields.email,
phone: fields.phone, phone: fields.phone,
residenceTypes: fields.residenceTypes,
preferredContactMethod: fields.preferredContact ?? undefined,
placeOfResidence: fields.placeOfResidence ?? undefined, placeOfResidence: fields.placeOfResidence ?? undefined,
notes: fields.comments ?? undefined, notes: fields.comments ?? undefined,
crmDeepLink: crmUrl, crmDeepLink: crmUrl,
@@ -183,7 +197,7 @@ export async function sendWebsiteSubmissionEmails(
fallback: alert.subject, fallback: alert.subject,
tokens: { portName, clientName: fields.fullName, email: fields.email, phone: fields.phone }, tokens: { portName, clientName: fields.fullName, email: fields.email, phone: fields.phone },
}); });
await sendEmail(recipients, subject, alert.html, undefined, undefined, portId); await sendEmail(recipients, subject, alert.html, undefined, alert.text, portId);
} }
return; return;
} }
@@ -206,7 +220,11 @@ export async function sendWebsiteSubmissionEmails(
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId); await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
} }
const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients'); // Contact-form alerts go to their own recipient list (the website routed
// them to hello@ separately from berth alerts). Falls back to
// inquiry_notification_recipients only via the shared resolver's
// inquiry_contact_email fallback when unset.
const recipients = await resolveRecipients(portId, 'contact_notification_recipients');
if (recipients.length > 0) { if (recipients.length > 0) {
const alert = await contactFormSalesAlert( const alert = await contactFormSalesAlert(
{ {

View File

@@ -24,6 +24,10 @@ export interface InquiryFields {
comments: string | null; comments: string | null;
/** The contact form's `interest` (string or string[]) joined for display. */ /** The contact form's `interest` (string or string[]) joined for display. */
interestType: string | null; interestType: string | null;
/** The residence form's `residence_types` multi-select (villa types chosen). */
residenceTypes: string[];
/** The register form's `method_of_contact` preference: 'email' | 'phone'. */
preferredContact: 'email' | 'phone' | null;
} }
function str(value: unknown): string { function str(value: unknown): string {
@@ -44,6 +48,20 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
? rawInterest.filter((v): v is string => typeof v === 'string').join(', ') || null ? rawInterest.filter((v): v is string => typeof v === 'string').join(', ') || null
: str(rawInterest) || null; : str(rawInterest) || null;
// The residence form posts `residence_types` as an array of villa-type
// strings. Defensively coerce a lone string to a single-item array so a
// future single-select form variant still maps cleanly.
const rawResidenceTypes = payload.residence_types;
const residenceTypes = Array.isArray(rawResidenceTypes)
? rawResidenceTypes.filter((v): v is string => typeof v === 'string' && v.trim() !== '')
: str(rawResidenceTypes)
? [str(rawResidenceTypes)]
: [];
const methodOfContact = str(payload.method_of_contact).toLowerCase();
const preferredContact =
methodOfContact === 'email' ? 'email' : methodOfContact === 'phone' ? 'phone' : null;
return { return {
firstName, firstName,
lastName, lastName,
@@ -54,5 +72,7 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
placeOfResidence, placeOfResidence,
comments, comments,
interestType, interestType,
residenceTypes,
preferredContact,
}; };
} }

View File

@@ -204,6 +204,20 @@ export const REGISTRY: SettingEntry[] = [
scope: 'port', scope: 'port',
defaultValue: 'documenso-template', defaultValue: 'documenso-template',
}, },
{
key: 'eoi_fill_method',
section: 'documenso.templates',
label: 'EOI form fill method',
description:
"How the EOI's detail fields (name, address, yacht, berth) get filled in. Local fill (recommended) = the CRM fills + flattens the PDF itself at a fixed 12pt with multiline address wrapping, then sends it to Documenso for signatures only — text renders cleanly. Documenso template fill = Documenso fills the template's form fields and auto-sizes the text, which oversizes/clips long values. Both still go through Documenso for signing, branded emails, and embedded signing.",
type: 'radio',
options: [
{ value: 'local', label: 'Local fill — clean text + address wrapping (recommended)' },
{ value: 'documenso', label: 'Documenso template fill — legacy (may clip long values)' },
],
scope: 'port',
defaultValue: 'local',
},
{ {
key: 'eoi_send_mode', key: 'eoi_send_mode',
section: 'documenso.templates', section: 'documenso.templates',
@@ -688,7 +702,7 @@ export const REGISTRY: SettingEntry[] = [
section: 'operations.intake', section: 'operations.intake',
label: 'CRM-owned website inquiry emails', label: 'CRM-owned website inquiry emails',
description: description:
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients / residential_notification_recipients (fallback inquiry_contact_email).', 'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients (berth) / contact_notification_recipients (contact form) / residential_notification_recipients (residences), each falling back to inquiry_contact_email.',
type: 'boolean', type: 'boolean',
scope: 'port', scope: 'port',
defaultValue: false, defaultValue: false,

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

@@ -69,12 +69,28 @@ export const DEFAULT_RESIDENTIAL_PIPELINE_STAGES = [
/** Backwards-compat alias kept for any existing imports. */ /** Backwards-compat alias kept for any existing imports. */
export const PIPELINE_STAGES = DEFAULT_RESIDENTIAL_PIPELINE_STAGES; export const PIPELINE_STAGES = DEFAULT_RESIDENTIAL_PIPELINE_STAGES;
/**
* Residence unit types offered at Port Nimara. Single source of truth for the
* residential interest's `residenceType` field + the residential UI select.
* Mirrors (intentionally duplicated, separate repo) the website register form's
* multi-select options.
*/
export const RESIDENCE_TYPES = [
'Two Bedroom Marina Villa',
'Four Bedroom Oceanfront Villa',
'Five Bedroom Oceanfront Villa',
] as const;
export const createResidentialInterestSchema = z.object({ export const createResidentialInterestSchema = z.object({
residentialClientId: z.string().min(1), residentialClientId: z.string().min(1),
pipelineStage: z.string().optional().default('new'), pipelineStage: z.string().optional().default('new'),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(), source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
notes: z.string().optional(), notes: z.string().optional(),
preferences: z.string().optional(), preferences: z.string().optional(),
// Accept the known unit types or null/'' (cleared via the inline select).
residenceType: z
.preprocess((v) => (v === '' ? null : v), z.enum(RESIDENCE_TYPES).nullable())
.optional(),
assignedTo: z.string().optional(), assignedTo: z.string().optional(),
}); });

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

@@ -27,8 +27,16 @@ function buildCspWithNonce(nonce: string, isProd: boolean): string {
scriptSrc, scriptSrc,
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"font-src 'self' data:", // https: so react-pdf/pdf.js can pull its standard-font pack (the PDF
// viewers fetch LiberationSans etc. from a CDN) and port-branding fonts.
"font-src 'self' data: https:",
connectSrc, connectSrc,
// PDF previews (signed EOIs etc.) iframe a presigned storage URL, and the
// embedded-signing card iframes the Documenso host. Both are per-port /
// per-env hosts, so allow https: (matching img-src/connect-src). This is
// what WE may embed; frame-ancestors 'none' below still blocks others
// from embedding us.
"frame-src 'self' blob: https:",
"frame-ancestors 'none'", "frame-ancestors 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",

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

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

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