302 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:49:22 +02:00
7591231c47 test(e2e): add Initiative 4 end-to-end + integration specs
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m12s
Build & Push Docker Images / build-and-push (push) Successful in 8m24s
Sales-process coverage (launch-readiness Initiative 4):
- exhaustive: full 7-stage sales journey + illegal-skip rejection + deposit
  total + tenancy/berth-sold; multi-berth EOI berth-range; EOI pathway parity
  (in-app vs Documenso, shared EoiContext); mobile-viewport journey.
- realapi (Documenso-gated, opt-in): generate-and-sign + post-EOI stages.
- integration: Documenso DOCUMENT_COMPLETED webhook idempotency (3x replay ->
  single file/audit write); storage backend swap (s3 <-> filesystem) with a
  real on-disk filesystem round-trip.
- visual: Reports UI snapshot cases (baselines captured separately).

1615 unit/integration pass; tsc + lint clean. Test-only change (specs are not
bundled into the app image) - no app behavior modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:10:35 +02:00
2e8c4b43bf fix(backup): install pg_dump (postgresql16-client) in app + worker images
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 8m54s
The DR backup engine spawns `pg_dump` (backup.service.ts), but neither
runner image installed a postgres client — so producing a bundle fails
in prod with ENOENT (only worked in dev, where the host has pg_dump).
Surfaced by testing the feature on the live prod container.

Add `postgresql16-client` (pg_dump 16.x, matched to the postgres:16
server) to the runner stage of Dockerfile (crm-app: on-demand export +
"back up now") and Dockerfile.worker (scheduled backup-push cron).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:58:14 +02:00
fe863a588e feat(backup): full DR bundle export + admin-configurable offsite destinations
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s
Backend-agnostic disaster-recovery backup engine that runs on the current
storage backend (no storage cutover required):

- Full-bundle export: db.dump (pg_dump custom) + every storage blob +
  manifest.json with per-object SHA-256, streamed as a tar. Entry points:
  admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts.
- Admin-configurable push destinations (backup_destinations table, migration
  0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted
  path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM
  at rest; API returns only *IsSet markers.
- Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) +
  scripts/decrypt-backup.ts for restore.
- Wired the previously-dead database-backup cron to runScheduledBackupPush
  (push to enabled destinations, prune to retention, alert super-admins on
  failure).

Tests: 1608 unit/integration pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:42 +02:00
05950ae0b6 feat(uat): file preview/download fix, clients-by-country page, residential column picker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m42s
Build & Push Docker Images / build-and-push (push) Successful in 7m20s
Batch #4 UAT items.

1. Documents — clicking any file dumped raw presigned-URL JSON. Was
   systemic: 6 surfaces linked a browser directly at the JSON-returning
   /files/[id]/{download,preview} routes. Those routes now 302-redirect
   when called with ?redirect=1 (default stays JSON for the dialog +
   interest-eoi-tab programmatic consumers); the six <Link> sites use it.
   The documents-hub file row now opens the inline FilePreviewDialog +
   has a per-row Download button, and the preview dialog header gained a
   persistent Download button for all file types.

2. Clients-by-country — the widget's "+N more" dead text is now a
   "Show all" link to a new /clients/by-country page rendering the full
   ranked country breakdown (each row drills into the filtered list).

3. Residential clients list — moved off its bespoke table onto the
   shared DataTable + ColumnPicker (same UX as clients/interests). Adds
   a "Date added" column, default-hides the empty "Residence" column,
   preserves the mobile card view, persists per-user column choices.

tsc clean, eslint clean, 1584/1584 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:34:47 +02:00
eff57af571 fix(storage): make S3 server-side-encryption optional (default off)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m46s
Build & Push Docker Images / build-and-push (push) Successful in 7m53s
Prod MinIO has no KMS/KES, so the unconditional
`x-amz-server-side-encryption: AES256` header on every PutObject was
rejected with `NotImplemented` ("KMS not configured") — breaking ALL
server-side uploads on prod: avatars, the signed-PDF deposit on
Documenso completion, GDPR exports, the nightly DB backup, generated
EOI/contract PDFs, report renders. Reads/presigned downloads were
unaffected, so the cutover walkthrough missed it.

The SSE header is now sent only when explicitly configured via the
per-port `storage_s3_sse` setting (or the STORAGE_S3_SSE env fallback);
the default is off so a vanilla S3-compatible backend accepts uploads.
This also resolves the put()-encrypts-but-presignUpload-doesn't
asymmetry — presigned PUTs never sent SSE, so both paths now match by
default.

Extracted buildPutObjectMetadata() as a pure, unit-tested helper.

Interim fix; the planned filesystem-storage migration removes SSE from
the prod path entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:08:41 +02:00
1750e265e7 feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m45s
Build & Push Docker Images / build-and-push (push) Successful in 8m11s
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
  toggleable, default-open) + explicit download. Interest Documents tab
  already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
  linked-berth rows show an amber "Pin overrides pitch" badge + corrected
  consequence copy when a berth is specifically-pitched but manually pinned
  (the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
  registry + admin Settings toggle, maintenance-module.service, port-provider
  useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
  Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
  deal (name + stage + in-EOI/primary + link); single stays a direct link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00
2a7f922a01 fix(uat): dashboard snapshots current-state, pulse-chip gate, phone display, chip width
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m54s
Build & Push Docker Images / build-and-push (push) Successful in 8m10s
- pipeline funnel: count active interests by current stage (drop created_at
  window) — backfill had collapsed it to early stages (UAT 2026-06-03)
- pipeline value tile: render current-state (don't thread the date range)
- deal pulse chip: gate on the pulse_enabled master toggle (default ON) —
  was rendering even when admin turned it off; useFeatureFlag gains a
  default arg + the feature-flag endpoint a ?default= param (default-ON safe)
- contact phone display: show international format + country flag (E164),
  not the bare national format that hid the country
- berths: remove the dead row-density toggle; widen "Under offer to" chip on
  desktop so client names aren't truncated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:27:56 +02:00
39c19b2340 feat(berths): click-to-change status from the list (chip → reason modal)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m11s
Build & Push Docker Images / build-and-push (push) Successful in 13m12s
Adds BerthStatusQuickEdit — wraps the status chip on the berths list (card +
table) in a click target that opens a compact change-status dialog: status
dropdown + required reason (quick-pick chips) + optional interest link when
moving to under_offer/sold. Reuses the existing PATCH /api/v1/berths/[id]/status
endpoint + validator + audit (same capability the detail page already had).
Gated by berths.edit (non-editors see a plain chip); stops click propagation
so it doesn't also navigate into the berth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:01:40 +02:00
d1f6d6a427 feat(eoi): signed-EOI hero + send-signed-copy; fix search dropdown z-order
- EOI tab: when an EOI is already signed and none is in flight, lead with a
  SignedEoiCard (preview + download + send-to-client) instead of the big
  "Generate EOI" empty state; quiet "Generate new EOI" remains for re-issue
- history rows + hero gain a "Send to client" action — POST
  /api/v1/documents/[id]/send-signed-copy emails the deal's client the
  finalized signed PDF (sendSignedCopyToClient reuses sendSigningCompleted),
  guarded by a confirm
- topbar: header gets z-30 so the global search dropdown paints above page
  content (charts/tables were bleeding through — header + main are sibling
  normal-flow boxes, so the dropdown's own z-50 couldn't win cross-context).
  Stays below the z-50 modal tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:55:28 +02:00
3b227fe9b2 feat(files): in-app .docx preview + allow office/text mimes
- .docx now renders client-side via docx-preview (fetches bytes from our
  own storage; works with private MinIO/disk). Drops Microsoft's hosted
  Office viewer which can't reach a private object store.
- add office (.docx/.doc/.xlsx/.xls) + text/csv to PREVIEWABLE_MIMES so
  /api/v1/files/[id]/preview returns a URL instead of rejecting them
  (was surfacing as a misleading "Failed to load preview")
- legacy .doc + spreadsheets fall through to a download CTA (can't render
  client-side); text/csv use the existing TextPreview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:45:11 +02:00
95724c8e3a fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint
- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:41:31 +02:00
93c6554c95 fix(ui+alerts+email): prod walkthrough batch
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m14s
Build & Push Docker Images / build-and-push (push) Successful in 9m10s
- proxy/banner: interest-berth-status-banner used the interest_berths
  junction id for /api/v1/berths/{id}/active-interests (404 on every
  interest with a sold/under-offer berth). Add berthId to BerthRow and use
  it for both the active-interests query and the BerthOccupancyChip.
- scroll-area: override Radix viewport `display:table` (`[&>div]:!block`) so
  content respects the viewport width — fixes notification alert cards
  overflowing past the popover. No horizontal-scroll ScrollArea in the app.
- alert-card: drop the raw `interest.stale` rule key from the footer
  (plaintext only; the title already conveys the alert).
- alert-rules (interest.stale): add a createdAt >14d floor so a bulk import
  that backdates dateLastContact doesn't instantly flag every migrated
  interest as stale and flood the alert rail. 14-day clock starts no earlier
  than when the interest entered this system.
- env: allow EMAIL_REDIRECT_TO in production behind an explicit
  ALLOW_PROD_EMAIL_REDIRECT=true opt-in (beta: route all outbound mail to
  the operator inbox; default still refuses the footgun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:09:16 +02:00
72028a7f32 fix(proxy): trust forwarded Host header for CSRF origin check
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m53s
Build & Push Docker Images / build-and-push (push) Successful in 7m22s
The previous attempt compared the Origin host against request.nextUrl.host,
but behind the custom-server + reverse-proxy setup nextUrl.host does NOT
resolve to the public host (mutations stayed 403 in prod). Accept the
Origin/Referer host if it matches ANY of: the forwarded Host header
(nginx sets `proxy_set_header Host $host` → crm.portnimara.com), APP_URL's
host, or nextUrl.host. The Host header is the reliable source here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:10:57 +02:00
d485695357 fix: CSRF host-compare behind proxy + default port = creation order
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 7m30s
Two prod-only breakages found after go-live:

1. CSRF guard rejected EVERY /api/v1 mutation ("Cross-origin state-changing
   request rejected", 403) — making the CRM read-only. It compared the
   browser Origin (https://crm.portnimara.com) against request.nextUrl.origin,
   but TLS terminates at nginx so the app sees http://127.0.0.1 → protocol
   mismatch. Compare hosts instead (Host header survives the proxy; a
   cross-site attacker can't forge the browser-set Origin host).

2. Post-login landed on port-amador (empty tenant), not port-nimara. Three
   queries ordered ports by name (alphabetical → Amador first): the bare
   /dashboard redirect (app/dashboard/page.tsx), the dashboard layout's
   defaultPortId, and /api/v1/me/ports. Order by createdAt so the primary
   (first-seeded) port — Port Nimara — leads, matching listPorts().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:38:12 +02:00
23a5811342 fix(proxy): accept the __Secure- prefixed session cookie in production
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m35s
Build & Push Docker Images / build-and-push (push) Successful in 13m38s
The auth gate read only `pn-crm.session_token`, but better-auth prefixes
the cookie `__Secure-pn-crm.session_token` whenever it issues secure
cookies (production/HTTPS). So in prod every authenticated request was
bounced to /login — sign-in returned 200 + Set-Cookie, but the gate
couldn't see the (prefixed) cookie on the next navigation. Worked in dev
(HTTP → no prefix). Check both names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:13:22 +02:00
102ee493f8 fix(ports): list ports in creation order so the primary port leads
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m43s
Build & Push Docker Images / build-and-push (push) Successful in 7m31s
listPorts() ordered alphabetically, putting "Port Amador" ahead of
"Port Nimara" in the switcher and as the default a super-admin lands on.
Order by creation instead (oldest first) so the first-seeded port — the
primary tenant, Port Nimara — leads. Name is a tiebreaker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:51:12 +02:00
c70eb1f945 fix(docker): merge prod deps into standalone node_modules (not replace)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m44s
Build & Push Docker Images / build-and-push (push) Successful in 6m53s
Replacing the Next standalone node_modules broke turbopack's externalized-
module resolution: the standalone tree is a matched set with the turbopack
server chunks, resolving externals (better-auth, postgres, pino, minio, ...)
by hashed id. With it replaced, every route using them 500'd with
"Failed to load external module <pkg>-<hash>" — confirmed on prod, while
`node .next/standalone/server.js` with the intact tree serves GET / (307)
and /api/health (200) cleanly.

So keep the standalone tree intact and MERGE the complete hoisted prod tree
in with `rsync --ignore-existing`: it adds the custom server's missing CJS
requires (socket.io closure: accepts/ws/engine.io/cors; drizzle-orm/index.cjs)
and skips everything the trace already provides — and tolerates the trace's
pnpm symlinks, where COPY/cp/tar/fs.cpSync all error on symlink-vs-dir.

Validated end-to-end on a host assembly of (intact standalone + merged prod
deps + the polyfilled server bundle): GET / → 307, /api/health → 200, zero
"Failed to load external module", zero MODULE_NOT_FOUND, server listening.
rsync --ignore-existing merge semantics verified in node:20-alpine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:31:33 +02:00
42baaf7bfc fix(docker): complete prod node_modules for the custom server
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m49s
Build & Push Docker Images / build-and-push (push) Successful in 11m22s
Follow-up to the NODE_PATH attempt, which fixed 'accepts' but not the
general case: server-custom.js is CJS (esbuild --packages=external) and
require()s deps the Next standalone trace ships ESM-only or omits, e.g.
drizzle-orm/index.cjs (present-but-incomplete in the traced tree, so a
NODE_PATH fallback can't rescue it). Replace the traced node_modules with
the complete hoisted prod tree so every external resolves.

That tree is prod-only, so move @next/bundle-analyzer (required at runtime
by next.config — its import is unconditional even though enabled is gated
on ANALYZE) from devDependencies to dependencies; otherwise the standalone
config load throws MODULE_NOT_FOUND in prod.

Validated end-to-end on a host prod install + standalone assembly: socket
server boots, Socket.io initializes, HTTP listens, /api/health → 200, no
MODULE_NOT_FOUND, no AsyncLocalStorage invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:03:11 +02:00
319fd7fd1a fix(server): resolve socket.io deps via NODE_PATH + polyfill AsyncLocalStorage
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m43s
Build & Push Docker Images / build-and-push (push) Successful in 7m31s
Two runtime defects in the crm-app prod image (never exercised before this
deploy; CI only builds + pushes):

1. Replacing the standalone node_modules wholesale to add socket.io's deps
   swapped out Next's standalone-tuned `next` and broke its runtime
   ("Invariant: AsyncLocalStorage accessed in runtime where it is not
   available"). Instead, stage the complete hoisted prod tree in a separate
   dir on NODE_PATH: the standalone node_modules (and its `next`) stay
   intact, and only the socket server's otherwise-missing deps
   (engine.io→accepts/ws/cors, @socket.io/redis-adapter) fall through to it.

2. Defensively set globalThis.AsyncLocalStorage before Next's app-render
   modules load, via a preamble that is the first import in server.ts.
   Next's node-environment-baseline normally sets it during the standalone
   bootstrap, but the custom server can load app-render storage first.
   Verified in the esbuild bundle that the assignment runs before
   require("next").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:34:28 +02:00
2315b58764 fix(docker): bundle socket.io transitive deps into crm-app runner
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Failing after 5m16s
The crm-app image cherry-copied only socket.io + @socket.io into the
runner's node_modules, omitting their transitive closure (engine.io →
accepts/ws/cors, ...). server-custom.js is built with esbuild
--packages=external, so it require()s those at runtime and crashed with
MODULE_NOT_FOUND 'accepts' on first boot. CI reported success because it
only builds+pushes — the image runtime was never exercised.

Add a hoisted (symlink-free) prod-deps stage and overlay the complete
prod dependency tree onto the traced standalone subset. Hoisted layout
makes the Docker COPY faithful (pnpm's default symlinked layout
dereferences and breaks resolution). Validated locally: accepts,
engine.io, socket.io, @socket.io/redis-adapter, ws, cors all resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:03:57 +02:00
15a139e86f feat(berths): website auto-promote toggle + manual-override soft-pin priority
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 6m49s
- website_berth_autopromote_enabled (default OFF): a website registration for a
  specific, currently-available berth auto-creates a prospect (client + optional
  yacht + interest) and links the berth is_specific_interest=true, flipping the
  public map to Under Offer; general/residence/contact submissions stay
  capture-only. Marks the submission converted so a rep never double-creates it.
- derivePublicStatus now honours a manual pin (soft pin): a manually-set status
  wins over the interest-derived Under Offer, but a real permanent tenancy or an
  explicit sold still override it.
- berth rules engine respects a manual pin EXCEPT for sale triggers (-> sold),
  so a confirmed sale still wins but soft auto-changes never stomp a pin.
- Reset-to-automatic action (service + API POST /berths/[id]/status/reset + UI)
  to drop a manual pin; lock badge on every manual override (list + detail);
  divergence banner prompting reset when a pinned-Available berth has a deal.
- migration stage map updated to the §4b signed-off mapping: GQI -> enquiry
  unless it named a berth/size marker (-> qualified); SQI -> qualified.

Tests: +public-berths soft-pin cases, +website-intake-promote helpers,
+migration GQI marker rule. 1582 unit/integration green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:10:04 +02:00
04ddd59662 chore(repo): untrack internal docs + CLAUDE.md (keep local-only)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m53s
Build & Push Docker Images / build-and-push (push) Successful in 6m32s
Per Matt: internal planning/audit/deployment docs + CLAUDE.md don't belong in the shared Gitea repo. git rm --cached (files kept in the working tree) + gitignored docs/ and CLAUDE.md. Tests kept. No history rewrite - what was exposed is infra topology (IP/SSH), not credentials (actual secrets were always in gitignored private/). Fresh repo-appropriate docs to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:02:45 +02:00
2a4dadd5a7 docs(launch): execute-ready initial-deployment runbook
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m51s
Locked decisions (Postgres=own, deploy dir /root/docker-compose/pn-crm, DB/Redis localhost-only), prerequisites checklist, ordered gated phases (recon -> CRM -> data -> Documenso -> website cutover), rollback anchors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:50:42 +02:00
44b004fa8f feat(intake): recipient picker UI (users/roles/everyone/emails)
Adds RecipientPicker (multi-select users + roles, everyone-with-inquiry-access toggle, free-text emails) and a new 'recipients' settings field type. The inquiry + residential notification-recipient settings now render the picker instead of a raw JSON textarea, persisting the structured {emails,userIds,roleIds,everyone} config the server resolver expands. tsc clean; full vitest suite (1570) green. Live browser verification of the picker pending a dev server (env currently on the prod build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:36:24 +02:00
5ea0c75fff feat(intake): structured notification-recipient resolver (emails/users/roles/everyone)
parseRecipientConfig (backward-compat: legacy string[] -> emails) + resolveRecipientEmails (expands userIds/roleIds/everyone-with-interests.view into deduped addresses) + resolveNotificationRecipients (load setting, fallback to inquiry_contact_email). Wired into the website-intake email path so berth/contact/residential staff alerts honor the richer recipients. TDD: parseRecipientConfig unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:28:48 +02:00
0416dc8d39 docs(launch): website-integration env vars + cutover sequence
deployment-plan.md gains a full env-var reference (CRM + website) and the cutover env-flip sequence; launch-readiness.md gets the 2026-06-02 closeout; BACKLOG.md adds the deferred integration-health-panel idea (section L).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:22:12 +02:00
990b566eff feat(intake): CRM-owned website inquiry emails + in-app notifications
Flag-gated (website_intake_email_enabled, default OFF) sending of registrant confirmation + staff alert for inquiries captured at /api/public/website-inquiries, reusing the branded berth + residential templates and adding contact-form client-confirmation + sales-alert templates. In-app (bell) notifications fire on every fresh capture, independent of the flag. Recipients resolve from the existing inquiry_/residential_notification_recipients settings; fires only on a fresh (non-deduped) insert so retries never re-send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:22:08 +02:00
f699533224 Merge feat/residential-toggle-and-reports-comparison into main
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m59s
Build & Push Docker Images / build-and-push (push) Failing after 5m43s
Reports overhaul (residential toggle, sales comparison + filters, financial
report, importer, migration scripts, reports polish, marketing 404 gate) +
pre-launch codebase/security audit with full remediation (85 findings: 4
CRITICAL / 17 HIGH / 29 MEDIUM / 35 LOW; 84 fixed, L21 false-positive) +
custom-report build-blocker fix.

Validation: 1103 unit + 458 integration tests green; tsc clean; production
build green. E2e smoke deferred to CI (needs the standalone server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:08:31 +02:00
79b6ab2ae0 fix(build): split custom-report registry into client-safe metadata + server query module
The custom-report builder (client component) imported the registry which pulls
in @/lib/db (postgres -> tls), breaking the production build. Extract
ENTITY_META/ENTITY_KEYS/column defs into registry-meta.ts (no DB imports);
registry.ts keeps runQuery + composes ENTITY_REGISTRY. Pre-existing blocker
surfaced during pre-merge build validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:28:51 +02:00
cd82958307 docs(launch): Initiative 2 (codebase + security audit) COMPLETE — 85 findings remediated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:32:04 +02:00
478aba1866 docs(audit): remediation complete — 84/85 fixed, L21 false-positive; M23/M25 DB migrations deferred
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:31:34 +02:00
8c4c9b967e fix(audit): UI — L18 (decorative emoji -> Lucide icons), L19 (gated NotesList timer + create-from-url ref-in-effect)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:30:25 +02:00
e7fdf75a6c fix(audit): residential/tenancies — M28 (unified stage validation), M29 (explicit-disable wins), L31 (active-tenancy warning), L32 (socket event + saveStages tx)
Updated tenancy-auto-create integration test to assert M29 (explicit disable
respected) instead of the old re-enable behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:18:28 +02:00
7b74e2314b fix(audit): M24 — reserve 'branding'/'avatar' file categories from the upload/update API
The public file-stream gate keys off files.category==='branding'; the API
upload/update schemas now reject the reserved categories so a user can't
self-set branding to publicly expose their own file. System writers (admin
image, avatar) set them via the service directly and are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:18:24 +02:00
fd69a75980 fix(audit): bounce/email — M8 (Message-ID port-safe bounce match), L16 (recipient validation, CRLF, header trust note)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:18:20 +02:00
cc5c053a79 fix(audit): reports workers — M9 (no duplicate scheduled emails), L5 (idempotent render artefacts), L6 (atomic schedule claim), L7 (per-port notification From)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:07:30 +02:00
64c73a5d77 fix(audit): rate-limit/DoS — M13 (bulk limiter on 6 routes), M14 (api limiter default in withAuth, fail-open), M15 (export-pdf payload bounds); L21 verified not-a-bug
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:07:25 +02:00
ebe5fe6ed8 fix(audit): GDPR/merge — M6 (drop false merge-reversibility claims), M7 (GDPR export adds 4 PII tables), L14 (docstring), L15 (hard-delete breadcrumb note)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:07:21 +02:00
aedbcfd58d fix(audit): AI — L8 (single recordAiUsage), L9 (budget-off warning), L10 (sanitize notes/subjects into prompt)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:59:16 +02:00
70bf26aea1 fix(audit): berth rules/recommender — M4 (bundle-wide status), M5 (berth_unlinked target), M20/L27 (interest_berths invariant + cross-port guard), L3 (recommender stage-scale), L4 (dead branch)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:59:12 +02:00
4084029962 fix(audit): documenso — M2 (reservation EOI-milestone pollution), L11 (v2 numericId GET fallback), L12 (API URL normalize/validate), L13 (event dedup)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:59:07 +02:00
37ffb2c3b4 fix(audit): financial — M19 (group-by-currency accumulation, full-precision rates), M23 (invoice money rounding + 0% discount), L25 (no silent unconverted/stale FX), L26 (companyNotes updatedAt)
M23 numeric(12,2) schema precision deferred to a migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:52:28 +02:00
49f5c3165b fix(audit): interests/pipeline — M1 (outcome terminal guard), M3 (single-UPDATE + milestone gating), L1 (dead 'completed'), L2 (nurturing edge), L24 (deposit re-lock on refund)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:52:24 +02:00
0ed4323826 fix(audit): socket cluster — M10 (isActive gate), M11 (permission-scoped entity rooms), L20 (join:entity validation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:52:20 +02:00
25988dbfad fix(audit): import cluster — M27 (commit idempotency), M25 (in-file dedup preview), M26 (undo destructive-update reporting), L33 (mapping/mooring), L35 (port-auth doc)
M25 DB unique-index backstop deferred: needs a migration (column + backfill +
insert-stamp trigger + dedup) — tracked as a follow-up. The classify in-file
dedup (preview accuracy) ships now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:41:00 +02:00
9305c030de fix(audit): storage cluster — M16 (presign doc/contract), M17 (per-port byte cap), M18 (replay-after-stat), L17 (mime allow-list, fingerprint hash), L22 (brochure portSlug)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:40:56 +02:00
65ed90b603 fix(audit): webhook cluster — M21 (test-send isActive), M22 (cross-tenant dead-letter), L28 (ipv6 SSRF), L29 (rebind doc), L30 (replay event-time)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:40:41 +02:00
29fb882478 fix(audit): H15 (saved-view sort) + H14 (back/forward URL resync) in usePaginatedQuery
H15: new applyView({filters,sort}) atomic mutator (one URL write) restores a
saved view's sort, threaded through all six list components instead of being
discarded. H14: a guarded effect resyncs page/sort/filters FROM the URL on
Back/Forward; the resync setStates carry a scoped, justified
set-state-in-effect disable (loop-guarded external-URL sync).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:26:10 +02:00
808e80744b fix(audit): H12 — consistent refund sign so refunds never inflate revenue
createPayment/updatePayment now store refunds as a negative magnitude, and
every financial reader (sumPaymentsInRange, getRevenueByMonth, getCashFlow)
subtracts refund magnitude regardless of stored sign — fixing both new rows
and legacy positive-stored refunds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:24:51 +02:00
77829485a7 fix(audit): H5 — keep yacht ownership-history ledger consistent on archive/restore
Extracts transferOwnershipTx (close open yacht_ownership_history row + open
a new one + update denormalized owner) from transferOwnership, and uses it in
client-archive + client-restore instead of writing only the denormalized
columns — which left the ledger showing the old owner as current and let the
next real transfer close the wrong row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:24:46 +02:00
1882bcb2e4 fix(audit): H11 — gate cross-port coverBrandPortId in report runs
Layer 1: createReportRun rejects a user-triggered run whose coverBrandPortId
is a port the triggering user can't access (userCanAccessPort: super-admin or
userPortRoles membership). Layer 2: renderReportRun only honors the override
when it equals run.portId or the run's user is a member, else falls back to
the source port's branding — so a forged/scheduled config can't leak another
tenant's logo/name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:18:11 +02:00
a335dbc117 fix(audit): H10 — neutralize CSV formula injection in expense + audit exports
Adds sanitizeCsvCell() (prefixes a quote when a cell starts with = + - @
tab/CR) and applies it to the audit-export escape() and the user-controlled
free-text columns of the expense export before Papa.unparse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:18:07 +02:00
4489ad2431 fix(audit): H9 — rate-limit AI routes + budget-gate email-draft token spend
Applies withRateLimit('ai') to all three AI routes (mirroring scan-receipt)
and adds a checkBudget gate before the OpenAI call in generateEmailDraft,
falling back to the template draft when the per-port budget is exhausted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:18:03 +02:00
b51d6d3030 fix(audit): H4 (reservation signing berth rule) + H13 (manual EOI-sign stage parity)
H4: reservation_agreement completion fired the contract_signed berth rule,
flipping the berth to 'sold' one-to-two stages early. Add a dedicated
reservation_signed berth trigger (defaults to under_offer) and fire it.
H13: the manual signed-EOI upload path advanced only to 'eoi' via the
ungated helper while the Documenso-webhook path advanced to 'reservation';
both now use advanceStageIfBehindGated(..., 'reservation', 'eoi_signed') so
manually- and webhook-signed deals reach the same stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:12:02 +02:00
865ae5c072 fix(audit): H2/H3 — client merge re-points payments, memberships, yacht & invoice ownership
Merge now re-points the loser's payments, company memberships (deduped
against unique_cm_exact), polymorphic yacht ownership, and polymorphic
invoice billing-entity to the winner inside the same transaction, before
archiving the loser. H2: the winner no longer silently loses those rows.
H3: because payments (notNull onDelete:cascade) are moved off the loser, a
later hard-delete of the archived loser can no longer cascade-delete the
winner's financial history. Counts wired into the merge result + audit row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:09:49 +02:00
7a7fd76081 fix(audit): H8 (residentialAccess caller-superset) + M12 (self-target guard) in updateUser
H8: enabling the residentialAccess flag grants the full residential CRUD
set, so a non-super-admin caller must now hold those leaves themselves to
grant it — closes the escalation back door around the role-superset check.
M12: an admin can no longer change their OWN isActive / roleId /
residentialAccess (self-lockout / self-escalation), mirroring the
permission-override route's self-target block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:06:06 +02:00
f4fb7aae84 fix(audit): H1 (webhook redirect SSRF), H6 (berth-status case), H7 (residential notes URL)
H1: webhook delivery fetch now uses redirect:'manual' and refuses to read
or expose a redirected (un-revalidated) response, closing the SSRF read
primitive. H6: dashboard report queries matched title-case 'Sold'/'Under
offer' that never match the lowercase canonical, silently reporting 0 sold
/ understated occupancy — now lowercase. H7: NotesList maps the entityType
discriminator to its REST path (residential_* -> residential/clients|
interests) instead of interpolating the raw underscore, which 404'd every
residential notes request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:03:35 +02:00
3c9310f81c fix(audit): critical C3 — enforce residential module gate on all v1 API routes
Adds assertResidentialModuleEnabled(ctx.portId) as the first statement in
every residential v1 handler (24 handlers across 13 files), mirroring the
Tenancies pattern. Previously the disabled-module state was enforced only
in the page layout, so a disabled module still accepted API writes
(including partner-forward emails on residential interest creation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:59:52 +02:00
7aa639f195 fix(audit): criticals C1 (currency-scoped deposit gate), C2 (outcome-aware berth rule), C4 (/q/ allowlist)
C1: getDepositTotalForInterest now filters to the interest's
depositExpectedCurrency for the auto-advance gate, so a wrong-currency
payment can no longer satisfy the deposit expectation (and mark the berth
Sold). C2: setInterestOutcome fires interest_completed only for 'won';
lost/cancelled fire a new 'deal_lost' rule that frees the berth instead of
flipping it to 'sold'. C4: add '/q/' to proxy PUBLIC_PATHS so tracked
links in outbound mail reach external recipients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:54:36 +02:00
30f6723fef docs(audit): complete unified master — all 17 lanes, 85 findings (4 CRIT/17 HIGH/29 MED/35 LOW)
Consolidates audit passes 1-3 + smoke test + reconciliation. Supersedes the
partial doc. Pre-fix; nothing remediated yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:38:44 +02:00
3337a20091 docs(audit): consolidated master findings — passes 1+2 (6/17 lanes, 3 CRIT/6 HIGH); 11 lanes pending re-run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:07:35 +02:00
366b0d79fd docs(launch): reports polish shipped — empty states + Operational Area filter
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:25:07 +02:00
0ee3cd6073 feat(reports): operational Area filter (FilterBar + query + template scope)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:21:57 +02:00
91d8ee226b feat(reports): financial report-level empty state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:19:57 +02:00
24e88ae32e feat(reports): sales report-level empty state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:56 +02:00
7cf364e03a feat(reports): shared ReportEmptyState component
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:05 +02:00
58203ca8ea feat(reports): financial hasData existence flag (service + route)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:13:42 +02:00
8b7099c4c1 feat(reports): sales hasData existence flag (service + route)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:12:54 +02:00
68da165b37 feat(reports): operational route — Area filter + areaOptions + hasData
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:11:26 +02:00
10b3b68851 feat(reports): thread Area filter + add area-options/hasData helpers (operational service)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:10:33 +02:00
3d9084c94b feat(reports): parseOperationalFilters pure parser (Area scope)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:08:16 +02:00
93e96da43b docs(reports): implementation plan for beta-finish polish
11 bite-sized TDD tasks: parseOperationalFilters (unit-tested), Area
filter threaded through the operational service + route, hasData
existence flags on all three report routes, shared ReportEmptyState
component, and per-client wiring. Verification + tracker update in the
final task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:05:13 +02:00
244fb14ce5 docs(reports): design spec for beta-finish polish (empty states + Operational area filter)
Locked decisions from brainstorming: report-level empty states across
Sales/Operational/Financial gated on a window-independent hasData flag;
Operational gains an Area-only berth-scope filter (Status dropped as a
light filter in this report); rep/source confirmed not applicable to
Operational.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:57:12 +02:00
41c64dc126 feat(reports): gate unbuilt Marketing report to 404 for beta
Sales/Operational/Financial are built + verified; Marketing is blocked
on the website cutover (launch-readiness Init 1b), not on code. Rather
than hide the whole reports surface behind a module toggle, keep it live
for beta and 404 the one unbuilt kind so a hand-typed /reports/marketing
URL can't reach the "in development" placeholder. The landing page
already advertises only the three live reports + Custom.

Remove the UNAVAILABLE_NEW_KINDS entry when the Marketing report ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:34:55 +02:00
0f7da79a64 docs(launch): Financial report SHIPPED (Phase 4) — payments-model reframe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:44:27 +02:00
b690fb8d56 feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.

Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
  pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
  funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
  (inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
  ledger
- every money figure normalised to port currency via a shared
  resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)

UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.

Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.

TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
75fdb9fab4 docs(launch): reconcile tracker — mark shipped rep/source filters + 3 stale-deferred items
- rep + source multi-select filters → SHIPPED in b97f6e94
- Waiting List + Maintenance Log tabs → SHIPPED in 8be7a6e2 (were still
  listed deferred)
- contract/reservation paper-upload misroute fix → SHIPPED in d98aa5cc
  (was still listed deferred)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:25:34 +02:00
b97f6e945c feat(reports): rep + source multi-select filters on Sales report
Closes the two cross-cutting filter gaps in launch-readiness (rep
multi-select + source multi-select). The Sales detail tables can now be
narrowed by assigned rep and lead source alongside the existing stage /
lead-category / outcome filters.

- service: thread `assignedTo` + `sources` through the 5 filtered Sales
  queries (rep-performance, stalled, closing-this-month, recent-wins,
  lost-reason); add `getRepFilterOptions` for the rep dropdown's stable
  option list (distinct assigned reps port-wide, window-independent).
- route: extract param parsing into a pure, unit-tested
  `parseSalesFilters` helper (source allowlisted against SOURCES;
  assignedTo passed through as free user-id list); return `repOptions`
  in the payload.
- ui: static Source filter (SOURCES) + dynamic "Assigned to" filter
  (from payload repOptions, hidden until loaded); decouple the query
  builder from dynamic options via a stable FILTER_KEYS list.

TDD: 8 new parseSalesFilters unit tests (allowlist drop, free-list
passthrough, combine). tsc clean; 12/12 reports unit tests; browser-
verified both filters fire `source=`/`assignedTo=` → 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:24:27 +02:00
c7325010e6 feat(import): commit runner + undo + wired BullMQ worker
Third importer increment — the write path, fully testable without UI.

- commit.ts: commitBatch streams classified rows, applies insert/update per
  the conflict policy via the adapter (each row in its own try/catch so valid
  rows still land), records every action in import_batch_rows, and keeps live
  counts on the batch header. undoBatch hard-deletes a batch's inserted rows
  (port-scoped); a delete blocked by a dependent FK is reported, not forced,
  and the batch flips to `undone` only when every inserted row was removed.
- import worker: replaced the no-op placeholder with the real processor —
  loads the batch, re-reads the uploaded file from storage, parses, and runs
  commitBatch under the batch's mapping + policy. Marks the batch failed on
  error. Concurrency 1 so imports don't race each other's dedup lookups.

Tests: commit (skip/insert/error counts + per-row ledger + real inserted
entity), undo (removes exactly the inserted row, flips status), and
update-matches overwrite. 2 passing.

Engine is now functional end-to-end at the service layer: parse → map →
dry-run → commit → undo. Remaining: 4 FK adapters, API routes + permission,
wizard UI + history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:36:42 +02:00
3cf12b3015 feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.

- types: ImportAdapter contract (targetFields, matchKey, findExisting,
  resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
  distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
  natural-key dedup, and the conflict-policy matrix
  (skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
  {headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
  validation): companies (name dedup, update), clients (flat email/phone →
  contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
  dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.

Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.

Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
372b585bf9 feat(import): data model for the bulk CSV/XLSX importer
First increment of the importer (docs/superpowers/specs/2026-06-01-bulk-import-design.md):
the three port-scoped tables, no changes to entity tables.

- import_batches — one row per run: entity_type, filename, storage_key,
  status, conflict_policy, mapping_json, live counts, created_by, timestamps.
- import_batch_rows — per-row action ledger (inserted/updated/skipped/errored)
  with entity_id + error; partial index on inserted rows powers Undo.
- import_mappings — saved column mappings, unique per (port, entity, name).

Migration 0090 applied via psql; schema re-exported from the index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:23:50 +02:00
a343eaa257 feat(migration): old-LOI EOI recovery, folded berth-links, contactless flag
Three polish items so the legacy seed is one-shot and complete:

- backfill-documents: recover the ~10 pre-Documenso "LOI process" EOIs
  whose signed PDF lives only as a NocoDB attachment in the `database`
  MinIO bucket (the pipeline keys EOI-doc creation off documensoID, so it
  never created rows for them). Reads EOI_Document attachment metadata
  from the local nocodb_legacy dump, pulls the PDF (read-only) from the
  `database` bucket, and CREATES the document + file + folder, linking the
  signed PDF. Idempotent via a `nocodb_eoi_document` ledger entry.
- connect-berth-links: refactored into an exported connectBerthLinks()
  and folded into migrate-from-nocodb --apply (best-effort; skips with a
  warning if the local dump isn't restored) so the multi-berth junction is
  reconnected as part of the one-shot seed, not a separate manual step.
- migration-apply: contactless legacy clients (no email/phone across the
  whole dedup cluster) get a per-port "Needs contact info" tag so staff
  can filter + chase them, instead of being dropped.

The current dev DB's 29 contactless clients were tagged via a one-off
mirroring the pipeline logic. EOI recovery code is ready but the actual
run needs LEGACY_MINIO_* read creds supplied at the command line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:18:28 +02:00
8be7a6e29d feat(berths): ship Waiting List + Maintenance Log tabs
Both berth-detail surfaces were stubbed/hidden behind a comment in
berth-tabs.tsx. Their backing schema already existed; this wires the UI
and fills the service gaps.

Maintenance Log (was ~60% built: schema/migration/add+get service/route):
- new edit + delete: updateMaintenanceLog / deleteMaintenanceLog service
  (port-scoped tenant guard), PATCH/DELETE at maintenance/[logId], plus
  updateMaintenanceLogSchema. add schema now accepts null for cost /
  responsibleParty so the shared add+edit dialog sends one body shape.
- BerthMaintenanceTab: list (newest first) + add/edit dialog + delete
  confirm, realtime invalidation. New berth:maintenanceUpdated/Removed
  socket events.

Waiting List (un-hide the orphaned manager + next-in-line notify):
- getWaitingList now left-joins the client so the queue renders names,
  not raw ids.
- WaitingListManager rewritten: ClientPicker instead of free-text id,
  client names, manage_waiting_list gating on add/reorder/remove, and a
  "Next in line" marker on position 1.
- notifyWaitlistNextInLine: when a berth transitions to available,
  surface the #1 client to staff who hold berths.manage_waiting_list
  (mirrors the interest-based notifyNextInLine; dedupeKey-suppressed).
  Hooked into updateBerthStatus on any -> available transition.

Tests: maintenance add/get/update/delete + cross-port guard; waitlist
notify recipient-resolution / payload / empty + no-permission no-ops.
Verified end-to-end in the browser (create/render/delete for both).

Also adds scripts/dev-reset-admin-pw.ts (reset a synthetic user's
password via the better-auth hasher after a dev reseed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:55:04 +02:00
d98aa5cc8a fix(signing): route paper-signed reservation/contract uploads to the right doc type
The Reservation and Contract tabs reused ExternalEoiUploadDialog, but the
service hard-coded the EOI document type, status columns, stage target, and
berth rule. A signed contract uploaded from the Contract tab filed as an
`eoi`, flipped `eoi_status`, and advanced the stage to `eoi` - wrong doc
kind, wrong sub-state, wrong stage.

- external-eoi.service: UPLOAD_CONFIG keyed off docType (eoi | reservation
  | contract) parameterises documentType, file category, storage prefix,
  doc-status column, signed-date column, target stage, advance-from set,
  and berth rule. eoi_status is written only for docType=eoi.
- route: parse docType from the form (default eoi).
- dialog: docType prop; generalised copy; EOI-only UI (active-EOI replace
  banner, public-map flip, cancelActiveDocumentId) gated to docType=eoi.
- reservation/contract tabs: pass docType; drop the coming-soon comments.
- test: docType routing cases (reservation -> reservation_agreement +
  reservation cols; contract -> contract + contract cols; eoi_status stays
  null on both; contract idempotent at/past contract stage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:28:04 +02:00
a7c11f2c51 feat(migration): exhaustive reconciliation + multi-berth link fix
reconcile-migration.ts: read-only cross-check of EVERY migrated record vs its
legacy source (via the ledger) — coverage (nothing dropped), field fidelity
(independently re-derives stage/eoiStatus/documensoId/berth/email), and
relationship integrity (orphans, dangling FKs).

connect-berth-links.ts: the dedup pipeline migrated only the single per-interest
Berth Number text field and missed the legacy _nc_m2m_Berths_Interests junction
(multi-berth deals) — 57 deals were missing links. Reads the junction from the
nocodb_legacy snapshot, resolves interest + berth via the ledger, inserts the
missing interest_berths rows (idempotent; respects the one-primary partial
unique index). Inserted 74 links, 51 new primaries.

After the fix: reconciliation = 0 discrepancies across all 255 deals, 165
expenses, 45 residential.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:16:41 +02:00
3e47793ebe feat(migration): verification/audit script (PDF↔person + completeness)
Read-only audit of migrated data:
- EOI PDF ↔ person: extracts each attached signed-EOI PDF text (unpdf), confirms
  the linked client name appears, flags any PDF where a different client name
  appears. Result: 35/35 strong match, 0 mismatches (visually spot-checked 2).
- Berth PDF ↔ mooring: soft text check; moorings render as graphics so the
  filename→mooring attachment is authoritative (113/113; A1 visually confirmed).
- Per-person completeness: 0 deals missing stage, 0 clients without a deal,
  29 clients without contact info (inherited legacy data gaps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:03:58 +02:00
14ab8a8161 feat(migration): document backfill — legacy MinIO → CRM storage (Phase 2)
backfill-documents.ts pulls signed EOI PDFs + berth spec PDFs from the legacy
MinIO (client-portal bucket; read-only via dedicated LEGACY_MINIO_* creds) and
deposits them into the CRM (getStorageBackend), linking:
- berth PDFs → berth_pdf_versions + berths.current_pdf_version_id (mooring from
  filename; 113/113 matched)
- signed EOIs → documents.signed_file_id + status=completed + a files row filed
  into the client folder (exact name + conservative lev<=2 fuzzy; 33 linked)
Idempotent (skips when signedFileId / current_pdf_version_id already set).
Strictly prod-READ-only; all writes local (dev storage_backend=filesystem).
Unmatched EOIs reported (mostly in-flight deals w/ no signed PDF yet + old-LOI
docs in the NocoDB attachment bucket).

Adds probe-minio.ts (read-only bucket inventory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:33:15 +02:00
6c040a617b feat(migration): add expenses + interest EOI status to NocoDB→CRM pipeline
A single idempotent --apply now seeds the full legacy dataset:
- Expenses: fetch the separate "Expenses" NocoDB base (mxfcefkk4dqs6uq),
  transform (price→amount+currency, payment status, receipt marker), apply to
  the expenses table under a new nocodb_expenses ledger tag.
- Interest EOI display state: set interests.eoiStatus/eoiDocStatus from the
  legacy EOI Status / LOI process so deals show signed / awaiting-signature
  (in-flight) state, not only a separate documents row.
- Runner reports expenses + tags createdBy with the seeded super-admin id.

Validated via --apply on the dev DB: 239 clients (multi-deal grouping intact),
255 interests (qualified 171/eoi 51/nurturing 30/reservation 2/contract 1),
48 signed + 3 in-flight EOIs, 165 expenses (5 currencies), 41 docs + 119
signers, 45 residential. tsc clean; 67 dedup unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:18:28 +02:00
7dba1a47bb fix(migration): modernize stale NocoDB→CRM pipeline stage map to current 7 stages
The 2026-05-03 migration pipeline (src/lib/dedup/*) predates the 9→7
pipeline-stage refactor; its STAGE_MAP emitted invalid stages
(open/details_sent/eoi_sent/…) that would write bad pipeline_stage values
on --apply. Remap to the current PIPELINE_STAGES (enquiry/qualified/
nurturing/eoi/reservation/deposit_paid/contract) + a deposit-received →
deposit_paid override. Frozen-fixture test expectations updated (17/17 pass).

Validated: live --dry-run = 239 clients / 255 interests / 41 EOI docs
(matches independent snapshot analysis; pipeline is more conservative and
flags 3 borderline pairs for review).

Adds the migration design spec (source map, scope lock to Port Nimara +
Expenses bases, EOI coverage 48/48, in-flight Documenso state, remaining
gaps: interest eoiStatus, expenses, doc-blob backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:03:32 +02:00
31ba72f344 chore(launch-prep): hide unfinished report/import surfaces, defer big builds
Ship-what's-done prep ahead of the prod cutover (launch ~today):

- Hide Financial + Marketing report cards from the reports landing
  (both were "Builder in development" placeholders gated on unbuilt
  data sources). Sales/Operational/Custom + templates/scheduling/
  exports remain live.
- Trim the Custom-report card copy to match the shipped basic builder
  (no group-by/filters yet; the builder page header was already honest).
- Hide the Bulk Import mockup from search-nav-catalog + the admin
  sections browser; /admin/import is now unreachable from the UI.
- Correct client-facing doc over-claims (waiting-list "next-in-line
  notification", Import) in features-list.md + new-system-feature-summary.md.
- Un-stale BACKLOG.md (Documenso phases 2-7 confirmed shipped).
- Log decisions + deferred work (full importer, full custom-builder,
  waiting-list, maintenance-log, paper-upload bug) to launch-readiness.md.

Deferred-importer design spec added at
docs/superpowers/specs/2026-06-01-bulk-import-design.md.

Verified: tsc --noEmit clean, eslint clean on changed files,
1512/1519 vitest pass (7 failures are Redis-down, unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:39:51 +02:00
681b94a8ef feat(reports): prior-period comparison toggle on the Sales report
Adds a "Compare to prior period" toggle to the Sales report header.
When on, the API recomputes the KPI window for the equal-length window
immediately preceding the selected range (previousPeriodBounds) behind
`?compare=1`, and the five window-derived KPI tiles (Won, Lost, Win
rate, Avg time-to-close, New leads) render colour-correct "vs prior"
deltas. Point-in-time tiles (Active interests, Pipeline value) have no
prior-window analogue and intentionally show no delta. The prior-window
query runs in parallel with the main batch and resolves to null when the
toggle is off (zero cost). Toggle state persists in the saved-template
config.

Closes the spec's "period comparison on every report" gap for Sales;
Operational already rendered period-start deltas.

Pure helpers TDD'd: previousPeriodBounds (range.ts) +
computeSalesKpiComparison (sales-comparison.ts), 7 unit tests. tsc +
lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:35 +02:00
172af02f81 feat(residential-toggle): port-level module gate for Residential
Adds a `residential_module_enabled` port setting (default ON) that
hides/disables the entire Residential surface when an admin turns it
off, mirroring the Tenancies / Invoices / Expenses module-toggle
pattern. Disabling is a soft hide — residential clients/interests are
preserved and reappear on re-enable.

Surfaces gated:
- Route guard: new residential/layout.tsx renders ModuleDisabledPage
  (covers all 5 residential pages)
- Sidebar "Residential" section + mobile more-sheet tile (SSR-resolved
  residentialModuleByPort threaded layout → app-shell → sidebar)
- Global search: residential client/interest buckets early-return at
  the shared chokepoint so disabled-port records don't dead-end
- Public intake: /api/public/residential-inquiries 404s when off
- Admin Switch in settings-manager (writes via settings PUT)

Service TDD'd (residential-module.test.ts, 6 tests) plus a
disabled-port rejection test on the public endpoint. tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:16 +02:00
cb8292464c feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers
Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:42:37 +02:00
3bdf59e917 feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.

Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
  velocity + win-rate-over-time + source conversion + rep leaderboard
  charts, deal-heat section, 5 detail tables, stage / lead-cat /
  outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
  churn, tenure histogram, signing box plot, occupancy by area, docs
  in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
  tenancies), column-whitelist composer, date filter, CSV download,
  save-as-template. Registry-only extension path for the remaining 6
  entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
  Custom. ?templateId= URL deep-link hydration via useRef guard.
  Active-template badge clears when the user drives view-state via
  wrapped setters; raw setters used on template apply so the badge
  survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
  renders, optionally emails. Recipients optional (zero-recipient
  schedules archive without sending). PDF-only output for v1.
  Schedule dialog re-mounts via key prop on schedule.id transitions
  to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
  (lib/pdf/reports/payload-report.tsx) so client + scheduler share
  one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
  consolidates 5 duplicated formatMoney helpers; fixes hardcoded
  'USD' in detail tables; pre-formats money rows so PDF export
  (which strips column.format callbacks at the JSON boundary)
  renders consistently with CSV / XLSX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
909dd44605 feat(uat-p5): activity-feed module, signing-order tri-state, webhook health card
- Activity-feed: shared formatting module
  (src/components/shared/activity-formatting.ts) centralises action
  verbs, badge variants, entity-type labels, enum-value normalisation,
  shortValue, and buildDiffLine. The dashboard widget feed and the
  per-entity audit feed now both consume it - duplicate ~250 lines
  collapsed, vocabularies aligned, badge palette unified.
- Signing order setting becomes tri-state. The new
  TEMPLATE_DEFAULT value (the new default) skips overriding the
  template's own signingOrder so each Documenso template's stored
  setting wins. PARALLEL / SEQUENTIAL keep forcing the override.
- Admin Documenso page now ships a Webhook health card backed by
  /api/v1/admin/documenso-webhook/health (secret status,
  expected URL, last received event, recent secret rejections) and
  a "Test now" button that fires a synthetic DOCUMENT_OPENED through
  /api/v1/admin/documenso-webhook/test against the local receiver
  to verify the full pipeline without driving a real Documenso event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:05:14 +02:00
6caf41651f feat(uat-p5): long-tail polish - tag chips, notes counts, hub context, tenancies toggle
- StageStepper renders now carry tag chips next to the progress bar
  (client interest cards, pipeline summary, preview sheet).
- Notes tab badge on the interest detail aggregates note counts across
  the interest, the linked client, the linked yacht, and any companies
  the client is an active member of - reps see the full surface area
  at a glance.
- Admin Settings: Tenancies Module toggle wired into the Feature Flags
  card. Disabling hides nav/tabs without deleting any rows; re-enabling
  brings them back. Service layer was already complete; this surfaces
  the control on the operations page.
- HubRoot recent-files rows now show folder breadcrumb + entity badge
  (Interest/Client/Yacht/Company) so reps can tell at a glance where a
  file lives. Backed by listFiles enrichment (5 batched lookups per
  page; no per-row queries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:57:20 +02:00
2592e28578 feat(uat-p4): inheritance polish - yacht dims, occupancy chip, map-flip flag
Phase 4 of the active UAT sweep wraps the inheritance/polish bucket.

- BerthOccupancyChip: new shared component that surfaces the competing
  active interest on a non-available berth as a colour-coded chip with
  a stage badge. Adopted in LinkedBerthRowItem, BerthRecommenderPanel
  recommendation card, and InterestBerthStatusBanner; the banner aligns
  query keys with the chip so React Query dedupes the network call.
- OverviewTab inheritance: getInterestById now ships a yachtDimensions
  block when the interest is linked to a yacht with dimensions. The
  Berth Requirements rows render a "↩ <value> from yacht" pill when
  the desired field is blank; clicking the pill copies the value into
  the interest. After a manual edit, a toast offers to write the new
  value back to the yacht record so the canonical truth stays in sync.
- Map-flip inheritance: ExternalEoiUploadDialog and UploadForSigningDialog
  now expose a single "Mark berth(s) as Under Offer on the public map"
  checkbox that defaults ON when any in-bundle berth already has
  is_specific_interest=true. On submit, PATCHes the in-bundle berths
  that don't already match; sister surface to the EOI generate
  dialog's per-berth picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:48:19 +02:00
fe5f98db23 feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast
Phase 3 of the comprehensive UAT round. Implements the Automate
Signing feature per the 2026-05-26 locked decisions.

P3.1 — documents.automation_mode schema
Migration 0088 adds the column with a CHECK constraint enforcing
the three-value enum: manual / sequential_auto / concurrent_auto.
Drizzle schema picks it up; default 'manual' preserves existing
behaviour.

P3.2 — Automate Signing orchestrator service
New src/lib/services/signing-automation.service.ts. enableSigningAutomation
resolves the mode from the envelope's signing order (SEQUENTIAL ->
sequential_auto fires first signer only; PARALLEL -> concurrent_auto
fires all signers in one parallel dispatch), updates documents.automationMode,
and dispatches invitations via the same sendSigningInvitation path
the manual route uses (so the email a recipient sees is identical
regardless of trigger). ensureSigningUrls recovers v2 signing URLs
if they're missing on the local signer rows. Hard guards: envelope
must exist, status in {draft, sent, partially_signed}, ≥2 signers.
disableSigningAutomation reverts to manual; idempotent.

P3.3 — Webhook cascade
The existing sendCascadingInviteForNextSigner in documents.service.ts
already fires the next pending signer on every recipient_signed event
(mode-independent). handleDocumentCompleted already sends the signed
PDF to all recipients via sendSigningCompleted on completion. So
"automate" really means "kick off the first invitation"; the rest
is mode-independent existing behaviour. Doc comment in the new
service explains the interaction.

P3.4 — ActiveEoiCard Automate signing button + banner
- DocumentRow type extended with automationMode + documensoId.
- New automateMutation hits POST /api/v1/documents/[id]/automate;
  pauseAutomationMutation hits DELETE.
- "Automate signing" button visible when totalCount ≥ 2 AND doc has
  documensoId AND envelope is in-flight AND mode === 'manual'.
- "Automating sequentially/concurrently · N of M signed" banner
  renders when automation is active, with a Pause button that
  reverts to manual.
- Per-row Send invitation / Send reminder buttons in SigningProgress
  stay visible per the locked decision (manual override during auto).

P3.5 — Automate Signing API route + tests
- POST /api/v1/documents/[id]/automate (enables) + DELETE (disables).
- Permission: documents.send_for_signing (mirrors the manual
  send-invitation route).
- vitest covering: NotFound on missing doc, Conflict on missing
  envelope, Conflict on completed status, Conflict on already-
  automated, Conflict on <2 signers, disable is idempotent when
  already manual. All 7 cases pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:29:05 +02:00
210748076f feat(wizard-refactor): drop inapp pathway + upload branch + per-port template defaults + mark-signed dropdown
Phase 2 of the comprehensive UAT round. Locked decisions from the
2026-05-26 question round (see docs/superpowers/audits/active-uat.md
"Decisions locked" block).

P2.1 — drop the inapp template pathway
Removed the dead pathway dropdown. Generate-from-template flow is
now exclusively documenso-template; the inapp (pdf-lib + CRM-render)
branch was never surfaced as a deliberate choice and was a config
trap. Server-side route still accepts pathway='inapp' for backcompat
with older clients - wizard now always sends 'documenso-template'.

P2.2 — delete the wizard's upload branch
Reps who want to upload a finished PDF go through the New-document
dropdown -> "Upload & send for signature" (UploadForSigningDialog,
the proper field-placement flow) instead of the wizard's
half-implemented upload sub-form. Wizard's Source section becomes
a one-line explainer + the template picker; no more redundant
radio-then-pathway-then-template layering.

P2.3 — per-port doc-type template defaults
New GET /api/v1/documents/template-defaults endpoint returns
{ eoi, contract, reservation_agreement } template ids from
getPortDocumensoConfig. Settings registry keys already existed for
contract + reservation; config + resolver already plumbed them.
CreateDocumentWizard now fetches the map on mount and auto-sets
templateId whenever documentType changes (empty picker OR currently
showing a different doc-type's default both get re-aligned). Admin
override via the picker still works.

P2.4 — surface flow 3 (mark signed offline) from the dropdown
NewDocumentMenu gains a 4th item: "Mark as signed (offline)".
Opens a small dialog that asks for the interest + doc type
(eoi/reservation/contract), then navigates to the matching
per-interest tab with ?tab=...&action=upload-signed query param.
Per-interest tabs are the single source of truth for the
pipeline-stage + doc-status side effects of the mark-signed flow;
the hub-level dropdown just routes the rep to the right place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:17:17 +02:00
b6c27b506d feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests
Phase 1 of the comprehensive Documenso upload audit per the
2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md.

P1.1 — persist documensoId immediately after create
Was set only at the late `status: 'sent'` commit. Any throw between
documensoCreate and the late update left an orphaned Documenso
envelope the CRM had no link to. Now the UPDATE runs right after
documensoCreate succeeds; rollback paths can find and void the
envelope.

P1.2 — pre-flight validation hard-blocks Submit
UploadForSigningDialog computes a submissionErrors memo over
recipients + fields. Submit button disabled when errors > 0. Inline
amber summary lists every issue (missing email, invalid email,
missing name, field assigned to non-existent recipient, no fields
placed). Service layer mirrors the same email + name checks so
direct API hits reject early. No override path per locked decision.

P1.3 — cancel/delete affordance audit + sweep
Document-list per-row Delete + Send for Signing actions now:
- Wrapped in PermissionGate (documents.delete + send_for_signing).
- Surface toast on success + toastError on failure (were silently
  swallowing errors).
- Use a broader predicate-based query invalidation so every doc
  list across the app refreshes, not just the local key.
EOI tab Regenerate + Cancel EOI buttons + reservation/contract
tab Cancel buttons wrapped in PermissionGate (documents.edit, the
cancel route's auth check).

P1.4 — Documenso webhook URL auto-PATCH (env-gated)
scripts/update-documenso-webhook.ts written. Reads
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise
no-op). Lists every webhook on the Documenso instance via v2 (with
v1 fallback), identifies webhooks pointing at trycloudflare.com
hosts OR /api/webhooks/documenso paths, PATCHes them to the new
tunnel URL. scripts/tunnel-url.sh chains the script after the URL
print so a re-tunnel auto-rotates the webhook (when flag set).

P1.5 — state-machine refactor with rollbackTo() helper
custom-document-upload.service.ts:
- Single try around create → send → place steps.
- state.step tracks which step is current; state.documensoDocId
  records the envelope id once we have it.
- rollbackTo(reason) composes the recovery: status='cancelled' on
  the CRM row, documensoVoidSafe on the envelope when applicable.
  Idempotent — calling twice is safe.
- Removes three independent try/catches.

P1.6 — recipient ↔ Documenso identity reconciliation
After documensoSend, validates every distinct email we sent
appears in sentDoc.recipients. If Documenso silently dropped one,
a ConflictError fires before field placement so the rollback path
triggers. Explicit message names the missing emails for the rep.

P1.7 — vitest extension + per-failure audit-log entries
- 5 new vitest cases (blank email, whitespace email, malformed
  email, blank name, duplicate-emails-OK semantic).
- rollbackTo writes a structured audit_log entry with failedStep,
  documensoEnvelopeId, errorClass, errorMessage. Post-mortem
  investigation has structured data instead of just logger lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
b00cc24565 docs(audit): lock decisions from the 2026-05-26 question round
User answered 11 blocking + clarifying questions across the audit doc.
Decisions inlined as a summary block in the audit doc prelude so any
session reading the doc sees the answers up front before drilling into
individual findings.

Highlights:
- Documenso comprehensive audit ships as 5 discrete sub-PRs.
- Pre-flight validation hard-blocks Submit; no override path.
- `/documents/new` wizard refactor: delete upload branch, drop inapp
  pathway, per-port doc-type template defaults, surface flow 3 from
  dropdown, drop the route entirely.
- Automate Signing: pick-up on mid-flow enable; broadcast to all
  recipients; single combined mode; manual override stays visible.
- Webhook URL auto-PATCH env-flag-gated.
- documenso_signing_order becomes a tri-state setting.
- OverviewTab inheritance writes to interest, prompt to also update
  yacht record.
- Public-map flag inheritance applies across every map-flip dialog.
- Cancel/Delete affordance audit sweeps EVERY remove route.
- Orphan-scan script deferred; dev DB nuke acceptable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:46:21 +02:00
8e81670b11 feat(uat-polish): live-UAT round — dialog widths, recommender polish, inline create, tenancy + notes plumbing
Compendium of polish + small-fix work captured during the 2026-05-26
live UAT session. Every change has a corresponding entry in
docs/superpowers/audits/active-uat.md with file:line evidence + root
cause + alternatives considered.

Dialog primitive width
- DialogContent default bumped from sm:max-w-lg (512px) to
  sm:max-w-xl + lg:max-w-3xl so every consumer gets a sane desktop
  default. Confirm dialogs override DOWN, content-heavy dialogs
  override UP.
- FilePreviewDialog full-viewport via w-[min(95vw,1400px)] +
  h-[85vh] so PDFs render at usable width on real desktops.

Recommender card
- Heat badge now a Popover with the score (X/100), the formula in
  plain English, the four component breakdowns (recency / furthest
  stage / interest count / EOI count), and a pointer to the admin
  weight tuning page.
- Area letter span dropped from the card header - mooring number
  already prefixes it.
- BerthRecommenderPanel + the dedicated "Berth Recommendations" tab
  both hidden when interest.desiredLengthFt is null. The empty
  guidance card was reading as noise. interest-tabs.tsx computes
  hasDesiredDims once and gates the inline mount + tab strip
  spread off it.

BerthPicker
- Drop area suffix from row labels. Mooring number already carries
  the area letter prefix; group heading conveys the same context.
  Same fix flows to every BerthPicker consumer (tenancy
  create/renew/transfer, interest form, linked-berths picker).

CreateDocumentWizard
- DOCUMENT_TYPE_LABELS constant added to constants.ts. Wizard reads
  from the map instead of naive replace(/_/g, ' '): "EOI",
  "Contract", "NDA", "Reservation Agreement", "Other".
- "Other" option surfaces a hint pointing the rep at the Title
  field so they describe what the doc actually is.

InterestForm inline client + yacht create
- ClientForm gains an onCreated(clientId) callback. Mutation
  returns { id } in create mode so onSuccess can forward.
- InterestForm renders an "Add new" Button next to the Client label
  (create mode only - hidden on edit), opens ClientForm, auto-
  selects the new client into the draft. Mirrors the existing
  inline yacht-create pattern.
- Reset path includes source: 'manual' alongside the other create-
  mode defaults; the manual flow was dropping back to a blank
  source dropdown on reopen.

Tenancy list
- ClientTenanciesTab activeTenancies query now includes status
  IN ('pending', 'active'). Was filtering to active-only; pending
  rows from manual create + webhook auto-create were invisible on
  the client detail's Tenancies tab.
- TenancyList rows are now keyboard- and click-navigable to the
  tenancy detail page (Enter/Space included). Inner links + buttons
  stop propagation so per-cell navigation works.

NotesList source badge
- Aggregated-mode source badge ("Yacht / Test Yacht") is now a Link
  to the source entity's detail page. New sourceLinkFor helper
  centralises the URL mapping across clients/companies/yachts/
  interests + residential variants.

Yacht transfer audit log
- transferOwnership emits a distinct 'transfer' AuditAction (added
  to AuditAction union in src/lib/audit.ts) with old/new owner
  names resolved at write time. EntityActivityFeed renders
  "Matt transferred owner to Jane Smith" instead of "Matt updated
  this record." formatValueForField unwraps the { name } shape so
  the audit_logs Record<string, unknown> typing stays clean.
- yacht-transfer-dialog copy: dropped "atomic" jargon. Reads "The
  change is logged in the audit history" instead.

Companies autocomplete
- /api/v1/companies/autocomplete now returns the 10 most-recently-
  updated companies when the query string is empty. Was returning
  []. CompanyPicker popover opens with results to scan instead of a
  blank dropdown.

DocumentsHub FlatFolderListing
- Uploaded files (the files table) now merge into the documents
  table view via a parallel /api/v1/files?folderId=X query +
  client-side merge into a unified row list. listFiles service
  honours the folderId filter that was already accepted by the
  validator. New renderFileRow renders file rows with an "Uploaded
  file" type pill + "Stored" status pill, links the filename to
  the download URL. Existing FolderDropZone invalidation covers
  the new query, so drag-drop and New-document-menu uploads
  refresh the list without a page reload.
- FlatFolderListing wrapped in a vertically-spaced container so
  subfolders / search row / list have consistent gap.
- Per-row chevron only renders when totalSigners > 0; empty
  placeholder column kept so grid alignment doesn't jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:07:45 +02:00
cae5d39607 feat(documenso): rejection reason + poll fallback + rollback hardening + recipient UX
Documenso reliability + signer-UX bundle from the 2026-05-26 live UAT.
Each piece detailed in docs/superpowers/audits/active-uat.md with full
file:line + root cause + alternatives.

Webhook + poll convergence
- DocumensoRecipient (webhook payload type) gains rejectionReason +
  declineReason. The DOCUMENT_REJECTED / DOCUMENT_DECLINED handler
  coalesces them at the boundary so downstream code sees one stable
  field. Empty/whitespace normalised to null.
- DocumensoDocument.recipients[] (normalized client output) gains
  rejectionReason. normalizeDocument coalesces v2 + v1 field names the
  same way so poller consumers see identical shape.
- handleDocumentRejected signature gains rejectionReason. Stored on
  document_events.eventData, persisted in audit_logs metadata, quoted
  inline in the in-CRM rep notification (truncated 120 chars; full
  reason still on the audit row). New 'transfer' AuditAction added
  alongside.
- signature-poll job now handles REJECTED / DECLINED. Previously only
  SIGNED / COMPLETED / EXPIRED were reconciled, so a missed rejection
  webhook (stale tunnel URL is the typical dev cause) left documents
  stuck in 'sent' forever. The 5-min poll cycle now closes that gap —
  webhook becomes an optimisation, not a correctness requirement.

placeFields rollback gap
- custom-document-upload.service moved the synchronous field-placement
  map() INSIDE the same try/catch that wraps placeFields(). Previously
  the map's throw bubbled past the catch-and-rollback block, leaving
  Documenso with a live envelope + recipients but no fields, and the
  CRM document row stuck in 'sent' with no signing UI for the signers.
  Logger captures looked-up email + map keys on miss for diagnosis.
- Comment documents Documenso's by-email dedupe semantic so future
  readers don't reintroduce the per-recipient-row map assumption.

UploadForSigningDialog recipient UX
- New RECIPIENT_ROLE_META + RecipientRoleBadge helpers. Placement-step
  sidebar list rebuilt as a two-line layout (name + role badge / email
  on its own line) so duplicate-named recipients are visually
  distinguishable. FieldSidePanel dropdown SelectItem mirrors the same
  stacked shape.
- "Recipient" label renamed to "Assign this field to" with an explainer
  paragraph below.

SigningProgress copy-link parity
- Copy-link button now always renders for pending signers (disabled +
  explainer tooltip when signingUrl not yet issued). Reps can copy
  even when the URL hasn't been distributed via email yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:06:12 +02:00
2f1eba3e57 docs(audit): start active-uat.md as the persistent live findings doc
Every UAT finding the user surfaces during a live walkthrough now lands
in docs/superpowers/audits/active-uat.md regardless of which session
captured it. Persists across sessions until the user explicitly says to
wrap a round and archive (rename to YYYY-MM-DD-uat.md, start fresh).

CLAUDE.md's "Manual UAT" guidance updated to point at the new file +
documents the status-tag taxonomy and the append-protocol detail level
(file:line, React-grab anchor, root cause, fix proposal walking each
layer, effort estimate, alternatives + rejection reasons, open
questions, bundle-with notes, cross-refs, acceptance criteria). Historical
alpha-uat-master.md retained as the previous master through 2026-05-26.

This commit seeds the doc with the full body of findings captured during
this live session — Documenso reliability work, dialog width sweep,
recipient UX, recommender card polish, tenancy + notes plumbing, the
larger Documenso upload audit and Automate Signing feature specs. Each
entry follows the detail contract documented in the file footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:05:47 +02:00
e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.

- next-intl ripped out (zero useTranslations callers ever existed):
  package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
  the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
  Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
  border-r/rounded-l-/rounded-r-) inside JSX className literals.
  Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
  label enabled at warn; 4 empty <th>/<td> action placeholders gain
  sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
  new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
  CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
  to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
  lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
  "Payments - Not received yet" or "Payments - \$X received - N payments
  - Expand"; per-interest collapse state persists in localStorage; the
  RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
  text-muted-foreground/{60,70,80} hits dropped to plain
  text-muted-foreground for AA contrast on muted bg. Icon-only
  (aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
  across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
  rewritten with cumulative state through today. Items genuinely still
  open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
  pixel E2E verification, and website-cutover work parked here so
  they don't get lost in the CRM audit doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:46 +02:00
353a31323e fix(tenancies): unblock first-tenancy chicken-and-egg in webhook
Webhook auto-create on signed Reservation Agreement was gating itself on
isTenanciesModuleEnabled, but autoCreatePendingTenancies never enabled
the module — so the very first tenancy on a fresh port was unreachable
even though the row-exists fallback in isTenanciesModuleEnabled was
designed exactly for this lazy auto-surface case. Drop the gate; the
inserted row now flips the module on automatically via the fallback.

docs/tenancies-design.md §"When disabled" and the P3 PR-table row
updated to reflect the new contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:15 +02:00
c8869338e8 feat(berth-deal-docs): clickable rows open in-page file preview
The Interest Documents tab on the berth detail page listed deal docs
read-only with only an "Open" link to the interest detail page —
forced reps to navigate away just to see the PDF. Now every row whose
backing PDF exists opens the existing FilePreviewDialog inline.

- Service: listDealDocumentsForBerth now joins files and returns
  fileId (COALESCE(signedFileId, fileId) so completed envelopes
  prefer the signed PDF), fileName, mimeType. Drafts without a blob
  yet still appear, just non-clickable.
- UI: row title area is a button that triggers FilePreviewDialog;
  Eye affordance on hover. Falls back to a "no file yet" hint when
  the document has no backing blob. "Open" link stays as the
  secondary "go to interest" action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:20:01 +02:00
400ff993d2 fix(berths): inline edits on detail Overview tab now persist visually
useBerthPatch invalidated ['berths', berthId] (plural+id), but the
berth detail page reads ['berth', berthId] (singular+id). Cache key
never matched, so the PATCH landed in the DB but the visible field
reverted to its pre-edit value on re-render. Realtime invalidation
covered for it via 'berth:updated', but Socket.IO is unavailable
in some dev environments.

Switch to the correct singular key + keep the plural-list invalidation
so list views (BerthList, bulk-edit sheet) also refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:09:17 +02:00
c549622af4 feat(b3-2): bulk-price editing UI — inline cell + bulk-edit sheet
Lands the UI half of the bulk-price feature (backend already shipped).
Reps with berths.update_prices can retune pricing without unlocking the
rest of the berth schema, both one-at-a-time and in bulk.

- berth-columns: PriceCell wraps InlineEditableField, gated by
  can('berths', 'update_prices'). Click → input → save through
  PATCH /api/v1/berths/[id]/price. stopPropagation so row click
  doesn't navigate while editing.
- bulk-price-edit-sheet: right-side Sheet listing selected berths from
  the React Query cache. Per-row price + currency inputs with dirty-
  highlight. "Set all to" + "Adjust by %" shortcuts. Diff-only POST to
  /bulk-update-prices reports updated/unchanged/missing. Body is keyed
  on the selection so useState initializes fresh per open.
- berth-list: new "Update prices" bulk action gated by the same
  permission, sits between Remove tag and Archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:44:14 +02:00
da391b1830 feat(b3-1): interest dimensions dual-source — yacht dims for the recommender
Per docs/superpowers/audits/alpha-uat-master.md Bucket 3 #1. When a
yacht is linked to the interest the rep can flip a per-interest toggle
so the berth recommender reads dimensions off the yacht record instead
of the rep-entered desired_* columns.

- Migration 0087 + interests.useYachtDimensions boolean (default false).
- Validator (createInterestSchema) accepts the new field; service insert
  + update paths spread it through automatically.
- berth-recommender.service.loadInterestInput dual-source resolution:
  when toggle=true AND yachtId is set AND the yacht has at least one
  measurement on file, the recommender uses the yacht's length / width /
  draft instead of the desired_* values. Falls back to the desired
  columns whenever any precondition fails (no yacht link, toggle off,
  or the yacht carries no measurements). Returned InterestInput gains
  a `dimensionsSource: 'interest' | 'yacht'` trace field.
- Interest form: under the "Berth size desired" section, when a yacht
  is linked, a checkbox surfaces — "Use the linked yacht's dimensions
  for the recommender". When checked, the three dimension inputs grey
  out (DimensionInput gains a `disabled` prop) so the rep can't
  accidentally edit the now-overridden values. Hint text spells out
  the fallback behaviour.

Verified: tsc clean, 1493/1493 vitest, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:22:57 +02:00
8998f68c0f feat(reports-p7): cover-page brand picker (admin-only)
- DashboardReportBuilder grows an optional Cover-page brand picker
  surfaced only when can('admin', 'manage_settings') AND the user has
  access to >1 port. Pulls ports from PortContext; default option is
  "Use active port brand", remaining options are the other ports the
  user can reach. Choice persists in config.coverBrandPortId; threaded
  through preview, download (/reports/generate), and queue
  (/reports/runs) payloads.
- render-report.service.ts: when run.config.coverBrandPortId resolves
  to an accessible port, the cover-page logo + portName come from THAT
  port's brand kit. Falls back to the source port silently when the
  override port is missing or stale. Source-port DATA stays — only the
  cover branding swaps. Useful for cross-port leadership decks.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:18:00 +02:00
d32e557e56 feat(tenancies-renew-transfer): tenure-aware renewal + transfer actions
- renewTenancy service:
  - permanent / fee_simple / strata_lot → mutate-in-place (startDate
    moves forward, endDate may extend or null out)
  - fixed_term / seasonal → end the current row at its existing endDate
    + mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
  transferDate, mint fresh active row with transferredFromTenancyId
  pointing back. New client + yacht cross-validated against port +
  ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
  tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
  TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
  filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
  src/lib/validators/tenancies.ts.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:13:34 +02:00
911b51a669 feat(tenancies-p6-followup): generic create dialog + edit dialog + self-FKs
- Migration 0086: berth_tenancies.previous_tenancy_id +
  transferred_from_tenancy_id self-FKs + partial indexes. Per
  docs/tenancies-design.md these chain renewal / transfer successors
  to predecessors for fixed-term and seasonal lineage. Schema mirrored
  in tenancies.ts with AnyPgColumn typed-import.
- POST /api/v1/tenancies (generic create): accepts berthId in the
  body so client + yacht tab entry points don't have to bounce through
  /api/v1/berths/[id]/tenancies. Same createPending service helper.
- TenancyCreateDialog: <TenancyCreateDialog open clientId? yachtId?
  berthId? /> with all three pickers; pre-fills the carrier from the
  parent entity. POSTs to /api/v1/tenancies; "Create" and
  "Create and activate" CTAs both wire to the new endpoint.
- Mounted on ClientTenanciesTab + YachtTenanciesTab behind
  <PermissionGate resource="tenancies" action="manage"> so reps can
  mint tenancies directly from those tabs without bouncing through
  the berth page.
- TenancyEditDialog: edit metadata only (start/end dates, tenure type,
  notes) via the new action='update' branch on the [id] PATCH route.
  Status transitions stay on activate/end/cancel. Wired into the
  tenancy detail page header. Outer wrapper unmounts on close so the
  form re-initialises from current row data without setState-in-effect.
- updateTenancy service helper + PATCH action='update' branch added.
  Audit-logged + emits berth_tenancy:activated to invalidate detail
  query caches.

Renew + Transfer dialogs deferred — both need lineage UX decisions
(tenure-aware mutate-in-place vs new-row spawn; client/yacht swap
semantics) and the self-FK columns this commit lands are the
underpinning. Next sub-task.

Verified: tsc clean, 1493/1493 vitest, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:10:06 +02:00
c4450dd852 feat(upload-for-signing): per-type field metadata panel + payload extension
- PlacedField gains optional defaultValue + fieldMeta carriers. The
  field-placement submit threads fieldMeta verbatim through the FormData
  payload (only when populated), where the API route + service +
  Documenso client already accepted it (v2 field/create-many honours
  fieldMeta per row).
- FieldSidePanel grows a FieldMetaSubPanel that renders per-type
  controls in the right rail:
  - TEXT — default text, label, required toggle
  - NUMBER — format string, min, max, required
  - CHECKBOX — multi-select option editor with per-option `checked`
  - RADIO — single-select option editor (mutually-exclusive default)
  - DROPDOWN — single-select option editor
  Each writes shallowly into field.fieldMeta so Documenso v2's
  create-many endpoint receives the shape it expects. SIGNATURE /
  INITIALS / DATE / EMAIL / NAME render nothing (no per-instance
  config today).
- ChoiceMetaEditor extracted as a top-level component so the option
  list doesn't recreate its DOM subtree on every keystroke
  (react-hooks/static-components rule).

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:45:39 +02:00
866b910ae9 feat(reports-p7): subtitle override field in dashboard builder
- DashboardReportBuilder gains an optional Subtitle input alongside
  Title. Persisted in the config payload sent to /api/v1/reports/runs
  + /api/v1/reports/generate + threaded through the preview payload's
  useMemo dep list so live preview reflects the override.
- Cover-page brand picker (admin-only) — deferred. Today the renderer
  uses the active port's brand kit; cross-port branding swap needs a
  permission gate, port-pick UI, and a renderer override and is queued
  for a follow-up. Subtitle alone covers the most common ad-hoc need
  (custom cover-page subtext like "Board pack — March 2026").

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:40:28 +02:00
3f9c4589e0 feat(reports-p6): CSV output renderer + per-kind serializers + UI selector
- report-render.service.ts: KindRenderer now carries a per-kind toCsv
  serializer alongside the PDF renderer. renderReportRun branches on
  run.outputFormat — 'pdf' (existing path), 'csv' (new), 'png' (throws
  with a clear "deferred" message so the run lands as 'failed' without
  a partial blob). Storage path, mime type, filename + extension all
  pick up the output-format suffix; the file row mirror records the
  matching mime so the standard download surface serves it correctly.
- csvCell / rowsToCsv helpers: RFC-4180 escaping (always double-quoted,
  doubles internal quotes, CRLF newlines).
- 4 per-kind serializers:
  - dashboard: stage-count + top-interests + meta as 3-col CSV
  - clients: activity log rows (id/createdAt/action/entityType/entityId/userId)
  - berths: occupancy metrics (totalBerths + occupancyRate + status counts)
  - interests: revenue metrics (completed + forecast + per-stage breakdown)
- DashboardReportBuilder + SimpleReportBuilder gain an Output-format
  toggle (PDF | CSV). DashboardReportBuilder threads it into the queued-
  run POST; SimpleReportBuilder threads it directly. Synchronous PDF
  download path (Dashboard "Download PDF" button) stays PDF-only since
  /api/v1/reports/generate returns a blob, not a run row.

PNG remains deferred — flagged with a follow-up TODO inside the render
branch + the builder selector deliberately omits PNG so reps don't pick
it and watch a run fail.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:35:13 +02:00
2072f6cac0 feat(reports-p4+p5): landing page + per-kind builder + Templates/Runs/Schedules sub-pages
P4 — landing + builder:
- /[portSlug]/reports — new landing page with 4 build-kind cards
  (dashboard / clients / berths / interests), 3 library cards
  (Templates / Runs / Schedules), and the pre-P4 reports list
  preserved under "Legacy library" so historical PDFs stay accessible.
- /[portSlug]/reports/[kind] — kind-aware builder route.
  - dashboard: refactored the existing export dialog body into
    DashboardReportBuilder (page-mounted; same widget grouping +
    date-range + SavedTemplatesPicker + preview). New "Queue + go to
    Runs" CTA enqueues a report_runs row via /api/v1/reports/runs
    (Reports P3 path); "Download PDF" keeps the synchronous /generate
    fallback for ad-hoc one-shots.
  - clients / berths / interests: SimpleReportBuilder — date-range +
    enqueue to /api/v1/reports/runs. Kind-specific filters land
    alongside dedicated renderers in P6+.
- Dashboard "Export as PDF" button rewired: no longer opens an
  in-dashboard Dialog. Becomes a Link → /reports/dashboard?from=...&to=...
  carrying the currently-active range through search params so the
  builder pre-fills it. Removes the dialog body (~290 lines) from the
  button file; the same UI lives in DashboardReportBuilder.
- ?from=YYYY-MM-DD&to=YYYY-MM-DD search params pass the range into the
  builder page.

P5 — sub-pages (functional, backed by P2 CRUD endpoints):
- /reports/runs — paginated table of report_runs with status badges,
  auto-polls every 5s while any row is pending/rendering, per-row
  Download (file by storageKey) + Re-run actions.
- /reports/templates — saved template grid. Clicking the name links to
  the builder with ?templateId=… so it pre-applies.
- /reports/schedules — schedule table with cadence labels (weekly /
  monthly / quarterly), next-run timestamps, recipient counts, and a
  per-row enable Switch (PATCH /api/v1/reports/schedules/[id]).

Verified: tsc clean, 1493/1493 vitest, dev-server compile clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:28:26 +02:00
dd25ccfb53 fix(tenancies-audit): resolve findings from 7-agent system-wide rename audit
MUST-FIX:
- src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:70 — the
  PUT allowlist still gated `reservations: {view,create,activate,cancel}`.
  Stale: would reject valid `tenancies.{view,manage,cancel}` writes and
  silently accept ghost `reservations.*` writes that never land. Replaced.
- src/lib/services/alert-rules.ts:68 — `reservation.no_agreement` alert
  emitted `entityType: 'reservation'`. Every other tenancy-related
  audit/socket/dashboard label is `'berth_tenancy'`. Inconsistent dedupe
  + activity-feed label miss.
- tests/e2e/exhaustive/08-portal.spec.ts:6 — hardcoded /portal/my-reservations
  navigates to a 404 every run.
- tests/e2e/exhaustive/03-reservations.spec.ts — entire spec renamed to
  03-tenancies.spec.ts; tab + button locators updated to match renamed UI.

SHOULD-FIX (consistency):
- src/components/clients/client-detail.tsx — useRealtimeInvalidation only
  caught 3 of the 4 berth_tenancy:* events; added the `:created` listener.
- src/lib/services/client-merge.service.ts — MergeResult.movedRows.reservations
  + snapshot.reservations + local loserReservations / movedReservations
  renamed to tenancies / loserTenancies / movedTenancies. No external
  consumers grep-confirmed.
- src/lib/services/gdpr-bundle-builder.ts — GdprBundle.reservations field
  renamed to .tenancies; user-facing HTML section "Reservations" → "Tenancies";
  local reservationRows → tenancyRows.
- 6 UI copy strings: gdpr-export-button, bulk-archive-wizard,
  bulk-hard-delete-dialog, hard-delete-dialog, admin-sections-browser ×2,
  admin/import/page, won-status-panel — all "reservations" prose updated
  to "tenancies" (occupancy-record sense).
- tests/integration/api/tenancies.test.ts — handler import aliases
  `createReservationHandler` etc renamed to `createTenancyHandler` etc.
- tests/unit/services/berth-tenancies.test.ts — local helper makeReservation
  → makeTenancyLocal (avoids shadow of the renamed factory).
- scripts/audit-permissions.ts — stale allowlist entry for
  /berth-reservations/[id]/route.ts removed (path no longer exists).
- docs/runbooks/permission-audit.md — stale row for same path removed.
- docs/tenancies-design.md — fixed factual error
  ("tenancies.service.ts" → "berth-tenancies.service.ts").

Verified: tsc clean, 1493/1493 vitest.

Dev-server note: the running `next dev` process started before P2 and
shows Turbopack cached compile errors against the renamed schema files.
Source is correct (./tenancies); restart `next dev` to clear the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:03:14 +02:00
e9ef5831aa feat(reports-p3): BullMQ render + email + schedule poll for report_runs
- new report-render.service.ts: renderReportRun(reportRunId) +
  emailReportRun(reportRunId). Render path fetches the run row,
  advances status to 'rendering', resolves the kind→fetcher+template
  pair from REPORT_RENDER_MAP (dashboard→pipeline, clients→activity,
  berths→occupancy, interests→revenue), generates the PDF, uploads to
  storage, mirrors onto `files` so the standard download/attachment
  surfaces serve it, and stamps storageKey + sizeBytes + status='complete'.
  Failure path stamps 'failed' + errorMessage + compensating
  storage.delete to keep blobs from orphaning. Email path resolves the
  schedule's recipients + the rendered file via the standard
  resolveAttachments port-isolation check, sends one message per
  recipient via the existing sendEmail helper, and stamps emailedAt.
- reports worker (src/lib/queue/workers/reports.ts) gains 3 jobs:
  - 'report-schedules-poll': scans report_schedules where enabled=true
    AND nextRunAt <= now, mints a report_runs row per due schedule via
    createReportRun (triggeredBy='schedule'), advances next_run_at via
    nextRunFor() BEFORE enqueue so a downstream failure doesn't pin the
    schedule on the same tick, then enqueues report-run-render.
  - 'report-run-render': calls renderReportRun + auto-cascades into
    report-run-email when the run was schedule-triggered.
  - 'report-run-email': calls emailReportRun.
  These coexist with the legacy 'report-scheduler' + 'generate-report'
  jobs operating on scheduled_reports/generated_reports.
- scheduler.ts registers 'report-schedules-poll' on a 1-minute cron so
  the system catches due schedules even when no API event nudges them.
- POST /api/v1/reports/runs now enqueues 'report-run-render' after
  createReportRun. Enqueue failures are logged + swallowed so the API
  still returns 201; the schedule poll picks pending rows up as a
  safety net.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:42:53 +02:00
db14056018 feat(tenancies-p7): 4 module-gated dashboard widgets
- tenancy-reports.service.ts: 4 read-only query functions backing the
  widgets. Heatmap uses a months×areas SQL grid with date-range overlap;
  renewals-at-risk filters active tenancies whose end_date is inside a
  90d window with NO successor pending/active row already minted on the
  same berth; revenue forecast buckets active tenancies by their
  end-date quarter; tenure breakdown is a simple GROUP BY status='active'.
- 4 new API routes under /api/v1/dashboard/tenancy-*:
  - tenancy-occupancy (heatmap)
  - tenancy-renewals (at-risk list)
  - tenancy-revenue (forecast)
  - tenancy-tenure (breakdown)
  Each prepended with assertTenanciesModuleEnabled so a port without
  the module gets 404 instead of an empty payload.
- 4 widget components:
  - TenancyOccupancyHeatmapWidget — areas × months table with shaded
    cells (5-tier emerald ramp by occupancy %)
  - TenancyRenewalsAtRiskWidget — top-10 list, 30-day urgency badge
  - TenancyRevenueForecastWidget — horizontal bar list by quarter,
    currency-formatted totals
  - TenancyByTenureTypeWidget — proportional bars, color-coded per
    tenure type
- WidgetIntegration union extended with 'tenancies_module'; the
  useDashboardIntegrations hook reads it off PortProvider (no extra
  fetch). All four widgets register with selfGates=true +
  requires='tenancies_module' so the picker AND render path filter
  them out when the module is off.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:34:43 +02:00
e4daa482de feat(tenancies-p6): module-gate entity tabs (berth / client / yacht)
- PortProvider exposes tenanciesModuleByPort + a useTenanciesModuleEnabled()
  hook that returns the flag for the currently-active port. Synchronous
  read off context (server-resolved in the dashboard layout), so no
  fetch latency / hydration flicker when the rep flips ports.
- buildBerthTabs / getClientTabs / getYachtTabs gain a
  tenanciesModuleEnabled option. When false, the Tenancies tab is
  filtered out entirely. When true, it slots into the entity-specific
  position (after Interests on berth + yacht; after Companies on client).
- BerthDetail / ClientDetail / YachtDetail pass the hook value through.
  Hook call ordered above the early-return so React's rules-of-hooks
  stays satisfied. Existing read-only tab content (Active tenancy card
  + History + the berth-side BerthReserveDialog "Create tenancy" CTA
  from P2) stays untouched — it just becomes visible when the module
  is on.

Deferred (separate ship): generic TenancyCreateDialog that pre-fills
clientId / yachtId from the parent entity context, so client / yacht
tabs can mint a tenancy without bouncing through the berth detail page.
Today client/yacht Tenancies tabs are read-only (the create entry-point
is the berth tab); the generic dialog will land alongside the Edit /
Renew / Transfer / End dialogs (design § P6 sub-tasks).

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:29:22 +02:00
3a48150d13 feat(tenancies-p5): sidebar entry + 404 top-level page + API module gate
- Dashboard layout resolves tenanciesModuleByPort server-side (one
  isTenanciesModuleEnabled call per port the user has access to) and
  passes the map through AppShell → Sidebar. Atomic SSR — no
  flicker of the nav entry in/out after hydration.
- Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies
  entry (KeyRound icon, immediately below Berths) only renders when
  the currently-active port has the flag flipped on. Per-port live
  switch fires when the rep toggles ports without reload.
- /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call
  isTenanciesModuleEnabled and notFound() when disabled — guards
  against direct URL access even when the sidebar is hidden.
- API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies)
  prepended with assertTenanciesModuleEnabled — matches design §
  "All routes ... return 404 when off". NotFoundError maps to 404.
- Existing tenancy API tests get a makePortWithTenancies() helper
  (calls enableTenanciesModule after makePort) so the gate is
  satisfied. Affects 2 test files (16 tests retargeted).

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:23:06 +02:00
bfb29ab619 feat(tenancies-p4): public-map status flip via active permanent tenancy
- derivePublicStatus gains optional hasActivePermanentTenancy flag;
  precedence updated to "sold > under_offer > available" where
  Sold can come from EITHER berths.status='sold' (admin set) OR an
  active permanent-class tenancy (only when module enabled).
- Permanent-class tenure types defined in one place
  (isPermanentTenureType): permanent | fee_simple | strata_lot.
  Seasonal / fixed_term tenancies do NOT flip — they fall through to
  the existing under_offer / available precedence.
- /api/public/berths (list) + /api/public/berths/[mooringNumber]
  (single) both gate the lookup on isTenanciesModuleEnabled(portId).
  Disabled module = lookup skipped entirely, preserving pre-module
  behaviour for ports that haven't opted in.
- 8 new unit tests covering: flip from available, flip from under_offer,
  explicit sold idempotency, false-flag fallthrough, default-omit pre-
  module behaviour, permanent-class membership for each tenure type,
  and null/undefined/unknown rejection.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:17:06 +02:00
20549fb22e feat(tenancies-p3): webhook auto-create on signed Reservation Agreement + first-insert flip
- berth-tenancies.service.ts: autoCreatePendingTenancies(portId, interestId, opts)
  loops over interest_berths WHERE is_in_eoi_bundle=true and mints ONE
  pending tenancy per in-bundle berth. Wrapped in pg_advisory_xact_lock
  per port + idempotent skip when a (pending|active) tenancy already
  exists for the berth (webhook retry-safe). Each insert audit-logged
  + emits berth_tenancy:created socket event.
- createPending: same advisory-lock + tx pattern, additionally calls
  enableTenanciesModule(portId) so the FIRST manual tenancy in a port
  lazily flips tenancies_module_enabled=true (idempotent UPSERT, no-op
  on subsequent inserts).
- handleDocumentCompleted: branch on reservation_agreement completion
  gates on isTenanciesModuleEnabled, then calls autoCreatePendingTenancies
  with the just-committed signedFileId. Per design §"When disabled":
  stage advance + reservationDocStatus flip still fire when the module
  is off; only the tenancy mint is skipped.
- 5-case integration test covering bundle expansion, idempotent retry,
  empty-bundle no-op, missing-interest no-op, and the first-insert
  module-enable side effect.

Verified: tsc clean, 1485/1485 vitest (5 new cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:14:37 +02:00
ccc775dc66 feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)
73-file atomic rename per docs/tenancies-design.md:

- Migration 0085: rename table + indexes + FK constraints; rename
  documents.reservation_id → tenancy_id; migrate jsonb permission maps
  (reservations resource → tenancies; collapse create+activate → manage);
  rewrite historical audit_logs.entity_type='berth_reservation' →
  'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
  the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
  BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
  { view, manage, cancel }; all 8 default seed bundles + role-form + matrix
  updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
  endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
  /api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
  TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
  /portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
  BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
  ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
  reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
  PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
  → activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
  tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
  (TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
  migrated historical audit rows).

KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
  fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
  Reservation Agreement doc; only its DB imports were renamed).

Verified: tsc clean, 1480/1480 vitest passing, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:35 +02:00
4f350d1fbd docs(audit): refresh 2026-05-25 tally with Reports P2 + form-error sweep + Wave G cleanup
Captures the second execution pass:
- Reports P2 CRUD landed on report_runs + report_schedules.
- Form-error sweep complete platform-wide (16 remaining callsites adopted).
- Audit-doc cleanup: dock-letters / email-test / cancelMode were already
  shipped earlier and should not have been listed as queued.

Total ~25 commits across this date; ~110 h still queued for follow-up
(Reports P3-P7, Tenancies P2-P7, UploadForSigning field metadata, B3 wave).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:29:04 +02:00
1e31ed66f1 feat(reports-p2): CRUD layer for report_runs + report_schedules
Builds the API + service layer the P1 schema migration 0084 set up:

- src/lib/validators/reports.ts: new schemas for list/create on runs +
  full CRUD on schedules. Locked enums for kind / output / cadence /
  status so the route layer can reject invalid combinations early.
- src/lib/services/report-runs.service.ts: list with kind/status/template
  filters, create with cross-port template guard + config.kind
  discriminator check, updateReportRunStatus for the future P3 worker to
  flip status through pending/rendering/complete/failed.
- src/lib/services/report-schedules.service.ts: full CRUD plus
  nextRunFor() deterministic cadence math. nextRunAt is recomputed on
  cadence change or on re-enable (off->on) but left untouched on no-op
  edits so a mid-cycle recipient swap doesn't slip the fire-time.
- /api/v1/reports/runs (GET + POST) + /api/v1/reports/runs/[id] (GET)
- /api/v1/reports/schedules (GET + POST) +
  /api/v1/reports/schedules/[id] (GET + PATCH + DELETE)
- tests/integration/report-runs-schedules.test.ts: 9 cases covering the
  cross-port FK guard, the config.kind cross-check, listing filters,
  cadence math for all three v1 cadences, the no-op-doesn't-slip rule,
  and the ON DELETE SET NULL contract on schedule deletion.

Permission gating: list/get on reports.view_dashboard (read), all mutations
on reports.export (write). Matches the existing /reports/templates routes.

P3 (the BullMQ render+email queue) is the next slice; it'll consume the
pending rows produced here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:26:18 +02:00
7476eabec6 feat(form-error-ux): adopt useFormScrollToError + FormErrorSummary across remaining 10 forms
Completes the form-error rollout the prior session shipped on the 6
highest-impact forms (client/interest/yacht/company/berth/expense). Adds
the scroll-to-first-error wrapper + the top-of-form summary banner to:

- src/app/(auth)/login/page.tsx
- src/app/(auth)/reset-password/page.tsx
- src/app/(auth)/set-password/page.tsx
- src/app/(auth)/setup/page.tsx
- src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
- src/components/berths/berth-detail-header.tsx (status-change dialog)
- src/components/companies/add-membership-dialog.tsx
- src/components/invoices/invoice-detail.tsx (record-payment form)
- src/components/reservations/berth-reserve-dialog.tsx
- src/components/yachts/yacht-transfer-dialog.tsx

Each call site: hook wraps handleSubmit, FormErrorSummary renders only
when 2+ errors fire (no visual change otherwise), and per-form `labels`
prop translates field names to human-readable strings. invoice-line-items
is a sub-form via useFormContext, so it inherits from the parent.

1471/1471 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:26:04 +02:00
35bd8c45d8 docs(audit): refresh 2026-05-25 tally with B4 sweep + B2 Wave F ships
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:16:20 +02:00
3a1c16ae71 feat(external-eoi): auto-cancel + replace generated EOI on upload
When ExternalEoiUploadDialog mounts on an interest with a non-terminal
generated EOI (status sent / partially_signed / draft), it now surfaces
an amber banner naming the active envelope and offering two paths via
radio:

- "Cancel the generated envelope and replace it" (default + recommended):
  upload posts cancelActiveDocumentId; the service voids the upstream
  Documenso envelope + flips the local doc row to cancelled BEFORE the
  new external-EOI doc lands. Audit-log on the new doc carries
  metadata.replacedDocumentId so reps can trace cause + effect.
- "Keep both records (advanced)": legacy behaviour - leaves two EOIs on
  the deal. Useful only for backfilling intentionally-parallel records.

Cancel runs outside the upload transaction so a Documenso void error
doesn't block the upload the rep has already photographed. The dialog
already shares cache + envelope shape with InterestDetail, so the recent
B4 #4 fix means opening the dialog no longer blanks the page.

cancelMode='delete' is hardwired in the replace path (kill the upstream
envelope on void). Pairs with the existing keep_remote affordance on the
manual Cancel-document flow shipped earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:15:22 +02:00
cd6b19e173 feat(eoi-generate): Include-yacht toggle to omit Section 3 when yacht is a placeholder
EoiGenerateDialog gains an inline "Include on EOI" checkbox in the
Section 3 header (renders only when ctx.yacht is set; defaults ON so
existing behaviour is unchanged). When OFF, the generate-and-sign POST
flips includeYachtDetails=false on the body; service blanks
eoiContext.yacht before either pathway runs:

- Documenso template payload: buildDocumensoPayload reads no yacht so
  yacht.* and owner.* merge fields ship empty. Existing template tolerates
  blanks per the "left blank if absent" copy.
- In-app PDF fill (pdf-lib): generateEoiPdfFromTemplate sees no yacht so
  AcroForm field writes for the yacht block are skipped.

Persists the rep's choice in the document-create audit log
(metadata.includeYachtDetails) so an audit trail records explicit opt-outs
even though documents has no JSONB metadata column today.

ft/m unit toggle in the Section 3 header now hides when Include is OFF
(unit choice is meaningless without yacht details).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:11:19 +02:00
7bdfc340ae feat(admin-settings): radio field type + adopt for Documenso signing-order + send-mode
Adds a 'radio' SettingType the registry-driven admin form can render. Same
shape as 'select' (options list, enum validation, resolved/source badges),
but renders inline radio cards instead of a dropdown so each option's
consequences sit side-by-side for the admin.

Adopted on the two highest-stakes Documenso behaviour toggles:
- `eoi_send_mode` — Manual vs Auto signing-invitation dispatch
- `documenso_signing_order` — Parallel vs Sequential recipient flow

Both choices are binary and materially different (one auto-sends mail, the
other doesn't; one routes signing serially, the other in parallel), so the
upfront comparison beats a hidden dropdown.

`documenso_redirect_url` keeps its url-input — it's already a single
free-text field with no enum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:06:04 +02:00
9138932d1b feat(docs-ui): include new FileIcon shared module (continuation)
Companion to prior commit — the untracked file-icon.tsx that both
EntityFolderView and FileGrid now import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:02:41 +02:00
dd6e8ee968 feat(docs-ui): shared FileIcon + signed-state pill on EntityFolderView rows
- Extract FileIcon mapping to `src/components/files/file-icon.tsx` (single
  source of truth for mime→icon+colour palette; was previously inline in
  FileGrid only).
- EntityFolderView file rows now render the type-specific icon (PDF/red,
  Image/blue, Sheet/green, Video/purple) instead of a generic FileText —
  multi-deal clients become scannable at a glance.
- Add an inline "Signed" pill on rows where signedFromDocumentId is set so
  reps can distinguish a signed-from-workflow copy from a vanilla upload
  without hovering for "View signing details".
- Tighter hover treatment (row picks up a subtle bg on hover) for affordance.
- FileGrid refactored to consume the shared FileIcon so both surfaces stay
  in lockstep on future mime additions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:02:38 +02:00
65b92cace1 fix(b4-bugs): external-EOI cache collision + stage-gate regression test + search popover opacity
Three B4 bug fixes shipped together:

- **#4 Upload-signed-copy blank body** — ExternalEoiUploadDialog used
  queryKey=['interests', interestId] but didn't unwrap the {data} envelope
  while the parent InterestDetail (same key) does, so opening the dialog
  clobbered the cache with a wrapped shape and blanked the detail page
  ("Unknown Client" + empty tab body). Dialog now unwraps to match.

- **#2 Legacy-stage canonicalization regression test** — new integration
  test locks in the external-EOI advance gate: canonical pre-EOI stages
  (enquiry/qualified/nurturing) advance to 'eoi' on upload; at-or-past-EOI
  stages stay put while metadata still writes. 7/7 passing. Backfill
  script intentionally not shipped — dev DB is test data, prod cutover
  is manual.

- **#3 Global-search dropdown translucent rows** — defensive opaque
  background on the popover wrapper (bg-white dark:bg-popover) guards
  against the subtle transparency UAT captured on the Berths page.
  Live-browser repro still needed to identify the exact bleeding row;
  this defense makes the surface unambiguously solid in light mode
  regardless of which class wins tailwind-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:59:25 +02:00
13834afa46 docs(audit): tally 2026-05-25 execution pass — shipped vs queued
Top-of-doc status block summarising what landed during the autonomous
execution pass (~12 commits across Bucket 1/2/3/4) + what remains
queued for follow-up sessions. Lets future sessions skip directly to
deferred items without re-triaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:02:53 +02:00
81e7aa284e fix(ui-sheet): widen default Sheet width sm:max-w-sm -> sm:max-w-md, +lg:max-w-xl
Locked decision from the audit: bump every Sheet width uniformly so
content-dense drawers (EoiGenerateDialog, InterestForm, ClientForm,
…) get more horizontal room without per-site overrides. Adds a
lg:max-w-xl tier so wide viewports get extra breathing room while
the sm tier stays tight on tablets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:01:50 +02:00
5d43953957 feat(reports-p1): schema + perms foundation for /reports page
Part of the locked Reports page design (docs/reports-page-design.md).
This PR is the data foundation — API routes, UI builder, scheduler,
and rendering pipeline land in subsequent PRs.

What ships:
- Migration 0084: extends report_templates with description + visibility
  + archived_at, softens the unique-name index to skip archived rows,
  adds report_runs (append-only audit log) and report_schedules
  (BullMQ recurring scheduler) tables with full indexes.
- Schema TypeScript additions in src/lib/db/schema/reports.ts:
  reportSchedules + reportRuns table definitions with strongly-typed
  recipients / config / status enums.

Behaviour today: no UI changes; existing /api/v1/reports/generate
keeps working unchanged. Saved templates can be archived via
report_templates.archived_at once the templates CRUD API lands in P2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:00:57 +02:00
d3ec9fdb4e feat(tenancies-p1): module-enabled gate + admin toggle endpoints
Part of the locked Tenancies module design (docs/tenancies-design.md).
This PR is the gating infrastructure — the actual table rename
(berth_reservations -> tenancies) + self-FKs + perm-rename + sidebar
entry land in subsequent PRs.

What ships:
- `system_settings.tenancies_module_enabled` registry entry (port-scoped
  boolean, default false). Surfaces in the registry-driven admin form
  + the resolveForAdminAPI chain.
- `src/lib/services/tenancies-module.service.ts` with:
  * isTenanciesModuleEnabled(portId) — checks the admin setting AND
    the lazy "any berth_reservations row exists" sentinel
  * enableTenanciesModule / disableTenanciesModule — idempotent
    upserts on the system_settings row
  * assertTenanciesModuleEnabled — throw-on-disabled helper for
    route handlers (NotFoundError -> 404)
- Three admin endpoints under /api/v1/admin/tenancies-module/
  (status / enable / disable), all gated on admin.manage_settings.

Behaviour today: with the module off (default), nothing changes.
Sidebar, entity tabs, top-level page, webhook auto-create branch,
and dashboard widgets all continue to read the same flag and stay
hidden until either an admin toggles it ON or the first auto-create
flips it via the lazy "row exists" sentinel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:58:19 +02:00
c7dbe0bb10 docs: lock Reports page + Tenancies module designs
docs/reports-page-design.md: ~400 lines covering
- Routing: /{portSlug}/reports landing + builder/templates/runs/schedules
- 3 new tables (report_templates_shared, report_runs, report_schedules)
  with full schema + indexes
- API surface (12 routes) gated on reports.export / reports.admin
- BullMQ queues (reports-render, reports-email) + cron scheduler
- UI plan for landing + two-panel builder + 3 sub-pages
- Quick-path dashboard button rewire
- 7-PR phased plan (~43h total)

docs/tenancies-design.md: ~350 lines covering
- Vocabulary split (Reservation vs Tenancy)
- Platform-wide module-enabled rule (auto-flips on first insert,
  admin Operations toggle, warning on disable)
- Rename migration berth_reservations -> tenancies + self-FKs
- Tenure-type behaviour matrix (renewals + public-map flip)
- Transfer flow (end + mint linked rows)
- 3 new perms (view/manage/cancel)
- Webhook auto-create branch (gated)
- Public-map status precedence (permanent-class only)
- Sidebar entry + top-level page + entity-tab CTAs
- All 4 reporting widgets (module-gated)
- Service layer additions
- API surface (10 routes)
- 7-PR phased plan (~42h total)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:54:32 +02:00
777b711548 feat(uat-b2): visual breakpoint fixes + form-error UX rollout
B2 Wave A (visual breakpoints):
- Documents Hub folder rail: widen ResizablePanel default 20→22%,
  min 14→18%, add min-w-[180px] CSS floor so names don't truncate
  at tablet 768
- Website analytics KPI tiles: switch lg grid 6→3 cols, restore 6
  at xl so "Visit duration" stops truncating in the 1024+sidebar
  layout
- Pipeline Value tile per-stage rows: compact $3.5M format on
  sm- breakpoint (responsive sm:hidden / hidden sm:inline pair)

B2 Wave D (form-error UX rollout):
- useFormScrollToError + FormErrorSummary wired into 5 high-impact
  forms: client-form, interest-form, yacht-form, company-form,
  berth-form. Validation failures now scroll the first errored
  field into view + render a top-of-form summary banner when ≥2
  errors exist. Remaining ~23 form surfaces queued for follow-up.

B2 Wave B (Umami follow-ups):
- TopList primitive: add onExpandRange + expandRangeLabel props
  for the empty-state nudge ("Try last 30 days" button). Callers
  can opt in to drive the page-level DateRange.

B2 Wave C (FieldLabel + admin tooltip audit):
- Verified FieldLabel primitive already exists + is adopted in
  custom-field-form. Registry-driven-form renders entry.description
  inline below labels for every entry — the broad sweep across
  15-20 admin pages is deferred to a focused polish session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:50:46 +02:00
14ae41d0fa feat(uat-b1): ship Wave A-E of Bucket 1 audit findings
Wave A (Interest+EOI form quick wins):
- Auto-select yacht after inline-create from interest form
- EOI generate dialog: "View EOI" action toast
- Interest form berth picker: formatBerthRange compact label
- Remove "Generate EOI" button from Documents tab (clean removal)
- Interest auto-assign: only sales_agent/sales_manager auto-claim
  ownership on create (explicit role check via user_port_roles join)
- LinkedBerthRowItem dims: drop "D" suffix + "L × W" format
- ExternalEoiUploadDialog: prefillSignatories prop threaded from
  active EOI signers
- EOI signature progress on Overview milestone card footer

Wave B (a11y + i18n sweeps):
- aria-live on supplemental-info error state
- text-[10px] -> text-xs in client-pipeline-summary
- Currency formatter: locale default removed (Intl uses runtime)
- en-US/en-GB hardcoded toLocaleString swept across 13 components

Wave C (Primary berth always in EOI bundle):
- Service guard strengthened on update path
- Migration 0083 backfills historical primary rows

Wave D (Onboarding super_admin discoverability):
- /api/v1/admin/onboarding/status endpoint + shared service
- Topbar OnboardingBanner (super_admin, session-dismissible)
- OnboardingTile dashboard widget (rail group, self-hides at 100%)
- Celebration toast + invalidate of shared status on last tick

Wave E (Branded post-completion email idempotency):
- Verified handleDocumentCompleted already owns the email fan-out
- Added regression test for the polling path + idempotency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:40:37 +02:00
41737fa950 feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
  7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
  legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
  EOI uploads from 'qualified' silently skipped the stage flip. Now also
  writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
  'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
  comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
  pdf/templates/{interest,client}-summary, interest-picker, timeline route
  all route through canonicalizeStage / stageLabelFor.

Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
  (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
  falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
  interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
  berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
  pipeline-column (kanban), interest-columns (list), interest-card,
  interest-detail (breadcrumb), client-pipeline-summary +
  client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.

Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
  resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
  canonical BERTH_STATUSES); cleaned from dashboard.service,
  dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
  hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
  "Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
  more 2-line wraps on "needs date range"); accepts initialRange?:
  DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
  rangeToBounds.

Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
  berths (where the only active deal touching the berth IS this same
  interest). Waits for all competing-queries before committing the
  count. Was showing "3 berths unavailable" when only 1 actually had a
  competitor.

Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
  instead of firstAt so visible timestamp matches the sort key.

Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
  inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.

EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
  one batched getAllBerthMooringsForInterests call across all groups.
  AggregatedFile type + EntityFolderView render the badge linking back
  to the parent interest.

External EOI upload dialog
- Title input pre-fills from the derived default via controlled
  displayTitle = title || defaultTitle (no setState-in-effect).

EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
  with tooltip: the primary IS the canonical "berth for this deal",
  excluding it is semantically nonsense.

Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
  whenever is_primary=true; update path coerces back to true when the
  caller tries to set false on a primary. Backfilled 7 existing rows.

Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
  documenso_redirect_url → public_site_url → null. Operators with
  public_site_url configured (most ports) now get sensible signer
  landing without setting two settings.

World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
  filtered Clients page via router.push instead of copying a URL to
  clipboard.

Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
  folder has children. Lets reps drill into subfolders from the main
  content area, not only via the sidebar tree.

Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
  param). Interest list passes updatedAt desc so the table header
  surfaces the active sort visibly + most-recently-added/edited bubble
  to the top.

Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
  — explicit input → port's default_new_interest_owner setting →
  creator (when not super-admin). Super-admins skipped since they often
  create on behalf of other reps.

Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
  aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
  flipped to true.

Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed

Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
70d1e7e9b2 feat(docs): nested-entity 'This deal' / 'From client' split (B4 #8 phase 4)
Finishes B4 #8 by completing the UI half of the per-interest filing
model. Backend foundations (files.interest_id column, ensureEntityFolder
for 'interest', upload-zone scope radio, outcome rename hook, backfill)
shipped earlier in this audit cycle.

- listFiles validator + service: optional interestId filter
- listFilesAggregatedByEntity: routes entityType='interest' to a new
  helper that returns "THIS DEAL" + "FROM CLIENT" + symmetric-reach
  company/yacht groups
- InterestDocumentsTab: Attachments section now renders two cohorts
  via two paginated queries, with client-side de-duplication so files
  filed under this deal don't double-count under "From client"
- FileRow type exposes the optional interestId so the de-dupe filter
  doesn't need a re-fetch
2026-05-23 01:06:45 +02:00
5bd0e1ad9a feat(documents): universal upload-with-fields UI wiring (B3 #11)
Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.

- UploadForSigningDialog: interestId now string | null; new entity?,
  folderId?, onCreated? props. Generic path POSTs to new endpoint
  /api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
  lookup, pipeline-stage advance, doc-status flip on the generic path.
  Routes file FK + auto-filed folder via either interest.clientId or the
  caller-supplied entity. Validation enforces the matching invariant
  (generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
  Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
  gated by documents.send_for_signing.

Existing unit tests for the service still pass (validation paths unchanged).
2026-05-23 01:01:52 +02:00
221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00
43719b49e9 feat(dashboard): merge rearrange into the Customize modal
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m46s
Build & Push Docker Images / build-and-push (push) Successful in 5m21s
Two days, two modals, both touching widget layout - collapsed into
one. The separate "Rearrange" button + RearrangeWidgetsDialog from
54c5d0f are gone; the Customize modal now does both jobs:

- Two sections in the body: "On dashboard (N)" and "Hidden (N)"
- Visible rows are sortable (drag handle on the left, position number,
  switch on the right). Single SortableContext, vertical strategy.
- Hidden rows are toggle-only (no drag handle - order doesn't matter
  for off-dashboard widgets). Flipping the switch on appends to the
  bottom of the visible section.
- Both visibility toggles and reorder commits optimistically via
  useDashboardWidgets so the dashboard reflows in the background.

dashboard-shell: removes the Rearrange button + RearrangeWidgetsDialog
import + setOrder destructure. rearrange-widgets-dialog.tsx deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:54:41 +02:00
54c5d0ff1e feat(dashboard): replace in-place widget drag with modal sortable list
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m49s
Build & Push Docker Images / build-and-push (push) Has been cancelled
The in-place drag (N46 / a147cbc) had two failure modes:
- Bucket constraints: each layout group (charts / rails / feed) was
  its own SortableContext; drops outside the active group silently
  no-op'd, so any cross-region drag did nothing.
- Long drags lost their drop target: dnd-kit's closestCenter
  collision detection on a sparse grid would intermittently null
  out `over` mid-drag, which presented as the dragged tile snapping
  back to its original slot.

Switched to a single-flat-list modal:
- New <RearrangeWidgetsDialog>: opens from the "Rearrange" button,
  shows every visible widget as a row with a drag handle and a
  position number, single vertical SortableContext, Save commits.
- Dashboard shell strips the DndContext + per-bucket SortableContext
  wrappers + the SortableWidget cell + all dnd-kit imports related
  to the canvas drag. Each widget renders as a plain <WidgetCell>.
- Rearrange button now opens the dialog instead of toggling a drag
  mode. Disabled when there's fewer than 2 visible widgets.

The drag persistence fix from ee4d5c8 still applies — the dialog's
Save calls the same setOrder() that PATCHes preferences.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:49:47 +02:00
e4fb425d05 fix(layout): persist resolved viewport tier in cookie to kill SSR flicker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m49s
Build & Push Docker Images / build-and-push (push) Successful in 5m33s
User reported: "when I refresh the page with this size viewport it
switches between tablet and desktop view." The root cause was the
two-step tier resolution:

  1. Server renders shell based on User-Agent (mobile vs desktop only).
  2. Client mounts with that hint, useEffect runs matchMedia, may flip.

When the UA says "desktop" but the viewport is actually 900px (so
matchMedia says "tablet"), the chrome visibly switches mid-render.
Most painful on macOS Safari dragged below 1024.

Fix: AppShell writes a `pn-crm.viewport-tier` cookie (1-year, Lax) on
every matchMedia evaluation. The dashboard layout reads the cookie
and prefers it over the UA classifier for `initialFormFactor`. First
visit can still flicker (no cookie yet); every subsequent reload uses
the resolved tier and renders the correct chrome on first paint.

The cookie values are 'mobile' / 'tablet' / 'desktop' but the server's
initialFormFactor prop only accepts 'mobile' | 'desktop' (binary by
design — AppShell's useEffect resolves the actual tier client-side
from matchMedia). 'tablet' from the cookie collapses to 'desktop' on
SSR; AppShell's useEffect re-resolves to tablet immediately. The
fluent path on cookie hit is desktop -> tablet (no flicker because
both shells render the desktop tree; only the sidebar Sheet wrapper
differs, and that's invisible until opened).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:33:36 +02:00
ee4d5c8610 fix(dashboard): persist widget drag-drop order (validator was dropping it)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
N46 (a147cbc) shipped the drag-drop UI + optimistic mutation, but the
PATCH body was being silently stripped by the user-preferences Zod
validator — `dashboardWidgetOrder` wasn't in the schema, so Zod's
default strip-unknown-keys behaviour dropped it before the DB write.

Symptom: drop the widget in a new position → UI reflects the order
optimistically → onSettled invalidates + refetches → GET returns the
unchanged-on-disk order → dashboard snaps back to the original
layout.

Added the field to updateUserPreferencesSchema with the same loose
shape (array-of-string) the schema declared 100+ lines earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:26:39 +02:00
355f242b8f fix(layout): topbar grid auto-expanded center column hid right buttons at 780-1280
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m37s
Build & Push Docker Images / build-and-push (push) Has been cancelled
User reported the search bar dropping to a second row + the top-right
buttons (+ New / Inbox / Avatar) going missing as they resized the
browser. Playwright probe confirmed: at every width 780-1280 the
search bar's intrinsic `max-w-2xl` (672px) forced the topbar's
center grid column to expand to that width, leaving the right
column too narrow to hold "+ New + Inbox + Avatar" without
overlapping the search OR going off-screen.

Two coordinated fixes:

1. Grid template `auto_1fr_auto` instead of `1fr_minmax(280,800)_1fr`.
   Side columns now size to their actual content (logo + breadcrumbs
   on the left; New + Inbox + Avatar on the right); the center
   column takes whatever's left. No more "intrinsic content forces
   the column to grow" behaviour.

2. Search wrapper max-width scales by tier: max-w-md (448px) at
   base, lg:max-w-xl (576px), xl:max-w-2xl (672px). Generous enough
   on wide screens, restrained enough on narrow ones so the side
   columns always get the space they need.

Verified via Playwright probe at 780/900/1023/1024/1100/1280 —
"+ New" button now lands inside the header at every width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:22:29 +02:00
9ae7940a04 fix(layout): migrate date pickers to useViewportTier mobile-only
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m33s
Build & Push Docker Images / build-and-push (push) Successful in 6m59s
Final Bucket 1 visual-audit follow-up. Audit of all 4 useIsMobile
callers:
- pipeline-chart.tsx + pipeline-funnel-chart.tsx → keep useIsMobile
  (short x-axis stage labels apply on tablet too — bar charts can't
  fit full "Reservation" / "Deposit Paid" text at narrow widths).
- date-picker.tsx + date-time-picker.tsx → migrate to useViewportTier.
  Tablet (768-1023) has plenty of room for the desktop Popover
  Calendar; only the smallest phone widths now fall back to the
  native datepicker input.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:06:50 +02:00
c24f9e5508 docs(uat): annotate the two Bucket 1 layout fixes as SHIPPED in 2f1e1b5
Some checks failed
Build & Push Docker Images / lint (push) Has been cancelled
Build & Push Docker Images / build-and-push (push) Has been cancelled
PageHeader stack point + tablet topbar trigger fixes verified via
Playwright re-screenshot at 768 + 1024.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:04:05 +02:00
2f1e1b5f3f fix(layout): unblock tablet topbar trigger + un-crush 1024 dashboard title
Two Bucket 1 quick-fixes from the 2026-05-22 visual audit, both
1-2-line CSS changes with outsized visual impact.

PageHeader stack point: lg → xl
The earlier sm → lg revision (commit 6d665d0) fixed the 768 tablet
crush but introduced a SECOND crush at exactly 1024: that's where
the desktop shell mounts (sidebar = 256px) AND lg:flex-row kicks
in, leaving the title cell to compete with a 4-button action row
in only ~720px of content. Title degraded to "(" and "Last 30
days" wrapped three-deep ("Last / 30 / days"). Moving to xl
(1280) keeps the strip stacked through tablet AND the narrowest
desktop width. Verified via Playwright at 1024 — title now reads
cleanly with the action row stacked below.

Topbar tablet logo trigger:
AppShell mounts a logo button in Topbar's leadingSlot prop on
tablet (the design intent: click logo → sidebar Sheet slides in).
Live screenshot at 768 showed zero affordance — search bar started
at the very left edge of the visible viewport. Two root causes,
both fixed:
- center grid column was minmax(420px, 800px) which starved the
  left column to ~100px at 768 width (no sidebar present).
  Changed to minmax(280px, 800px) at base, minmax(420px, 800px)
  only at lg+.
- search container had unconditional sm:-translate-x-...
  shifting it 128px LEFT to compensate for a sidebar that isn't
  present at tablet, pulling the search input over the leading-
  slot. Gated the translate to lg: so it only kicks in when the
  sidebar is actually inline.

Verified via Playwright at 768 — hamburger icon now appears in
the top-left corner; search bar sits to its right without overlap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:02:57 +02:00
d0639421bd docs(uat): append visual breakpoint audit findings to master doc
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Has been cancelled
Captured 2026-05-22 from a Playwright MCP pass at 5 viewports × 20
surfaces (375 / 768 / 1024 / 1440 / 1920 px). The tablet tier
infrastructure shipped in 6d665d0 + the dashboard PageHeader
stacking fix lit up the tier — these are the residual bugs the
audit surfaced.

3 Bucket 1 quick-fixes:
- Tablet topbar logo trigger doesn't render visibly (search-bar
  translate shifts over leading slot + center column min-width
  too wide).
- Dashboard PageHeader at exactly 1024 viewport (sidebar present +
  lg:flex-row kicks in, crushing the title).
- useIsMobile call-site audit needed (kept as tier !== desktop
  alias; some sites want strict mobile-only).

4 Bucket 2 mediums:
- Documents Hub folder rail truncates to 3 chars at tablet.
- Website analytics 6-KPI row too cramped at 1024.
- Pipeline Value mobile (375) per-stage rows overflow right margin.
- Berths list 1024 — only 5-6 of 14 columns fit before h-scroll.

Screenshots local at tmp/visual-audit-2026-05-22/ (gitignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:59:50 +02:00
c5affc9b45 chore: gitignore tmp/ + remove accidentally-committed audit screenshots
The 100 PNGs from the in-progress visual audit pass landed in cb91f78
because tmp/ wasn't gitignored. Removing from HEAD + adding the rule
so future runs stay local. (Original blobs remain reachable from the
prior commit if needed; not worth a destructive filter-branch.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:49:18 +02:00
cb91f78cbc fix(turbopack): drop pino logger from berth-range — async_hooks leaked to client bundle
Same client-server boundary bug class as adf4e2b. berth-range.ts
imported `logger` (-> request-context.ts -> node:async_hooks) for
two debug/warn calls. external-eoi-upload-dialog.tsx is a client
component and imports formatBerthRange — Turbopack chunked
async_hooks into the client bundle and crashed with:

  Code generation for chunk item errored
  Caused by: the chunking context (unknown) does not support
  external modules (request: node:async_hooks)

Surface was the entire interest detail page on every viewport: dev
shell rendered the Turbopack overlay instead of the actual UI, so
the planned visual audit couldn't take any meaningful screenshots.

Replaced logger.debug + logger.warn with a single console.warn that
summarises non-canonical moorings. console.warn is safe in both
server and client contexts and the formatter's failure mode is
non-critical (verbatim passthrough — no data loss).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:48:49 +02:00
fcab7745aa fix(lint): use Route cast in ClientsByCountryWidget so prettier doesn't reflow the eslint-disable
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Failing after 2m10s
The prior fix (c1daed1) collapsed the JSX onto one line so the
eslint-disable-next-line directive correctly targeted the `as any`
cast. Lint-staged's prettier ran on the next commit and reflowed the
attribute back across multiple lines, separating the directive from
the cast and re-triggering @typescript-eslint/no-explicit-any.

Cast to `Route` (typed-routes' own escape hatch) instead of `any`.
No eslint-disable required, and prettier can reflow freely without
breaking the lint contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:42:16 +02:00
c1daed1991 fix(lint): unbreak CI build — misplaced eslint-disable directives
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m40s
Build & Push Docker Images / build-and-push (push) Has been skipped
Two findings + a stale comment crossed the production build threshold
because the eslint-disable-next-line directives didn't actually cover
the line that triggered the rule.

- clients-by-country-widget.tsx: the disable on line 96 targeted the
  JSX `href={` opener on line 97, but the `as any` cast lived on
  line 98. Collapsed to one line so the directive applies to the
  cast directly.
- use-form-scroll-to-error.ts: single disable above the type alias
  targeted the type's name line, not the `any` typed params two lines
  below. Moved per-param disables next to each `any`.

`pnpm lint`: 3 errors -> 0 errors (41 warnings unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:40:25 +02:00
6d665d0113 feat(layout): add tablet viewport tier (mobile/tablet/desktop)
Previously the app used a binary matchMedia split at 1023.98px, so iPad
portrait + half-screen-on-13"-Mac both fell into the mobile shell —
neither is really mobile. The tablet tier fills that gap.

- `use-is-mobile.ts` gains `useViewportTier()` returning
  'mobile' | 'tablet' | 'desktop' (mobile < 768, tablet 768-1023,
  desktop ≥ 1024). Backed by useSyncExternalStore so render reads
  stay pure. `useIsMobile()` retained as a back-compat alias =
  `tier !== 'desktop'` so existing call sites don't have to change
  in lockstep.

- `app-shell.tsx` now renders three branches. Mobile + desktop
  unchanged. Tablet renders the desktop shell, but the Sidebar lives
  inside a left-side `<Sheet>` opened by a new leading logo button
  in the Topbar. SheetContent width matches `--width-sidebar` so the
  open state reads consistent. Children subtree position stays
  invariant across tier flips so inline-edit drafts survive a resize.

- `topbar.tsx` accepts an optional `leadingSlot` rendered before the
  back button + breadcrumbs in the LEFT column. AppShell mounts a
  port-logo button in that slot on tablet (or a three-bar menu icon
  when the port has no logo yet) that triggers the sheet.

- `page-header.tsx` was the dashboard "title card looks bad on
  tablet" surface — the actions row was forced no-wrap at sm (640px)
  which crushed the title on iPad-portrait. Stack point moved from
  sm to lg, so tablet stacks vertically (title above, actions
  below); desktop returns to side-by-side.

tsc clean, 1454/1454 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:37:23 +02:00
6af75eda01 docs(uat): backfill SHIPPED markers across master doc
Previous "annotate plan with per-group SHIPPED commits" pass (6aaccb6)
touched only the per-session plan doc; the long-lived
alpha-uat-master.md was missing markers for ~20 ships across Groups
C-T and two regression catches from the current session.

Added markers for: 991e222 (C21+C22+C23 ft/m + bulk), 431375d (D24
wizard ft/m + D25 dock letters + E26 regenerate/resend/history),
94c24a1 (F28 past-milestones + F29 watchers + G30 invitations merge
+ H32 email explainer + H33 branded supplemental email), 989cc4d
(I34 residential header + I35 interests parity + I36 partner forward
+ I37 auto-link), 03a7521 (J38 set-X-to-Y + J39 link company + K40
resolver chain), 65ff596 (L41 upload-for-signing rework), 0ddaf46
(M42 universal preview), a147cbc (N44/45/46), a7cbee0 (O48/52/53/54),
0ed03fc (P56 phases 2/3), c14f80a (Q58/59/61), aa1f5d2 (R62/T64/T65).
Two fresh entries: be261f3 LAN-dev fix, adf4e2b dashboard PDF widget
split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:30:25 +02:00
589be0bfed docs(uat): annotate U66 SHIPPED in plan + master doc
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m36s
Build & Push Docker Images / build-and-push (push) Has been skipped
Plan item 66 (EOI bundle UX rework) fully closed:
- (a) defaults flip — 05e727f (prior session)
- (b) LinkedBerthsList rename — PR10 (prior session)
- (c) picker inside EoiGenerateDialog — ef37901 (this session)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:08:17 +02:00
ef379013e6 feat(uat-batch): U66 — EOI berth-scope picker inside generate dialog
Closes plan item 66 part (c). Parts (a)+(b) shipped earlier (05e727f
defaults flip + linked-berths-list rename); this is the
picker-inside-generate-dialog that the rep sees at the moment the
"which berths does this EOI cover?" question is actually live in their
head, instead of relying on them having visited LinkedBerthsList
toggles upstream.

EoiGenerateDialog gains:
- A new useQuery against /api/v1/interests/[id]/berths returning every
  linked berth + its current isInEoiBundle / isSpecificInterest flags.
- A local Map<berthId, {isInEoiBundle, isSpecificInterest}> seeded
  once from the server snapshot and isolated from subsequent refetches
  (so a background refetch doesn't wipe pending checks). Resets when
  the dialog closes.
- A new "EOI scope" section in the body listing every linked berth
  with two checkboxes ("In EOI" / "Public map"), primary-marked
  visually, plus a one-line legend explaining the bundle-vs-public
  distinction (matters more post-(a) since the two flags routinely
  diverge).
- handleGenerate diffs the picker state against the server snapshot
  before kicking off the envelope; only changed berths get PATCHed,
  and we wait for all PATCHes to settle (so a 5xx surfaces before the
  EOI fires). Cache invalidation extended to bounce the new
  ['interests', id, 'berths'] queryKey so the LinkedBerthsList tab
  picks up the new state on navigation.

The "Manage linked berths" cross-link below is preserved — the picker
is the in-dialog fast path, not a replacement for the full management
surface.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:07:29 +02:00
adf4e2ba78 fix(reports): split PDF widget catalogue out of the DB-touching service
export-dashboard-pdf-button.tsx imported PDF_DASHBOARD_WIDGETS +
PdfDashboardWidgetId from dashboard-report-data.service.ts. JS modules
evaluate their imports eagerly, so the button transitively pulled in
that file's top-level `import { getKpis } from './dashboard.service'`,
which pulled in `@/lib/db`, which pulls in `postgres`, which crashed
the client bundle with:

  Module not found: Can't resolve 'fs'
    ./node_modules/.../postgres/src/index.js [Client Component Browser]

Split the pure data + types into the new file
src/lib/services/dashboard-report-widgets.ts and re-export from the
original service for backwards compatibility. The button now imports
from the pure file; the server-only route (reports/generate) keeps
using the resolver as before.

tsc clean, dashboard loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:03:44 +02:00
52493801e0 feat(uat-batch): M43 follow-up — yacht detail field history
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m35s
Build & Push Docker Images / build-and-push (push) Has been skipped
Extends Phase 3 from the M43 commit to yacht detail:
- New /api/v1/yachts/[id]/field-history endpoint joins through
  interests.yachtId (no schema migration needed) and filters to
  'yacht.%' paths so client-scoped overrides on the same interest
  don't bleed into the yacht surface.
- FieldHistoryScope.type accepts 'yacht'; provider URL routing
  generalised to /api/v1/<type>s/<id>/field-history.
- yacht-tabs OverviewTab wrapped in the provider; Name + the three
  ft-dimension rows get historyPath wired (m-dimension rows skipped —
  they're a unit-converted view of the same source value, and the
  supplemental writer only ever stores ft).

Addresses tab on Client detail intentionally left unwired — would
need AddressesEditor (a shared component) to surface icons per row,
which is more than the 5-min scope.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:57:47 +02:00
f6cb733424 docs(uat): annotate M43 + plan with SHIPPED markers
Closes plan item 43 in the remaining-plan doc; alpha-uat-master annotated
with the SHA. Per CLAUDE.md's "annotate the master doc" rule after a
batch ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:53:12 +02:00
91be0f9136 feat(uat-batch): M43 — form-template bindings + inline field history
Closes plan item 43 (Form-template fields bind to Interest/Client data —
autofill, override-preservation history, dual-surface audit trail).

Phase 1 — Editor:
- New bindable-fields catalog (src/lib/templates/bindable-fields.ts):
  client/yacht/interest paths, each tagged with the entity, column, and
  default input type. Source of truth for what can bind + what
  interest_field_history.field_path strings the writers should use.
- formFieldSchema gains optional bindTo, validated against the catalog
  as an allow-list (no arbitrary paths sneak through).
- form-template-form admin sheet: per-field "Bind to" dropdown grouped
  by entity, auto-derives label/key/type when a binding is picked,
  shows "Autofills from + writes back to {label} . {path}" badge.

Phase 2 — Runtime + history writes:
- supplemental-forms.service.applySubmission already wrote
  interest_field_history rows for client name/email/address from the
  earlier 0081 migration session. Extended to also capture phone +
  yacht (name, length, width, draft) diffs that were silently going
  to the entity without an audit row, and to push insert-path
  overrides for the no-existing-address case.
- Field paths aligned with the bindable-fields catalog so detail-page
  lookups work via exact-match WHERE field_path = ?.

Phase 3 — Inline history surface:
- New /api/v1/clients/[id]/field-history (mirror of the existing
  interests endpoint).
- shared/field-history: FieldHistoryProvider wraps a detail tab and
  fires a single keyed GET; FieldHistoryIcon consumes the context and
  renders a small clock affordance only when at least one override
  exists, opening a popover with the reverse-chrono diff list.
- Client + Interest detail Overview tabs wrapped in the provider;
  EditableRow gains an optional historyPath prop; ContactsEditor
  renders the icon next to the canonical primary email/phone.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:51:39 +02:00
be261f3f90 fix(dev-lan): unblock phone-on-LAN testing of the dev server
Branding URLs were baked with env.APP_URL=http://localhost:3000 at
upload time and stored verbatim in system_settings, so any logo/
background loaded from a non-localhost origin (an iPhone hitting the
Mac's LAN IP) failed to resolve. Same pattern bit Socket.IO (CORS +
client connection target) and the portal logout redirect.

- Branding: getPortBrandingConfig normalizes localhost/private-LAN
  hosts to path-only; both upload routes store path-only going
  forward; email shell re-absolutizes via absolutizeBrandingUrl() so
  inboxes (no app origin) still get fetchable URLs. DB backfilled to
  strip http://localhost:3000 from existing rows.
- Socket.IO: client connects to window.location.origin (io() with no
  URL); server CORS allows localhost + private-LAN ranges in dev,
  stays locked to APP_URL in prod.
- Portal logout: redirect target built from the request URL instead
  of env.APP_URL.
- next.config: allowedDevOrigins widened from a hardcoded IP to
  192.168/10/172.16-31 wildcards so HMR works across networks
  without an edit per-network. (Without HMR the login form's React
  click handler never hydrates and the form falls back to GET,
  leaking the password into the URL.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:28:34 +02:00
6aaccb6d33 docs(uat): annotate plan with per-group SHIPPED commits
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m56s
Build & Push Docker Images / build-and-push (push) Has been skipped
Stamps the 2026-05-21 plan with the SHA of every group's landed
commit. Groups A through T are worked end-to-end across this
session; Group U (EOI bundle UX rework) is the only remaining
parked item with reasoning in its commit.

Per-group commit notes document what shipped fully vs. what stayed
parked within each group (e.g. Q57 recharts→ECharts deferred,
M43 form-template editor UI deferred, O47-O50 marketing-site
phases deferred). Vitest 1454/1454 + tsc clean across all groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:53:48 +02:00
aa1f5d2835 feat(uat-batch): Groups R + T — Documenso list + deferred bugs
R62, T64, T65 from the 2026-05-21 plan. U66 deferred with reasoning.

Shipped:
  R62  Documenso-first templates (list endpoint + admin route).
       New `listTemplates(portId)` in documenso-client paginates
       through every visible template on the configured instance
       (5-page cap at 100/page = 500 templates which comfortably
       covers every observed Documenso deploy). Handles v1 + v2
       endpoint shapes; normalises to `{ id, name }` summaries.
       New `GET /api/v1/admin/documenso/templates` route exposes
       the list to the admin UI (gated on `admin.manage_settings`).
       Powers the upcoming admin template picker — the field-mapping
       editor + sync-now button + per-template badges stay as the
       picker-UI follow-up. Data path is in place; UI surface
       lands in a dedicated PR alongside the field-mapping editor.

  T64  Duplicate E17 + missing partial unique index. Migration 0082
       deduplicates any existing (port_id, mooring_number) collisions
       by archiving all but the canonical row (prefers price-bearing
       rows, then earliest-created; archived rows carry an explicit
       `archive_reason` noting the migration). Adds partial unique
       index `uniq_berths_port_mooring_active` on (port_id,
       mooring_number) WHERE archived_at IS NULL so archived
       moorings can be reissued but live duplicates can't be
       created in the first place. Migration applied to dev DB.

  T65  Stage-advance gate. `changeInterestStage` now blocks any
       non-override transition into eoi / reservation / deposit_paid
       / contract when the primary berth has no price (NULL or 0)
       — these stages all render the price in templates / merge
       fields and a $0 generation is a real production gotcha.
       Override path (sales-manager fix) stays open and records
       the reason in audit log per the existing override-reason
       gate.

Deferred:
  U66  EOI bundle UX rework (10-14h) — multi-berth picker inside
       the EOI generate dialog. Schema (`interest_berths.isInEoiBundle`)
       and the rendered bundle-range preview row both exist; the
       remaining work is the picker UI + re-deriving merge tokens
       per selection state. Best done as a focused session with
       Documenso-side verification.

Verified: tsc clean, vitest 1454/1454, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:52:57 +02:00
c14f80a4f7 feat(uat-batch): Group Q — platform refactors
Q58, Q59, Q61 from the 2026-05-21 plan. Q57 + Q60 (sweep-scope) parked.

Shipped:
  Q58  SelectTrigger size variant. <SelectTrigger> now accepts
       `size?: 'default' | 'sm'`. Default = `h-11` so the trigger
       matches <Input>'s h-11 default and the 8px height mismatch
       called out in the UAT vanishes platform-wide. Existing call
       sites that need the legacy compact look (FilterBar, dense
       table headers) opt back in via `size="sm"`. Nothing breaks —
       the default render flips height without touching any other
       styling.
  Q59  Table density min-widths + nowrap. DataTable cells now
       default to `whitespace-nowrap` so long values (URLs, names,
       addresses) don't wrap into 4-5 lines and inflate row height.
       Columns that need wrapping override via the column def's
       `meta.wrap = true`. Min-width comes from
       `column.getSize?.()` when set so a column doesn't shrink-
       wrap below readability — opt-in per column rather than a
       sweeping width change.
  Q61  Error message audit foundation — Documenso 401/403 path
       enriched. <PortDocumensoConfig> gains `apiKeySource` +
       `apiUrlSource` ('port' | 'global' | 'env' | 'default' |
       'none'). `getPortDocumensoConfig` populates them based on
       which layer of the resolver chain produced the value.
       documenso-client's <ResolvedCreds> exposes the source flags;
       the 401/403 branch surfaces them in the
       `DOCUMENSO_AUTH_FAILURE` internalMessage so operators see
       "api key source: env, port: <id>" instead of the prior
       generic `path → 401` body. Solves the Documenso diagnosis
       loop that prompted the platform-wide error audit. Same
       pattern can extend to other integration error paths in
       follow-ups (S3, Redis, IMAP) — the resolver-source helper
       lives on PortConfig now.
  Q60  Tooltip audit primitive already shipped — <FieldLabel> in
       `ui/field-label.tsx` is the canonical surface with an Info
       icon + Tooltip slot. One adopter live (custom-field-form);
       remaining admin-form sweep is the lift that's parked.

Deferred:
  Q57  recharts → ECharts migration (6-10h). Pure visual port of
       8 chart components; safer as a focused session with
       per-chart visual review. Pre-reqs (ECharts deps + the
       transpilePackages config + the d3-geo install) are in place
       so the migration can be picked up cleanly.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:49:22 +02:00
0ed03fcd7f feat(uat-batch): Group P — nested document subfolders phases 2/3
P56 from the 2026-05-21 plan. Foundation (phase 1) shipped in e91055f.

Shipped:
  - **UploadZone scope radio.** <FileUploadZone> accepts an optional
    `interestId` prop. When set (currently passed from
    InterestDocumentsTab) the upload-zone surfaces a small fieldset:
    "File at: ⦿ This deal | ◯ Client-level (all deals)". Default is
    deal-scope so reps don't accidentally surface deal-specific docs
    across every historical interest of the client. The interest FK
    is forwarded to /api/v1/files/upload only when "This deal" is
    selected; client-level uploads omit it and land at the client
    folder.
  - **Outcome → folder rename lifecycle hook.** New
    `renameInterestFolderForOutcome(interestId, portId, outcome)` in
    document-folders.service. Strips any prior outcome suffix from
    the folder name (so re-running on a lost→won flip doesn't
    accumulate parens) and appends `(Won)` / `(Lost)` / `(Cancelled)`.
    Fired fire-and-forget from interests.service.setInterestOutcome
    via dynamic import to dodge the circular dep with this module's
    primary-berth label resolver. No-op when the folder hasn't been
    created yet (first upload happens later).
  - **Backfill script.** scripts/backfill-nested-document-folders.ts
    iterates every (port_id, interest_id) pair in `files` that has
    a non-null interest_id and calls ensureEntityFolder so the
    nested `Clients/<Name>/Deal …/` folder exists. Idempotent —
    `ensureEntityFolder` short-circuits when the folder is already
    there. Per-port advisory lock (FNV-1a of port_id) keeps two
    operators from racing. Dry-run by default; `--apply` to commit.

Deferred:
  - listFilesAggregatedByEntity rewrite to show "This deal" vs "From
    client" subheadings — UI polish; the per-row filing already
    happens correctly via the upload-zone scope radio.
  - Documents Hub tree rendering for nested interest folders — the
    folder rows already exist with `parent_id` set; the tree
    component picks them up automatically.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:43:55 +02:00
a7cbee09ee feat(uat-batch): Group O — Umami in-repo polish
O48, O51-O54 from the 2026-05-21 plan. Phase 4a / 3 / 5 marketing-site
work explicitly deferred — they live in the marketing repo + are
blocked on instrumentation that isn't this codebase's to ship.

Shipped:
  O48  Tracked-link composer button.
       New POST /api/v1/tracked-links mints a redirect-link the rep can
       drop into an outgoing email. Body { targetUrl, sendId? }; returns
       { id, slug, targetUrl, url }. Gated on `email.send` (same as the
       server-side check on existing send routes). `sendId` lets the
       click-tracker attribute back to a specific document_sends row.
       <TrackedLinkComposerButton> renders a small inline button (or a
       sized default variant) that opens a dialog: rep pastes the
       destination URL → Create → gets the public /q/<slug> URL with
       a Copy + an "Insert into message" action that calls back to the
       parent compose surface. Wired into <SendDocumentDialog>'s
       Message body label row so reps can mint + insert without
       leaving the dialog.
  O51  Quiet-range nudge. WebsiteAnalyticsShell surfaces a small amber
       banner when the active range returned <5 visitors so the rep
       doesn't think the integration is broken on a fresh port or
       off-season range. Threshold keeps the banner off legitimate
       traffic.
  O52  Apple Mail privacy disclaimer. The sends-log "Not opened" badge
       carries an inline tooltip explaining that Apple Mail's privacy
       protection routes opens through Apple's proxy and can suppress
       this signal even when the recipient read the email.
  O53  Open-rate column on the document_sends list. SendRow type
       extended with `trackOpens` / `openCount` / `firstOpenedAt`; the
       sends-log card chrome renders an "Opened × N" badge with the
       first-open timestamp in the title, or "Not opened" when tracking
       is on but no opens yet, or no badge at all when tracking was
       disabled for that send.
  O54  Click-to-filter world map. VisitorWorldMap already supported
       `onCountryClick`; wired it through to copy the
       `/<portSlug>/clients?nationality=<ISO>` deep-link to the
       clipboard with a toast on click. Inline filtering of the
       analytics view itself stays parked alongside Phase 5 — the
       useUmami* hooks don't yet accept a country filter.

Deferred (not in this repo or blocked):
  O47  Phase 4a marketing-site instrumentation — marketing repo work.
  O49  Phase 3 Events tab — blocked on 4a.
  O50  Phase 5 Funnels + Journeys — blocked on 4a.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:39:19 +02:00
a147cbcd93 feat(uat-batch): Group N — dashboard upgrades
N44, N45, N46 from the 2026-05-21 plan.

Shipped:
  N44  Pipeline Value tile respects dashboard timeframe. Tile accepts
       optional `range` prop and threads it through
       /api/v1/dashboard/kpis?range=<slug> + /forecast?range=<slug>.
       Service functions accept optional {from,to} bounds and scope
       the pipeline-value SQL to interests created within the window.
       New parseRangeSlug helper inverts rangeToSlug. Widget registry
       forwards the active dashboard range to the tile.
  N45  Clients by country widget. New GET
       /api/v1/dashboard/clients-by-country groups non-archived
       clients by nationality_iso. <ClientsByCountryWidget> renders a
       compact ranked list with mini-bars; rows link to
       /clients?nationality=<ISO>. Registered as default-visible rail.
  N46  Drag-and-drop dashboard widgets. New
       preferences.dashboardWidgetOrder?: string[] on user_profiles;
       useDashboardWidgets sorts visibleWidgets by the order
       (unlisted ids fall through to registry order) and exposes
       setOrder(nextOrder) that PATCHes optimistically.
       DashboardShell wires @dnd-kit/core + sortable: Rearrange toggle
       turns on per-widget grip handles + sortable-context wraps each
       group (charts / rails / feed) so drops stay in-group.
       PointerSensor 8px activation distance, KeyboardSensor for a11y.
       New <SortableWidget> wraps the render — zero footprint when
       off.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:32:21 +02:00
0ddaf462c7 feat(uat-batch): Group M — universal preview + field-history foundation
M42, M43 from the 2026-05-21 plan.

Shipped:
  M42  FilePreviewDialog now handles seven preview kinds via a single
       previewKindFor() router (mime + filename fallback). Image and
       PDF stay on the existing lightbox + pdf viewer; plain text
       (.txt / .md / .csv / .tsv / .json / .xml / .log / .yaml / .ini
       / .html — text/* and application/json and friends) renders via
       a new <TextPreview> that fetches via the presigned URL and
       caps the body at 1 MB with a "showing first 1 MB" banner.
       Audio / video render through native HTML5 <audio> / <video>
       elements with preload="metadata". Office documents (.docx /
       .xlsx / .pptx / .odt / .ods / .odp + the official mime variants)
       embed via Microsoft's hosted Office viewer (view.officeapps
       .live.com/op/embed.aspx) — presigned download URLs carry the
       token so the embed works without making the file world-public.
       Unknown mime types render a friendly "preview not supported"
       block with a Download CTA instead of an empty pane.
  M43  Field-level override history foundation. Migration 0081 adds
       `interest_field_history` (id, port_id, interest_id?, client_id?,
       field_path, old_value, new_value, source, submission_id?,
       created_at, created_by) with port-scoped indexes on
       (interest_id, created_at desc) and (client_id, created_at desc).
       Drizzle schema + index exports added. supplemental-forms
       applySubmission now collects an `overrides` array as it diffs
       each field against the current entity state and writes them all
       in one batch insert at the end of the transaction, so the
       rep-facing Field history panel can surface every override the
       client made via the form. New
       `GET /api/v1/interests/[id]/field-history` endpoint returns
       the rows newest-first (100-cap). Source on supplemental-info
       submissions is hardcoded to 'supplemental_form'; future
       channels (form-templates, AI extraction) drop new source
       values into the same table.

       The full form-template editor UI (Field-history panels on
       Interest + Client detail, autofill from the bound entity on
       the public form, drag-bind builder in /admin/forms) is queued
       as the next-layer follow-up; the data model + audit trail
       this commit ships are the necessary foundation for it.

Verified: tsc clean, vitest 1454/1454, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:21:14 +02:00
65ff5961f2 feat(uat-batch): Group L — UploadForSigningDialog rework
L41 from the 2026-05-21 plan.

Shipped (4 sub-tasks):
  - **Dialog width**: already fixed in an earlier session
    (max-w-[1400px] w-[95vw] on the DialogContent).
  - **Draft persistence to localStorage**: scoped per
    interest+documentType (`pn-crm.upload-for-signing.draft.v1:<id>:<type>`),
    versioned for future shape evolution. Persists step / title /
    recipients / fields / invitationMessage with a 500ms debounce so
    rapid edits (typing the custom note, dragging a field) don't
    hammer storage. The PDF File object itself is NOT persisted
    (large blobs + browser quota); on reopen the rep re-picks the
    file but every other piece of state survives. Pristine "no
    progress yet" state actively clears any stale draft. Header
    surfaces a "Draft saved" indicator + Discard button when a
    draft exists. Successful submission clears the draft so the
    shadow doesn't outlive the doc.
  - **PDF preview error handling + zoom**: `onLoadError` now sets
    `pdfLoadError` and replaces the spinner with a useful failure
    block (error message + re-pick guidance) so reps don't see an
    infinite loading state on a broken file. Toolbar gains zoom
    controls (50–200% in 25% steps); field coordinates stay in %
    of page dimensions so placements scale automatically with the
    canvas.
  - **Field-placement keyboard shortcuts**: window-level keydown
    handler responds to Delete / Backspace (remove selected field),
    arrow keys (nudge 0.5% per press, Shift + arrow = 5% per press).
    Ignored when focus is in a real input / textarea / contenteditable
    so the shortcuts never steal typing.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:16:00 +02:00
03a7521729 feat(uat-batch): Groups J + K — activity feed + onboarding resolver-chain
J38, J39, K40 (core) from the 2026-05-21 plan.

Shipped:
  J38  EntityActivityFeed sentence rendering surfaces the new value
       inline. Was "<actor> updated the X"; now "<actor> set X to
       <value>" when the audit row carries `newValue`. Field-level
       diff line underneath keeps showing the old → new strikethrough
       for context. Truncates inline value at 60 chars to keep long
       notes / descriptions from blowing out the row.
  J39  Client → Companies tab CTA. Empty state gains a "Link to a
       company" action; populated state grows a top-right "Link to
       company" button. New <LinkCompanyDialog> wraps the existing
       <CompanyPicker> + a membership-role select + an "is primary"
       checkbox, then POSTs to /api/v1/companies/[id]/members.
       Empty-state copy dropped "Add a membership from a company's
       detail page" — the rep can act inline now.
  K40  OnboardingChecklist resolver-chain. The auto-check no longer
       reads raw `/admin/settings` rows (which miss env fallbacks).
       Resolved endpoint widened to accept `?keys=k1,k2,...` so the
       checklist can batch-resolve any heterogenous set of registry
       keys through port → global → env → default in one round-trip.
       Checklist captures the dominant source per step ("env fallback",
       "global default", "built-in default") and surfaces it inline
       under the green tick so super-admins see when a step is
       relying on env rather than a per-port override. Compound-key
       gates report the weakest sub-key's source so a partially-env
       config still flags clearly.
       Topbar banner / dashboard tile / weekly nudge / celebration
       sub-items remain queued — the core resolver-chain gap was
       the actual cause of the "step never ticks" UAT complaint.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:02:33 +02:00
989cc4d72b feat(uat-batch): Group I — Residential parity (4 ships)
I34–I37 from the 2026-05-21 plan.

Shipped:
  I34  Residential client header layout parity. Email / Call /
       WhatsApp action buttons mirror the main ClientDetailHeader.
       WhatsApp number resolves from phoneE164 (preferred) or strips
       the free-text phone to digits. Header surfaces "Linked to
       main client" chip when the auto-link matcher (I37) finds a
       counterpart in the main CRM.
  I35  Residential interests list rebuilt for parity with the main
       InterestList. New ResidentialInterestCard +
       getResidentialInterestColumns + residentialInterestFilter-
       Definitions; the list page drives DataTable + FilterBar +
       ColumnPicker + SavedViewsDropdown + bulkActions. List
       endpoint validator widened to accept pipelineStage as a
       string OR string[] and added a source filter. Service post-
       fetches client names via a single IN-list lookup so the
       table renders fullName in column 1 without N+1.
       New /api/v1/residential/interests/bulk supports
       change_stage + archive (100-id cap). Kanban view deferred.
  I36  Residential inquiries auto-forward to partner email(s).
       New registry entry residential_partner_recipients (comma-
       separated) under section residential.partner.
       createResidentialInterest fires
       forwardResidentialInquiryToPartner after the row lands.
       Helper uses the same branded shell other transactional
       emails use. Failures log + never block create. The
       /admin/residential-stages page picks up a registry-driven
       card so admins manage recipients alongside stages.
  I37  Auto-link residential ↔ main client. Migration 0080 adds
       residential_clients.linked_client_id (nullable FK, SET NULL
       on cascade) + partial index. New findAndLinkMatchingMainClient
       service matches by email first (case-insensitive client_contacts
       lookup) then by E.164 phone. First exact match wins. Fires
       fire-and-forget from createResidentialClient. Header surfaces
       the link via a "Linked to main client" chip. Backfill script
       + reverse-direction link from main ClientDetailHeader stay
       as follow-ups.

Verified: tsc clean, vitest 1454/1454, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:57:19 +02:00
94c24a123a feat(uat-batch): Groups F + G + H — DocsHub/signing + admin consolidation + email
F27–F29, G30, G31, H32, H33 from the 2026-05-21 plan.

Shipped now:
  F28  Past-milestones expandable history. The Past strip on the
       Interest overview becomes an <Accordion> — each row collapses
       to the same one-line summary as before, expands to render the
       full <MilestoneSection> (steps list, sub-status, inline doc
       actions). Reuses the existing MilestoneSection so no new
       per-milestone rendering needs to be maintained.
  F29  Watchers configurable at document creation time. The unified
       create-document wizard gets a Watchers section with a
       multi-select checkbox list backed by /api/v1/admin/users/picker.
       Selected user ids are sent in the `watchers` array on the POST
       (replacing the prior hardcoded `[]`). UI matches the
       post-creation WatchersCard so reps see the same identity rows
       regardless of entry point.
  G30  /admin/invitations merged into /admin/users. The Users page
       now wraps the existing UserList + InvitationsManager in a
       Tabs control (Active users / Invitations). The standalone
       /admin/invitations route returns a redirect to the merged page
       for bookmark back-compat. Removed nav catalog entry +
       admin-sections-browser tile; extended the Users catalog
       keywords with "invitations / pending invites / onboarding"
       so command-K search still lands on the right surface.
  G31  /admin/ai picks up the berth-PDF-parser section + a "planned
       AI surfaces" placeholder. Berth PDF parser remains
       env-configured today; the page now documents it so admins
       don't hunt for the controls. Closes the "where do I configure
       AI?" loop.
  H32  Email settings explainer panel above the SMTP cards. Spells
       out why noreply + sales have separate credentials and which
       workflows ship from each mailbox. Existing field titles
       gained the "(noreply)" suffix so the model maps cleanly.
  H33  Supplemental-info-request email rebuilt to use the shared
       branded shell (logo + blurred overhead background + max-
       width 600 table layout) instead of the prior plain-HTML
       page. Per-port branding (logo / primary color / background /
       header / footer) flows from getPortBrandingConfig. CTA
       button picks up the port's primary color.

Already shipped (verified pre-shipped):
  F27  DocumentsHub root view already hides the breadcrumb via
       `selectedFolderId !== undefined` conditional.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:40:48 +02:00
431375d794 feat(uat-batch): Groups D + E — wizard polish + supplemental-info history
D24 + D25 + E26 from the 2026-05-21 plan. All three shipped.

Shipped now:
  D24  BulkAddBerthsWizard ft/m toggle. Step 2 header gets a small
       monospaced ft/m button that flips the dimension entry unit
       wizard-wide. Cell values stay as-typed; on submit a single
       `inputToFt(v)` helper converts m→ft (1 m = 3.28084 ft) before
       posting the canonical feet payload. Column headers update
       Length/Width/Draft labels to reflect the active unit.
  D25  BulkAddBerthsWizard dock-letter expansion. Replaced the
       Select-of-A–E with a chip group + free-text "Other…" input.
       Common letters (A-E) are quick-pick chips; reps can type any
       uppercase letter sequence (AA, BB, F, …) for ports whose dock
       layout extends past the five-letter shortlist. New
       `handleGenerate` validation rejects empty / non-uppercase
       inputs with a toast. Custom-input path uppercases + strips
       non-letters as the rep types so the canonical
       `^[A-Z]+\d+$` mooring regex always matches.
  E26  Supplemental-info Regenerate / Resend / history.
       Service: new `listTokensForInterest(portId, interestId)`
       returns the latest 20 issuances with expired/consumed flags;
       new `getTokenForResend(portId, interestId, tokenId)` snapshots
       a specific token back into the issue-shape so the route can
       re-email without minting a fresh token.
       Route: GET lists the issuances (gated on `interests.view`);
       POST accepts an optional `tokenId` for the Resend branch
       (forces `sendEmail=true` since the rep clicked with intent)
       and returns `resent: true/false` on the success payload.
       UI: button card now shows three actions — Generate /
       Regenerate link, Generate + email (or "New link + email"
       when a usable token exists), and Resend current (only when
       there's an active unconsumed unexpired token). Issuance
       history list shows Active / Submitted / Expired per row.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:30:22 +02:00
991e2223c7 feat(uat-batch): Group C Berth list features (3 new ships + 1 verified)
C20–C23 from the 2026-05-21 plan.

Shipped now:
  C21  Dimensions ft/m column toggle persisted to user prefs.
       `TablePreferences.dimensionUnit` ('ft' | 'm') added to the user-
       profiles JSONB. `useTablePreferences` returns `dimensionUnit` +
       `setDimensionUnit` alongside hidden/density. New
       `getBerthColumns(unit)` factory rewrites the dimensions /
       nominalBoatSize / waterDepth cells when ft is requested
       (waterDepth converts on-the-fly from the canonical meters
       column at 3.2808 ft/m). Berth-list toolbar gains a small
       ft/m toggle button next to the density toggle.
  C22  ft/m switching on Berth Requirements rows.
       `interest-tabs.tsx` Berth-requirements section now honours
       `interest.desiredLengthUnit`. Labels flip to "(m)" when set;
       value reads from `desired*M` columns; on save, both the chosen-
       unit and the canonical counterpart columns are PATCHed (3.28084
       ratio) so downstream surfaces (recommender, EOI merge fields)
       stay in lockstep. `InterestPatchField` widened with `desired*M`
       variants.
  C23  Berth list bulk-edit affordance.
       New `POST /api/v1/berths/bulk` (mirror of /interests/bulk):
       discriminated union of `change_status` / `change_tenure_type` /
       `add_tag` / `remove_tag` / `archive`, 500-id cap, per-row
       failure reporting, single `berths.edit` permission gate
       (no separate `archive` perm exists on berths today). Status
       mutations route through `updateBerthStatus` so under-offer /
       sold transitions still trigger the primary interest_berths
       auto-link + the rules-engine evaluation.
       BerthList toolbar wires `bulkActions` on the DataTable —
       Change status (Select dialog), Change tenure (permanent /
       fixed-term), Add tag, Remove tag, Archive (destructive +
       confirmation). Each dialog uses the same `bulkMutation` so
       toast + cache-invalidation behaviour is consistent across
       actions.

Already shipped (verified):
  C20  Berth list rates / pricing valid columns hidden by default —
       already in `BERTH_DEFAULT_HIDDEN`.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:22:30 +02:00
a0a4a5d487 docs(uat): annotate master doc for Group B ships (7ecf4ee)
5 master-doc entries now carry the `SHIPPED in 7ecf4ee` line:
  - Interest Overview Email + Phone contact picker (Design A)
  - Inline phone editor on the Contact row (reuses InlinePhoneField)
  - Client Overview interest summary (Wants L × W × D · Source)
  - InterestBerthStatusBanner names + links competing deal
  - Notes Latest-note teaser stage pill (current-stage variant)

2 entries already shipped / no annotation needed:
  - B13 (Inbox embedded filter) — pre-shipped, marker already present
  - B19 (intent auto-confirm on EOI+) — already shipped in 51ca875

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:10:17 +02:00
7ecf4ee813 feat(uat-batch): Group B Interest detail polish (5 new ships + 2 verified)
B13–B19 from the 2026-05-21 plan. Five new ships; two items already in
place from earlier work but flagged for verification.

Shipped now:
  B14  Interest Overview Email + Phone rows: new <ClientChannelEditor>
       combobox. Primary value renders inline (free-text for email,
       <InlinePhoneField> for phone with country picker). Chevron opens
       a popover listing every contact in the channel — promote to
       primary, delete non-primaries, or inline-add a new contact.
       Backed by the existing /clients/[id]/contacts CRUD + promote-
       to-primary endpoints. Wired into the Email + Phone rows on
       interest-tabs.tsx Overview.
  B15  Inline phone editor: the phone branch of <ClientChannelEditor>
       uses <InlinePhoneField> (country code + national-format split).
       interests.service.ts now returns `clientPrimaryPhoneCountry` so
       the editor can preserve the ISO-3166-1 alpha-2 round-trip.
  B16  Client Overview interest summary: PanelVariant of
       <ClientPipelineSummary> renders a one-line "Wants L × W × D ·
       Source" under each interest's header when constraints / source
       are captured. Hidden when both are empty.
       <ClientInterestRow> type extended with the new fields; the
       /api/v1/interests query already returns them.
  B17  Notes Latest-note teaser stage pill: stage-badge chip next to
       the "5 minutes ago · Matt" line. Shows the deal's CURRENT
       pipelineStage — a stage-at-note-time lookup would require a
       per-render audit_logs read, over-engineered for a context hint.
  B18  InterestBerthStatusBanner names + links the competing deal:
       reuses /berths/[id]/active-interests endpoint shipped in 292a8b5;
       one query per conflicting berth via useQueries. Picks the
       isPrimary competing interest (falls back to first non-self
       row); renders an inline <Link> to the competing detail page.

Already shipped (verified pre-shipped):
  B13  Inbox Reminders embedded filter row — `embedded` prop already
       wired in reminder-list.tsx.
  B19  Qualification auto-confirm intent at stage ≥ EOI — already
       handled by computeAutoSatisfied's `stageIdx > qualifiedIdx`
       gate (covers eoi / reservation / deposit_paid / contract).

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:08:41 +02:00
670ca16a05 docs(uat): annotate master doc + plan for Group A ships (e33313b)
7 master-doc entries now carry the `SHIPPED in e33313b` line:
  - Admin Documenso env-fallback pills
  - WatchersCard empty-state padding (follow-up bump)
  - /invoices/upload-receipts copy rewrite
  - Pageviews chart X-axis tick thinning
  - CommandList scroll-cap (popover-aware max-h)
  - DropdownMenu max-h cap (Radix-aware)
  - Residential InterestsTab standalone-list whole-row navigate
  - StageStepper visible stage names

3 master-doc entries verified pre-shipped (A3, A6, A8) — already
carrying SHIPPED markers from earlier commits; A6 + A8 confirmed
in the new commit notes for cross-reference.

Plan doc (`2026-05-21-remaining-plan.md`) Group A section
collapsed to a 12-line ✓ list pointing at the verifying commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:38:27 +02:00
e33313bd64 feat(uat-batch): Group A quick-fixes — 7 items shipped, 5 verified pre-shipped
Sweeps Group A of the 2026-05-21 remaining-plan. Several items the
plan listed as open turned out to already be shipped (annotation gap
in the master doc) — those are confirmed in the commit notes.

Shipped now:
  A1  Documenso settings: collapsed `V2_FEATURE_FIELDS` +
      `CONTRACT_RESERVATION_FIELDS` (legacy SettingsFormCard) into
      `RegistryDrivenForm` sections (`documenso.behavior` +
      `documenso.templates`). Every Documenso setting now flows
      through the registry path that surfaces the env-fallback /
      port / global source badge per field. EOI generation card
      retitled to "Templates & signing pathway" since it now covers
      EOI + reservation + contract template IDs (registry already
      had all three under `documenso.templates`).
  A2  WatchersCard empty state: bumped `mb-3 → mb-4 pb-1` so the
      "No one is watching yet" line has breathing room above the
      "Add a watcher…" select.
  A4  /invoices/upload-receipts guide copy: terse luxury-CRM tone.
      Drop "Snap a photo", "fancy phone camera", "No typing. No
      spreadsheets." Tighten OCR explainer to one sentence;
      action-oriented step + best-practices headers.
  A5  Pageviews chart X-axis: added `interval="preserveStartEnd"` +
      `minTickGap={52}` so multi-week ranges thin out the middle
      ticks instead of overlapping. The MM-DD formatter was already
      in place from an earlier session.
  A7  Inbox doc comment: was stale ("Alerts first, Reminders
      second") but the JSX already had Reminders before Alerts.
      Fixed the docstring.
  A9  CommandList scroll-cap: `max-h-[300px]` now `max-h-[min(300px,
      var(--radix-popover-content-available-height,300px))]` so the
      cmdk list never extends past the host Popover's available
      area. Non-Popover hosts fall through to the 300px static cap.
  A10 DropdownMenuContent: `max-h-96` now
      `max-h-[min(24rem,var(--radix-dropdown-menu-content-
      available-height,24rem))]` for the same available-space
      behaviour on long menus near the viewport edge.
  A11 Residential InterestsTab (list page): row gets an onClick →
      `router.push`; first-cell Link stops propagation so middle-
      click / Cmd-click "open in new tab" still works.
  A12 StageStepper: gained a stage-name row below the bar showing
      every reached stage's short label inline (muted for future
      stages). `size="xs"` variant keeps the cramped table-cell
      footprint intact (no labels).

Already shipped (just annotation gap in master doc):
  A3  EOI "Mark as signed without file" button — line 599 of
      interest-eoi-tab.tsx, parent passes onMarkSigned. Master doc
      already has `SHIPPED in 52342ee` annotation.
  A6  Pageviews vs Sessions explainer — Info popover at line
      157-181 of website-analytics-shell.tsx.
  A8  BulkAddBerthsWizard CurrencySelect — line 376 (apply-to-all)
      + line 456 (per-row).

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:34:20 +02:00
a555798cfe docs(uat): structured plan for remaining master-doc items
Captures all 66 still-open items from `alpha-uat-master.md` (Buckets
1-4 plus the deferred bugs and DEFERRED-tagged features) into a
single sequential plan. Items grouped so logically-related work
lands as one PR rather than scattered commits.

Groups (suggested execution order):
  A — Tiny copy / UI fixes (12 items, ~1 h)
  B — Interest detail polish (7 items, ~2 h)
  C — Berth list features (4 items incl. bulk-edit, ~2.5 h)
  D — BulkAddBerthsWizard polish (2 items, ~1.5 h)
  E — Supplemental-info-request (1 item, ~1 h)
  F — DocumentsHub + signing flow polish (3 items, ~3 h)
  G — Admin sections consolidation (2 items, ~6 h)
  H — Email + branding (2 items, ~2 h)
  I — Residential parity (4 items, ~10 h)
  J — Activity feed + EntityActivityFeed (2 items, ~2 h)
  K — OnboardingChecklist + nudges (1 item, ~6-8 h)
  L — UploadForSigningDialog rework (1 item, ~12-16 h)
  M — Universal preview + form-templates (2 items, ~12-16 h)
  N — Dashboard upgrades (3 items, ~10-14 h)
  O — Umami phases 3 / 4 / 5 (9 items, ~14-18 h)
  P — Nested document subfolders phases 2/3 (1 item, ~5-6 h)
  Q — Platform-wide refactors (5 items, ~14-18 h)
  R — Documenso-first templates (1 item, ~6-8 h)
  S — AI extraction (deferred, ~10-14 h)
  T — Deferred bugs (2 items)
  U — EOI bundle UX rework (1 item, ~10-14 h)

Per-item: scope summary, file pointers, effort estimate, blocks-on /
pairs-with annotations. Execution discipline section at the bottom
describes the per-item workflow (quote source bullet → verify not
already shipped → implement + test → annotate master doc → tick
off plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:18:52 +02:00
d879188322 docs(uat): SHIPPED annotations for session — 12 items closed across all buckets
Brings the master UAT doc in sync with this session's actual ship state.

Annotated (commit SHA after each):
  - Em-dash sweep + lint bump to error (f0dbefc)
  - Berth-list active-interests popover + density tokens (292a8b5)
  - LinkedBerthsList "Add berth" CTA (3999d4b)
  - BulkAddBerthsWizard mooring-exists pre-flight (ca172fa)
  - Email / SMTP admin "Send test email" (7881da6)
  - Smart-search pipeline-stage fuzzy match (d912f02)
  - External-EOI edit-metadata UI (235e064) — closes the (e) sub-item
  - Date-input migration sweep, remaining 14 sites (0c6e7b7)
  - Nested document subfolders foundation only (e91055f)
  - PDF report exporter, full 4-phase build (3b199c2, 47c2ba9, 1cdc2fd, 5a9b5f6)

Yacht ft↔m + click-to-preview on EntityFolderView/HubRootView were
already annotated earlier in the session (5320398, 1f591ff).

The "Remaining" notes on each entry call out what stays parked
(e.g. nested-subfolders phases 2/3 — UploadZone scope radio,
lifecycle hooks, list-query rewrite, tree rendering, backfill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:13:06 +02:00
5a9b5f687f feat(reports): PDF preview modal (phase D — feature complete)
Closes out the report exporter. Adds a Preview button alongside
Download on every export dialog (dashboard + 3 list kinds). The
modal POSTs the current form payload to /api/v1/reports/generate,
renders the resulting Blob in a sandboxed iframe via
URL.createObjectURL, and exposes the cached Blob to the Download
button so committing the download doesn't re-fetch.

PdfPreviewModal:
  - Re-fetches when the payload changes (rep tweaks config, opens
    preview again — fresh PDF every time).
  - Cleans up the object URL on close + on unmount, no leak.
  - sandbox="allow-same-origin" lets the iframe read the blob URL
    but blocks any embedded scripts from reaching cookies /
    LocalStorage.
  - Surfaces preview failures inline instead of a toast so the rep
    can read the error without dismissing the modal.

UI integration:
  - Both ExportDashboardPdfButton + ExportListPdfButton gain an
    "Eye" Preview button between Cancel and Download.
  - previewPayload is memoised on the form state so the modal's
    fetch effect only re-fires when the rep actually changes
    something.

Verified: tsc clean, vitest 1454/1454. Manual end-to-end test
(open a real dashboard, pick widgets, preview, download) is the
next gate; build is production-ready otherwise.

Final exporter shape (phases A → D):
  - 4 report kinds: dashboard / clients / berths / interests
  - Per-port branding: logo + primary color (luminance-checked
    accent foreground for AA contrast on dark brands)
  - Customizable: widget picker for dashboard, include-archived
    toggle, custom title, save-as-template, apply saved template
  - Preview modal with sandboxed iframe + cached Blob for Download
  - 1 000-row export cap with "Showing top N of <total>" notice
  - Permission-gated on reports.export server-side + client-side
  - Audit-logged on every successful generation
  - RFC 5987 Content-Disposition for unicode filenames

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:50:11 +02:00
1cdc2fdc6d feat(reports): saved-template store + CRUD + dialog integration (phase C)
Saves rep-configured export setups so a "Monthly board report" or
"Weekly pipeline review" template only has to be assembled once.

Schema (migration 0079_report_templates.sql + drizzle entry):
  - report_templates: id, port_id, kind, name, description, config
    (jsonb), created_by, created_at, updated_at.
  - Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so
    Port A and Port B can both have "Quarterly review" without
    colliding, and two different KINDS in the same port can share a
    name (a clients "Quarterly review" + an interests "Quarterly
    review" coexist).
  - port_id FK cascades on delete; templates evaporate with the
    parent port. No cross-port enumeration risk since every query
    filters by port_id.

Service (src/lib/services/report-templates.service.ts):
  - createReportTemplate / listReportTemplates / getReportTemplate /
    updateReportTemplate / deleteReportTemplate.
  - Audit-logs every write with old/new values for the rename case.
  - Surfaces sibling-name collisions as ConflictError with a
    rep-readable message ('A "Monthly board report" template
    already exists for the dashboard kind').

Routes:
  - GET  /api/v1/reports/templates?kind=clients
  - POST /api/v1/reports/templates
  - GET  /api/v1/reports/templates/[id]
  - PATCH /api/v1/reports/templates/[id]
  - DELETE /api/v1/reports/templates/[id]
  All gated on `reports.export` — same permission as generating
  reports lets the rep manage the templates that drive them.
  POST cross-validates that `body.kind === body.config.kind` so a
  rep can't sneak a dashboard config into a clients template and
  confuse the rendering path at use time.

UI:
  - SavedTemplatesPicker reusable component — dropdown of templates
    for this port + kind, inline "Save as template" toggle that
    expands to a name input + Save button, delete button next to
    the picker once a template is selected.
  - Wired into both ExportDashboardPdfButton + ExportListPdfButton.
    Applying a saved template hydrates the dialog's form (selected
    widgets / filters / title) from the saved config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
47c2ba9a99 feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).

Data fetchers in `src/lib/services/list-report-data.service.ts`:
  - resolveClientReportData: clients table joined to per-client
    primary email + phone via DISTINCT-style subqueries (matches the
    canonical listClients ordering: is_primary DESC, created_at DESC
    per channel).
  - resolveBerthReportData: berths table, default sort by mooring
    number for printed familiarity.
  - resolveInterestReportData: interests left-joined to clients +
    primary berth, sort by updatedAt desc.

All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.

Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.

UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.

Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
3b199c245c feat(reports): PDF report exporter foundation + dashboard report (phase A)
Production-grade PDF reporting for the CRM. Phase A ships the
foundation (branded layout, render pipeline, API route) plus the
first report kind — the dashboard summary. Phases B, C, D add the
remaining report kinds, saved templates, and the preview modal.

Stack: @react-pdf/renderer (already in package.json). Single primary
font (Helvetica/Helvetica-Bold), per-port primary color + logo,
table-based section layout. Charts will become tables here on
purpose; reports are for printed reference and review, where
exact numbers beat at-a-glance shapes. We can revisit Recharts-as-
SVG embedding if a stakeholder asks for chart visuals.

New files:
  - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig
    covering dashboard / clients / berths / interests kinds. Only
    dashboard is wired in phase A; the others throw a clear
    not-implemented error from pickDocument().
  - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off
    branding.primaryColor. Computes a readable foreground color
    (luminance check) for the accent stripe so dark-brand ports
    still read at AA.
  - src/lib/pdf/reports/branded-document.tsx: page wrapper with
    fixed footer (port name, generated-at timestamp, page numbers
    via react-pdf's render-prop pattern).
  - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget
    SimpleTable sections. Each section gated on the widget id being
    present in config.widgetIds AND data being supplied.
  - src/lib/pdf/reports/render-report.ts: single entry point that
    resolves branding (logoUrl + primaryColor + portName from
    getPortBrandingConfig + ports.name), dispatches via
    discriminated-union switch, returns Buffer via renderToBuffer.
    Exhaustiveness check at the bottom catches unhandled variants
    at compile time.
  - src/lib/services/dashboard-report-data.service.ts: server-side
    data resolver. PDF_DASHBOARD_WIDGETS is the public widget list
    for the dialog picker; each id maps to a dashboard.service.ts
    fetcher invoked only when the rep selected that widget.
  - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod
    discriminated-union body schema, withAuth + withPermission
    'reports.export' gating, audit-log write on success, RFC 5987
    Content-Disposition for unicode-safe filenames.
  - src/components/reports/export-dashboard-pdf-button.tsx: dialog
    with section checkboxes + title input. Permission-gated client-
    side (server re-checks). Raw fetch (not apiFetch) to pull the
    binary blob with X-Port-Id header attached manually.
  - tests/unit/pdf-report-renderer.test.ts: renders three fixture
    cases — full set / sparse / no-logo — and asserts the buffer
    starts with the `%PDF-` magic bytes and is non-trivial in size.

DashboardShell gains an Export PDF button between the date-range
picker and the Customize widgets menu (gated on reports.export).

Verified: tsc clean, vitest 1451/1451 (3 new render tests included).
The first end-to-end manual test (export a real dashboard) is in
Phase D after the preview modal lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
e91055f784 feat(documents): foundation for nested interest subfolders (phase 1/3)
Sets up the schema + service primitives the rest of the nested-
document-subfolders feature will build on (master UAT line 728+).
This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio,
lifecycle hooks for outcome rename, aggregated-projection list
query, and backfill script are deferred to follow-up commits.

Schema (migration 0078_files_interest_id.sql):
  - `files.interest_id` text REFERENCES interests(id) ON DELETE SET
    NULL. Mirrors the existing documents.interest_id; lets file
    uploads be scoped to a deal while still rolling up to the parent
    client folder.
  - idx_files_interest + idx_files_port_interest for the aggregated-
    projection queries that will surface "This deal" vs "From
    client" file lists.

Service:
  - EntityType extended to include 'interest'. Interest folders parent
    under the owning client's entity folder (not at a system root), so
    the tree reads Clients/Acme/Deal A1-A3/ — nested.
  - ensureEntityFolder recursively ensures the parent client folder
    first when given an interest, guaranteeing the deal folder lands
    inside the right client subfolder even when the first artifact on
    the deal predates any client-level upload.
  - resolveEntityDisplayName for interest: "Deal — <mooringNumber>"
    (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the
    stable fallback. Dynamic-import on getPrimaryBerth dodges the
    circular dep between document-folders.service and
    interest-berths.service.

Aggregated projection (files.ts):
  - listFilesAggregatedByEntity SELECT now includes the new
    interest_id column so AggregatedFileRow's structural type matches.
    Downstream consumers gain access to the deal scope; the actual
    "From this deal" subheading in InterestDocumentsTab is wired in
    the follow-up.

Remaining work (tracked in master UAT line 728+, parked for next
session):
  - UploadZone `scopeOptions` radio (single-option pickers hide the
    radio entirely for client/yacht/company surfaces).
  - Lifecycle hooks for interest outcome → folder rename ("Deal
    A1-A3 (Won)") via soft-rescue per CLAUDE.md.
  - listFilesAggregatedByEntity rewrite to surface "This deal" vs
    "From client" subheadings on InterestDocumentsTab.
  - Documents Hub tree rendering for nested interest folders.
  - backfill script: existing files with entity_type='interest' +
    entity_id but missing interest_id column → populate.

Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
0c6e7b72af feat(forms): migrate remaining native date inputs to <DatePicker> / <DateTimePicker>
Sweeps the last ~17 native `<Input type="date"|"datetime-local">`
call sites onto the shared `<DatePicker>` / `<DateTimePicker>`
primitives so date entry is uniform across the app (calendar popover
on desktop, native OS picker on mobile via the primitive's
viewport-aware fallback).

Three patterns handled:

  1. Controlled value/onChange — direct swap to <DatePicker
     value/onChange>:
       audit-log-list.tsx (audit-from / audit-to filters)
       reports/generate-report-form.tsx (date range)
       scan/scan-shell.tsx (expense date)
       reservations/reservation-detail.tsx (end-reservation dialog)
       shared/filter-bar.tsx ('date' filter variant)

  2. RHF `register('field')` pattern — wrapped in <Controller> with
     field.value/field.onChange bridge. The picker's '' → undefined
     normalisation kicks in via `field.onChange(v || undefined)`:
       berths/berth-form.tsx (tenureStartDate + tenureEndDate)
       reservations/berth-reserve-dialog.tsx (startDate)
       companies/add-membership-dialog.tsx (startDate)
       yachts/yacht-transfer-dialog.tsx (effectiveDate)
       invoices/invoice-detail.tsx (paymentDate)

  3. RHF + Date-typed schema — same Controller wrap, plus a
     Date<->YYYY-MM-DD bridge in the render() since the zod schema
     coerces these to Date:
       expenses/expense-form-dialog.tsx (expenseDate)
       companies/company-form.tsx (incorporationDate)

  4. Datetime variants — swapped onto <DateTimePicker>:
       interests/interest-contact-log-tab.tsx (occurredAt + followUpAt)

Skipped because they ARE picker primitives or internal date variants:
  - ui/date-picker.tsx, ui/date-time-picker.tsx (the primitives)
  - shared/inline-editable-field.tsx (the InlineEditableField date variant)
  - dashboard/date-range-picker.tsx (its own popover with min/max gating
    that doesn't map cleanly onto the shared primitive)

Removed now-unused Input imports from four files.

Verified: tsc clean, vitest 1448/1448.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:14:33 +02:00
f0dbefcac2 chore(copy): em-dash sweep across user-facing JSX text + bump lint to error
Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49
files in src/components + src/app. The em-dash reads as a tell-tale
"AI-generated" marker per the user's design feedback; hyphens with
spaces preserve the connector semantics without the AI tint.

Touched only lines outside pure-comment context (// /* * */). Code
comments, JSDoc, audit-log strings, structured logging strings, and
templates outside the lint scope retain their em-dashes for now —
they're not user-visible.

Also captured two remaining cases that used the `&mdash;` HTML entity
instead of the literal character (system-monitoring-dashboard,
interest-stage-picker) — replaced with a plain hyphen.

Bumped the existing `no-restricted-syntax` rule from `warn` → `error`
in eslint.config.mjs scoped to src/components/**/*.tsx +
src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now
fails the lint gate.

Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:02:58 +02:00
292a8b5e4a feat(berths): active-interests popover + row-density toggle on berth list
Two complementary UX upgrades on the berth list:

1. Active-interests popover — replaces the plain "Active interests"
   count cell with a click-to-expand popover. Each row shows the
   linked deal's client name, pipeline stage (with stage-badge tint),
   and a primary-star icon. Lazy-loads on first open (30s stale),
   capped at 20 entries server-side, sorted most-recently-updated
   first. Backed by `GET /api/v1/berths/[id]/active-interests`.

2. Row-density toggle — DataTable gains a `density: 'comfortable' |
   'compact'` prop. Compact drops cell vertical padding from py-3 to
   py-1.5 so reps can scan many more berths per viewport on the
   high-density admin lists.

   Persisted alongside hidden-columns in `user_profiles.preferences.
   tablePreferences[entityType].density`. Hook returns `density +
   setDensity`; defaults to 'comfortable' for users who haven't
   chosen. The setter shares the same debounced PATCH with setHidden
   so toggling both doesn't multiply the network round-trips.

   Toolbar adds a Rows3/Rows4 icon button between the saved-views
   dropdown and the ColumnPicker. tooltip + aria-label flip to
   communicate the next state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:56:00 +02:00
3999d4bbea feat(interests): explicit "Add berth" CTA on LinkedBerthsList
Previously reps could only add berths through the recommender panel
below the list or by indirect side-effects (EOI generation). New
button on the card header opens a searchable picker dialog backed by
/api/v1/berths/options.

- AddBerthDialog uses the existing Command primitive (cmdk) for the
  searchable list. Berths already linked to the interest are filtered
  out so the rep can't double-add.
- "Specifically pitching" switch surfaces the same Under Offer
  consequence the per-row toggle does. Defaults off (interest is
  internal-only until the rep promotes it).
- Mutation hits POST /api/v1/interests/[id]/berths with the new
  link's `isSpecificInterest` flag. is_in_eoi_bundle / is_primary
  stay at their server defaults — the rep flips them on the row after
  the link lands. Invalidates interest-berths + berth-recommendations
  caches so the row appears immediately and the recommender drops
  the just-added berth.
- Dialog only mounts while open so picker state resets on each
  invocation (avoids set-state-in-effect re-hydration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:50:27 +02:00
ca172fa2b8 feat(berths): pre-flight duplicate check on bulk-add wizard
Bulk-adding berths previously failed at submit-time when any mooring
number in the range was already taken — admins had to mentally diff
the existing berth list against their seeded range and edit Step 2
rows out one-at-a-time. Now the wizard catches collisions before the
admin invests time filling out dimensions / pricing.

- `POST /api/v1/berths/check-duplicates` accepts up to 500 mooring
  numbers + returns the subset that already exist as non-archived
  berths in the port. Format validated against the canonical
  `^[A-Z]+\d+$` regex; permission `berths.import` (same as bulk-add).
- Wizard fires the check during the Step 1 → Step 2 transition. The
  Continue button shows a "Checking…" state while in flight; failure
  is non-blocking (bulk-add still enforces uniqueness server-side).
- Step 2 banner lists the first 8 duplicates plus a "Remove all
  duplicates" action. Duplicate rows render with an amber background
  + "Dup" pill in the Mooring column.
- Submit button disables while any duplicate row remains, with a
  tooltip that says how to resolve. The admin can either prune them
  via the banner action, edit per-row, or step back and re-range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:48:16 +02:00
d912f02b97 feat(search): pipeline-stage fuzzy match shortcut
Typing a stage name in the topbar search now surfaces a "Stage: <Label>"
shortcut row that lands the rep on the interests list filtered by that
stage. Previously reps had to know the navigation path and either click
through the kanban board or hand-type the URL filter.

Match flavours (case-insensitive, query tokens split on whitespace):
  1. Modern label prefix — every query token must prefix a token in
     `STAGE_LABELS[stage]` or the raw enum slug. "eoi" → EOI, "dep" →
     Deposit Paid, "qua" → Qualified.
  2. Stage-key substring on the raw enum slug.
  3. Legacy aliases via `LEGACY_STAGE_REMAP` — "eoi_signed" /
     "deposit_10pct" / "contract_signed" lands on the modern 7-stage
     equivalent so reps with muscle memory still find a useful target.

Each row carries a live COUNT(*) of non-archived interests in that
stage (single grouped query — O(stages)). Empty queries skip the
bucket entirely.

- `searchStages(portId, query, limit)` in search.service.ts with the
  scoring logic + count query.
- New `StageSuggestionResult` type added to SearchResults + the
  client-side mirror in use-search.ts.
- `searchStages` wired into the parallel `Promise.all` block of the
  main `search()` and the single-bucket runSingleBucket dispatch
  (exhaustive ts-pattern match required the new branch).
- Gated on `interests.view` — destination of the filter.
- New 'stages' bucket in command-search.tsx BUCKETS list (between
  Tags and Notes) + a `buildFlatRows` arm that pushes one row per
  matched stage. Mobile overlay reuses `buildFlatRows`, so the new
  rows appear there too once BUCKET_LABELS picks up the entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:45:50 +02:00
235e0645cb feat(documents): edit-metadata UI for externally-uploaded EOIs
External-EOI uploads previously had no edit path. Once the rep clicked
Upload, the recorded title / signed-date / signatories / notes were
stuck. Fixing a misspelled signer name or a wrong signing date meant
re-uploading the whole document.

- New service helper `updateExternalEoiMetadata` patches:
    documents.title, documents.notes
    interests.dateEoiSigned (when signedAt changes)
    document_signers (full-replacement by id-presence: rows with an id
      are UPDATEd, rows without are INSERTed, existing rows whose id
      isn't in the array are DELETEd)
  Mirrors the upload-time invariants. CC rows are stored but excluded
  from the X/Y signed count; non-CC rows pre-stamp `status='signed'`
  with the effective signedAt. Refuses to touch Documenso-managed docs
  (vendor owns their signer rows) or non-EOI types (form shape isn't
  widened yet) with ConflictError.

- `PATCH /api/v1/documents/[id]/metadata` route uses strict zod schema
  + documents.edit permission. 204 on success; service throws surface
  as the normal errorResponse mapping.

- `<ExternalEoiEditDialog>` mirrors the upload-dialog's signatory
  affordance (name + email + role + add/remove) plus title / signed
  date / notes. Title is required; remove rows via the trash icon.

- Document detail page gains an "Edit metadata" button (Pencil icon)
  that renders only when `isManualUpload && documentType === 'eoi'`.
  Initial signing date derives from the earliest stamped signer's
  signedAt to match what the upload service writes.

- Trails the edit in document_events as `metadata_updated` so the
  activity timeline distinguishes upload-time vs edit-time changes.

Dialog state is initialised once per mount; the parent only renders the
dialog while open so each open is a fresh mount (avoids
setState-in-effect re-hydration banned by lint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:34:19 +02:00
7881da675b feat(admin-email): SMTP test-send card on /admin/email
Adds a plaintext-only SMTP connectivity test on the email-settings
page. Distinct from the branding-preview "Send a test" affordance:

  - branding-preview exercises the full rendering pipeline (logo +
    branded shell + colour) — useful for confirming the email *looks*
    right.
  - this test isolates SMTP — minimal HTML, plaintext alternative, no
    logo dependency — so a failure is purely transport. Confirms the
    configured credentials (env or per-port DB) reach the wire before
    a real notification flow depends on them.

SMTP errors surface inline below the input (auth failure, ENOTFOUND,
connection refused, etc.) rather than as a passing toast — the whole
point of the test is to read them.

`/api/v1/admin/email/test-send` route reuses `sendEmail(...,
ctx.portId)` so per-port SMTP overrides are exercised the same way a
real notification would.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:28:01 +02:00
5320398501 docs(uat): SHIPPED annotation for PR25 (yacht ft↔m round-trip)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:26:00 +02:00
8e9efe5ae8 fix(yachts): ft↔m round-trip is lossless (4dp + canonical helpers)
Three copies of the imperial/metric conversion logic existed:
  - src/components/yachts/yacht-dimensions.ts   (canonical, used by
    read-side `formatYachtDimensionsBothUnits`)
  - src/components/yachts/yacht-form.tsx        (create/edit sheet —
    local `ftToM`/`mToFt` with 2dp precision)
  - src/components/yachts/yacht-tabs.tsx        (detail-tab inline
    edit — local arithmetic with 2dp precision)

The 2dp rounding lost precision on the round-trip: `1 ft → 0.30 m →
0.98 ft`. Whenever a rep entered ft, then later touched the m field,
the ft column silently shifted off. Same for sub-meter draft values.

Consolidate both surfaces onto `feetToMeters` / `metersToFeet` from
yacht-dimensions.ts and bump display precision to 4dp. After
trimZero strips trailing zeros the rendered string stays clean
("3.81" not "3.8100") but the round-trip now lands back on the
original value:

  1 ft → 0.3048 m → 1 ft
  12.5 ft → 3.81 m → 12.5 ft
  50 ft → 15.24 m → 50 ft
  0.5 m → 1.6404 ft → 0.5 m

New unit test (`tests/unit/yacht-dimensions.test.ts`) covers the
helpers + the form-shape round-trip, including the canonical
12.5 ft ↔ 3.81 m case from the UAT bug report.

29/29 new tests pass; full vitest 1448/1448.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:25:28 +02:00
1f591ff7ae docs(uat): SHIPPED annotation for PR24 (click-to-preview sweep complete)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:30 +02:00
ded16f4a5b feat(uat-batch-24): click-to-preview on EntityFolderView + HubRootView Files
Completes the click-to-preview sweep across all file-row surfaces. The
filename cells in entity-folder-view.tsx (entity-scoped Files panel)
and hub-root-view.tsx (Documents Hub root "Recent files") were the
last two non-clickable surfaces — both now wrap the filename in a
button that opens FilePreviewDialog directly, matching the FileGrid
and DocumentList pattern shipped in 52342ee.

HubRootFile shape extended to include mimeType (already returned by
the /api/v1/files endpoint via the buildListQuery passthrough) so the
preview dialog can branch on image vs PDF without a second request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:11 +02:00
a263a202d9 docs(backlog): per-port branded login (section K) + next-env regen
Section K documents the recommended path for multi-tenant branded auth
screens: a single Next.js app behind `*.crm.example.com` wildcard DNS
that derives the active portSlug from the Host header (instead of the
current "first active port wins" fallback in resolveAuthShellBranding).
Includes the open work: wildcard cert, parent-domain cookie scope,
middleware host-resolver, switcher UI, and bootstrap seed.

next-env.d.ts is auto-regenerated by Next typegen with double-quote
formatting; included so the diff stays clean for the next dev session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:18:22 +02:00
363ef0b882 chore(assets): branded auth-shell logo + email-bg fallback images
Public assets used as the bundled fallback when a port hasn't uploaded
its own branded logo / email-background through /admin/branding:
  - Overhead_1_blur.png — the blurred overhead shot rendered behind
    the branded auth-shell and the white email card.
  - Port Nimara New Logo-Circular Frame_250px.png — circular-frame
    logo for the default Port Nimara tenant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:18:15 +02:00
96069fad16 chore(dev): Cloudflare tunnel helper + env-to-admin migration in .env templates
- scripts/tunnel-url.sh prints (and optionally --copy's) the current
  quick-tunnel URL by tailing the launchd job's log. Paired with the
  launchd plist at ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist
  so Documenso webhooks can target the local dev box.
- CLAUDE.md gains the start/stop/print one-liners next to the existing
  dev helpers.
- .env.example rewritten to document the env-to-admin migration: the
  REQUIRED block (DB/Redis/auth/encryption) stays in env; integration
  blocks (Documenso, AI, email, storage) moved to /admin/* with env
  still working as fallback for boot-time defaults.
- .env.dev.template / .env.prod.template added — minimal-required
  starting points reflecting the post-migration story (the admin UI
  covers the rest). Placeholder secrets only (GENERATE_OPENSSL_RAND_HEX_*).

Pre-commit hook bypassed (--no-verify) per CLAUDE.md "Blocks all .env*
files — pass them via a separate workflow if needed".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:18:08 +02:00
e52b3a6d38 feat(notifications): include berth-range suffix in stage-change titles
Stage-change notification titles previously read "Acme Corp moved to
Reservation" with no context on which berths the deal covers. For
multi-berth deals the rep had to drill into the interest to see what
moved. With multiple deals in flight per client the bell tray became
ambiguous.

Switch the title-build path from `getPrimaryBerth` (single-row) to
`listBerthsForInterest` (full set) and append a compact suffix via
`formatBerthRange()`:

    Acme Corp moved to Reservation [A1-A3, B5]

Falls back to plain "<subject> moved to <stage>" when the interest
has no linked berths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:07:00 +02:00
bb7a371d1f feat(navigation): persist last-port for next-login + root → /dashboard
Login routing previously always landed at the user's first port-role.
With a multi-port operator (super-admins, multi-tenant ops) the active
port reverted on every login, breaking the "I was working in X
yesterday" continuity.

- PortProvider PATCHes `/api/v1/me` with `preferences.defaultPortId =
  currentPort.id` whenever the active port changes (URL or explicit
  switch). Ref-keyed dedupe; fire-and-forget so navigation isn't
  blocked by a transient PATCH failure.
- UserMenu's port-switcher also writes the preference on click so the
  preference is captured even for users who never re-render through
  PortProvider.
- /dashboard resolver checks `preferences.defaultPortId` first, falling
  back to first-port-by-name (super-admin) or first-role (everyone
  else). The preference is verified against current access before being
  honoured — a stale id from a revoked role or archived port can't
  strand the user on a 403.
- Add /src/app/page.tsx that redirects `/` → `/dashboard` so the
  middleware's `redirect=/` post-login parameter doesn't dump users on
  an empty 404. The existing /dashboard handler then routes them on to
  their resolved port.
- UserMenu sign-out: replace `router.push('/api/auth/sign-out')` (which
  issued a GET against better-auth's POST-only endpoint, causing Safari
  and Comet/Arc to land the JSON response as a `sign_out` download)
  with `signOut()` from the auth client + an explicit redirect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:06:48 +02:00
3ae86f2854 fix(auth): set-password endpoint accepts both invite and reset tokens
The /set-password page is the landing target for two unrelated email
flows:
  1. CRM admin invite → `crm_user_invites` row, consumed via
     `consumeCrmInvite` (creates the better-auth user + profile).
  2. Forgot-password → better-auth verification row, consumed via
     `auth.api.resetPassword` (rotates the password on an existing
     user).

The endpoint previously only handled (1). A user clicking a
reset-password link landed on the same page but hit a token-not-found
error because their token isn't in the invite table.

Try the invite path first (the historical behaviour); on NotFoundError
fall through to better-auth's resetPassword. Both stores rejecting
returns a single unified `INVITE_OR_RESET_INVALID` error matching the
page's existing error-rendering shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:06:32 +02:00
83f75ef0f5 feat(uploads): preserve PNG alpha + X-Port-Id headers on admin image uploads
Logo / avatar / branding-image uploads were silently flattening alpha
channels because the cropper hardcoded JPEG output and the upload routes
hardcoded the `.jpg` extension. Transparent PNGs landed in storage as
opaque JPEGs with black-composited fringes around logo edges.

- ImageCropperDialog gains an `outputFormat: 'auto' | 'jpeg' | 'png'`
  prop. `auto` (the new default) preserves alpha: PNG output when the
  source MIME is PNG / GIF / WebP / AVIF, JPEG otherwise.
- SettingsFormCard's image-upload field forwards the cropper's chosen
  MIME and extension into the FormData payload and adds an
  `imageFormat` field-def hook for fields that should override the
  auto-detection.
- Admin settings + avatar routes pick the storage-filename extension
  from the upload MIME so PNG sources stay PNG end-to-end.
- Branding-routes refactor: the X-Port-Id header that apiFetch injects
  is missing on raw FormData uploads, so the routes 400'd with "No
  active port". Resolve port id from the URL slug via the now-exported
  `resolvePortIdFromSlug` and attach the header manually.
- Logo previewUrl points at /api/public/files/{id} (returns image
  bytes) instead of /api/v1/files/{id}/preview (returns JSON), so the
  preview <img> actually renders.
- Email-background field declares 16:9 aspect so the cropper doesn't
  fall back to a 1:1 circular mask for a viewport-cover image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:06:19 +02:00
b7533fee3e docs(uat): SHIPPED annotation for PR23 (supplemental-info Generate / Send split)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:10 +02:00
a4e30ea16c feat(uat-batch-23): supplemental-info — separate Generate link + Send by email
The single-button "Request more info" conflated link generation with
email send. Once tokens became reusable until expiry (PR15), the
two-step UX makes more sense — reps often need to copy the link and
share it via WhatsApp / iMessage instead of letting SMTP route it.

- API: POST /supplemental-info-request now accepts an optional
  `{ sendEmail?: boolean }` body (defaults true for back-compat).
  Generate-only callers pass `{ sendEmail: false }`.
- UI: two buttons replace the single CTA — "Generate link" (always
  generates, never emails) + "Send by email" (the original
  full-blow behaviour). Re-clicking "Generate link" with a token
  already issued mints a fresh one (labeled "Regenerate link").
- Email body copy: drop "can only be used once" since PR15 made the
  link reusable until expiry.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:55:39 +02:00
d97a08bf5f docs(uat): SHIPPED annotation for PR21 (auth link contrast)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:47:57 +02:00
ae8867d832 feat(uat-batch-21): a11y — auth-page link contrast bumped past AA
`text-[#007bff] hover:underline` (light blue, 12-14px) was falling
below WCAG 1.4.3 AA contrast against the auth shell's white card.
Bumped to `text-[#0058b3]` (darker variant of the same hue) and
added `underline underline-offset-2 hover:no-underline` so the link
is always visibly underlined as a backup affordance.

Affects: /login, /reset-password, /set-password, /portal/login,
/portal/forgot-password, portal password-set-form. Button bg colors
(white-text on the same blue) are unchanged — those pass AA at
button sizes.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:47:33 +02:00
28eb76a9d8 docs(uat): SHIPPED annotation for PR20 (form-error UX primitives)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:45:23 +02:00
ec6f90f335 feat(uat-batch-20): form-error UX primitive — scroll-to-first-error hook + summary banner
Two new building blocks for the platform-wide form-error UX rework.
Expense form adopts both as the validation that the pattern works
before the broader sweep across the ~29 useForm callers.

- `useFormScrollToError(handleSubmit, errors)` — wraps RHF's
  handleSubmit. On validation failure it locates the first errored
  field via `[name="..."]` (or id fallback), walks ancestors to find
  the nearest scrolling container (key for forms inside Sheet /
  Dialog bodies that own their own overflow-y), and
  scrollTo({ behavior: 'smooth' }) + focus({ preventScroll }) on it.
  Type-loose handleSubmit signature so 2-arg and 3-arg useForm()
  callers (input vs transformed types) both work.
- `<FormErrorSummary errors={errors} labels={…}>` — top-of-form alert
  banner listing each failed field as a clickable anchor. Renders
  only when ≥2 errors (single-error case is handled by the hook
  alone). role="alert" aria-live="polite" for SR users.
- expense-form-dialog adopts both: `onSubmitWithScroll(onSubmit)`
  replaces the bare `handleSubmit(onSubmit)`, plus a labelled
  `<FormErrorSummary>` at the top of the form. Closes the loop on
  the silent-no-op zod-refine bug fixed in PR1 (the underlying
  setValue() fix already routes errors through formState; this
  surfaces them visibly).

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:44:54 +02:00
7d48349a75 docs(uat): SHIPPED annotations for PR19 (a11y + i18n micro-fixes)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:41:12 +02:00
72d7803be5 feat(uat-batch-19): a11y th scopes + legend styling + i18n locale fixes
- Raw `<th>` cells gain `scope="col"` so SR users get proper column
  association: berth-interests-tab, bulk-add-berths-wizard,
  clients/bulk-hard-delete-dialog. shadcn `<TableHead>` migration
  would be cleaner but the scope attribute is the minimum-effort fix
  the queue's a11y entry asks for.
- supplemental-info form `<legend>` elements styled with
  `mb-2 px-1 font-semibold` so they read as section headings rather
  than blending into the surrounding fieldset border (default browser
  legend rendering is barely visible).
- payments-section: invalid `'en-EU'` BCP-47 locale → `undefined` to
  honour browser locale.
- ui/calendar: literal `'default'` → `undefined` on the month
  dropdown formatter, same reason.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:40:34 +02:00
5a2dabea05 docs(uat): SHIPPED annotations for PR18 (interest-berths defaults + a11y)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:36:47 +02:00
05e727f462 feat(uat-batch-18): interest-berths defaults + a11y loading/hint fixes
- `addInterestBerth` insert-time defaults now match the locked
  multi-berth EOI UX (queue B2):
    is_in_eoi_bundle: true   (was false)
    is_specific_interest: matches `isPrimary` (was always true)
  This means a newly-linked berth is covered by the EOI signature by
  default but the public map only shows the primary as "Under Offer"
  until the rep marks others isSpecificInterest. The two existing
  integration tests pass explicit values so they're unaffected.
- A11y: `set-password` form's password-requirements hint linked via
  aria-describedby so SR users hear the rules on focus.
- A11y: Loading fallbacks on set-password / portal/activate /
  supplemental-info wrapped in role="status" aria-live="polite" with
  sr-only "Loading" copy where only a spinner was visible.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:35:52 +02:00
1f8bd47a7b docs(uat): SHIPPED annotations for PR17 (layout polish)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:32:43 +02:00
8fcbe45d36 feat(uat-batch-17): layout polish — DocumentsHub flush-left, breadcrumb wrap fix, viewport-centered topbar search
- DocumentsHub root container gains `sm:-mx-6 sm:-mt-3 sm:-mb-6` to
  escape the AppShell main padding (`px-6 pt-3 pb-6`). The folder
  column now sits flush against the global app sidebar, reading as an
  extension of navigation rather than a card-inside-a-page. Mobile
  layout retains the AppShell padding.
- Breadcrumbs: each crumb + its trailing separator now share a single
  `<BreadcrumbItem>` instead of being separate `<li>`s. Flex-wrap can
  no longer strand an orphan separator at end-of-line above a wrapped
  child crumb. Drops the standalone `<BreadcrumbSeparator>` usage from
  the consumer; the primitive is still exported for backcompat.
- Topbar search visually centered against the full viewport via a
  `translate-x:calc(-var(--width-sidebar)/2)` shift. Grid middle slot
  bumped from `minmax(360px, 640px)` → `minmax(420px, 800px)` and the
  search wrapper from `max-w-md` → `max-w-2xl` so reps actually have
  room to read long results.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:31:32 +02:00
9adb80ada4 docs(uat): SHIPPED annotations for PR16 (Overview cleanup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:27:59 +02:00
f39f0aa7bc feat(uat-batch-16): Interest Overview cleanup — hide legacy reminder panel, deprioritize PaymentsSection
Two coordinated layout changes on the interest Overview tab so the
active milestone gets visual priority.

- Legacy `interest.reminderEnabled` panel removed from Overview. The
  field still drives the auto-follow-up worker
  (`processFollowUpReminders`) and the REMINDERS section + bell-in-
  header surface active reminders, so the read-only duplicate panel
  was pure noise. Backend behaviour unchanged; no schema impact.
- PaymentsSection mount relocated from above the milestone strip to
  below it. The active milestone above carries the rep's day-to-day
  attention; deposits-tracking is reference / history once expected.
  Render order: past strip → current milestone(s) → future
  (collapsed) → PaymentsSection → Lead/Source grid. Pre-Reservation
  the section still doesn't render at all (unchanged). Collapsed-bar
  + summary-chip refinement parked.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:27:17 +02:00
348dc94858 docs(uat): SHIPPED annotation for PR15 (reusable supplemental token)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:24:06 +02:00
b74fc56a3b feat(uat-batch-15): supplemental-info link reusable until expiry
The supplemental-info token now stays valid for re-submissions until
the 14-day TTL expires. Previously the link was single-use:
`applySubmission` required `consumedAt IS NULL`, which locked clients
out of correcting a typo or finishing a partial submission.

- Service: drops the `isNull(consumedAt)` filter; TTL is the sole
  validity check. `consumedAt` is still stamped on each submit so the
  rep / loader can see "last submitted at" context.
- Public form: the "already submitted" lockout screen is removed.
  Instead, when the token has been used before, the form renders with
  the prefill (already reflecting the latest data) plus a soft amber
  banner noting that changes overwrite the previous submission.
- Drive-by em-dash fix on the post-submit thank-you copy (matches the
  Wave-1 lint guard).

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:23:44 +02:00
4d3d7489bf docs(uat): SHIPPED annotations for PR14 (signature docs rename + tooltip + yacht Transfer)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:19:21 +02:00
552b966903 feat(uat-batch-14): InterestDocumentsTab rename, custom-field tooltip, yacht Transfer surface
- InterestDocumentsTab section "Legal documents" renamed to
  "Signature documents" so its scope is unambiguous. The section
  holds Documenso envelopes (EOI / Reservation / Contract); generic
  legal uploads belong in Attachments below.
- Custom-field admin form's "Sort Order" label now uses the
  FieldLabel primitive with an explainer tooltip ("Lower numbers
  render first... use to pin frequently-edited fields to the top").
  First adoption of the FieldLabel primitive shipped in PR4.2.
- Yacht Ownership History tab gains a "Transfer ownership" button:
  in the populated state as a header CTA (perm-gated by yachts.edit),
  in the empty state as the EmptyState action. Reuses the existing
  YachtTransferDialog from the header. Closes the "no way to enter/
  change" UX gap without duplicating the transfer logic.
- Verified the existing row-owner rendering already uses OwnerLink,
  so the row-click affordance was already in place.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:18:29 +02:00
610154395a docs(uat): SHIPPED annotation for PR13 (activity feed UUID resolution)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:14:52 +02:00
2cb0b99314 feat(uat-batch-13): activity feed resolves user UUIDs to display names
Audit-log rows with user-FK diffs (assignedTo, ownerId, reassignedTo,
createdBy, addedBy, changedBy, transferredBy) previously rendered the
raw user UUID in the activity feed (e.g. "→ mEcsLxo5kyFMyhbOSehxJjY…").
Same gap on the row's actor — the rep had no idea who did what.

- getRecentActivity collects all userIds referenced by either the row's
  actor (auditLogs.userId) or by user-FK diff values, then bulk-fetches
  user_profiles in a single query. Output rows now carry an
  `actorName` field and have their `oldValue`/`newValue` swapped for
  display names on user-FK fields.
- Unknown / deleted users fall back to "Unknown user (#short-uuid)" so
  the audit trail stays useful for forensics.
- ActivityItem client type extended with `actorName`. Existing
  consumers still read the raw `userId` for forensics + deep-link.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:14:21 +02:00
f99d2cd9ec docs(uat): SHIPPED annotations for PR12 (env-reveal + stage sortable)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:12:04 +02:00
ca51000401 feat(uat-batch-12): password-reveal env messaging + berth Latest-stage sortable
- registry-driven-form password-reveal eye toggle: when the value is
  resolved from env / default fallback (not port / global override),
  the toggle is now disabled with a tooltip explaining "Value comes
  from the environment. Configure in admin to enable reveal." Stops
  the silent-no-op confusion that read as a broken toggle.
- Berth list: 'Latest deal stage' column dropped enableSorting:false.
  Service-side adds a stageSort correlated subquery that ranks each
  berth by the highest active interest's pipelineStage (enquiry=1 →
  contract=7); NULLS LAST regardless of direction so empty rows
  always land at the bottom.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:11:17 +02:00
901fc363a5 docs(uat): SHIPPED annotations for PR11 (picker polish + currency + breadcrumb)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:07:40 +02:00
2bcf544cbc feat(uat-batch-11): picker polish + BulkAddBerthsWizard currency + DocumentsHub root cleanup
- BulkAddBerthsWizard `priceCurrency` row + apply-to-all swapped from
  freetext Input to the shared CurrencySelect. Same idiom as
  berth-form + expense-form-dialog.
- /api/v1/yachts/autocomplete no longer short-circuits to `[]` when
  the search query is empty — the service returns the top 20
  most-recently-updated yachts so the picker has a useful default
  view the moment it opens. Saves the rep from a dead-end empty
  state.
- YachtPicker gains a fallback useQuery against `/api/v1/yachts/{id}`
  when the selected yacht isn't present in the current autocomplete
  window. Trigger label now shows the real name (was falling back to
  "Yacht <uuid-prefix>" when a parent pre-selected a value from a URL
  param).
- DocumentsHub: breadcrumb row only renders when a folder is
  selected. The "Home / All documents" placeholder was wasted
  vertical space above the PageHeader on the root view.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:06:41 +02:00
c18dbbd61b docs(uat): SHIPPED annotations for PR10 (copy polish + a11y)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:02:04 +02:00
db511063df feat(uat-batch-10): copy polish, TTL trim, and a11y discrete fixes
- Supplemental-info link TTL trimmed from 30 → 14 days (single
  constant in supplemental-forms.service).
- LinkedBerthsList toggle renamed "Mark in EOI bundle" →
  "Include in EOI"; tooltip aria-label updated to match.
- Icon-only row-action triggers on the interest / client / berth list
  tables gain aria-label (Row actions for <name>) so SR users hear
  the row context.
- Table / Board view toggle on interest list gains aria-label +
  aria-pressed on each variant; wrapper gets role="group".
- Upcoming-milestones disclosure on interest-tabs gains
  aria-expanded + aria-controls; recommender Hide/Add filters
  button matches.
- BrandedAuthShell logo alt no longer defaults to "Sign in" — uses
  the configured `appName` when known, empty string otherwise so
  screen readers don't announce "Sign in" on password-reset /
  set-password pages.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:01:17 +02:00
5f937b4551 docs(uat): SHIPPED annotations for PR9 (milestone classifier + backfill)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:55:12 +02:00
d8da1f634d feat(uat-batch-9): milestone classifier + skip-ahead backfill controls
Two coordinated UX changes that finally make the rep's manual-stage-
jump workflow legible:

- Milestone phase classifier introduces a "stage-owning milestone"
  rule. When the rep manually advances the deal to Reservation+ but
  earlier sub-statuses are still un-signed, the current-stage
  milestone now stays marked `'current'` (no longer collapses into
  the past-strip / upcoming-accordion based on completion alone).
  Earlier-than-stage milestones bucket to `'past'` so the rep can
  backfill them; later slots stay `'future'`. The previous
  firstIncompleteKey-driven rule still applies in stages without an
  owning milestone (enquiry / qualified / nurturing).
- Skip-ahead backfill control `<MilestoneBackfillButton>` lands in
  the past-milestones strip whenever a milestone's date column is
  null. Opens a DatePicker popover (today default, accepts any past
  date) and PATCHes the relevant date_* column directly via
  useInterestPatch — no stage transition fires.
- `InterestPatchField` extended with the five milestone date keys;
  validator gains `dateDepositReceived` (was the only missing one).

Together this means: a deal manually-advanced from EOI Sent → Deposit
no longer hides Reservation under upcoming-milestones AND the rep can
record the EOI/reservation signing dates without re-triggering the
stage transition.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:54:33 +02:00
535ff69fc4 docs(uat): SHIPPED annotations for PR8 (qualification rework)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:48:31 +02:00
51ca875665 feat(uat-batch-8): qualification rework — intent auto-confirm + derived-only + collapse-when-done
Three coordinated changes to the per-interest qualification checklist
that collectively trim it from a noisy gate into an out-of-the-way
audit log once the deal moves forward.

- Auto-confirm `intent_confirmed` once `pipelineStage > qualified`.
  Signing an EOI (or later) is the strongest signal of intent; the
  checklist no longer requires a redundant explicit tick. Evidence
  string reads "Stage advanced past Qualified".
- `dimensions` becomes derived-only — explicit ticks no longer
  override removed evidence. When the rep deletes a yacht link or
  clears desired dims, the row un-ticks immediately. Judgement-based
  criteria keep the OR semantic so a manual confirmation survives an
  evidence change.
- Checklist auto-collapses when fully confirmed: header shows ✓ All
  confirmed (label · label) with a chevron; rep clicks to expand and
  inspect or untick. Forced-expanded whenever an item is still
  outstanding. ARIA-controlled.
- `qualification.service` gains a `pipelineStage` column-select and
  threads it through `AutoCtx`; `DERIVED_ONLY_KEYS` Set sentinel
  drives the new merge semantic.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:47:38 +02:00
b9d388a362 docs(uat): SHIPPED annotations for PR7 (Wave-2 polish batch)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:42:44 +02:00
c6dcf49e18 feat(uat-batch-7): Wave-2 polish — Open-in-Documents, berth label, residential, NotesList parity
- InterestEoiTab history link renamed "Open" → "Open in Documents"
  so the cross-section nav target is unambiguous.
- DocumentDetail Interest link sub-text now shows the derived
  `berthLabel` (formatBerthRange of the in-EOI-bundle subset, falling
  back to primary, then all linked berths). The link no longer
  duplicates the Client name; falls back to clientName or "No berths
  linked" when no berths exist.
- New /<port>/residential/page.tsx redirects to /residential/clients
  so the breadcrumb's Residential link works.
- Residential interests list — whole row is now a Link target (was
  hidden behind a trailing "View" link); hover + border accent on the
  full row.
- Expenses PageHeader description "Track and manage port expenses" →
  "Track and manage business expenses" (drop the redundant "port",
  same audit pattern flagged in the queue).
- DropdownMenu base content capped at `max-h-96` (was the Radix
  available-height variable, which stretched menus edge-to-edge); the
  existing internal scroll handles overflow.
- Yacht Overview Notes block: replaced the legacy single-field
  textarea with the threaded `<NotesList entityType="yachts">` for
  parity with clients/interests/companies. Legacy `yacht.notes`
  column stays in schema for EOI/contract merge-field path.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:41:02 +02:00
a673b6cec2 docs(uat): SHIPPED annotations for PR6 (structured signatories + signers)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:35:32 +02:00
301375a3c3 feat(uat-batch-6): external-EOI structured signatories + X/Y signed counter
Replace the freetext CSV signer-names field with a structured recipient
editor (name / email / role per row). Service now persists each
non-CC signatory as a `document_signers` row pre-stamped
`status='signed'` so the document-detail "X / Y signed" badge counts
correctly for manually-uploaded EOIs.

- ExternalEoiInput gains a structured `signatories` field; legacy
  `signerNames` retained for back-compat. Role enum:
  `client | developer | rep | witness | cc`.
- uploadExternallySignedEoi inserts `document_signers` rows for every
  non-CC entry inside the existing transaction.
- documentEvents.completed event records both shapes for full audit
  fidelity.
- POST /api/v1/interests/[id]/external-eoi parses the `signatories`
  JSON multipart field defensively; malformed payloads fall back to
  signerNames.
- Dialog UI: per-row Name / Email / Role inputs with add / remove.
  Seeds from interest's clientName + clientPrimaryEmail via a
  signatoriesOverride/null pattern (React-Compiler safe — no
  setState-in-effect).

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:34:59 +02:00
7cdfed27fa docs(uat): SHIPPED annotations for PR5 (UI polish batch)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:29:53 +02:00
203f543e60 feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:

- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
  the place-fields step actually has room; recipient row converts from
  fixed grid to flex (name flex-1, email flex-[2] for the longer
  string, role w-40, delete shrink-0); invitation-message textarea
  rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
  so charts vertically center when neighbouring cards make the row
  taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
  just the plain-English label ("Open" / "Fall-through" / "Active
  interest" / "Late stage") as a Popover trigger that explains the
  4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
  /<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
  priority); PageHeader title flips to "Reminders & Alerts". Section
  ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
  shares the filter row (right-aligned via ml-auto) instead of
  occupying its own dedicated row above the filters.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
70c7d84dea docs(uat): SHIPPED annotations for PR4 (a11y primitives + click-to-preview)
Annotate 4 finding entries:
- em-dash lint guard (sweep parked)
- DocumentList Download in kebab
- WatchersCard empty-state padding
- EOI empty-state Mark Signed button
- Platform-wide click-to-preview (FileGrid + DocumentList; 2 remaining surfaces parked)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:21:33 +02:00
52342ee45d feat(uat-batch-4): a11y form primitives + click-to-preview + EOI empty-state + lint guards
- FieldError primitive (role=alert, aria-live) — used by Wave 3
  form-error UX work.
- FieldLabel primitive (Label + Info-tooltip slot) — foundational for
  the platform-wide admin-settings tooltip audit.
- ESLint guard against em-dash in user-facing JSX text inside
  src/components + src/app (warning, not error; 111 existing instances
  flagged for follow-up sweep).
- FileGrid card body becomes click-to-preview button (was hidden under
  a kebab); aria-label per row; kebab keeps Download/Rename/Delete.
- DocumentList: title cell on rows with signedFileId opens
  FilePreviewDialog; kebab gains Download action (was missing
  per UAT). Single FilePreviewDialog instance lifted to the parent.
- DocumentList type extended with signedFileId.
- EOI empty state: third ghost button "Mark signed without file"
  wired to existing MarkExternallySignedDialog (parity with
  reservation tab).
- Watcher empty-state padding fix on document-detail.

tsc clean. 1419/1419 vitest. lint clean on touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:20:13 +02:00
6a4f4ea1dd docs(uat): SHIPPED annotations for PR3 (primitives)
Annotate ColumnPicker, FileInputButton, and DatePicker / DateTimePicker
entries with the 8f42940 summary. Notes the deferred sweeps:
- 15+ remaining date-input sites
- raw-input file sweep was a no-op (audit showed only 1 actual
  default-UI site, already migrated)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:11:02 +02:00
8f42940c52 feat(uat-batch-3): wave-1 primitives — DatePicker, DateTimePicker, FileInputButton, ColumnPicker hideAll
Builds the foundational primitives that subsequent waves depend on.
None of these introduce new deps — date-fns, react-day-picker, and
shadcn Calendar were already in the tree.

- `<DatePicker>` and `<DateTimePicker>` in src/components/ui — desktop
  popover wrapping the existing shadcn Calendar (caption-dropdown nav
  so reps can jump months/years for the SkipAheadBanner backfill UX),
  mobile native input via useIsMobile. Drop-in for `<Input type=date>`
  / `<Input type=datetime-local>`.
- `<FileInputButton>` in src/components/ui — styled Button + hidden
  input, replaces browser-default file picker UI. Most queued sweep
  sites already used the hidden-input + Button-trigger pattern; the
  primitive lands for any new caller plus consistent filename display
  + clear button.
- ColumnPicker `hideAll()` footer item — symmetric to existing
  `showAll()`, with the same visibility gate. Lands platform-wide via
  the shared component.
- Migrated highest-leverage call sites to the new primitives:
  * MilestoneAdvanceButton (backfill UX)
  * Reminder form (datetime-local → DateTimePicker)
  * Snooze dialog (datetime-local → DateTimePicker)
  * External-EOI upload dialog (date + file picker)
  * Payments section (received-on date)
- Remaining 15+ date-input call sites parked for a follow-up sweep —
  several use react-hook-form `register` patterns that need careful
  migration to the new controlled-value contract.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:10:02 +02:00
69444878ab docs(uat): SHIPPED annotations for PR2 (external-EOI bundle)
Annotate B4 #5 with the 6cdb9af summary of what landed (a/b/c/d +
default title) and what's deferred (e — edit metadata UI bundles with
later signing-flow rework).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:02:12 +02:00
6cdb9af6b2 fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override
Tackles the linked B4 #5 findings on the external-EOI flow. Item (e)
[Edit metadata affordance per row] is deferred to a later wave so it
can share infra with the broader signing-flow rework.

- (a) lying toast: uploadExternallySignedEoi now returns
  { stageChanged, newStage }. Client toasts conditionally so a
  Reservation+ deal that uploads paper-signing evidence no longer
  claims the stage advanced.
- (b) View downloads instead of previewing: SignedPdfActions takes an
  onView callback; InterestEoiTab lifts a single FilePreviewDialog and
  passes the callback down. Click-View opens the in-app preview rather
  than the presigned URL (which the storage backend served as
  attachment).
- (c) UUID filename on download: getDownloadUrl now passes the
  canonical filename through presignDownloadUrl; S3 backend adds a
  response-content-disposition override (filename + UTF-8 filename*)
  to the presign. Filesystem backend already passed it through.
- (d) Discarded dateEoiSigned: external-eoi service splits document-
  metadata writes (always — dateEoiSigned, eoiStatus='signed') from
  stage advance (gated on past-EOI). Also fires
  evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI
  is filed manually.
- Default title for external-EOI dialog now derives
  "External EOI — <Client> — <berth range> — <date>" via the existing
  formatBerthRange helper; rep can override.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
abbaf406ab docs(uat): SHIPPED annotations for PR1 batch + accumulated UAT findings
PR1 batch (2d57417) covered 7 Wave-1 blockers; each finding entry now
carries an inline `**SHIPPED in 2d57417:**` line summarizing what
landed and (where applicable) what remains parked for later waves
(backfill scripts, nested-folder migration, platform-wide form-error
audit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:52:59 +02:00
2d574172ec fix(uat-batch-1): wave-1 blocker bugs — supplemental gate, file FK, downloads, search dedup, notes stale, expense form, vocab
Surgical fixes for the 7 UAT blockers that prevent productive forward
testing. Each item has a corresponding entry in alpha-uat-master.md.

- supplemental-info route relocated out of (portal) so it bypasses the
  isPortalDisabledGlobally() kill-switch. URL unchanged.
- file upload service derives client_id/company_id/yacht_id from
  (entityType, entityId) when not explicitly passed, so interest-tab
  uploads no longer land with client_id=NULL and stay visible in the
  Attachments list.
- triggerBlobDownload / triggerUrlDownload helpers in src/lib/utils
  attach the anchor to the DOM before click so Chromium honours the
  download attribute; 7 sites refactored, file-named downloads stop
  arriving as bare UUIDs.
- search-nav-catalog dedupes by href at the result-collection layer so
  the same href can no longer surface twice in the command-K dropdown
  (kills the React duplicate-key warning); /admin/templates entries
  merged into a single richer-keyword variant.
- NotesList gains a parentInvalidateKey prop, wired through all five
  callers (interest, client, yacht, company, residential client/
  interest) so the Overview "Latest note" teaser refreshes when a note
  is added in the Notes tab.
- expense-form-dialog: setValue('receiptFileIds') / setValue(
  'noReceiptAcknowledged') on upload/clear/checkbox so the schema-level
  refine sees the field and Create stops silently no-op'ing on submit.
- bulk-add-berths-wizard: side-pontoon dropdown now reads through
  useVocabulary('berth_side_pontoon_options') instead of a wrong local
  enum ('Port', 'Starboard', 'Bow', 'Stern') — wizard data now matches
  the rest of the platform + honours admin-editable per-port overrides.

tsc clean. 1419/1419 vitest. lint clean on touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:50:58 +02:00
449b9497ab fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes;
single grouped commit so the index doesn't fragment into 30 one-liners.

User & auth:
- `user-settings`: name now updates the avatar + topbar menu after save
  (was reading stale session).
- `me/password-reset`: 3 bugs (token validation, error response shape,
  redirect chain).
- Admin user permission-overrides route honours the same envelope as
  the rest of the admin surface.

Dashboard:
- Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card`
  (replaced by the customisable widget grid).
- Strip `revenue_breakdown` from analytics route + use-analytics +
  service + integration test so nothing renders an empty card.
- Activity log timeline overshoot fix (`interest-timeline` +
  `entity-activity-feed`).
- Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile.
- `dev-mode-banner`: derive dismissed state synchronously instead of
  via an effect (set-state-in-effect lint rule).

Forms & lists (assorted polish):
- client / company / yacht / interest / reminder forms — validation +
  empty-state copy + tab transitions.
- companies/yachts list tweaks; berth recommender panel; qualification
  checklist; supplemental info request button.

Infra & misc:
- Queue workers (ai / email / notifications) — log shape +
  per-job timeout consistency.
- Auth / brochures / users schema small adjustments; seeds reflect
  permissions matrix changes.
- Scan shell + scanner manifest + AI admin page small fixes.
- `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react`
  (recommended config from echarts-for-react inside Next).

Docs:
- `docs/superpowers/audits/alpha-uat-master.md` — single rolling
  cross-cutting UAT findings doc (per CLAUDE.md convention).
- `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log
  normalization (§J).
- 2026-05-18 audit log updated with this batch.
- `CLAUDE.md` — small manual UAT scaffold notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:56:11 +02:00
8c669e2918 feat(berths): bulk price update + per-berth price API
Two new endpoints lift price editing out of the full berth-update form:

- `PATCH /api/v1/berths/[id]/price` — single-berth price edit triggered
  inline from the berth list / detail (no need to open the heavy edit
  modal just to retag a price).
- `POST /api/v1/berths/bulk-update-prices` — multi-row update from a
  selection in the berth list; transactional, audit-logged per row.

Berth list column gets an inline price-edit affordance backed by the
single-berth endpoint; the bulk action lives in the row-selection
toolbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:54:27 +02:00
b4bf9cca3f feat(branding): multi-tenant brand naming + per-port email shell + auth UI continuity
Removes the last hardcoded "Port Nimara" references so a tenant cloning
the deploy with a fresh slug sees their own brand throughout.

Browser + native chrome:
- `generateMetadata` reads `branding_app_name` from the first port row
  so the browser tab title, apple-web-app title, and template literal
  reflect the tenant (fallback "CRM" until DB is seeded).
- Mobile topbar derives the brand-mark initials from the port slug
  ("port-nimara" → "PN", "marina-alpha" → "MA") — no code edit on clone.
- `documenso-payload` default redirect URL is `""` so Documenso falls
  back to its own post-sign page instead of routing every tenant's
  signers to portnimara.com; per-port `redirectUrl` setting still wins.
- Server-startup log uses generic "CRM server listening".

Email + auth shell:
- New `auth-shell-branding.ts` resolves logo / background / appName once
  per request from `system_settings`; used by both the email shell and
  the auth-pages SSR layout.
- `auth-branding-provider` wraps `/login`, `/reset-password`, `/set-password`,
  portal `/portal/*` so the branded shell hydrates with the same assets
  the inbox sees.
- `me/email` change email uses the branded shell instead of inline HTML
  with "Port Nimara CRM" baked into copy.
- Admin branding page adds an email-preview card (POSTs to
  `/api/v1/admin/branding/email-preview`) so an admin can spot-check
  their templates before going live.
- `/api/public/files/[id]` exposes branding-category files anonymously
  so inbox images (no session cookie) can render; any other category
  still flows through authenticated `/api/v1/files/[id]/preview`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:54:10 +02:00
bac253b360 feat(analytics): Umami website-analytics suite — world map, realtime, sessions, heatmap, pixel tracking, tracked links
Adds the read-side Umami integration queued in last week's
website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`):

- Realtime panel polls Umami at 5s intervals; world map renders visitor
  origins via echarts + `public/world-map/echarts-world.json` topo.
- Sessions list + session-detail-sheet drill-down (per-session event
  timeline pulled from `/api/v1/website-analytics`).
- Weekly heatmap (day-of-week × hour-of-day) for engagement timing.
- Metric-detail pages under `/[portSlug]/website-analytics/[metric]`
  for pageviews / referrers / events deep-dives.
- Email-pixel write path: `/api/public/email-pixel/[sendId]` 1×1 GIF
  beacon backed by `email_open_tracking` (migration 0076); resolves
  inline on render in inbox.
- Tracked-link redirect: `/q/[slug]` routes through `tracked_links`
  (migration 0077) and forwards to the canonical destination after
  logging the click.
- Dashboard `website-glance-tile` now reads from the live Umami service
  instead of placeholder data.

Deps: `@umami/node`, `echarts`, `echarts-for-react`, `@types/geojson`,
`@types/topojson-client`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:53:41 +02:00
292800b643 docs(claude-md): manual UAT scaffold trigger
When the user starts a "manual testing" / "UAT" walkthrough,
auto-scaffold docs/superpowers/audits/YYYY-MM-DD-manual-uat-findings.md
with the standard buckets (quick fixes / medium / features / bugs /
cross-references) so I don't have to re-paste the layout each session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:03:35 +02:00
1b8dacfa54 docs(audit): full codebase audit — 128 findings across 16 areas
Spawned 16-agent sonnet[1m] audit team covering schemas (people/orgs,
pipeline, docs+infra), APIs (public, admin, v1 CRUD, webhooks/auth/
storage), services (EOI/Documenso, domain, observability), background
jobs, UI (admin, entity), and cross-cutting security/performance/tests-
deps. 13 of 16 agents delivered detailed JSON reports; A1/F1/B3 audited
inline after their agents stalled. E1/E2 (admin + entity UI) couldn't
complete in a single spawn — flagged for re-attempt with narrower scope.

Top findings:
- 5 CRITICAL: send-invoice and invoice-overdue-notify silently no-op
  (D1#1); 5 maintenance crons including database-backup scheduled but
  unimplemented (D1#2); tenure-expiry-check ditto (D1#3); GDPR export
  bundles not deleted on RTBF (C3#1, gap in A.7 shipped today);
  residential_clients has no hard-delete path at all (C3#2).
- 15 HIGH including: /api/public/interests doesn't validate portId
  (B1#1, cross-tenant injection); documents.documenso_id has zero
  index (A3#1, every webhook is a full scan); better-auth rate limit
  is in-memory (B4#1, multi-replica bypass); generateAndSignViaInApp
  omits portId on Documenso calls (C1#1); custom-doc-upload calls
  placeFields after distribute (C1#2); {{eoi.berthRange}} +
  {{reservation.*}} tokens never resolved (C1#3); recommender SQL/JS
  stage-scale off-by-one (C2#1); getClientById runs 6 queries serial
  (F2#1); no CI pipeline + zero tests on client-hard-delete (F3#1,2).
- 36 medium, 53 low, 19 info.

Triage groups in the doc:
  Tier S: 7 ship-stopping bugs (today)
  Tier 1: ~12 high-severity items (this week)
  Tier 2: ~36 medium (next sprint)
  Tier 3: ~53 low (rolling)
  Tier 4: re-spawn E1+E2 with narrower scope

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:38:10 +02:00
b3f87563c6 feat(audit-cleanup): finish all 15 outstanding items from verified backlog
Audit cleanup completion plan, all tiers shipped:

Tier 1 (security + data integrity)
- A.7 RTBF true wipe: redact email_messages body/subject/addresses for
  threads owned by deleted client; redact document_sends.recipient_email;
  collect file storage keys + delete blobs post-commit.
- A.8 user_permission_overrides FK: documented inline why cascade is
  correct (not set-null as audit suggested) — overrides have no value
  without their user.
- W2.14 PII redaction: camelCase normalization in audit.ts +
  error-events.service.ts isSensitiveKey; added city/postal/country/
  birth fragments. firstName/lastName/dateOfBirth/postalCode etc. now
  caught in BOTH masker paths. 12 new test cases lock the coverage.

Tier 2 (Documenso completion + refactor)
- C.2: documentEvents.recipient_email column + partial unique index for
  per-recipient webhook dedup (migration 0075). handleDocumentSigned
  now sets recipient_email on insert.
- Phase 2: completion_cc_emails distribution. handleDocumentCompleted
  reads documents.completionCcEmails, filters out signer-duplicates
  case-insensitively, fans signed PDF out to non-signer recipients.
- C.4: extracted createPublicInterest() service from the 346-line
  api/public/interests route. Route becomes a thin shell (rate-limit,
  port resolution, audit log, email fan-out). The trio creation logic
  is now unit-testable without an HTTP fixture.
- Phase 4: POST /api/v1/document-templates/[id]/detect-fields wired
  to document-field-detector.detectFields(). Sparkles "Auto-detect"
  button added to template-editor.tsx — maps DetectedField → marker
  with best-guess merge token (DATE / NAME / EMAIL); user retags.

Tier 3 (reporting + recommender snapshot lockfiles)
- W7.reports: extracted rollupStageRevenue / rollupStageCounts /
  computeTotalForecast / computeOccupancyRate / rollupBerthStatusCounts
  into src/lib/services/report-math.ts (pure functions). 16 new tests
  including an inline-snapshot lockfile on a representative 7-stage
  forecast. report-generators.ts now delegates.
- W7.recommender: 18 new toMatchSnapshot tripwires on classifyTier
  boundaries + computeHeat at canonical input points.

Tier 4 (rolling)
- W6.attach: fixed outdated CLAUDE.md claim — threshold banner is
  informational and never depended on IMAP; bounce monitoring (the
  IMAP poller) is separate.
- D.1 + D.2: documented deferral inline with full why-not-build-it
  reasoning so a future engineer sees the rationale.
- G.1: representative formatDate sweep (audit-log-list, user-list,
  document-templates merge tokens, document-signing email). Rest of
  the ~100 sites stay rolling.

Quality gates: 1420/1420 vitest (46 new tests above baseline of 1374),
tsc clean, 0 lint errors.

Plan: docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md
Migration: 0075_c2_document_events_recipient_email.sql (applied to dev DB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:22:36 +02:00
ef0dc5abc4 feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work
Phase 3 — EOI overrides (now ☑):
- Address override field with the same per-component input UX as the
  canonical address form (line1/line2/city/state/postal + ISO
  subdivision + CountryCombobox). Two-checkbox intent semantics
  identical to email/phone — useOnlyForThisEoi writes only to
  documents.override_client_address_* columns; setAsDefault promotes
  to the canonical client_addresses primary inside the override
  transaction; neither flag inserts a non-primary address row for
  future reuse. eoi-context route now returns available.addresses so
  the dialog can render the picker over existing rows.
- yachts.source_document_id backfill — yachts spawned via EOI run
  BEFORE generateAndSign creates the document row, so source_document_id
  stayed NULL. Mirrored the bounded-recent backfill pattern from
  contacts into persistDocumentOverrides for both client_addresses and
  yachts (every row inserted in the last 60s with NULL source_document_id
  and the right source flag gets attributed).
- Audit-log filter chips for the new verbs — eoi_field_override,
  promote_to_primary, eoi_spawn_yacht now appear in /admin/audit
  dropdown + get human labels in the card view.

Phase 4 — reminders inline section (now ☑):
- New <RemindersInline> shared component shows the 3-5 most recent
  open reminders for an entity. Mounted on Overview tab of yacht /
  client / interest detail. Empty state hints at the header button
  rather than duplicating it.

Phase 5 — email tone (now ☑ across all 8 templates):
- admin-email-change, crm-invite, inquiry-sales-notification,
  residential-inquiry — voice + sign-off match the 4 shipped earlier
  ("Dear X", "With warm regards, The {portName} Team", sentence-case
  subjects). Snapshot tests deferred — they'd need a 2nd-port fixture
  set up to catch port-name leaks; templates are correct in review.

Phase 7 — PDF editor (now ☑):
- 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes"
  badge), ResizeObserver-driven responsive PDF width, required-tokens-
  unplaced indicator reading template.mergeFields.
- 7.2 drag-to-move with on-page clamping.
- 7.2 four-corner resize handles with min-size enforcement.
- 7.2 right-click context delete via onContextMenu.
- 7.2 multi-page navigation + per-page marker filter.
- 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview
  runs the in-app pdf-lib fill against the supplied interest, uploads
  to a transient previews/ key, returns a 15-min presigned URL.
- 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf
  takes multipart FormData, magic-byte verifies %PDF-, parses page
  count via pdf-lib, swaps documentTemplates.sourceFileId. Editor
  warns when the new page count truncates the prior set.

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:09:19 +02:00
f938847ed9 feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons
Phase 5 — luxury-port email tone (4 of 8 templates):
- portal-auth.tsx — activation + reset: "It's our pleasure to invite
  you to the {portName} client portal — your private space to review
  your berth, manage signed documents, and stay in touch with your
  sales liaison", sign-off "With warm regards, The {portName} Team",
  subjects "Welcome to {portName} — activate your client portal" /
  "Reset your {portName} portal password".
- inquiry-client-confirmation.tsx — "We've noted your enquiry, and a
  member of our team will be in touch shortly through your preferred
  channel", "should anything come to mind in the meantime", sign-off
  "With warm regards, The {portName} Sales Team".
- notification-digest.tsx — "Your {portName} update" header, "Here's
  what's waiting for you", "With warm regards, The {portName} Team".
- document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The
  {portName} team") rewritten to "With warm regards, The {portName} Team"
  with capitalised Team for consistency.
- Voice captured from old-CRM Nuxt repo
  (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/
  server/utils/signature-notifications.ts) which already used "Dear",
  "Best regards", and collective sign-offs.

Remaining 4 templates (admin-email-change, crm-invite,
inquiry-sales-notification, residential-inquiry) + cross-port snapshot
tests queued as follow-up.

Phase 7.1 — PDF editor scaffold:
- New admin route /admin/templates/[id]/editor/page.tsx wired to a
  client-side <TemplateEditor>.
- Renders page 1 via react-pdf (worker URL pattern mirrors
  components/files/pdf-viewer.tsx); click-to-place markers in percent
  coordinates so a future page-size swap doesn't shift placements.
- Token picker over VALID_MERGE_TOKENS (sorted).
- Save persists overlayPositions via PATCH against the existing
  document_templates row; validator accepts the new field via
  fieldMapSchema from lib/templates/field-map.ts (no migration needed
  — overlay_positions JSONB column already exists).
- Outer/inner-body split + key-by-templateId remount avoids the
  in-render setState antipattern when seeding from server data.
- Add + delete markers supported. Multi-page, drag, resize, preview,
  new-PDF upload all defer to 7.2.

Per-entity polish:
- [+ Reminder] button on yacht / client / interest detail headers,
  threading defaultYachtId / defaultClientId / defaultInterestId so the
  ReminderForm opens with the entity pre-linked.
- [EOI] badge on yacht detail header when yacht.source === 'eoi-generated'
  (mirrors the contacts-editor pattern shipped in eaab149).

Phase 6 hardening:
- imap-bounce-poller strips whitespace from IMAP_PASS so Google
  Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work
  whether pasted with or without spaces. Confirmed via Google docs that
  the visual spaces are formatting only and must not reach the IMAP
  server.

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
eaab14943b feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker
Phase 3b — EOI dialog field overrides:
- New EoiOverridesInput shape (clientEmail / clientPhone / yachtName)
  threaded through generate-and-sign validator + both pathways
  (in-app pdf-lib fill, Documenso template generate).
- src/lib/services/eoi-overrides.service.ts applies side-effects in one
  transaction: useOnlyForThisEoi writes documents.override_* and stops;
  setAsDefault demotes the prior primary + promotes (existing contactId)
  or inserts + promotes (fresh value); neither flag inserts a non-primary
  client_contacts row for future dropdown reuse.
- Document override columns persisted post-insert, with a 1-minute
  source_document_id backfill on freshly inserted contact rows.
- eoi-context route returns available.{emails, phones} so the dialog
  can render combobox options.
- <OverridableContactField> in eoi-generate-dialog.tsx renders the
  combobox + manual input + 2 checkboxes per field with mutually
  exclusive intent semantics.

Phase 3c — yacht spawn from EOI dialog:
- YachtForm gains createExtras + onCreated callbacks; the EOI dialog
  opens it as a nested Sheet pre-filled with the linked client as owner.
  On save the new yacht is stamped source='eoi-generated' and the
  interest is PATCHed with the new yachtId so the EOI context reflows.

Phase 3d — promote-to-primary + audit + [EOI] badge:
- POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary
  (transactional demote+promote via promoteContactToPrimary).
- src/lib/audit.ts AuditAction type adds eoi_field_override,
  promote_to_primary, eoi_spawn_yacht (DB column is free-text).
- ContactsEditor surfaces an [EOI] badge on non-primary rows where
  source='eoi-custom-input'.

Phase 4 — worker + TOD picker:
- processOverdueReminders refactored to UPDATE...RETURNING with a
  fired_at IS NULL gate so parallel workers can't double-fire. Uses
  the idx_reminders_due_unfired partial index from migration 0072.
- /settings gets a "Default reminder time" time-of-day picker; the
  value lands in user_profiles.preferences.digestTimeOfDay (validated
  HH:MM at the route). <ReminderForm> seeds its dueAt from this
  preference via a React-Query me-prefs fetch.

Phase 6 hardening:
- IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste
  of Google Workspace's 16-char App Password formatted as
  "abcd efgh ijkl mnop" still authenticates. Workspace activation
  procedure documented in MASTER-PLAN §Phase 6 (was previously written
  to CLAUDE.md, which was bloat — moved to the plan).

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
503207ef68 feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md
Three of the master plan's "suggested execution order" items shipped this
session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the
remaining session time.

- Phase 4 polish: yachtId field on <ReminderForm> via the existing
  YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter
  by yachtId, getReminder joins the yacht relation.
- Phase 2 risk-signal data wiring: getInterestById derives the 3 dates
  (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther)
  from document_events / berth_reservations / cross-interest interest_berths
  in parallel — chosen over new schema columns to keep the master plan's
  "no new tables" promise. Threaded through to DealPulseChip.
- Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the
  configured IMAP mailbox (IMAP_* env), matches NDRs to recent
  document_sends rows via recipient + 7-day window, idempotent via
  bounceDetectedAt, fires email_bounced notifications on hard/soft
  (skips OOO). State persisted to system_settings.bounce_poller_state.
  Wired into maintenance queue at */15 * * * *. Admin /admin/sends page
  surfaces the bounce badge + reason inline.
- CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy
  Documenso webhook / v1-v2 routing / Document folders sections rewritten
  as scannable bullets. Added a new "Working in this repo — skills, MCPs,
  agents" section promoting brainstorming/TDD/debugging/frontend-design
  skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev
  agents. Documented Phase 2 derivation choice in the data-model section.

Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
a6e79231f3 docs(plan): mark Phase 1+2 ☑, Phase 3-7 ◐ partial
Phase status after this session:
- Phase 1: full ship (1.1+1.2 already in code; 1.3+1.4 done)
- Phase 2: full ship (compute + admin page + registry)
- Phase 3: schema only (3a done; 3b/c/d UI deferred)
- Phase 4: schema + service (UI dialog + worker deferred)
- Phase 5: branding background URL (tone rewrite deferred)
- Phase 6: schema + parser library (cron worker + UI deferred)
- Phase 7: type definitions only (editor UI deferred to 7.1/7.2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:13:28 +02:00
df1594d596 feat(email): Phase 5 — branding chain ext'd with per-port background
Surface hard-coded portnimara.com background image as a per-port
override:

- BrandingShell gains backgroundUrl; renderShell reads from
  branding.backgroundUrl with the existing Port Nimara overhead URL
  as the fallback default.
- getBrandingShell threads the value through from getPortBrandingConfig.
- PortBrandingConfig gains emailBackgroundUrl; SETTING_KEYS adds
  brandingEmailBackgroundUrl mapped to 'branding_email_background_url'.
- /admin/branding page exposes the new field as an image-upload below
  the logo with sizing guidance (1920x1080 JPG, pre-blurred).

This closes the last hard-coded portnimara.com asset URL in the email
shell — every transactional email now fully respects per-port branding
when the admin uploads their own assets. Logo override path was
already in place from R2-H15; the background was the missing piece.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:12:28 +02:00
9f5786890e feat(post-audit): Phase 3/6/7 schema foundations + bounce parser
Phase 3 — EOI override foundation (migration 0073):
- client_contacts/addresses/yachts get source + source_document_id
  with FK SET NULL on doc deletion. CHECK constraints enforce the
  allow-list of source values (manual/imported/eoi-custom-input or
  manual/imported/eoi-generated for yachts).
- documents.override_client_* + override_yacht_* columns mirror the
  AcroForm field set per docs/eoi-documenso-field-mapping.md. When
  NULL the canonical record value flows; when set, this document
  uses the override without touching the underlying record.
- Drizzle schema mirrors all new columns; numeric import added to
  documents schema for the yacht-dimensions override columns.

Phase 6 — IMAP bounce foundation (migration 0074):
- document_sends.bounce_status / bounce_reason / bounce_detected_at
  with bounce_status CHECK constraint (hard/soft/ooo).
- Partial index for the "show bounced sends" UI filter.
- New src/lib/email/bounce-parser.ts library — handles RFC 3464 DSN
  + Outlook NDR shapes + OOO auto-replies. Returns null recipient
  + 'unknown' class when shape isn't recognizable. Cron worker
  deferred to Phase 6b.

Phase 7 — PDF editor field-map types:
- New src/lib/templates/field-map.ts defines FieldMap shape with
  percent-coord positioning so placements survive page-size changes.
- Zod schemas for API boundary validation.
- validateFieldMapAgainstPageCount helper for the "new PDF upload"
  warning.
- No schema migration needed — existing document_templates.
  overlay_positions JSONB column accepts the new shape; the editor
  migrates legacy absolute-coord entries on first save.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:09:22 +02:00
fb4a09e2ec feat(reminders): Phase 4 partial — schema + service + validators
Migration 0072 — reminders/interests expansion:
- interests.reminder_note: optional cadence note for the existing
  reminderEnabled+reminderDays flow. Surfaces in notification body
  + inbox row.
- reminders.yacht_id (+ FK + relation): fourth entity link so
  yacht-scoped tasks have a typed home alongside client/interest/berth.
- reminders.fired_at: worker idempotency. Partial index
  idx_reminders_due_unfired drives the scan.

Service + validator updates:
- createReminderSchema / updateReminderSchema accept yachtId.
- assertReminderFksInPort validates yacht ownership against the
  caller's port — defense-in-depth, same shape as other entity FKs.
- createReminder / updateReminder thread yachtId through.

Worker scheduler + CreateReminderDialog yachtId UI deferred. The
existing reminders/reminder-form.tsx already covers the dialog
contract — Phase 4b extends it with yachtId + the per-user
digest_time_of_day picker.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:03:12 +02:00
918c23fc0b feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin
Phase 1.3 — signing-invitation role copy
- Order-agnostic phrasing (was assuming client→developer→approver order;
  ports configure any sequence so the "client has already signed"
  assumption was brittle).
- Explicit developer-role branch + safe default for unknown roles.

Phase 1.4 — supplemental form per-port URL
- New supplemental_form_url registry entry (email.from section).
- Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl.
- /api/v1/interests/[id]/supplemental-info-request resolves the link
  via per-port URL when set, falls back to /public/supplemental-info/<token>
  CRM route when blank.

Phase 2 — deal-pulse signal expansion + admin config
- Compute function gains:
  - +5 eoi_sent_recent (≤14d) — was previously invisible
  - +15 deposit_received — strongest near-commit signal
  - +10 contract_signed — closed-loop reinforcement until outcome flips
  - -25 document_declined — strongest cooling signal
  - -20 reservation_cancelled — booked-then-cancelled warning
  - -30 berth_sold_to_other — primary berth lost to another deal
- Each signal honours optional per-port `signal_<id>_enabled` toggle.
- Registry adds master toggle (pulse_enabled), per-signal toggles, and
  per-port label overrides (Hot/Warm/Cold rename).
- New /admin/pulse page mounted via RegistryDrivenForm.
- AdminSectionsBrowser entry under Configuration.

Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other)
needs follow-up: requires either schema timestamps on interests or
derivation from event tables. Master plan §B captures the gap.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
ee3cbb9b39 docs(plan): expand master plan with detailed implementation appendix
Adds per-phase appendices A–H with:
- Per-file change lists for every phase
- Schema migration SQL skeletons (Phases 2, 3, 4, 6, 7)
- API request/response shapes (Phases 3, 4, 6, 7)
- Component-level UI breakdowns
- Sub-session day-budget breakdowns
- Cross-phase risks + definition of done

Appendix A flags Phase 1.1 + 1.2 as already-shipped — narrows
remaining Phase 1 work to ~3-4h (1.3 copy audit + 1.4 supplemental
form per-port URL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:50:00 +02:00
c9debce442 docs(plan): comprehensive 7-phase master plan for post-audit work
Single source of truth for all remaining audit + feature work:
Documenso completion, deal-pulse signals + admin config, EOI overrides,
Reminders, email-copy refactor, IMAP bounce linking, PDF editor.

Each phase carries goal, scope, schema, API/UI surfaces, acceptance
criteria, test plan, effort estimate, and a sub-task tracker that
fresh sessions tick through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:43:12 +02:00
0f99f054b3 feat(post-audit): batch A+B quick-wins + audit-side residuals
Bundles the user-prioritised follow-ups from the post-audit punch-list.

Batch A — pipeline + EOI safety:
 - §1.1 timeline buildAuditDescription renders diff fields ("leadCategory → hot_lead").
 - §4.13 EOI rejection cascade: notification to assigned rep + audit row + rose banner.
 - §4.10b finish doc-detail: SigningProgress reuse, linked-entity names (server-resolved),
   per-event icons + tooltips + show-more in activity panel.
 - §7.2 stage guidance card replaces empty Payments slot pre-reservation.
 - §4.15 deal-pulse trigger audit (docs/deal-pulse-trigger-audit.md).

Batch B — UX consistency + docs:
 - §1.4 quick log-contact button on interest header.
 - §2.1 contact-log compose: Dialog → Sheet.
 - §7.1 docs/deal-pulse explainer page; /docs/ in PUBLIC_PATHS.
 - DocumentStatus now includes 'rejected' + 'declined' across constants, labels, tone maps.

Audit-side residuals:
 - M-NEW-1 /me/ports skips port-context requirement.
 - M-AU03 audit log CSV export endpoint + UI button.
 - M-IN03 dead receipt-scanner.ts deleted; live path already per-port.
 - M-P01 pg_trgm GIN indexes (migration 0071).
 - §10.1 webhook tests verified passing (was stale).

Deferred per user direction:
 - §11.3 email copy refactor (needs old-CRM reference).
 - M-EM03 IMAP bounce-to-interest linking.

Tests: 1374/1374. tsc + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:22:11 +02:00
4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
397dbd1490 docs(spec): env-to-admin migration design
Design spec for moving tenant-configurable env vars into the per-port
admin UI via a settings registry. Covers scope decisions, registry
shape, resolver, encryption, admin UI generation, env catalog by
disposition, migration plan, and testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:22:39 +02:00
d15f5509ad docs(audit): progress report for the 2026-05-15 fix wave
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m6s
Build & Push Docker Images / build-and-push (push) Successful in 1m13s
11 of 13 known issues (A1-A20) fixed and verified; legacy-stage rank
tables in clients.service.ts + berth-recommender.service.ts purged of
9-stage enum keys. 1373/1373 vitest pass.

Remaining catalog (300+ checks) listed by section so it's clear what's
covered vs. still on the to-do list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:22:14 +02:00
98211066a5 fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m4s
Build & Push Docker Images / build-and-push (push) Has been cancelled
L-001 hunt landed these:

  - src/lib/services/clients.service.ts — stageRank used pre-refactor
    9-stage names exclusively (`contract_signed`, `deposit_10pct`, …).
    Every modern 7-stage interest fell to rank 0, making client-list
    "most-progressed deal" sort effectively random. Modern values now
    own the canonical ranks; legacy aliases map to their 7-stage
    equivalents so historical audit data still sorts.

  - src/lib/services/berth-recommender.service.ts — STAGE_ORDER had
    the same 9-stage shape. LATE_STAGE_THRESHOLD pointed at the (now
    nonexistent) `deposit_10pct` slot. Reworked to the 7-stage scale;
    threshold now at `deposit_paid` (5).

  - Stale comments referencing `deposit_10pct` in schema (clients,
    financial) and client-archive services updated to current copy.

  - Smart-archive dialog rendered `i.pipelineStage` as raw enum; now
    routes through `stageLabelFor` (the new helper added with A2).

Test fixture updates: berth-recommender.test.ts numeric inputs
re-mapped to the new 7-stage scale (eoi_signed=5 → eoi=3, etc.).
1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:18:13 +02:00
0d9208a052 fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep
Knocks out 10 of the 13 known issues from yesterday's Playwright audit.

A4 — Client form silently rejected submit when a contact row had an
empty value. The F19 filter ran in mutationFn after zod's
handleSubmit had already short-circuited on min(1). Now wraps the
onSubmit to prune empty rows BEFORE handleSubmit/zod sees them.

A16 — File upload to documents hub root 400'd because FormData.get
returns null for absent fields and zod's .optional() rejects null.
Route handler now coerces null/empty → undefined before parse.

A17 — Added /api/v1/me/ports endpoint that any authenticated user
can hit; client.ts now uses it as the bootstrap port-slug→port-id
resolver. Eliminates the wasteful 400s sales-reps and viewers were
firing on every page load against the super-admin-gated /admin/ports.

A1 — Filter permission_denied actions from the dashboard activity
feed. Still in the audit log; just not noise on the dashboard.

A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor
helpers in lib/constants. Activity-feed maps legacy 9-stage enum
values (deposit_10pct, contract_sent, etc.) to their 7-stage labels
on the way out, so historical audit rows read as "Deposit Paid" not
"Deposit 10Pct".

A19 — Same-stage write now returns 204 No Content. Service returns
a STAGE_NOOP sentinel; the route handler translates it.

A9 — Catch-up wizard now derives stage from berth status (under_offer
→ EOI, sold → contract) with a stageOverride state for explicit
user picks. Avoids the set-state-in-effect rule violation.

A20 — OwnerPicker shows a "Client / Company" hint chip on the
trigger when no value is set, so users know the trigger opens a
two-tab picker instead of just a client list.

A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'`
to NULL so the column lives at strictly 3 states.

A6 — file-preview-dialog gets a screen-reader DialogDescription so
the Radix "Missing aria-describedby" warning stops firing on every
preview.

A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist
(Next returns 404); /api/v1/admin/audit exists and 403s.

A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate
pass — both are dev-only cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
1309 changed files with 123328 additions and 46617 deletions

58
.env.dev.template Normal file
View File

@@ -0,0 +1,58 @@
# ─── Port Nimara CRM — DEV environment template ──────────────────────────────
#
# Copy to `.env` for local development. Values match the docker-compose.dev.yml
# defaults (Postgres on :5434, Redis on :6379, MinIO on :9000).
#
# Integration credentials (Documenso, OpenAI, SMTP, S3, etc.) belong in the
# admin UI after first login — see /admin/<integration>. The fallbacks at the
# bottom are commented out by default to make the admin path obvious.
# ─── Required (boot-time) ────────────────────────────────────────────────────
DATABASE_URL=postgresql://crm:changeme@localhost:5434/port_nimara_crm
REDIS_URL=redis://:changeme@localhost:6379
BETTER_AUTH_SECRET=dev-secret-please-change-32-chars-minimum-12345678
BETTER_AUTH_URL=http://localhost:3000
CSRF_SECRET=dev-csrf-secret-please-change-32-chars-minimum-12345
# Generated once for local dev. Production uses a different rotated key.
EMAIL_CREDENTIAL_KEY=0000000000000000000000000000000000000000000000000000000000000000
APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
NODE_ENV=development
LOG_LEVEL=debug
# ─── Dev-only safety net ─────────────────────────────────────────────────────
# When set, every outbound email is rerouted to this address.
# Configure to YOUR personal email so seeded fake-client sends don't escape.
# EMAIL_REDIRECT_TO=
# Skip env validation (used by Docker build only).
# SKIP_ENV_VALIDATION=
# ─── Optional integration env fallbacks (admin UI is canonical) ──────────────
# Uncomment + set ONLY if you want to bootstrap a port via env. Otherwise
# configure each integration via /admin/<integration> after first login.
# DOCUMENSO_API_URL=https://documenso.dev.example
# DOCUMENSO_API_KEY=
# DOCUMENSO_API_VERSION=v2
# DOCUMENSO_WEBHOOK_SECRET=
# SMTP_HOST=smtp.example
# SMTP_PORT=587
# OPENAI_API_KEY=
# Local MinIO (set if NOT using the admin UI to configure storage)
# MINIO_ENDPOINT=localhost
# MINIO_PORT=9000
# MINIO_ACCESS_KEY=minioadmin
# MINIO_SECRET_KEY=minioadmin
# MINIO_BUCKET=crm-files
# MINIO_USE_SSL=false
# MINIO_AUTO_CREATE_BUCKET=true

View File

@@ -1,66 +1,115 @@
# ─── Port Nimara CRM env template ─────────────────────────────────────────────
#
# This file documents every env var the CRM understands. Most integration
# settings have been moved into the per-port admin UI (see
# `docs/superpowers/specs/2026-05-15-env-to-admin-migration-design.md`):
#
# /admin/documenso — Documenso API URL, key, version, webhook secret,
# signers, templates
# /admin/ai — OpenAI API key + model + master switch
# /admin/email — SMTP host/port/user/pass, from-address
# /admin/storage — S3/MinIO endpoint, bucket, access key, secret key
#
# After a fresh deploy:
# 1. Set the REQUIRED block below (DB/Redis/auth secrets/encryption key).
# 2. Boot the app and run `/setup` to create the first super-admin.
# 3. Open `/admin/<integration>` and configure each one. Each field shows
# a "Using env fallback" badge if it's still inheriting from env, plus
# a "Copy from env" button for one-click migration into the DB.
#
# The COMMENTED env vars in the OPTIONAL block below still work as a runtime
# fallback if you set them — useful for staging / dev to bootstrap quickly,
# or for backward compatibility with older deployments. New ports inherit
# from these as their initial defaults until the admin UI overrides them.
#
# ─── REQUIRED (boot-time secrets — must be in env) ────────────────────────────
# Database
DATABASE_URL=postgresql://crm:changeme@localhost:5432/port_nimara_crm
# Redis
# Redis (BullMQ + Socket.IO adapter)
REDIS_URL=redis://:changeme@localhost:6379
# Auth
# Auth (must be 32+ char random strings; rotate carefully)
BETTER_AUTH_SECRET=change-me-to-a-random-string-at-least-32-chars
BETTER_AUTH_URL=http://localhost:3000
CSRF_SECRET=change-me-to-a-random-string-at-least-32-chars
# MinIO
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=crm-files
MINIO_USE_SSL=false
# When `true`, the S3 backend auto-creates the configured bucket on boot if it
# does not exist (otherwise boot throws so deployment-time misconfigs surface
# immediately). Leave unset in production.
MINIO_AUTO_CREATE_BUCKET=false
# Documenso
# Use the bare host — never include `/api/v1` in this URL. The Documenso
# client constructs versioned paths internally based on DOCUMENSO_API_VERSION
# below, and a double-pathed URL (https://.../api/v1/api/v1/...) returns 404
# on every call. Trailing-slash values are fine.
DOCUMENSO_API_URL=https://documenso.example.com
# `v1` (Documenso 1.13.x) or `v2` (Documenso 2.x). Determines which API path
# prefix the client uses and which response-shape normalizer runs.
DOCUMENSO_API_VERSION=v1
DOCUMENSO_API_KEY=your-documenso-api-key
DOCUMENSO_WEBHOOK_SECRET=your-webhook-secret-min-16-chars
# The Documenso template id used by the EOI send pathway. Per-port overrides
# live in `system_settings.documenso_template_id_eoi`; this env value is the
# global fallback when no per-port row exists.
DOCUMENSO_TEMPLATE_ID_EOI=
# Recipient role ids on the EOI template. The send service copies the template
# layout but re-targets recipients per interest, so we need the role ids to
# look up which template recipient becomes the Client / Sales signer.
DOCUMENSO_RECIPIENT_ID_CLIENT=
DOCUMENSO_RECIPIENT_ID_SALES=
# Email (SMTP)
SMTP_HOST=mail.portnimara.com
SMTP_PORT=587
# Encryption (64-char hex string for AES-256)
# AES-256 key for credential encryption at rest. 64-char hex string.
# Generate with: openssl rand -hex 32
# CRITICAL: rotating this orphans every encrypted credential in system_settings
# (Documenso API key, SMTP password, OpenAI key, S3 access/secret keys).
# Plan a re-keying flow before rotating in production.
EMAIL_CREDENTIAL_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Google OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# OpenAI (optional)
OPENAI_API_KEY=
# App
# App URL — used by middleware redirects + outbound email link construction.
APP_URL=http://localhost:3000
PUBLIC_SITE_URL=https://portnimara.com
# Inlined into the client JS bundle at build time. Must match APP_URL.
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Process basics
NODE_ENV=development
LOG_LEVEL=info
# Next.js public
NEXT_PUBLIC_APP_URL=http://localhost:3000
# When true, the filesystem storage backend refuses to start. Multi-node
# deploys MUST use the s3-compatible backend (per CLAUDE.md).
# MULTI_NODE_DEPLOYMENT=false
# ─── OPTIONAL: integration env fallbacks ──────────────────────────────────────
# Each of the following is configurable in the admin UI. Uncomment + set ANY
# of these to provide a fallback that ports inherit when their admin field is
# blank. The admin UI labels each inherited field with a "Using env fallback"
# badge and offers a "Copy from env" button for one-click migration into the
# port-scoped DB row.
# ─ Documenso (admin: /admin/documenso) ─
# DOCUMENSO_API_URL=https://documenso.example.com # Bare host. Never include /api/v1.
# DOCUMENSO_API_KEY=your-documenso-api-key # AES-encrypted once written via admin
# DOCUMENSO_API_VERSION=v1 # v1 (1.13.x) or v2 (2.x)
# DOCUMENSO_WEBHOOK_SECRET= # Min 16 chars. Generate: openssl rand -hex 16
# DOCUMENSO_TEMPLATE_ID_EOI=
# DOCUMENSO_CLIENT_RECIPIENT_ID=
# DOCUMENSO_DEVELOPER_RECIPIENT_ID=
# DOCUMENSO_APPROVAL_RECIPIENT_ID=
# ─ Email / SMTP (admin: /admin/email) ─
# SMTP_HOST=mail.portnimara.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASS= # AES-encrypted once written via admin
# SMTP_FROM= # e.g. "Port Nimara <noreply@example.com>"
# Dev/test safety net: when set, every outbound email is rerouted to this
# address regardless of recipient. Subject is prefixed with [redirected from <orig>].
# CRITICAL: env validation refuses boot if NODE_ENV=production AND this is set.
# EMAIL_REDIRECT_TO=
# ─ Storage / S3 / MinIO (admin: /admin/storage) ─
# MINIO_ENDPOINT=localhost
# MINIO_PORT=9000
# MINIO_ACCESS_KEY= # AES-encrypted once written via admin
# MINIO_SECRET_KEY= # AES-encrypted (already)
# MINIO_BUCKET=crm-files
# MINIO_USE_SSL=false
# MINIO_AUTO_CREATE_BUCKET=false # Auto-create bucket at boot
# ─ OpenAI (admin: /admin/ai) ─
# OPENAI_API_KEY= # AES-encrypted once written via admin
# ─ Public marketing site URL (admin: /admin/general — TODO) ─
# PUBLIC_SITE_URL=https://portnimara.com
# ─ Webhook intake from marketing site (deployment-shared, env-only) ─
# Shared secret with the marketing website's CRM_INTAKE_SECRET. Min 16 chars.
# WEBSITE_INTAKE_SECRET=
# ─ Sentry (optional — when unset the SDK is a no-op) ─
# NEXT_PUBLIC_SENTRY_DSN=
# SENTRY_ENVIRONMENT=
# SENTRY_TRACES_SAMPLE_RATE=0.1
# ─ Google OAuth (not currently used) ─
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=

58
.env.prod.template Normal file
View File

@@ -0,0 +1,58 @@
# ─── Port Nimara CRM — PROD environment template ─────────────────────────────
#
# Production env contains ONLY the boot-time minimum: DB connection, auth
# secrets, encryption key, app URL, log level. Every integration credential
# (Documenso, OpenAI, SMTP, S3) is configured per-port in the admin UI after
# the first super-admin completes /setup. This keeps secrets out of the
# infrastructure layer (k8s ConfigMap, .env files, deploy logs).
#
# Generate fresh secrets:
# openssl rand -hex 32 # for BETTER_AUTH_SECRET, CSRF_SECRET
# openssl rand -hex 32 # for EMAIL_CREDENTIAL_KEY (must be 64 hex chars)
# ─── Required ────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://USER:PASS@HOST:5432/port_nimara_crm
REDIS_URL=redis://:PASS@HOST:6379
BETTER_AUTH_SECRET=GENERATE_OPENSSL_RAND_HEX_32
BETTER_AUTH_URL=https://crm.example.com
CSRF_SECRET=GENERATE_OPENSSL_RAND_HEX_32
# CRITICAL: rotating this orphans every encrypted credential in
# system_settings. Plan a re-keying flow before rotating.
EMAIL_CREDENTIAL_KEY=GENERATE_OPENSSL_RAND_HEX_32_PRODUCES_64_CHARS
APP_URL=https://crm.example.com
NEXT_PUBLIC_APP_URL=https://crm.example.com
NODE_ENV=production
LOG_LEVEL=info
# ─── Multi-node guard ────────────────────────────────────────────────────────
# Set true if running > 1 app instance. Forces the storage backend off
# filesystem onto S3-compatible (filesystem mode is single-node only).
MULTI_NODE_DEPLOYMENT=true
# ─── Sentry (highly recommended in prod) ─────────────────────────────────────
NEXT_PUBLIC_SENTRY_DSN=https://YOUR_KEY@YOUR_PROJECT.ingest.sentry.io/PROJECT_ID
SENTRY_ENVIRONMENT=production
SENTRY_TRACES_SAMPLE_RATE=0.1
# ─── Webhook intake from marketing site (deployment-shared) ──────────────────
# Must match the marketing site's CRM_INTAKE_SECRET. Min 16 chars.
WEBSITE_INTAKE_SECRET=GENERATE_OPENSSL_RAND_HEX_16
# ─── DO NOT SET in production ────────────────────────────────────────────────
# EMAIL_REDIRECT_TO — Will fail boot validation (silently rewrites every
# outbound email recipient).
# SKIP_ENV_VALIDATION — Bypasses safety checks. Internal use only.
# ─── Integration credentials live in /admin/<integration>, NOT here ──────────
# Once deployed:
# 1. Run `pnpm exec drizzle-kit push` (or your migration script)
# 2. Hit https://crm.example.com/setup to create the first super-admin
# 3. Log in → /admin/documenso, /admin/email, /admin/storage, /admin/ai
# 4. Configure each integration. AES-encrypted at rest.
# 5. Run `pnpm tsx scripts/encrypt-plaintext-credentials.ts` once to encrypt
# any legacy plaintext rows from older deployments.

10
.gitignore vendored
View File

@@ -58,3 +58,13 @@ docker-compose.override.yml
# Local berth-PDF + brochure samples used as upload fixtures during dev.
/berth_pdf_example/
# Scratch / audit artefacts
tmp/
# Internal docs + Claude instructions: kept local-only, not in the shared repo
docs/
/CLAUDE.md
# Client-facing feature screenshots (real PII — do not commit)
docs/feature-screenshots/

185
CLAUDE.md
View File

@@ -1,185 +0,0 @@
# Port Nimara CRM
Multi-tenant CRM for marina/port management. Built with Next.js 15 App Router (standalone output), React 19, TypeScript (strict), Tailwind CSS 3, and Drizzle ORM on PostgreSQL.
## Quick reference
```bash
pnpm dev # Start dev server
pnpm build # Production build
pnpm lint # ESLint
pnpm format # Prettier
pnpm db:generate # Generate Drizzle migrations
pnpm db:push # Push schema to DB
pnpm db:studio # Drizzle Studio GUI
pnpm db:seed # Seed database (tsx src/lib/db/seed.ts)
# Tests
pnpm exec vitest run # Unit + integration (~3s)
pnpm exec playwright test --project=smoke # Click-through smoke (~10min)
pnpm exec playwright test --project=exhaustive # Full UI exhaustive
pnpm exec playwright test --project=destructive # Archive/delete flows
pnpm exec playwright test --project=realapi # Real Documenso/IMAP (opt-in)
pnpm exec playwright test --project=visual # Pixel-diff baselines
pnpm exec playwright test --project=visual --update-snapshots # Regenerate baselines
# Dev helpers
pnpm tsx scripts/dev-trigger-portal-invite.ts # Send a portal activation email
pnpm tsx scripts/dev-imap-probe.ts # Dump recent IMAP inbox messages
```
## Tech stack
- **Framework:** Next.js 15.1 App Router, `output: 'standalone'`, `experimental.typedRoutes`
- **Auth:** better-auth (session cookie: `pn-crm.session_token`)
- **Database:** PostgreSQL via `postgres` driver + Drizzle ORM
- **Queue:** BullMQ + Redis (ioredis)
- **Storage:** MinIO (S3-compatible)
- **Realtime:** Socket.IO with Redis adapter
- **UI:** Radix UI primitives, shadcn/ui components (`src/components/ui/`), Lucide icons, CVA + tailwind-merge + clsx
- **Forms:** react-hook-form + zod resolvers
- **Tables:** TanStack Table
- **State:** Zustand stores (`src/stores/`), TanStack React Query
- **PDF:** pdfme
- **Email:** nodemailer + imapflow + mailparser
- **AI:** OpenAI SDK (optional)
- **Testing:** Vitest (unit), Playwright (e2e)
- **Logging:** pino + pino-pretty
## Project structure
```
src/
app/
(auth)/ # Login/auth pages
(dashboard)/ # Main app - route: /[portSlug]/...
(portal)/ # Client portal
api/ # API routes
components/
ui/ # shadcn/ui base components
layout/ # Shell, sidebar, header
[domain]/ # Domain components (clients, invoices, berths, etc.)
shared/ # Cross-domain shared components
hooks/ # React hooks (use-auth, use-permissions, use-socket, etc.)
lib/
api/ # API client utilities
auth/ # better-auth config
db/
schema/ # Drizzle schema (one file per domain)
migrations/ # Generated Drizzle migrations
env.ts # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
services/ # Business logic services
validators/ # Zod schemas for API input validation
utils/ # Shared utilities
middleware.ts # Auth middleware (cookie check, redirects)
providers/ # React context providers
stores/ # Zustand stores
types/ # Shared TypeScript types
```
## Conventions
- **TypeScript:** Strict mode with `noUncheckedIndexedAccess`. No `any` (ESLint error).
- **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
- **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed.
- **Imports:** Use `@/*` path alias (maps to `src/*`).
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`. Yacht / company / reservation domains live in `components/yachts`, `components/companies`, `components/reservations` respectively.
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. Domain files include `clients.ts`, `yachts.ts`, `companies.ts`, `reservations.ts`, `interests.ts`, `berths.ts`, `documents.ts`, `invoices.ts`, etc.
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` column pairs (`'client' | 'company'`). Resolve owner identity through `src/lib/services/yachts.service.ts` / `eoi-context.ts` rather than reading the columns ad hoc — those services apply the type discriminator.
- **EOI generation:** Two pathways share the same `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway calls the template-generate endpoint via `documenso-payload.ts`; in-app pathway fills the same source PDF (`assets/eoi-template.pdf`) via `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm). Routed through `generateAndSign(...)` in `src/lib/services/document-templates.ts` with a `pathway` parameter.
- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time.
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat. `handleDocumentCompleted` is **idempotent** — early-returns when `doc.status === 'completed' && doc.signedFileId` so Documenso retries on 5xx don't insert duplicate file rows + orphan blobs. The switch handles `DOCUMENT_SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED`, plus v2 aliases `RECIPIENT_VIEWED` / `RECIPIENT_SIGNED` (logged + routed to v1 equivalents).
- **Documenso API responses:** 2.x renamed `id``documentId` and recipient `id``recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers.
- **Documenso v1 vs v2 endpoint routing:** `getPortDocumensoConfig(portId)` resolves the per-port `apiVersion` ('v1' | 'v2'). `documenso-client.ts` exports version-aware wrappers: `getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`. v2 → `/api/v2/envelope/*` (`create` is multipart with `{payload, files}`; `distribute` returns per-recipient `signingUrl` in one round-trip; `redistribute` for reminders; `field/create-many` for bulk placement with percent coords + `fieldMeta`). v1 → existing `/api/v1/documents/*` paths. **Template flow is intentionally still v1** (`/api/v1/templates/{id}/generate-document` with `formValues` keyed by name) — v2 instances accept it via backward compat. Full v2 `/template/use` migration with `prefillFields` by ID needs per-template field-ID capture in admin settings and is deferred. Two per-port v2 settings now wired through `buildDocumensoPayload` + `documensoCreate.meta`: `documenso_signing_order` (PARALLEL/SEQUENTIAL — v2-enforced) and `documenso_redirect_url` (post-sign redirect; both versions honour). `checkDocumensoHealth` returns the resolved `apiVersion` for the admin Test button.
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
- **Sheet vs Drawer doctrine:** `<Sheet side="right">` (`src/components/ui/sheet.tsx`, Radix dialog) is the canonical side-panel for forms and previews on **both** desktop and mobile (`w-3/4 ... sm:max-w-sm` adapts naturally). Vaul `<Drawer>` (`src/components/shared/drawer.tsx`) is reserved for **mobile-only bottom-sheet UX** — currently just the `MoreSheet` nav (`src/components/layout/mobile/more-sheet.tsx`). If you need a side panel of any kind, use Sheet. Don't add new Vaul drawers without a mobile-bottom-sheet justification.
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain.
Three system roots (`Clients/`, `Companies/`, `Yachts/`) are auto-created on port init via `ensureSystemRoots`. Per-entity subfolders are created lazily on first auto-deposit / manual upload via `ensureEntityFolder` — concurrent callers race safely via the partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. The `chk_system_folder_shape` CHECK pins the shape of system rows. Rename/move/delete on `system_managed = true` folders is rejected by `assertNotSystemManaged` (service-level, not DB-level). Entity rename auto-syncs the folder name via `syncEntityFolderName`; archive applies a ` (archived)` suffix via `applyEntityArchivedSuffix`; hard-delete demotes (`system_managed = false`) + appends ` (deleted)` via `demoteSystemFolderOnEntityDelete`.
Auto-deposit on signing completion: `handleDocumentCompleted` resolves the owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.clientId ?? interest.yachtId`), ensures the matching entity subfolder, and sets `files.folder_id` + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable. (Note: `interests` table has no `companyId` column, hence the chain's interest fallback omits it.)
Aggregated projection: `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk the relationship graph from the requested entity (symmetric reach: Client ↔ Company via `company_memberships` filtered to active rows via `isNull(end_date)`, ↔ Yacht via `yachts.current_owner_type/id`) and return results grouped by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT). Each group caps at 20 rows with a total for `Show all (N)`. The files projection LEFT JOINs `documents` on `signed_file_id` to surface `signedFromDocumentId` per row — used by the UI's "view signing details" link. **File-FK snapshot is the source of truth** — historical files stay where they were filed even if the linked entity's relationships change. **Defense-in-depth `port_id` filter at every join** (per recommender precedent) — entry-point check alone is rejected. Completed workflows are hidden from folder views (`listDocuments` excludes `status='completed'` when `folderId` is set); the signed-PDF file surfaces in the Files section with a "view signing details" link to the workflow audit trail (via `GET /api/v1/documents/[id]/signing-details`).
Hub UI: rebuilt around three render modes — `HubRootView` (no folder), `EntityFolderView` (system-managed entity subfolder, renders Signing-in-progress + Files via the aggregated projection), `FlatFolderListing` (any other folder). Sidebar shows lock markers on system folders and mutes archived entity folders. The signing-status tabs strip (`in_progress` / `awaiting_them` / etc.) was removed; folders are now the primary navigation.
Permission gating: `documents.view` for read of folders + entity-aggregated listing; `documents.manage_folders` for create / rename / move / delete of user folders (system folders are immutable through the API entirely).
Deploy: schema migration `0051_documents_hub_split.sql` ships the columns; `pnpm db:backfill:doc-folders` (script `scripts/backfill-document-folders.ts`) runs after the migration and is idempotent (per-port `pg_advisory_xact_lock`).
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
- **Multi-berth interest model:** `interest_berths` is the source of truth for which berths an interest is linked to; `interests.berth_id` does not exist (dropped in migration 0029). Three role flags: `is_primary` (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), `is_specific_interest` (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), `is_in_eoi_bundle` (covered by the interest's EOI signature). Read/write through `src/lib/services/interest-berths.service.ts` helpers (`getPrimaryBerth`, `getPrimaryBerthsForInterests`, `upsertInterestBerth`, `setPrimaryBerth`, `removeInterestBerth`); never query `interest_berths` from outside that service.
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.
- **Public berths API:** `/api/public/berths` (list) and `/api/public/berths/[mooringNumber]` (single) are the public-facing data feed for the marketing website. Output shape mirrors the legacy NocoDB Berths shape verbatim (`"Mooring Number"`, `"Side Pontoon"`, etc.) — see `src/lib/services/public-berths.ts`. Cache headers: `s-maxage=300, stale-while-revalidate=60`. Status mapping: `"Sold"` (berth.status=sold) > `"Under Offer"` (status=under_offer OR has any active `interest_berths.is_specific_interest=true` link with `interests.outcome IS NULL`) > `"Available"`. The companion `/api/public/health` endpoint is dual-mode: anonymous callers get `{status, timestamp}` (uptime monitors, never 503); requests carrying a timing-safe-matched `X-Intake-Secret` (compared against `WEBSITE_INTAKE_SECRET`) get the full `{status, env, appUrl, timestamp, checks: {db, redis}}` payload and a 503 if any dependency is down. The website uses the authenticated form on startup so it refuses to start when its `CRM_PUBLIC_URL` points at a different deployment env.
- **Berth recommender:** Pure SQL ranking (no AI). Lives in `src/lib/services/berth-recommender.service.ts`. Tier ladder A/B/C/D classifies each feasible berth based on its `interest_berths` aggregates. Heat scoring (recency / furthest stage / interest count / EOI count) only fires for tier B (lost/cancelled-only history); per-port admin tunes weights via `system_settings` keys (`heat_weight_*`, `recommender_max_oversize_pct`, `recommender_top_n_default`, `fallthrough_policy`, `fallthrough_cooldown_days`, `tier_ladder_hide_late_stage`). The recommender enforces multi-port isolation both at the entry point (rejects cross-port interest lookups) AND inside the SQL aggregates CTE (defense-in-depth `i.port_id` filter).
- **Berth rules engine:** Per-port `system_settings` rules in `src/lib/services/berth-rules-engine.ts`. Seven triggers, all wired: `eoi_sent`, `eoi_signed`, `deposit_received` (invoices.ts), `contract_signed` (documents.service.ts), `interest_archived` / `interest_completed` (interests.service.ts), `berth_unlinked` (interest-berths.service.ts). Service callers fire `evaluateRule(trigger, interestId, portId, meta)` via dynamic import to avoid circular deps. Default modes vary (`auto` for state changes, `suggest` for recommendations, `off` for `berth_unlinked`); admins tune via `berth_rules` system_settings key. Webhook auto-advance pairs the rule with `advanceStageIfBehind` so the pipeline stage and berth status move together.
- **EOI bundle / range formatter:** Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via `formatBerthRange()` in `src/lib/templates/berth-range.ts`. The output populates the existing `Berth Number` Documenso form field (single-berth output is byte-identical to the primary mooring, multi-berth shows the full range). CRM UI always shows berths as individual chips. The `{{eoi.berthRange}}` token is in `VALID_MERGE_TOKENS` for template body copy.
- **Pluggable storage backend:** Code never imports MinIO/S3 directly. All file I/O goes through `getStorageBackend()` from `src/lib/storage/`. The `StorageBackend` interface requires `put`, `get`, `head`, `delete`, `listByPrefix`, `presignUpload`, `presignDownload` — any new backend must implement all seven. Configured via `system_settings.storage_backend` ('s3' | 'filesystem'). Switching backends is a settings change + `pnpm tsx scripts/migrate-storage.ts` run (the migrator round-trips every blob in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports` and verifies SHA-256 — `TABLES_WITH_STORAGE_KEYS` populated in 9a5ba87; was no-op before). MinIO ops are wrapped in a 30s `withTimeout` to prevent TCP-blackhole worker stalls. **Filesystem backend is single-node only**: refuses to start when `MULTI_NODE_DEPLOYMENT=true`. Multi-node deployments must use the s3-compatible backend.
- **Per-berth PDFs:** Versioned via `berth_pdf_versions`; `berths.current_pdf_version_id` always points to the latest active version. Storage key is UUID-based per upload (not version-numbered) so concurrent uploads can't collide on blob paths; `pg_advisory_xact_lock` per berth_id serializes the version-number allocation. 3-tier parser: AcroForm → OCR (Tesseract.js with positional heuristics) → optional AI (rep clicks "AI parse" only when OCR confidence is low). Magic-byte (`%PDF-`) check enforced on BOTH the in-server upload path AND the presigned-PUT path (the post-upload service streams the first 5 bytes via the storage backend). Mooring-number mismatch between PDF and target berth surfaces as a service-level `ConflictError` unless the apply call passes `confirmMooringMismatch: true`.
- **Brochures:** Per-port; default brochure marked via `is_default` (enforced by partial unique index on `(port_id) WHERE is_default=true AND archived_at IS NULL`). Archived brochures retain version history. Same upload flow as berth PDFs (presign + magic-byte verification on the post-upload register endpoint).
- **Send-from accounts (sales send-outs):** Configurable via `system_settings`; defaults to `sales@portnimara.com` for human-touch and `noreply@portnimara.com` for automation. SMTP/IMAP passwords are AES-256-GCM encrypted at rest; the API never returns decrypted secrets — only `*PassIsSet` boolean markers. Send-out audit goes to `document_sends` (separate from `audit_logs` because of volume + binary refs). Body markdown is XSS-safe via `renderEmailBody()` (escape-then-allowlist; tested against the standard XSS vector list). Rate limit: 50 sends/user/hour individual. Pre-send size threshold: files > `email_attach_threshold_mb` ship as a 24h signed-URL link rather than an attachment (avoids the duplicate-send race from async bounces). The download-link fallback HTML-escapes the filename to prevent injection from admin-supplied brochure names. Bounce monitoring requires IMAP credentials in addition to SMTP — without them, the size-rejection banner stays disabled.
- **NocoDB berth import:** `pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara` re-imports from the legacy NocoDB Berths table. Idempotent: rows where `updated_at > last_imported_at` (the "human edited this since last import" guard) are skipped unless `--force`. Adds `--update-snapshot` to also rewrite `src/lib/db/seed-data/berths.json`. Uses `pg_advisory_xact_lock` so two simultaneous runs serialize. Pure helpers in `src/lib/services/berth-import.ts` are unit-tested.
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
- **API response shapes:** Conventional envelope is `{ data: <T> }` for any endpoint that returns content (read OR write). Mutations that return nothing emit `204 No Content` (`new NextResponse(null, { status: 204 })`). Don't use `{ success: true }` for CRM mutations — it was a legacy pattern, normalized away in 2026-05-07. Public portal-auth endpoints are an exception: they return `{ success: true }` because the frontend needs a non-error JSON body to chain on. List/paginated reads return `{ data: <T[]>, total?, hasMore? }` (see `/api/v1/clients` for the shape). Errors always go through `errorResponse(error)` from `@/lib/errors` so request-id propagation and the audit-tier mapping stay uniform.
- **Body parsing:** Always use `parseBody(req, schema)` from `@/lib/api/route-helpers` instead of `await req.json(); schema.parse(body)`. The helper returns a uniform 400 with field-level errors that the frontend's `toastError` hook recognizes; raw `req.json` + `schema.parse` produces a generic 500 because the ZodError isn't caught in the same shape.
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
## Schema migrations during dev
When you run a `db:push` or apply a migration via `psql` against a running dev server, **restart the dev server afterwards**. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes `column X does not exist` errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with `errorMissingColumn`/`42703` after a successful migration. Fix: kill `next dev` and restart it.
## Environment
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
Required env gotchas:
- `DOCUMENSO_API_URL`**bare host only**, never include `/api/v1`. The client appends versioned paths based on `DOCUMENSO_API_VERSION` (`v1` for 1.13.x, `v2` for 2.x). A double-pathed URL returns 404 on every call with no useful diagnostic.
Optional dev/test-only env vars (not in `.env.example`):
- `EMAIL_REDIRECT_TO=<address>` — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with `[redirected from <original>]`. Dev safety net so seeded fake-client emails don't escape; **must be unset in production**.
- `IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS` — read by `tests/e2e/realapi/portal-imap-activation.spec.ts` to fetch the activation email from a real mailbox during the IMAP round-trip test. The spec skips when any are missing.
## Testing
Five Playwright projects, defined in `playwright.config.ts`:
- `setup` — global setup (seeds users, port, berths, system settings).
- `smoke` — fast click-through over every major flow. Run on every change (~10 min, 125 specs).
- `exhaustive` — deeper UI coverage that takes longer.
- `destructive` — archive/delete/cancel paths against throwaway entities.
- `realapi` — opt-in suite that hits real external services (Documenso send-side + IMAP round-trip). Requires `DOCUMENSO_API_*`, `SMTP_*`, `IMAP_*` env. Cloudflared tunnel needs to be running so Documenso can call the local webhook receiver.
- `visual` — pixel-diff baselines for stable list/landing pages. Snapshots committed under `tests/e2e/visual/snapshots.spec.ts-snapshots/`. Regenerate with `--update-snapshots` after intentional UI changes.
Vitest covers unit + integration with mocked external services (`tests/unit/`, `tests/integration/`).
## Docker
- `Dockerfile` - Production multi-stage build (deps -> build -> runner)
- `Dockerfile.dev` - Dev with bind-mounted source
- `Dockerfile.worker` - BullMQ worker process
- `docker-compose.yml` / `docker-compose.dev.yml` / `docker-compose.prod.yml`
## Architecture docs
Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.
Domain-specific references:
- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext`
paths to the Documenso template's `formValues` keys, with the matching
AcroForm field names used by the in-app pathway. The `Berth Number`
field carries the `formatBerthRange()` output — single-berth EOIs
populate it with just the primary mooring (e.g. `A1`), multi-berth
EOIs with the compact range (`A1-A3, B5`). No separate `Berth Range`
template field is needed (the dedicated field was retired 2026-05-14).
- `assets/README.md` — what the in-app EOI source PDF must contain and how
to override its path in dev/test.
- `docs/berth-recommender-and-pdf-plan.md` — the comprehensive plan for the
Phase 08 berth-recommender + PDF + send-outs work bundle. Single source
of truth for the multi-berth interest model, recommender tier ladder,
pluggable storage, per-berth PDF parser, and sales send-out flows.

View File

@@ -5,6 +5,17 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# Stage 1b: Production dependency tree in a flat (hoisted) node_modules.
# Hoisted = symlink-free, so a Docker COPY into the runner is faithful
# (copying pnpm's default symlinked layout dereferences and breaks
# transitive resolution); complete = the custom socket.io server's deps
# (engine.io, accepts, ws, ...) all resolve at runtime.
FROM node:20-alpine AS prod-deps
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN echo "node-linker=hoisted" > .npmrc && pnpm install --frozen-lockfile --prod
# Stage 2: Build the application
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
@@ -30,12 +41,27 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js
# Pin socket.io + @socket.io/redis-adapter into the runner — the custom
# server (server-custom.js) requires them at runtime, but the Next
# tracer has no reason to include them in .next/standalone since no
# Next route imports the socket server. (build-auditor C3)
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/socket.io ./node_modules/socket.io
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@socket.io ./node_modules/@socket.io
# The Next standalone node_modules is a MATCHED SET with the turbopack
# server chunks — it resolves turbopack's externalized packages (better-auth,
# postgres, pino, minio, ...) by their hashed ids, so REPLACING it makes
# every route that uses them 500 with "Failed to load external module".
# But the custom server (server-custom.js, CJS via esbuild --packages=external)
# require()s deps the trace omits or ships ESM-only: socket.io's closure
# (accepts/ws/engine.io/cors) and drizzle-orm's CJS entry (index.cjs). So
# MERGE the complete hoisted prod tree INTO the standalone node_modules with
# rsync --ignore-existing: it ADDS the missing packages/files and SKIPS
# everything the trace already provides (and unlike COPY/cp it tolerates the
# trace's pnpm symlinks instead of erroring on symlink-vs-dir). The one
# thing the standalone server bootstrap would set — globalThis.AsyncLocalStorage
# — is handled up-front by src/server-runtime-preamble.ts.
COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules /opt/prod-node-modules
RUN apk add --no-cache --virtual .merge-deps rsync \
&& rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \
&& rm -rf /opt/prod-node-modules \
&& apk del .merge-deps
# pg_dump for the backup/DR bundle engine (src/lib/services/backup.service.ts
# spawns `pg_dump`). Version pinned to match the postgres:16 server.
RUN apk add --no-cache postgresql16-client
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \

View File

@@ -26,6 +26,9 @@ FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
WORKDIR /app
# pg_dump for the scheduled backup-push cron (maintenance worker runs
# runScheduledBackupPush → pg_dump). Pinned to match the postgres:16 server.
RUN apk add --no-cache postgresql16-client
RUN chown -R worker:nodejs /app
USER worker
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./

View File

@@ -67,3 +67,23 @@ exact bytes:
1. In Documenso, open the EOI template.
2. Download the source PDF.
3. Drop it here as `eoi-template.pdf`.
### Known asset issue: Email field clipped at top
The current `eoi-template.pdf` has the `Email` AcroForm field box positioned
slightly too low — long email addresses render with the top pixel row
clipped. **Fix is asset-side, not code-side**: pdf-lib only fills field
boxes, it can't move them. To resolve:
1. Open `eoi-template.pdf` in any PDF form editor (Acrobat, PDFescape,
PDF Studio, or Documenso's own template editor).
2. Select the `Email` field box; nudge its `y` origin down by ~3 pt (or
increase its height by ~3 pt) so the rendered text has visual margin
from the top edge.
3. Save → re-upload to Documenso (so both pathways stay in sync) →
bump the sha256 in this README + `EXPECTED_EOI_SHA256` per the steps
above.
Affects both the in-app pathway (renders via pdf-lib AcroForm fill) and
the Documenso pathway (Documenso's own renderer respects the same field
geometry).

File diff suppressed because it is too large Load Diff

View File

@@ -1,733 +0,0 @@
# Comprehensive Audit Catalog — 2026-05-15
Every audit-worthy surface in Port Nimara CRM, organized by area. Each entry is a discrete check we _could_ run. Pick the subset you want to actually execute.
**Legend:**
- **Effort:** XS (~minutes) · S (~30 min) · M (~half day) · L (~1+ day)
- **Severity if broken:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 cosmetic
- **Coverage today:** ✅ confirmed working · ⚠️ partially checked · ❓ unchecked · ❌ known broken (see prior audits)
---
## 0. Already-known issues (cross-reference)
These were caught in the 2026-05-15 sweep (`docs/audit-2026-05-15.md`) but listed here so we don't re-discover them:
| ID | Issue | Status |
| ----- | -------------------------------------------------------------------------- | --------------------- |
| A1 | Dashboard activity feed surfaces raw `permission_denied` rows, no label | ❌ unfixed |
| A2 | Activity feed renders legacy 9-stage enum values (`deposit_10pct` etc.) | ❌ unfixed |
| A3 | react-grab CSP error spam in dev | ❌ unfixed (dev only) |
| A4 | New Client form silently rejects when contact row has empty value | ❌ unfixed |
| A5 | Socket.IO WebSocket never connects in `pnpm dev` | ❌ unfixed |
| A6 | Some DialogContent missing `aria-describedby` | ❌ unfixed |
| A8 | Legacy `statusOverrideMode = "auto"` values still in DB | ❌ unfixed |
| A9 | Catch-up wizard defaults to "New Enquiry" instead of "EOI" for under_offer | ❌ unfixed |
| A16 | File upload at documents-hub root fails with null vs string validator | ❌ unfixed |
| A17 | `/api/v1/admin/ports` is super-admin-only but used as bootstrap resolver | ❌ unfixed |
| A18 | 404 vs 403 inconsistency on permission denials | ❌ unfixed |
| A19 | F27 same-stage PATCH returns 200 + body instead of 204 | ❌ unfixed |
| A20 | OwnerPicker Client/Company toggle hidden until popover opens | ❌ unfixed |
| A19_b | Portal `/portal/login` shows "unavailable" — scope undefined | ❌ unfixed |
---
## 1. Legacy stage enum bleed (the `deposit_10pct` class of bug)
**Why this matters:** the pipeline was refactored 9 stages → 7 stages but historical data still carries the old enum values in audit logs, soft-deleted rows, and possibly some hard-coded UI lookups. Every place that renders a stage value should map legacy → modern.
| ID | Check | Effort | Severity | Coverage |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
| L-001 | Grep entire `src/` for hard-coded references to legacy stage names: `details_sent`, `in_communication`, `eoi_sent`, `eoi_signed`, `deposit_10pct`, `contract_sent`, `contract_signed`, `completed` (as stage) | S | 🟠 | ❓ |
| L-002 | Audit log diff display: does old `pipelineStage` value get human-friendly mapping? | S | 🟡 | ❌ (A2) |
| L-003 | Activity feed labels: same mapping needed | S | 🟡 | ❌ (A2) |
| L-004 | Email templates: any merge token surfacing raw stage values? | XS | 🟡 | ❓ |
| L-005 | Documenso payload (`buildDocumensoPayload`): any stage references? | XS | 🟠 | ❓ |
| L-006 | Public berths API: is `status` filter accepting any legacy values? | XS | 🟡 | ❓ |
| L-007 | Webhook payloads: do outbound `interest.updated` events use 7-stage or legacy? | S | 🟠 | ❓ |
| L-008 | Reports / analytics SQL: are funnel rollups using 7-stage enum exclusively? | M | 🟠 | ❓ |
| L-009 | Search FTS indexes: do they include the mapped human stage or the raw enum? | S | 🟡 | ❓ |
| L-010 | Notification copy: does "Stage moved to X" use the mapped label? | XS | 🟢 | ❓ |
| L-011 | CSV import templates / column mappers: does anyone still accept legacy stage names? | XS | 🟢 | ❓ |
| L-012 | Seed data: confirm no legacy stages in current seed (was migrated in `seed-synthetic-data.ts`) | XS | 🟢 | ✅ |
| L-013 | Migration safety: would a re-import via NocoDB re-introduce legacy values? | S | 🟠 | ❓ |
| L-014 | Status override mode: legacy `"auto"` value (see A8) — same class of bug | XS | 🟢 | ❌ (A8) |
| L-015 | Outcome enum: confirm `won` / `lost_*` are the only modern values; no legacy `completed` outcome anywhere | S | 🟡 | ❓ |
| L-016 | Lead category enum: any legacy values? | XS | 🟢 | ❓ |
| L-017 | Lead source enum: ditto | XS | 🟢 | ❓ |
| L-018 | Tenure type enum: ditto | XS | 🟢 | ❓ |
| L-019 | Document doc-status sub-states: `sent`, `signed`, `completed`, `expired`, `rejected` — are they consistently applied? | S | 🟡 | ❓ |
| L-020 | Reservation/contract status enum: any legacy / deprecated values lingering? | S | 🟡 | ❓ |
---
## 2. Routes — every page reachable and correct
| ID | Check | Effort | Severity | Coverage |
| ----- | ----------------------------------------------------------------------------------------------------------- | ------ | -------- | ------------------- |
| R-001 | All `/[portSlug]/*` routes return 200 for super-admin (sweep) | S | 🟠 | ⚠️ admin only |
| R-002 | All `/[portSlug]/*` routes return 200 or proper 403/redirect for sales-agent | S | 🟠 | ⚠️ partial |
| R-003 | All `/[portSlug]/*` routes for viewer | S | 🟡 | ❓ |
| R-004 | Cross-port URL access: paste `/port-amador/clients/<port-nimara-uuid>` → expects 404, not silent | XS | 🟠 | ✅ (F17) |
| R-005 | Archived entity detail page: 404 with "Restored?" affordance | XS | 🟡 | ❓ |
| R-006 | Soft-deleted folder URL: expects 404 / fallback to parent | XS | 🟡 | ❓ |
| R-007 | Hard-deleted berth UUID URL (e.g. A1 in port-amador): expects 404 | XS | 🟡 | ❓ |
| R-008 | URL-encoded mooring number (`A1` vs `A%201` vs `a1`): canonicalization | XS | 🟡 | ❓ |
| R-009 | Trailing slash redirects | XS | 🟢 | ❓ |
| R-010 | Query-string preservation across nav (filters, sort, page) | S | 🟡 | ❓ |
| R-011 | Browser back/forward state on detail pages (does Tab selection persist?) | S | 🟡 | ❓ |
| R-012 | Deep-link with `?folder=<id>` on documents (F25 verified for root, what about deep folder?) | XS | 🟢 | ⚠️ |
| R-013 | Deep-link to specific interest tab (`?tab=documents`) | XS | 🟢 | ❓ |
| R-014 | Deep-link with filter pre-applied (`/interests?stage=eoi`) | XS | 🟡 | ❓ |
| R-015 | typedRoutes enforcement: any string-as-route escapes via `as never` casts that point to non-existent paths? | M | 🟡 | ❓ |
| R-016 | Middleware / proxy.ts: public-path allow-list correctness (regex anchors, prefix matches) | S | 🟠 | ❓ |
| R-017 | Auth redirect: visiting `/dashboard` while logged-out → `/login?next=...` | XS | 🟠 | ❓ |
| R-018 | Post-login redirect honours `next` param | XS | 🟠 | ❓ |
| R-019 | Portal routes when `client_portal_enabled=false`: gate page (verified A19_b) | XS | 🟢 | ✅ |
| R-020 | Portal routes when `client_portal_enabled=true`: dashboard, docs, activate flows | S | 🟠 | ❓ |
| R-021 | `/setup` bootstrap flow on fresh DB (no super admin yet) | M | 🔴 | ❓ (F1 fixed proxy) |
| R-022 | Reset-password token validity + expiry | S | 🟠 | ❓ |
| R-023 | Set-password (first-time after invite) flow | S | 🟠 | ❓ |
| R-024 | Portal activate via `#token` fragment | S | 🟠 | ❓ |
| R-025 | API routes that should be HEAD-cacheable (public/berths) return correct cache headers | S | 🟢 | ❓ |
| R-026 | Public health: anonymous mode minimal payload | XS | 🟡 | ❓ |
| R-027 | Public health: secret mode full payload | XS | 🟡 | ❓ |
| R-028 | OPTIONS preflight on API routes (CORS) | XS | 🟡 | ❓ |
| R-029 | API rate-limit headers on auth endpoints | XS | 🟡 | ❓ |
| R-030 | `/api/v1/me` returns expected user shape | XS | 🟢 | ✅ |
---
## 3. UX consistency — every list, detail, form
### 3a. Empty / loading / error states
| ID | Surface | Effort | Severity | Coverage |
| ----- | ------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
| U-001 | Clients list: empty state copy + CTA | XS | 🟢 | ❓ |
| U-002 | Yachts list: empty state | XS | 🟢 | ❓ |
| U-003 | Companies list: empty state | XS | 🟢 | ❓ |
| U-004 | Interests list: empty state | XS | 🟢 | ❓ |
| U-005 | Berths list: empty state | XS | 🟢 | ❓ |
| U-006 | Reservations list: empty state | XS | 🟢 | ❓ |
| U-007 | Invoices list: empty state | XS | 🟢 | ❓ |
| U-008 | Inbox: empty state | XS | 🟢 | ❓ |
| U-009 | Documents hub root: empty state | XS | 🟢 | ❓ |
| U-010 | Documents hub folder: empty state (verified earlier) | XS | 🟢 | ✅ |
| U-011 | Audit log: empty state (filter to nothing) | XS | 🟢 | ❓ |
| U-012 | Reconcile berths: empty state (verified) | XS | 🟢 | ✅ |
| U-013 | Recommender: empty result copy (verified F28) | XS | 🟢 | ✅ |
| U-014 | All list pages: loading skeleton vs spinner — is the pattern consistent? | S | 🟢 | ❓ |
| U-015 | All detail pages: 404 fallback (DetailNotFound) — confirmed for 5 entities, check residential/reservation/invoice/expense | S | 🟡 | ⚠️ |
| U-016 | All forms: server-error toast surfaces requestId | S | 🟡 | ❓ |
| U-017 | All forms: validation summary at top vs inline messages | S | 🟡 | ❓ |
| U-018 | All forms: submit-while-pending state (button disabled + spinner) | S | 🟢 | ❓ |
| U-019 | Drag-drop file zone: hover state visible | XS | 🟢 | ❓ |
| U-020 | Drag-drop file zone: drop-target overlay on entity folder | XS | 🟢 | ❓ |
### 3b. Form design
| ID | Check | Effort | Severity | Coverage |
| ----- | --------------------------------------------------------------------- | ------ | -------- | -------- |
| U-021 | Required-field markers consistent ("\*" vs label suffix vs help text) | S | 🟢 | ❓ |
| U-022 | Field-help-text discoverability (tooltip vs always-visible) | S | 🟢 | ❓ |
| U-023 | Field-level errors: every field has visible error after blur+submit | M | 🟡 | ❓ |
| U-024 | Cancel behaviour: discards or saves draft? | S | 🟡 | ❓ |
| U-025 | Unsaved changes warning on dialog dismiss | S | 🟡 | ❓ |
| U-026 | Multi-step wizards: persist state across step nav | M | 🟡 | ❓ |
| U-027 | Phone E.164 conversion preview | S | 🟢 | ❓ |
| U-028 | Currency input: locale-aware separators | S | 🟡 | ❓ |
| U-029 | Date picker: keyboard input + calendar both work | S | 🟢 | ❓ |
| U-030 | Date range constraint enforcement (start ≤ end) | XS | 🟡 | ❓ |
| U-031 | File-type accept attribute matches server magic-byte check | XS | 🟡 | ❓ |
| U-032 | File-size limit copy matches server limit | XS | 🟢 | ❓ |
| U-033 | Combobox keyboard nav (↑↓, Enter, Esc, type-ahead) | S | 🟢 | ❓ |
| U-034 | Multi-select chip removal (X button + backspace) | S | 🟢 | ❓ |
| U-035 | Tag colour-picker: contrast check | XS | 🟢 | ❓ |
| U-036 | "Save changes" copy consistency (vs "Update" vs "Save") | S | 🟢 | ❓ |
| U-037 | Inline-edit save trigger (blur vs Enter vs explicit save) | S | 🟢 | ❓ |
| U-038 | Inline-edit cancel (Esc reverts) | XS | 🟢 | ❓ |
| U-039 | Inline-tag-editor: tab order across the chip strip | XS | 🟢 | ❓ |
### 3c. Tables / lists / filters
| ID | Check | Effort | Severity | Coverage |
| ----- | ------------------------------------------------------- | ------ | -------- | -------- |
| U-040 | Sort direction indicator on column header | XS | 🟢 | ❓ |
| U-041 | Multi-column sort (shift-click) | S | 🟢 | ❓ |
| U-042 | Filter chips dismissable via X | XS | 🟢 | ❓ |
| U-043 | "Clear all filters" button presence | XS | 🟢 | ❓ |
| U-044 | Pagination: page size selector | XS | 🟢 | ❓ |
| U-045 | Pagination: jump-to-page | XS | 🟢 | ❓ |
| U-046 | Pagination: total count accuracy with filters | XS | 🟡 | ❓ |
| U-047 | Row selection: select-all-page vs select-all-filtered | S | 🟡 | ❓ |
| U-048 | Bulk action toolbar appearance + dismiss | S | 🟢 | ❓ |
| U-049 | Sticky header on scroll | XS | 🟢 | ❓ |
| U-050 | Column resize / reorder / show-hide persistence | S | 🟢 | ❓ |
| U-051 | Virtual list performance with 1000+ rows | M | 🟡 | ❓ |
| U-052 | CSV export of current view (respects filters + columns) | S | 🟡 | ❓ |
| U-053 | Sorted-by-relevance vs sorted-by-date default | XS | 🟢 | ❓ |
### 3d. Badges, icons, colours
| ID | Check | Effort | Severity | Coverage |
| ----- | ----------------------------------------------------------------------------- | ------ | -------- | -------- |
| U-054 | Stage badge palette: 7 stages each have a distinct, consistent colour | XS | 🟢 | ❓ |
| U-055 | Outcome badge: won = green, lost\_\* = red shades, distinct enough | XS | 🟢 | ❓ |
| U-056 | Berth status pill: available/under_offer/sold colour consistency | XS | 🟢 | ✅ |
| U-057 | Document status pill: draft/sent/partial/completed/expired/cancelled/rejected | XS | 🟢 | ❓ |
| U-058 | "Manual" chip on berth list (F67 phase 2) | XS | 🟢 | ✅ |
| U-059 | Icon usage: Lucide-only — no decorative unicode glyphs (memory: avoid emoji) | S | 🟡 | ⚠️ |
| U-060 | Button hierarchy: primary/secondary/ghost/destructive used consistently | S | 🟢 | ❓ |
| U-061 | Destructive actions colour-coded red | XS | 🟡 | ❓ |
| U-062 | Loading spinner sizing consistent (size-3.5 vs size-4 vs animate-spin) | S | 🟢 | ❓ |
| U-063 | Tooltip delay + position consistency | S | 🟢 | ❓ |
| U-064 | Status pill withDot vs no dot: is the rule consistent? | XS | 🟢 | ❓ |
### 3e. Modal / sheet / drawer doctrine
| ID | Check | Effort | Severity | Coverage |
| ----- | ------------------------------------------------------------------------------ | ------ | -------- | -------- |
| U-065 | Sheet used for forms + previews on desktop AND mobile (per CLAUDE.md doctrine) | S | 🟡 | ❓ |
| U-066 | Vaul Drawer only used for mobile-bottom-sheet (only `MoreSheet` qualifies) | XS | 🟢 | ❓ |
| U-067 | AlertDialog used for destructive confirmations | XS | 🟢 | ❓ |
| U-068 | Dialog used for short interactive forms (new yacht, catch-up, won-dialog) | XS | 🟢 | ❓ |
| U-069 | Esc closes all overlays consistently | XS | 🟢 | ❓ |
| U-070 | Click-outside closes / doesn't close: rule consistent | S | 🟡 | ❓ |
| U-071 | Focus trap inside overlays | S | 🟠 | ❓ |
| U-072 | Focus restoration to trigger element on close | S | 🟡 | ❓ |
### 3f. Toasts / feedback
| ID | Check | Effort | Severity | Coverage |
| ----- | -------------------------------------------------------------------------- | ------ | -------- | -------- |
| U-073 | Toast position consistent (top-right, sonner config) | XS | 🟢 | ✅ |
| U-074 | Success toast on every mutation (create, update, archive, delete, restore) | M | 🟡 | ⚠️ |
| U-075 | Error toast includes copyable requestId | S | 🟡 | ⚠️ |
| U-076 | Toast timing (auto-dismiss vs persistent for errors) | XS | 🟢 | ❓ |
| U-077 | Multiple toasts stack vs replace | XS | 🟢 | ❓ |
### 3g. Accessibility / keyboard
| ID | Check | Effort | Severity | Coverage |
| ----- | ---------------------------------------------------------- | ------ | -------- | -------- |
| U-078 | Tab order natural on each form | M | 🟡 | ❓ |
| U-079 | All icons inside buttons have `aria-label` or sibling text | S | 🟡 | ❓ |
| U-080 | All `<img>` have alt | XS | 🟡 | ❓ |
| U-081 | Heading hierarchy (h1 → h2 → h3, no skips) | S | 🟢 | ❓ |
| U-082 | Color contrast WCAG AA (4.5:1 body, 3:1 large) | M | 🟡 | ❓ |
| U-083 | Focus rings visible on all interactive elements | S | 🟡 | ❓ |
| U-084 | Skip-to-content link | XS | 🟢 | ❓ |
| U-085 | Reduced-motion media query honoured | S | 🟢 | ❓ |
| U-086 | `aria-describedby` set on DialogContent (A6) | S | 🟡 | ❌ |
| U-087 | Live regions for async updates (toast, notification count) | S | 🟢 | ❓ |
| U-088 | Form errors announced to screen readers | S | 🟡 | ❓ |
| U-089 | Touch target min 44×44px on mobile | S | 🟡 | ❓ |
### 3h. Mobile-specific UX
| ID | Check | Effort | Severity | Coverage |
| ----- | ----------------------------------------------------------------- | ------ | -------- | -------- |
| U-090 | Bottom-tab nav reachable on every page | XS | 🟢 | ✅ |
| U-091 | Mobile topbar shows correct title via `useMobileChrome` | S | 🟢 | ⚠️ |
| U-092 | More sheet contains every nav item not on bottom bar | XS | 🟡 | ❓ |
| U-093 | Search overlay covers viewport on tap | XS | 🟢 | ❓ |
| U-094 | iOS safe-area-inset-top / bottom respected | S | 🟡 | ❓ |
| U-095 | Pull-to-refresh: present or absent? (consistency) | XS | 🟢 | ❓ |
| U-096 | Camera capture on file upload (image\* mime type triggers camera) | S | 🟢 | ❓ |
| U-097 | Soft keyboard occlusion on form input (visualViewport handling) | S | 🟡 | ❓ |
| U-098 | Long-press menu absence (not native iOS overrides) | XS | 🟢 | ❓ |
| U-099 | Sheet side="right" responsiveness | XS | 🟢 | ❓ |
| U-100 | Mobile bottom tab active-state highlight | XS | 🟢 | ❓ |
---
## 4. Sales workflows — every end-to-end path
### 4a. Happy paths
| ID | Flow | Effort | Severity | Coverage |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
| W-001 | Create client → create interest → link yacht → advance to EOI → send EOI → receive webhook → auto-advance to Reservation → record deposit → auto-advance to Deposit Paid → send contract → mark contract signed → mark won | L | 🔴 | ⚠️ |
| W-002 | Multi-berth interest: link 3 berths, mark one primary, send EOI bundle with range formatter | M | 🟠 | ❓ |
| W-003 | Company-owned yacht: company → membership → yacht owned by company → interest | M | 🟠 | ❓ |
| W-004 | Residential client + residential interest end-to-end | M | 🟡 | ❓ |
| W-005 | Public berth inquiry → admin/inquiries triage → create client via prefill | M | 🟠 | ❓ |
| W-006 | Catch-up wizard from berth list row-menu | S | 🟠 | ⚠️ |
| W-007 | Catch-up wizard from reconcile queue (verified) | S | 🟢 | ✅ |
| W-008 | Mark won → reopen → outcome cleared toast (F26) | XS | 🟢 | ⚠️ |
| W-009 | Mark lost (each lost reason) | S | 🟢 | ❓ |
| W-010 | Mark externally signed | S | 🟡 | ❓ |
### 4b. Edge cases
| ID | Flow | Effort | Severity | Coverage |
| ----- | ---------------------------------------------------------------------- | ------ | -------- | --------- |
| W-011 | Try to leave Enquiry without yacht → F23 inline prereq picker fires | XS | 🟢 | ✅ |
| W-012 | Try forbidden transition (e.g. Reservation → Enquiry) without override | XS | 🟡 | ❓ |
| W-013 | Override transition: requires reason ≥ 5 chars | XS | 🟡 | ❓ |
| W-014 | Override transition: insufficient permission → blocked tooltip | XS | 🟡 | ❓ |
| W-015 | Rewind to enquiry with linked berths → unlink-or-keep prompt | S | 🟡 | ❓ |
| W-016 | Same-stage write (F27): expects 204 | XS | 🟢 | ❌ (A19) |
| W-017 | Concurrent stage edits (two browser tabs) | M | 🟡 | ❓ |
| W-018 | Stage transition emits audit log + realtime event | S | 🟡 | ❓ |
| W-019 | Auto-advance via berth-rule on `deposit_received` | S | 🟠 | ❓ |
| W-020 | Auto-advance via Documenso webhook (`DOCUMENT_SIGNED`) | S | 🟠 | ❓ |
| W-021 | Webhook arrives twice (idempotency) | S | 🟠 | ✅ (R2-G) |
| W-022 | Webhook with v2 envelope shape | S | 🟠 | ❓ |
| W-023 | Webhook lowercase-dotted event name → forward-compat | XS | 🟢 | ❓ |
| W-024 | Webhook with wrong secret → 401 + rate limit | S | 🟠 | ❓ |
| W-025 | Berth unlink mid-EOI → rule fires? | S | 🟡 | ❓ |
| W-026 | Yacht reassignment mid-deal | S | 🟡 | ❓ |
| W-027 | Client merge (duplicate dedup) — interest carry-over | M | 🟠 | ❓ |
| W-028 | Recommender on 0ft yacht (empty dims) | XS | 🟢 | ❓ |
| W-029 | Recommender on 300ft yacht (no matching berth) | XS | 🟢 | ✅ (F28) |
| W-030 | Recommender weight tuning re-ranks | S | 🟡 | ❓ |
| W-031 | Recommender fallthrough policy (cooldown after lost) | M | 🟡 | ❓ |
| W-032 | Recommender tier ladder A/B/C/D classification | M | 🟠 | ❓ |
| W-033 | Heat scoring weights (recency, furthest stage, count, EOI count) | M | 🟡 | ❓ |
| W-034 | Reservation cancel mid-flow | S | 🟡 | ❓ |
| W-035 | EOI document expiry | S | 🟡 | ❓ |
| W-036 | Contract sent + bounced email | S | 🟡 | ❓ |
| W-037 | Reminder snooze / dismiss | S | 🟢 | ❓ |
| W-038 | Reminder digest delivery | M | 🟢 | ❓ |
| W-039 | Default-owner auto-assign on new interest | XS | 🟢 | ❓ |
| W-040 | Reassignment notification email | S | 🟢 | ❓ |
| W-041 | Cascading invites (secondary signers) | M | 🟠 | ❓ |
| W-042 | Field-level signing verification | M | 🟡 | ❓ |
| W-043 | Voice-note attach on activity | S | 🟢 | ❓ |
| W-044 | Quick-template log entry | S | 🟢 | ❓ |
| W-045 | Note add / edit / delete (polymorphic across entities) | S | 🟢 | ❓ |
| W-046 | Tag add via inline-tag-editor (verified F16 inline create flow) | XS | 🟢 | ⚠️ |
| W-047 | Tag delete cascade (remove tag from all entities) | S | 🟡 | ❓ |
| W-048 | Bulk archive (clients) | S | 🟡 | ❓ |
| W-049 | Bulk archive (interests) | S | 🟡 | ❓ |
| W-050 | Restore archived (any entity) | S | 🟡 | ❓ |
| W-051 | Hard-delete request (GDPR Article 17) | M | 🟠 | ❓ |
| W-052 | GDPR export download | M | 🟠 | ✅ (R2-O) |
---
## 5. Admin workflows
| ID | Flow | Effort | Severity | Coverage |
| ------ | ---------------------------------------------------------------------------- | ------ | -------- | --------------- |
| AD-001 | Role create + permission edit | S | 🟠 | ❓ |
| AD-002 | Per-port role override | S | 🟠 | ❓ |
| AD-003 | User invite send + email delivered | M | 🟠 | ❓ |
| AD-004 | Invite accept + activate (token in #fragment) | S | 🟠 | ❓ |
| AD-005 | Invitation revoke / resend | XS | 🟡 | ❓ |
| AD-006 | User edit (display name, residential access toggle) | XS | 🟢 | ❓ |
| AD-007 | User deactivate | S | 🟠 | ❓ |
| AD-008 | System settings key update | XS | 🟡 | ❓ |
| AD-009 | Branding logo upload + render in email templates | S | 🟢 | ❓ |
| AD-010 | Branding primary colour propagation | S | 🟢 | ❓ |
| AD-011 | Document template create with merge tokens | S | 🟠 | ❓ |
| AD-012 | Template merge field validation (unknown token rejected) | XS | 🟢 | ❓ |
| AD-013 | Email template subject preview / override | S | 🟢 | ❓ |
| AD-014 | Tag create + colour pick + delete | XS | 🟢 | ✅ |
| AD-015 | Vocabulary list edit (interest temperatures, etc) | S | 🟢 | ❓ |
| AD-016 | Custom field add (text, number, select, date) | S | 🟡 | ❓ |
| AD-017 | Custom field retrofit on existing rows | S | 🟡 | ❓ |
| AD-018 | Webhook create + secret rotate | S | 🟠 | ❓ |
| AD-019 | Webhook delivery log + retry | S | 🟡 | ❓ |
| AD-020 | Brochure upload + magic-byte check | S | 🟡 | ❓ |
| AD-021 | Brochure default toggle (partial unique index) | S | 🟡 | ❓ |
| AD-022 | Brochure archive | XS | 🟢 | ❓ |
| AD-023 | Per-berth PDF upload + parse | M | 🟠 | ❓ |
| AD-024 | Per-berth PDF version rollback | S | 🟡 | ❓ |
| AD-025 | OCR parse confidence threshold + AI parse fallback | M | 🟡 | ❓ |
| AD-026 | NocoDB import: --apply, --force, --update-snapshot | M | 🟠 | ❓ |
| AD-027 | NocoDB import idempotency (re-run after no changes) | S | 🟡 | ❓ |
| AD-028 | NocoDB import vs human-edited row skip (updated_at > last_imported_at) | S | 🟡 | ❓ |
| AD-029 | Bulk berth add wizard end-to-end | S | 🟠 | ⚠️ (loads only) |
| AD-030 | CSV import (clients) — column mapper | M | 🟠 | ❓ |
| AD-031 | CSV import (yachts) | M | 🟡 | ❓ |
| AD-032 | CSV import error report (rejected rows) | S | 🟡 | ❓ |
| AD-033 | Duplicates queue review + merge | M | 🟠 | ❓ |
| AD-034 | Duplicates queue: false-positive dismiss | XS | 🟢 | ❓ |
| AD-035 | Audit log search/FTS — text query | S | 🟡 | ❓ |
| AD-036 | Audit log filter by action / entity / user / date range | S | 🟡 | ❓ |
| AD-037 | Audit log diff display (old vs new) | S | 🟡 | ❓ |
| AD-038 | Audit log mask of sensitive fields (passwords, tokens) | S | 🟠 | ❓ |
| AD-039 | Backup status read | XS | 🟢 | ❓ |
| AD-040 | Storage backend swap dry-run (filesystem ↔ s3) | M | 🟠 | ❓ |
| AD-041 | Multi-node deployment refuses filesystem backend | XS | 🟠 | ❓ |
| AD-042 | Documenso health check Test button (v1 + v2) | S | 🟠 | ❓ |
| AD-043 | Documenso API version toggle per-port | S | 🟠 | ❓ |
| AD-044 | Documenso signing-order setting (parallel/sequential) | S | 🟡 | ❓ |
| AD-045 | Documenso redirect URL setting | XS | 🟢 | ❓ |
| AD-046 | AI provider credentials test | S | 🟡 | ❓ |
| AD-047 | Receipt OCR config + retry on bad parse | M | 🟡 | ❓ |
| AD-048 | Send-from account config + encrypted secret roundtrip | M | 🟠 | ❓ |
| AD-049 | Bounce monitoring (IMAP probe + dev-imap-probe script) | M | 🟡 | ❓ |
| AD-050 | Reminders default behaviour + digest window edit | S | 🟢 | ❓ |
| AD-051 | Residential pipeline stages edit + reassignment on stage removal | M | 🟡 | ❓ |
| AD-052 | Qualification criteria reorder (DnD) | S | 🟢 | ❓ |
| AD-053 | Berth rules engine config (7 triggers, 3 modes) | M | 🟠 | ❓ |
| AD-054 | Recommender weights tune | S | 🟡 | ❓ |
| AD-055 | Onboarding checklist progression | S | 🟢 | ❓ |
| AD-056 | Reports: pipeline funnel, occupancy timeline, revenue breakdown, lead source | S | 🟡 | ❓ |
| AD-057 | Forms: form template create + public submission roundtrip | M | 🟠 | ❓ |
| AD-058 | Inquiry inbox triage → convert to client | M | 🟠 | ❓ |
| AD-059 | Website analytics (Umami) config | S | 🟢 | ❓ |
| AD-060 | Queue monitoring dashboard (BullMQ stats) | XS | 🟢 | ❓ |
---
## 6. Multi-tenancy (port isolation)
| ID | Check | Effort | Severity | Coverage |
| ----- | -------------------------------------------------------------------- | ------ | -------- | --------- |
| MT-01 | GET /api/v1/clients/<other-port-uuid> with X-Port-Id=this-port → 404 | XS | 🟠 | ✅ (R2-N) |
| MT-02 | PATCH /api/v1/interests/<other-port-uuid> → 404 | XS | 🟠 | ❓ |
| MT-03 | Berth recommender cross-port leak guard (entry + SQL CTE) | S | 🔴 | ✅ |
| MT-04 | Document folder defense-in-depth port_id filter on every join | S | 🟠 | ❓ |
| MT-05 | Audit log scope per port | XS | 🟠 | ❓ |
| MT-06 | Webhook subscriptions scoped to port | XS | 🟠 | ❓ |
| MT-07 | System settings per-port | XS | 🟡 | ❓ |
| MT-08 | Tags scoped to port | XS | 🟡 | ❓ |
| MT-09 | Custom fields scoped to port | XS | 🟡 | ❓ |
| MT-10 | Vocabularies scoped to port | XS | 🟡 | ❓ |
| MT-11 | Seed runs idempotent across ports | S | 🟡 | ❓ |
---
## 7. Security
| ID | Check | Effort | Severity | Coverage |
| ---- | ---------------------------------------------------------- | ------ | -------- | --------- |
| S-01 | XSS via client.fullName render (verified ✓) | XS | 🟠 | ✅ |
| S-02 | XSS via tag.name | XS | 🟠 | ❓ |
| S-03 | XSS via note.content (markdown) | S | 🟠 | ❓ |
| S-04 | XSS via email body markdown (verified) | S | 🟠 | ✅ (R2-I) |
| S-05 | SQL injection via search query | S | 🔴 | ❓ |
| S-06 | Path traversal in folder name | S | 🟠 | ❓ |
| S-07 | Path traversal in file name | XS | 🟠 | ❓ |
| S-08 | SSRF via attachment URL or webhook target | S | 🟠 | ❓ |
| S-09 | Open redirect on `next` param | XS | 🟠 | ❓ |
| S-10 | CSRF on state-changing requests (proxy.ts checks) | S | 🟠 | ❓ |
| S-11 | Cookie flags: HttpOnly, Secure, SameSite | XS | 🟠 | ❓ |
| S-12 | CSP headers (production) | S | 🟡 | ❓ |
| S-13 | CORS allow-list narrow | XS | 🟡 | ❓ |
| S-14 | Rate limit on login (verified F7) | XS | 🟠 | ✅ |
| S-15 | Rate limit on forget-password | XS | 🟠 | ✅ |
| S-16 | Rate limit on file upload | S | 🟡 | ❓ |
| S-17 | Session fixation (regen sid on login) | S | 🟠 | ❓ |
| S-18 | Token expiry / refresh (better-auth) | S | 🟠 | ❓ |
| S-19 | Audit log tamper-resistance (append-only) | S | 🟡 | ❓ |
| S-20 | Documenso webhook secret rotation (verified) | S | 🟠 | ✅ |
| S-21 | SMTP credential at-rest encryption (AES-256-GCM) | S | 🟠 | ❓ |
| S-22 | IMAP credential at-rest encryption | S | 🟠 | ❓ |
| S-23 | Storage credential at-rest encryption | S | 🟠 | ❓ |
| S-24 | Privilege escalation: viewer → agent → admin paths | M | 🔴 | ❓ |
| S-25 | Direct ID enumeration (UUID guess immune) | XS | 🟢 | ✅ (R2) |
| S-26 | Audit log read-back of own permission denials | S | 🟢 | ❓ |
| S-27 | Magic-byte verification on every uploaded file (verified) | S | 🟠 | ✅ |
| S-28 | Filename HTML-escape in download links | XS | 🟡 | ❓ |
| S-29 | Bounce-monitor email subject parsing (injection) | S | 🟡 | ❓ |
| S-30 | Email body redirect mode never escapes in prod (env guard) | XS | 🟠 | ❓ |
---
## 8. Realtime / sockets
| ID | Check | Effort | Severity | Coverage |
| ----- | -------------------------------------------------------------- | ------ | -------- | -------- |
| RT-01 | Socket.IO server actually running in dev (A5) | S | 🟡 | ❌ |
| RT-02 | Realtime invalidation: interest:updated fires from another tab | S | 🟡 | ❓ |
| RT-03 | document:completed event invalidates files | S | 🟡 | ❓ |
| RT-04 | folder:created event invalidates document-folders | S | 🟡 | ❓ |
| RT-05 | berth:statusChanged event invalidates berths | S | 🟡 | ❓ |
| RT-06 | Subscription teardown on unmount (no leaks) | S | 🟡 | ❓ |
| RT-07 | Cross-tab broadcast (BroadcastChannel?) | M | 🟢 | ❓ |
| RT-08 | Reconnect after server restart | S | 🟡 | ❓ |
| RT-09 | Room-level scoping (port:X room) | XS | 🟠 | ❓ |
---
## 9. Performance
| ID | Check | Effort | Severity | Coverage |
| ---- | ------------------------------------------------------------------------ | ------ | -------- | --------------------------- |
| P-01 | Web vitals report endpoint accepts beacons (verified — A2 is dev cancel) | XS | 🟢 | ✅ |
| P-02 | LCP under 2.5s on dashboard | S | 🟡 | ❓ |
| P-03 | CLS under 0.1 | S | 🟢 | ❓ |
| P-04 | TTI under 3s | S | 🟡 | ❓ |
| P-05 | N+1 detection on interests list (tags / berths / yacht joins) | M | 🟡 | ❓ |
| P-06 | DataTable virtual rendering for 1000+ rows | M | 🟡 | ⚠️ (audit-log uses virtual) |
| P-07 | Image lazy-load on documents list | XS | 🟢 | ❓ |
| P-08 | Bundle size growth budget | S | 🟢 | ❓ |
| P-09 | Slow-query log review | M | 🟡 | ❓ |
| P-10 | DB connection pool exhaustion behaviour (verified F8 fix landed) | S | 🟠 | ✅ |
| P-11 | Memory leak after long session (open same form 50 times) | M | 🟡 | ❓ |
| P-12 | Worker queue throughput under load | M | 🟡 | ❓ |
| P-13 | Search FTS query plan (uses GIN index?) | S | 🟡 | ❓ |
| P-14 | API response size budget (paginated list ≤ 256 KB) | XS | 🟢 | ❓ |
---
## 10. Documents / files
| ID | Check | Effort | Severity | Coverage |
| ---- | ----------------------------------------------------------------------------- | ------ | -------- | -------- |
| D-01 | Upload via drag-drop on hub root (A16 — broken) | XS | 🟠 | ❌ |
| D-02 | Upload via drag-drop on entity folder | S | 🟠 | ❓ |
| D-03 | Upload via file picker on dialog | XS | 🟠 | ❌ (A16) |
| D-04 | PDF preview inline | S | 🟢 | ❓ |
| D-05 | Image preview inline (jpg, png, webp, gif) | S | 🟢 | ❓ |
| D-06 | Word / Excel: download fallback | XS | 🟢 | ❓ |
| D-07 | Signed PDF download from completed workflow | S | 🟠 | ❓ |
| D-08 | Folder soft-rescue on delete (children re-parent) | S | 🟠 | ❓ |
| D-09 | Folder rename → entity name sync | S | 🟡 | ❓ |
| D-10 | Folder move cycle prevention | S | 🟡 | ❓ |
| D-11 | Folder permission: system folders immutable through API | S | 🟠 | ❓ |
| D-12 | Aggregated entity view (Clients/Companies/Yachts subfolders) | S | 🟡 | ❓ |
| D-13 | Hub root view: 3 cards (in-progress, files, completed) | S | 🟢 | ❓ |
| D-14 | EntityFolderView: signing-in-progress + files | S | 🟢 | ❓ |
| D-15 | "View signing details" link on signed file row | XS | 🟢 | ❓ |
| D-16 | Auto-deposit on signing completion (resolves owner via Owner-wins chain) | M | 🟠 | ❓ |
| D-17 | listFilesAggregatedByEntity walks Client↔Company↔Yacht reach symmetrically | M | 🟠 | ❓ |
| D-18 | Folder URL state with `?folder=<uuid>` (F25 deep folder) | XS | 🟢 | ⚠️ |
| D-19 | Concurrent ensureEntityFolder race-safety (partial unique index) | M | 🟡 | ❓ |
| D-20 | Magic-byte verification on presign + post-upload paths | S | 🟠 | ✅ |
| D-21 | Filename HTML-escape in fallback download link | XS | 🟡 | ❓ |
| D-22 | File size > email_attach_threshold_mb → signed-URL link instead of attachment | M | 🟡 | ❓ |
---
## 11. Audit log
| ID | Check | Effort | Severity | Coverage |
| ----- | --------------------------------------------------------------------------------- | ------ | -------- | -------- |
| AU-01 | Every mutation creates an audit row (sample 10 endpoints) | M | 🟠 | ⚠️ |
| AU-02 | Sensitive-field mask works (test: password rotation row) | S | 🟠 | ❓ |
| AU-03 | FTS query returns expected results | S | 🟡 | ❓ |
| AU-04 | Filter by action: only stage_change shows | XS | 🟢 | ❓ |
| AU-05 | Filter by entity type: only berth/interest/etc shows | XS | 🟢 | ❓ |
| AU-06 | Filter by user | XS | 🟢 | ❓ |
| AU-07 | Filter by date range | XS | 🟢 | ❓ |
| AU-08 | Diff display correctly highlights old vs new | S | 🟡 | ❓ |
| AU-09 | "Reconcile" event tag visible in metadata | XS | 🟢 | ✅ |
| AU-10 | Cascade events grouped or distinct? (e.g. archive client + auto-archive interest) | S | 🟡 | ❓ |
| AU-11 | Permission-denied entries render readable (A1) | XS | 🟡 | ❌ |
| AU-12 | Audit log export to CSV | S | 🟢 | ❓ |
| AU-13 | Outcome-change action tag distinct from generic 'update' (R2-B finding) | S | 🟡 | ❓ |
| AU-14 | Tier-mapping (audit_logs.audit_tier_map) — high-tier vs noise tier | S | 🟡 | ❓ |
---
## 12. Email / SMTP / IMAP
| ID | Check | Effort | Severity | Coverage |
| ----- | ---------------------------------------------------------------- | ------ | -------- | -------- |
| EM-01 | Per-port SMTP override picks up | S | 🟠 | ❓ |
| EM-02 | Default sales send-from (`sales@portnimara.com`) | XS | 🟢 | ❓ |
| EM-03 | Default noreply send-from (`noreply@portnimara.com`) | XS | 🟢 | ❓ |
| EM-04 | EMAIL_REDIRECT_TO in dev: subject prefix `[redirected from ...]` | XS | 🟡 | ❓ |
| EM-05 | Branded template render (logo, blurred bg, max-w-600) | S | 🟢 | ❓ |
| EM-06 | Reply-to override | XS | 🟡 | ❓ |
| EM-07 | CC/BCC handling | S | 🟡 | ❓ |
| EM-08 | Send rate limit 50/user/hour | XS | 🟡 | ❓ |
| EM-09 | Send size > threshold falls back to signed link | M | 🟡 | ❓ |
| EM-10 | IMAP bounce probe (`dev-imap-probe.ts`) | M | 🟢 | ❓ |
| EM-11 | Bounce subject parse + interest linking | M | 🟡 | ❓ |
| EM-12 | Document_sends audit row per send | S | 🟡 | ❓ |
| EM-13 | Portal activation email arrives & token works | M | 🟠 | ❓ |
| EM-14 | Reset-password email | S | 🟠 | ❓ |
| EM-15 | Invite email | M | 🟠 | ❓ |
| EM-16 | Reminder digest email | M | 🟢 | ❓ |
| EM-17 | EOI generated PDF attached or inline? | S | 🟡 | ❓ |
| EM-18 | Outbound email markdown body XSS (verified) | S | 🟠 | ✅ |
| EM-19 | Subject override CSP/XSS | S | 🟠 | ✅ |
---
## 13. Integrations
| ID | Check | Effort | Severity | Coverage |
| ----- | --------------------------------------------------------------------- | ------ | -------- | -------- |
| IN-01 | Documenso send EOI via v1 template-generate | M | 🟠 | ❓ |
| IN-02 | Documenso v2 envelope/create multipart | M | 🟠 | ❓ |
| IN-03 | Documenso distribute (v2) | S | 🟠 | ❓ |
| IN-04 | Documenso redistribute / send reminder | S | 🟡 | ❓ |
| IN-05 | Documenso downloadSignedPdf | S | 🟠 | ❓ |
| IN-06 | Documenso voidDocument | S | 🟡 | ❓ |
| IN-07 | Documenso placeFields (v2 field/create-many) | M | 🟡 | ❓ |
| IN-08 | Documenso normalizeDocument id ↔ documentId | XS | 🟡 | ❓ |
| IN-09 | NocoDB import idempotency | S | 🟡 | ❓ |
| IN-10 | S3 / MinIO upload + download | S | 🟠 | ❓ |
| IN-11 | S3 presigned URL expiry | XS | 🟡 | ❓ |
| IN-12 | Filesystem backend: MULTI_NODE_DEPLOYMENT guard | XS | 🟠 | ❓ |
| IN-13 | BullMQ job retry on failure | S | 🟡 | ❓ |
| IN-14 | BullMQ Redis `noeviction` policy (verified) | XS | 🟠 | ✅ |
| IN-15 | Worker process boot + queue subscribe | S | 🟠 | ❓ |
| IN-16 | Public berths API: anon cache headers | XS | 🟢 | ❓ |
| IN-17 | Public berths API: status filter (`Under Offer`, `Sold`, `Available`) | S | 🟡 | ❓ |
| IN-18 | Public berths single endpoint via mooringNumber (canonical format) | S | 🟡 | ❓ |
| IN-19 | Public health anonymous mode (verified A26) | XS | 🟡 | ✅ |
| IN-20 | Public health secret mode (verified A26) | XS | 🟡 | ✅ |
| IN-21 | OpenAI / AI parser credentials test | S | 🟡 | ❓ |
| IN-22 | Tesseract OCR positional heuristics on per-berth PDF | M | 🟡 | ❓ |
| IN-23 | Receipt OCR: full receipt parse end-to-end | M | 🟡 | ❓ |
| IN-24 | Pdfme PDF generation (any per-port template) | M | 🟡 | ❓ |
| IN-25 | PDF-lib AcroForm fill (in-app EOI pathway) | M | 🟠 | ❓ |
| IN-26 | EOI merge token expansion (`{{eoi.berthRange}}` etc) | S | 🟠 | ❓ |
| IN-27 | Berth-range formatter (single + multi-berth) | S | 🟡 | ❓ |
| IN-28 | Portal magic-link consume | S | 🟠 | ❓ |
| IN-29 | Umami analytics widget render | XS | 🟢 | ❓ |
---
## 14. Schema / migration
| ID | Check | Effort | Severity | Coverage |
| ----- | ------------------------------------------------------------------------------- | ------ | -------- | -------- |
| SC-01 | All migrations idempotent (re-run safe) | M | 🟠 | ❓ |
| SC-02 | All FKs have ON DELETE behaviour spec'd (CASCADE, SET NULL, RESTRICT) | S | 🟠 | ❓ |
| SC-03 | All soft-delete columns indexed (`archivedAt IS NULL`) | S | 🟡 | ❓ |
| SC-04 | All search columns have GIN/FTS indexes | S | 🟡 | ❓ |
| SC-05 | Composite unique constraints (sibling folder name, default brochure) | S | 🟡 | ❓ |
| SC-06 | Partial unique constraints (entity-folder, isPrimary) | S | 🟡 | ❓ |
| SC-07 | CHECK constraints (chk_system_folder_shape) | XS | 🟢 | ❓ |
| SC-08 | Generated column accuracy (FTS search_text) | S | 🟡 | ❓ |
| SC-09 | Column nullability matches Drizzle schema | M | 🟡 | ❓ |
| SC-10 | Schema migration restart-after-push (CLAUDE.md gotcha) | XS | 🟠 | ❓ |
| SC-11 | Backfill scripts idempotent (`backfill-document-folders.ts`) | S | 🟡 | ❓ |
| SC-12 | Legacy enum migration drift (every place that compared against an old value) | M | 🟠 | ❓ |
| SC-13 | Currency code enum | XS | 🟡 | ❓ |
| SC-14 | Address-component enum | XS | 🟢 | ❓ |
| SC-15 | Polymorphic owner: every read-site uses the service helper, not raw column read | M | 🟠 | ❓ |
---
## 15. i18n / l10n
| ID | Check | Effort | Severity | Coverage |
| ---- | ---------------------------------------------- | ------ | -------- | -------- |
| L-01 | Currency formatting per locale | S | 🟢 | ❓ |
| L-02 | Date formatting per timezone | S | 🟢 | ❓ |
| L-03 | Number formatting (1,000.5 vs 1.000,5) | S | 🟢 | ❓ |
| L-04 | Plural forms | S | 🟢 | ❓ |
| L-05 | RTL support (test with Arabic UA) | S | 🟢 | ❓ |
| L-06 | Translation completeness (Phase C status) | M | 🟢 | ❓ |
| L-07 | next-intl messages.json coverage | S | 🟢 | ❓ |
| L-08 | Server-rendered locale match (Accept-Language) | S | 🟢 | ❓ |
---
## 16. Browser / device
| ID | Check | Effort | Severity | Coverage |
| ----- | --------------------------------------------------- | ------ | -------- | -------- |
| BR-01 | Safari (macOS) primary flows | M | 🟡 | ❓ |
| BR-02 | Safari (iOS) primary flows | M | 🟡 | ❓ |
| BR-03 | Firefox (latest) | M | 🟢 | ❓ |
| BR-04 | Edge (latest) | M | 🟢 | ❓ |
| BR-05 | Chrome (latest) — primary | S | 🟢 | ✅ |
| BR-06 | iPad (Safari) — tier "click" via computer-use rules | M | 🟢 | ❓ |
| BR-07 | Print stylesheet (interest detail, invoice) | S | 🟢 | ❓ |
---
## 17. Specific behavioral correctness checks
| ID | Check | Effort | Severity | Coverage |
| ---- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ----------- |
| B-01 | Berth A1 hard-deleted earlier; confirm no 404 anywhere (interests' linked-berth, public feed, recommender) | M | 🟠 | ❓ |
| B-02 | Sara Laurent interest in stage=contract WITHOUT yachtId → render correctness | XS | 🟡 | ❓ |
| B-03 | Outcome-set interests filtered from active queries via `activeInterestsWhere` | S | 🟠 | ❓ |
| B-04 | EOI bundle range formatter: `A1-A3, B5` for non-contiguous berths | S | 🟡 | ❓ |
| B-05 | EOI single-berth case formats to just mooring (`A1`) | XS | 🟢 | ❓ |
| B-06 | Activity timeline 7-day window inclusive of today | XS | 🟢 | ✅ (F2 fix) |
| B-07 | Heat-scoring tier B only fires for lost/cancelled-only history | M | 🟡 | ❓ |
| B-08 | Permission-denied audit row sequencing (does denied API call still log?) | S | 🟡 | ❓ |
| B-09 | Same-stage no-op DOES NOT emit audit/socket event (F27) | S | 🟢 | ⚠️ |
| B-10 | Documenso webhook with empty body / malformed payload | S | 🟠 | ❓ |
| B-11 | Berth status_override_mode transitions through automated → manual → null | M | 🟡 | ❓ |
| B-12 | Reconcile clear stamps reason correctly with interest id (verified) | XS | 🟢 | ✅ |
| B-13 | Catch-up wizard "contract" stage auto-sets `outcome=won` | S | 🟡 | ❓ |
| B-14 | Catch-up wizard surfaces in API audit log as `reconcile_manual` type | XS | 🟢 | ✅ |
| B-15 | Mobile shell when initialFormFactor is wrong (Playwright UA = desktop, viewport = mobile) — shell ends up correct after mount | XS | 🟢 | ✅ |
| B-16 | Resizing across breakpoint mid-form-edit: state preservation? | S | 🟡 | ❓ |
| B-17 | Berths bulk-add wizard: step transitions persist input | M | 🟡 | ❓ |
| B-18 | NotesList polymorphic across all 4 entity types (clients, interests, yachts, companies) | S | 🟡 | ❓ |
| B-19 | InlineEditableField on every detail page works | M | 🟡 | ❓ |
| B-20 | InlineTagEditor: focus management (F45 verified) | S | 🟢 | ⚠️ |
| B-21 | OwnerPicker: client+company tabs render correctly (F44 verified) | XS | 🟢 | ✅ |
| B-22 | Mark externally signed sets `documentId=null`, `signedAt=now` | S | 🟡 | ❓ |
---
## 18. Data-clean-up jobs
| ID | Check | Effort | Severity | Coverage |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
| DC-01 | Orphan-blob cleanup on document delete | S | 🟠 | ❓ |
| DC-02 | Soft-deleted entities older than X days hard-purged | M | 🟡 | ❓ |
| DC-03 | Test entities in DB (per prior audit notes): `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `François 🏄 المعتمد`, `CSRF Test`, etc — `db:reseed:synthetic`? | S | 🟢 | ❓ |
| DC-04 | Berth A1 hard-deletion in port-amador: was that recovered? | S | 🟡 | ❓ |
| DC-05 | Legacy `statusOverrideMode = "auto"` normalize migration | XS | 🟢 | ❌ (A8) |
---
## 19. CI / dev experience
| ID | Check | Effort | Severity | Coverage |
| ----- | ---------------------------------------------------------------- | ------ | -------- | -------- |
| CI-01 | Husky lint-staged blocks bad commits | XS | 🟢 | ✅ |
| CI-02 | `pnpm exec tsc --noEmit` clean | XS | 🟢 | ✅ |
| CI-03 | `pnpm lint` zero errors | XS | 🟢 | ✅ |
| CI-04 | `pnpm exec vitest run` 1373/1373 pass | S | 🟢 | ✅ |
| CI-05 | `pnpm exec playwright test --project=smoke` ~10min | M | 🟢 | ❓ |
| CI-06 | `pnpm exec playwright test --project=destructive` | M | 🟢 | ❓ |
| CI-07 | `pnpm exec playwright test --project=realapi` (Documenso + IMAP) | M | 🟢 | ❓ |
| CI-08 | `pnpm exec playwright test --project=visual` baselines current | S | 🟢 | ❓ |
| CI-09 | Gitea CI lint + build-and-push workflows | S | 🟢 | ❓ |
| CI-10 | Docker prod build succeeds | M | 🟠 | ❓ |
| CI-11 | docker-compose dev startup with all services | S | 🟢 | ❓ |
| CI-12 | Pre-commit hook also blocks `.env*` files | XS | 🟢 | ❓ |
| CI-13 | `SKIP_ENV_VALIDATION=1` actually bypasses in Docker build | XS | 🟢 | ❓ |
---
## Recommendation: priority short-list
If we want maximum coverage with limited time, I'd pick:
### Tier 0 — fix what's already known (from A1-A20)
- A4 (client form silent-fail)
- A16 (file upload null vs string)
- A17 (/admin/ports bootstrap)
- A19 (F27 204 implementation)
- A9 (catch-up wizard stage default)
- A1/A2 (activity feed labels)
### Tier 1 — discover new
- **L-001** through **L-020** — legacy stage enum hunt (the user's specific concern)
- **W-001** — full end-to-end happy-path workflow (one full deal)
- **U-001** through **U-013** — every empty state surface
- **MT-01-11** — multi-tenancy cross-port checks (full sweep)
- **AU-01-14** — audit log surface (search, filters, mask, FTS)
- **U-021-039** — form design sweep across major forms
### Tier 2 — fill in coverage
- **R-001-030** — route correctness
- **AD-\* (admin pages)** — at least one mutation per admin section to confirm wiring
- **D-01-22** — documents/files end-to-end
### Tier 3 — depth checks
- **S-\* (security)** — penetration sweep
- **P-\* (performance)** — load + LCP + N+1
- **W-011-052** — every edge-case workflow
---
**Total surfaces catalogued:** 320+ discrete checks across 19 areas.
Pick what you want and I'll run it.

View File

@@ -1,716 +0,0 @@
# Audit Follow-ups — 2026-05-08 visual audit
This is the single index for everything from the 2026-05-08 mobile visual
audit. Owns: status of each item, file pointers, every open question,
and a ready-to-paste prompt for resuming in a fresh session.
Items are grouped by **wave** (the original triage buckets, kept stable
across sessions). Numbering inside each wave matches the original audit
message order where possible.
> **If you only have time for one section, read § "Resuming in a fresh
> session" at the bottom.**
---
## Quick status snapshot — 2026-05-09 (post-execution)
| Wave | Topic | Status |
| --------- | ------------------------------------------ | ----------------------------------------------------------------------- |
| 1 | Small confident fixes | ✅ Done |
| 2 | Country dropdown unification + cmdk scroll | ✅ Done (country/nationality split still deferred — see Wave 11.E) |
| 3 | Berth field overhaul (NocoDB enums) | ✅ Done |
| 4 | Currency platform-wide | ✅ Done |
| 5 | Configurable enums (admin Vocabularies) | ✅ Admin page + read endpoint shipped; consumer wiring is owed |
| 6 | Notes unification (aggregate-on-read) | ✅ Done — yacht / company / residential aggregators + UI |
| 7 | Clients / yachts / companies misc | ✅ Status-link flow done; client form expansion still large (Wave 11.A) |
| 8 | Expenses revisit | ✅ Done — trip-label combobox (free text + past suggestions) |
| 9 | Interests + notifications | ✅ Done |
| 10 | Settings polish | ✅ Done — first/last name + collapse notif prefs |
| 11.A | Manual client form expansion | 🔴 Not started (large) |
| 11.B | Documents folders (unlimited nesting) | 🔴 Not started — needs deep design (sidebar tree + breadcrumb) |
| 11.C | Reports system + templates | 🔴 Not started |
| 11.D | Receipts inline in expense PDF | 🔴 Not started |
| 11.E | Country / Nationality split on Client form | 🔴 Not started |
| 11.F | Inquiry triage | 🔴 Deferred |
| 11.G | Per-port email branding admin UI | 🔴 Deferred |
| **Bonus** | **Public berth feed (website map)** | ✅ Parity fields shipped; cutover deferred (see runbook) |
| **Bonus** | **Website cutover runbook** | ✅ Doc shipped (`docs/website-cutover-runbook.md`); execution deferred |
| **Bonus** | **Berth Documents tab → Spec + Deal** | ✅ Done |
Test status: `pnpm exec vitest run`**1187/1187 pass**.
TS check: `pnpm exec tsc --noEmit`**clean**.
Git: 9 commits this session (Waves 4-10 + admin Vocabularies + status-change link + Berth Documents tab split + decisions log).
---
## Ground rules / invariants we picked up
- **Notes unification model**: aggregate-on-read (option 1 from the
AskUserQuestion, picked by user). One canonical service per entity
unions own-notes + related-entity notes; no replication, no schema
migration.
- **NocoDB MCP**: connected at `~/.claude.json` under
`mcpServers."NocoDB Base - Port Nimara"`. Verified Berths schema +
records pull cleanly. The seed-data JSON snapshot
(`src/lib/db/seed-data/berths.json`) is also a reasonable fallback
if the MCP is unavailable.
- **Berth dropdown values** are now sourced from the NocoDB SingleSelect
choices verbatim — see `src/lib/constants.ts` (look for
`BERTH_*_OPTIONS` / `_TYPES`). Power Capacity and Voltage stay numeric
inputs because NocoDB stores them as `Number`. Bow Facing is
`SingleLineText` in NocoDB but constrained to the 4 cardinal values
in the CRM dropdown for UX.
- **Dual-unit fields** auto-cross-fill via `linkedUnit` on
`EditableSpec` in `src/components/berths/berth-tabs.tsx`. The user
edits the imperial value; the metric column is computed × 0.3048 and
patched in the same request.
- **Receipts in expense PDF**: user's clarified preference is "PDF
images should show inline with the relevant expense" — i.e. images
inline; PDF receipts also rendered inline (one page each, via
pdfme + `pdf-lib.copyPages`).
- **Configurable enums**: the existing pattern is `system_settings`
with composite PK `(key, port_id)` and `<SettingsManager>` admin
page. Use the same pattern for the new vocabularies.
- **Turbopack dev**: `pnpm dev` runs `next dev --turbopack`. Cold
compiles ~1s boot, ~3s per route. No webpack hooks in
`next.config.ts` so flipping back is one line if needed.
---
## ✅ Completed this session
### Wave 1 — small confident fixes
1. **Berth list ordering bug**`\d+$` regex in the Drizzle SQL
template was being eaten by JS string literal escape rules
(`\d``d`). Fixed by switching to `[0-9]+$` POSIX class.
File: `src/lib/services/berths.service.ts:69-72`.
2. **Dashboard KPI grid removed** — "Total Clients / Active Interests
/ Pipeline Value / Occupancy Rate" deleted. The four chart widgets
below (pipeline funnel, occupancy timeline, revenue breakdown,
lead source) and the activity feed remain.
File: `src/components/dashboard/dashboard-shell.tsx`.
3. **Per-dock color stripe on mobile berth cards** — was the _status_
color, which made every same-dock berth different. Now uses
`mooringLetterDot()` so the stripe groups by dock letter; status
conveyed by the existing pill below.
File: `src/components/berths/berth-card.tsx`.
4. **`{Letter} Dock` chip** on the berth detail header replaces the
bare "A" / "B" text. Colored by `mooringLetterDot()`.
File: `src/components/berths/berth-detail-header.tsx`.
5. **cmdk wheel-scroll bug** — Radix Popover swallowed wheel events on
the country dropdown for macOS users. Added `onWheel` translator on
`CommandList` + `overscroll-contain`. Lights up country pickers in
Companies, Residential Clients, Clients, Yachts.
File: `src/components/ui/command.tsx`.
6. **Mobile "Columns" button hidden**`ColumnPicker` is now
`hidden sm:inline-flex`. Mobile renders cards (no columns to
toggle).
File: `src/components/shared/column-picker.tsx`.
7. **Mobile kanban toggle hidden + auto-fallback** — Interest list
hides the table-vs-kanban toggle on small viewports and snaps
`viewMode` back to `'table'` if the user's persisted choice was
`'board'`.
File: `src/components/interests/interest-list.tsx`.
8. **Inbox entry removed from mobile More-sheet** — email/IMAP feature
is deferred (`sidebar.tsx` calls this out); the More-sheet entry was
a dead link.
9. **Website Analytics conditional** — desktop sidebar Insights section
AND mobile MoreSheet hide the Website Analytics nav when Umami
isn't configured for the port. Reuses `useUmamiActive()`.
Files: `src/components/layout/sidebar.tsx`,
`src/components/layout/mobile/more-sheet.tsx`.
10. **"Other" comm-channel UX hint** — when a contact's channel is
`'other'`, the inline `Label` field switches its label/placeholder
to "Specify" / "e.g. Telegram, Signal".
File: `src/components/clients/client-form.tsx:289-302`.
11. **End Membership wording** — renamed to "Remove from company" in
the company members tab dropdown.
File: `src/components/companies/company-members-tab.tsx:249`.
12. **Berth area filter → letter dropdown** — was free-text; now a
`<Select>` constrained to `A / B / C / D / E`. Label changed to
"Dock" to match how the user refers to it.
File: `src/components/berths/berth-filters.tsx`.
13. **Yacht flag → CountryCombobox** — was a free-text 2-letter input
(`placeholder="e.g. MT"`); now uses the same country picker as
client / residential.
File: `src/components/yachts/yacht-form.tsx`.
### Wave 2 — country dropdown unification
1. **cmdk wheel-scroll** — covered in Wave 1 (single shared command).
2. **Country → timezone auto-set** in client form: when nationality is
picked and timezone empty, the primary IANA zone is pre-filled. Skips
when the user already chose a zone explicitly.
File: `src/components/clients/client-form.tsx` (look for
`primaryTimezoneFor`).
3. **Browser-detected timezone fallback** in user settings: timezone
pre-populates from `Intl.DateTimeFormat().resolvedOptions().timeZone`
on first load (was empty before).
File: `src/components/settings/user-settings.tsx`.
4. **Country → timezone auto-fill** also fires in user settings when
the country changes with no zone set.
5. **Dropdown widths match trigger**`CountryCombobox` and
`TimezoneCombobox` popover content set to
`w-[var(--radix-popper-anchor-width)]` with sensible `min-w-*`
floors so wide triggers get wide popovers.
6. **DEFERRED: country/nationality split** on the client form — needs
a Drizzle migration (`alter table clients add column country_iso
text`) plus a copy-on-migrate of existing `nationality_iso` values.
See § Wave 11 / pending — large.
### Wave 3 — berth field overhaul (NocoDB enums)
1. **Live NocoDB pull via MCP** — confirmed canonical SingleSelect
choices for: Side Pontoon (10 values), Mooring Type (5),
Cleat Type (2), Cleat Capacity (2), Bollard Type (2),
Bollard Capacity (2), Access (5), Area (AE). Power Capacity and
Voltage are `Number` fields (not enums). Bow Facing is
`SingleLineText` (we still use a 4-value dropdown for UX).
2. **`BERTH_BOW_FACING_OPTIONS`** added to `src/lib/constants.ts`
alongside the existing `BERTH_*_OPTIONS` constants.
3. **`toSelectOptions()` helper** added to `src/lib/constants.ts` for
mapping readonly tuples → shadcn `<Select>` `{value,label}` objects.
4. **All berth dropdown fields → `<Select>`** in both the modal form
(`berth-form.tsx`) and the inline-edit detail tabs
(`berth-tabs.tsx`). Bow facing / side pontoon / mooring type /
access / cleat type / cleat capacity / bollard type / bollard
capacity / area / tenure type.
5. **Inline-edit `EditableSpec`** in `berth-tabs.tsx` now supports
`selectOptions: readonly string[]` to render a `<Select>` variant.
6. **Dimensional auto-conversion**`EditableSpec` gained a
`linkedUnit: { field, multiplier }` prop. Saving the imperial value
also patches the metric column (× 0.3048). Applied to length, width,
draft, nominal boat size, water depth.
7. **Nominal boat size editable** — was read-only `<SpecRow>`; now an
`<EditableSpec numeric linkedUnit>` so editing ft auto-fills m.
8. **Tenure type editable** — was read-only; now an inline-edit Select
bound to the validator's `'permanent' | 'fixed_term'` set. Will be
replaced by the per-port configurable list once Wave 5 ships.
### Wave 9 — interests + notifications
1. **StageLegend popover** — small "Legend" button in the interest
list filter row decodes the colored stripes on each card to the
pipeline stage name. Stays in sync with `STAGE_DOT` automatically.
File: `src/components/interests/stage-legend.tsx`.
2. **Mobile kanban hidden** — see Wave 1.
3. **Notifications nav 404 fixed** — More-sheet entry pointed at
`/notifications` which had no `page.tsx`. Now points at
`/notifications/preferences` and is labeled "Notification
preferences" — real notifications come via the topbar bell.
File: `src/components/layout/mobile/more-sheet.tsx`.
### Wave 10 — settings polish
1. **Phone input upgraded** — user settings now uses the existing
shared `<PhoneInput>` (country flag dropdown + AsYouType formatter)
instead of a plain `<Input type="tel">`. Country state from the
page seeds the dropdown.
File: `src/components/settings/user-settings.tsx`.
2. **Timezone auto-detect** — covered in Wave 2.
3. **Dropdown widths match trigger** — covered in Wave 2.
### Bonus — public berth feed wired to replace NocoDB as source of truth
Triggered by user prompt "ensure we are properly wired up to replace
the NocoDB table as the source of truth for the berth map".
**State before audit:**
- API endpoints existed (`/api/public/berths`,
`/api/public/berths/[mooringNumber]`) — wiring fine.
- `src/lib/services/public-berths.ts` mapped the response shape to
NocoDB-verbatim keys.
- Tests passed (`tests/unit/services/public-berths.test.ts`).
- **Map data was empty: 0 rows in `berth_map_data` against 234 berths
total (117 per port).** Without polygons the website map literally
has no shapes to render.
**Action taken:**
- Ran `pnpm tsx scripts/import-berths-from-nocodb.ts --apply
--port-slug port-nimara` (after a clean dry-run). Result:
117 berths updated, 117 `berth_map_data` rows inserted.
- Spot-checked the public API: `GET /api/public/berths` returns the
correct shape with `Map Data` populated, byte-for-byte identical
to NocoDB for berth A1 (`path`, `x`, `y`, `transform`, `fontSize`).
**Field-parity gaps still present** (see Wave Bonus pending below).
### Misc UI polish
- **Berth Documents tab explainer** — added a one-paragraph header
explaining it's the spec PDF, not deal documents (with a pointer
to the Interests tab for prospect-linked docs).
File: `src/components/berths/berth-documents-tab.tsx`.
---
## 🟡 Pending — medium
### Wave 4: currency formatting platform-wide
- Build `<CurrencyInput>` shared component (formatted display, raw
number value). Replace raw `<Input type="number">` price spots in:
`berth-form.tsx` (price), `expense-form-dialog.tsx` (amount),
`invoices.tsx` (totals), client deal amounts on dossier / invoice.
- Currency selector dropdown on expense form (NocoDB has no expense
currency field, so source from a curated supported-currency list:
USD / EUR / GBP / CAD / AUD / CHF / JPY / …). Replace the free-text
3-letter input.
- Sweep for `${currency} ${amount}` string concatenations and replace
with `Intl.NumberFormat`.
### Wave 5: configurable enum infrastructure
We have a `system_settings` table with composite PK `(key, port_id)`
and an `<SettingsManager>` admin page. Add a "Vocabularies" admin tab
that exposes per-port vocabularies. Suggested keys grouped by domain:
- `interest_temperature_levels` — replaces the hardcoded "HOT" badge.
Pill is rendered in `src/components/interests/interest-card.tsx`.
- `berth_status_change_reasons` — list shown as quick-pick chips in
`<StatusChangeDialog>` (see `berth-detail-header.tsx`). Tied to the
prospect-picker concept (see Wave 7 below).
- `berth_tenure_types` — replaces the static
`'permanent' | 'fixed_term'` validator union. Berths column is
`text`, so any value can land at the DB layer.
- `expense_categories` — current hardcoded list at
`src/lib/constants.ts:EXPENSE_CATEGORIES`.
- `document_types` — current hardcoded list at
`src/lib/constants.ts:DOCUMENT_TYPES`.
- `interest_outcome_statuses` — already exist in schema enum, could
be overridable.
- `berth_side_pontoon_options` / `berth_cleat_types` /
`berth_bollard_types` / `berth_access_options` — currently
hardcoded to NocoDB values. Worth making editable once a non-Port-
Nimara port appears with different infrastructure.
**Open question (#1)**: see § Open Questions.
### Wave 6: notes unification — aggregate-on-read
User chose option 1 ("aggregate on read") from the brainstorm. The
`listForClientAggregated` pattern in `notes.service.ts` (lines
130242) already unions a client's notes + interest notes + owned
yacht notes into a single feed with `source` metadata.
Symmetric extensions to add:
- `listForYachtAggregated` — yacht own notes + owner client notes
- linked interest notes.
- `listForCompanyAggregated` — company own notes + owned yacht notes
- linked interest notes.
- `listForResidentialClientAggregated` — residential client notes
- residential interest notes.
UI:
- `<NotesList entityType="…">` should render the source-label badge
(already implemented for clients — copy the pattern).
- Convert single-textarea spots to entry-list pattern: the
Companies overview tab has a `notes` textarea (from
`companies.notes` text column) AND a Notes tab with the threaded
`companyNotes` table. Drop the textarea in favor of the threaded
feed only. Same for residential interests.
- Note for the schema fix-it list: `companyNotes` is missing
`updatedAt`. Service substitutes `createdAt` to keep the read shape
uniform — see `notes.service.ts:566`. Fix when convenient.
### Wave 7: clients / yachts / companies misc
Done in this session:
- **Yacht flag** → CountryCombobox (Wave 1).
- **End Membership** → "Remove from company" (Wave 1).
- **Berth Documents tab** explainer paragraph.
Pending:
- **Status change modal — prospect picker**: when user changes berth
status to `under_offer` or `sold`, surface an interest/prospect
selector below the reason dropdown so the recorded reason can link
to a known deal. Tie into `interest_berths` so the link is
bidirectional. Depends on Wave 5
(`berth_status_change_reasons` vocabulary).
- **Documents tagged with company** show up in main `/documents` view
with company tag — verify after the documents overhaul (Wave 11.B).
### Wave 9 follow-up
- **HOT/WARM/COLD admin-config** — covered by Wave 5
(`interest_temperature_levels`).
- **Color-codes legend**: shipped as a popover. Optional polish: add
a one-time tooltip on first pageload so users discover it.
### Wave 10 follow-up
- **Photo upload picker bug**: Playwright captured a `[File chooser]`
modal when clicking "Upload photo," so the wiring works in headless
Chromium. User reported "doesn't open" on macOS — possibly a focus
/ window issue or a content-blocking extension. Need a real-machine
repro to diagnose. The hidden `<input type="file" ref={fileInputRef}>`
- `fileInputRef.current?.click()` wiring is at
`user-settings.tsx:247-258`.
- **Display name + first / last name fields** — current schema only
has `displayName`. Adding first/last requires a Drizzle migration on
`users` or `user_profiles` plus migration of existing data (split
on first space). **Open question (#3)**: see § Open Questions.
- **Notification preferences placement** — settings vs notifications
page. Today notification toggles live on the user-settings page; a
dedicated `/notifications/preferences` page also exists. **Open
question (#2)**: see § Open Questions.
### Wave Bonus follow-up — public berth feed field parity
Map data is now wired. Field gaps the website _might_ consume but we
don't expose:
| NocoDB field | Currently in PublicBerth? | DB has it? | Notes |
| ---------------------------- | ------------------------- | ---------------------------------- | ----------------------------------------------------------- |
| `Price` | ❌ | ✅ `berths.price` | Pricing-public is a policy decision. **Open question (#4)** |
| `Berth Approved` | ❌ | ✅ `berths.berth_approved` | Boolean. Often used to gate "Sold" display |
| `Water Depth` | ❌ | ✅ `berths.water_depth` | Sometimes shown in tooltip |
| `Width Is Minimum` | ❌ | ✅ `berths.width_is_minimum` | Modifier for "Width" display |
| `Water Depth Is Minimum` | ❌ | ✅ `berths.water_depth_is_minimum` | ditto |
| `Length (Metric)` | ❌ | ✅ `berths.length_m` | Derivable. Website may consume |
| `Width (Metric)` | ❌ | ✅ `berths.width_m` | ditto |
| `Draft (Metric)` | ❌ | ✅ `berths.draft_m` | ditto |
| `Water Depth (Metric)` | ❌ | ✅ `berths.water_depth_m` | ditto |
| `Nominal Boat Size (Metric)` | ❌ | ✅ `berths.nominal_boat_size_m` | ditto |
| `CreatedAt` / `UpdatedAt` | ❌ | ✅ timestamps | Cache invalidation hints |
| `Interests` (count) | ❌ | derivable | Probably internal-only |
| `Interested Parties` (count) | ❌ | derivable | Probably internal-only |
**Plan once questions are answered:** Add the chosen fields to
`PublicBerth` interface in `src/lib/services/public-berths.ts`, the
`toPublicBerth()` mapper, and the test fixtures. Trivial; gated only
by which fields the website actually uses.
**Other public-feed concerns to flag**:
- **No archive flag**: when a berth is retired the public feed will
still serve it. Need a `berths.archived_at` column + filter on the
route. Plan §4.5 hinted at this. Not urgent.
- **CRM-edit drift vs re-imports**: now that reps can edit berth
fields (Wave 3), running the import script will skip-edited those
rows (`updated_at > last_imported_at`) — that's the right design,
but it means once cutover happens the website **must** call CRM
`/api/public/berths`, never NocoDB. Coordinate this in the website
repo. Useful guard already exists: `/api/public/health`.
- **Cache TTL: 5 min**: when a CRM rep marks a berth `sold`, the
public website serves "Available" for up to 5 minutes due to
`s-maxage=300`. Acceptable for marketing; bump if needed.
- **Health endpoint shape**: `/api/public/health` currently returns
`{status, timestamp}` but `CLAUDE.md` claims `{env, appUrl}`. One
of them is stale; the website may expect either shape. Not blocking
but worth aligning.
---
## 🔴 Pending — large (group-discussion items, Wave 11)
### A. Manual client form expansion
User wants "New Client" to support assigning yachts / companies /
berths inline (without leaving the form), plus a mini-recommender for
picking a berth at create time.
Scope:
- "Existing yacht / new yacht" picker.
- "Existing company / new company" picker.
- "Open an interest with this client" affordance that wires through
`interest_berths` and the recommender.
- Make sure all standard client modal fields (nationality / source /
preferred contact / timezone / tags) remain present.
Multi-component composition with a lot of cross-entity plumbing.
Estimate fully before starting (likely 23 days).
### B. Documents section overhaul
User wants:
- Folders (create / delete / nested).
- Sort + filter (by date, type, owner).
- Wider file-type allowlist (PDF + Office + image is current; expand).
- "Documents in progress" filter (contracts / EOIs awaiting signature,
things uploaded but unparsed).
- Drop or rename the "Signature-based only" pill — confusing copy.
- "Expired" tab admin-configurable visibility.
- Type-filter dropdown reflects actual types in use (vs the full
hardcoded list).
Refactor of `documents.service.ts` plus a new folders schema
(`document_folders` table with port-scoped tree).
### C. Reports system
User asked for:
- Defined report types (Pipeline summary / Revenue / Activity log /
Berth occupancy) with documented data shape per type.
- Test fixtures for visual QA.
- Admin "report templates" with field-level checkboxes letting an
admin compose a custom report shape (toggles for each available
data field).
Infra exists (`/api/v1/reports`) but templates are stubs. A proper
templating system + per-template field selection adds a few days.
### D. Receipts inline in expense PDF
User confirmed: image receipts render inline beneath each expense row,
**and** PDF receipts also render inline (one page each). pdfme
(already used for EOI) handles both — inline images via the renderer,
PDF pages via `pdf-lib.copyPages`. Depends on Wave 8 expense form work.
### E. Country / Nationality split on Client form
Client schema has only `nationalityIso`. User wants:
- New `country_iso` column for _country of residence_ (visible
/ primary).
- Keep `nationality_iso` as an _optional_ secondary field.
Requires:
- Drizzle migration (`alter table clients add column country_iso text`).
- Migrate existing data: copy `nationality_iso → country_iso` for
every client (current value is more often country of residence in
practice).
- Update API validators (`clients.ts`).
- Update client form UI: primary "Country" CountryCombobox, secondary
collapsible "Nationality" row.
- Same for residential clients (parallel schema).
### F. Inquiry triage (legacy spec carryover)
Per project memory and the "deferred" list at the top of
`today-2026-05-08.md`: inquiry triage was explicitly deferred. Tied
into the inquiry routing settings (`inquiry_notification_recipients`,
`inquiry_contact_email`, `residential_notification_recipients` —
already in `system_settings`). Pick this back up when ready to
auto-classify website inquiries.
### G. Per-port email branding
Also in the deferred list. Templates and settings keys exist
(per memory note); the admin UI for editing per-port email branding
overrides remains.
---
## ✅ Decisions log — 2026-05-09
All 11 open questions answered. Implementation implications inline.
1. **Vocabularies admin layout (Wave 5)** → **New `/admin/vocabularies`
page, grouped by domain, admin-only.** User considered exposing to
non-admins (since reps use them daily) but settled on admin-only as
the safer default for now. Implementation: new top-level admin
route + page, reuse `system_settings` `(key, port_id)` composite
PK. Each vocabulary key gets its own card section (interest temps,
status-change reasons, tenure types, expense categories, document
types, etc.).
2. **Notification preferences placement (Wave 10)** → **Collapse to
user-settings only.** Keep `/notifications/preferences` as a
server-side redirect to the user-settings notifications panel for
back-compat links.
3. **Display name vs first/last (Wave 10)** → **Add `first_name` and
`last_name` columns.** Don't worry about migrations during dev (we
can iterate freely), but write the migration carefully so it
applies cleanly when we eventually deploy. Keep `display_name` as
a derived/optional override.
4. **Public-feed `Price` exposure (Bonus)** → **No — keep Price
internal.** Don't add to PublicBerth payload.
5. **Public-feed remaining fields (Bonus)** → **Yes, add all.** Add
Berth Approved, Water Depth, Width Is Minimum, Water Depth Is
Minimum, all four metric variants, plus CreatedAt/UpdatedAt to
PublicBerth + mapper + tests. User noted "not sure if we'll use
all of them but best to keep them in" — verbatim NocoDB parity.
6. **Website cutover plan (Bonus)** → **Double-write transition
window.** Keep both feeds live, write to both for the transition
period, then decommission NocoDB. Coordinate with website repo
(`CRM_PUBLIC_URL`).
7. **Status-change modal → prospect link (Wave 7)** → **Force
interest pick + auto-create primary `interest_berths` row.**
When status moves to `under_offer` or `sold`, the modal surfaces
an interest selector below the reason dropdown. Picking an
interest creates an `interest_berths` row with `is_primary=true`
if one doesn't already exist for that pair. Depends on Wave 5
`berth_status_change_reasons` vocabulary.
8. **Trip label on expenses (Wave 8)** → **Combobox: free-text on
first entry, dropdown of existing labels on subsequent entries.**
No new entity. Source the dropdown from
`SELECT DISTINCT trip_label FROM expenses WHERE port_id=?`
ordered by recency. UI is a `<Combobox>` with "Create
'<typed value>'" affordance.
9. **Documents folders (Wave 11.B)** → **Per-port, unlimited
nesting depth — but render carefully.** User wants flexibility;
we owe a UI design that handles deep trees gracefully (likely
collapsed-by-default with a breadcrumb header inside the folder
view rather than always-expanded sidebar tree).
10. **Berth Documents tab (Wave 1 carryover)** → **Split into two
tabs: "Spec" (versioned spec PDF) and "Deal Documents"
(aggregated EOIs/contracts from interests on this berth).**
Permission scoping: deal docs only show entries the viewer can
already see via the linked interest.
11. **Mooring type re-import** → ✅ **Verified.** All 117 records
have `mooring_type` populated post-import (e.g. "Side Pier / Med
Mooring"). No action needed.
---
## File-pointer cheat sheet
### Berth-related
| Concern | File(s) |
| ---------------------------------- | ---------------------------------------------------- |
| Canonical berth enums | `src/lib/constants.ts` (search `BERTH_`) |
| Berth list ordering SQL | `src/lib/services/berths.service.ts:69-72` |
| Berth detail inline edit | `src/components/berths/berth-tabs.tsx` |
| Berth modal form | `src/components/berths/berth-form.tsx` |
| Berth area filter | `src/components/berths/berth-filters.tsx` |
| Berth detail header / status modal | `src/components/berths/berth-detail-header.tsx:90` |
| Berth Documents tab | `src/components/berths/berth-documents-tab.tsx` |
| Berth list query + sort | `src/lib/services/berths.service.ts:25-140` |
| Berth import script | `scripts/import-berths-from-nocodb.ts` |
| Berth import service / parsers | `src/lib/services/berth-import.ts` |
| Public berth API route | `src/app/api/public/berths/route.ts` |
| Public berth single route | `src/app/api/public/berths/[mooringNumber]/route.ts` |
| Public berth mapper | `src/lib/services/public-berths.ts` |
| Public berth tests | `tests/unit/services/public-berths.test.ts` |
| Berth seed snapshot | `src/lib/db/seed-data/berths.json` |
| Berth schema | `src/lib/db/schema/berths.ts` (incl. `berthMapData`) |
### Other domains
| Concern | File(s) |
| --------------------------------- | -------------------------------------------------------------------------------------- |
| Interest stage colors / legend | `src/components/interests/stage-legend.tsx` + `src/lib/constants.ts:STAGE_DOT` |
| Mobile kanban toggle / fallback | `src/components/interests/interest-list.tsx` |
| Country / timezone autoset | `src/components/clients/client-form.tsx` + `src/components/settings/user-settings.tsx` |
| Phone input | `src/components/shared/phone-input.tsx` |
| Country combobox + scroll patch | `src/components/shared/country-combobox.tsx` + `src/components/ui/command.tsx` |
| Sidebar Umami gate | `src/components/layout/sidebar.tsx` (search `umamiRequired`) |
| Mobile More-sheet | `src/components/layout/mobile/more-sheet.tsx` |
| Notes service (aggregate-on-read) | `src/lib/services/notes.service.ts:130-242` |
| Notes UI | `src/components/shared/notes-list.tsx` |
| Settings manager (admin) | `src/components/admin/settings/settings-manager.tsx` |
| User settings page | `src/components/settings/user-settings.tsx` |
| Status change dialog | `src/components/berths/berth-detail-header.tsx:90` |
| Companies members tab | `src/components/companies/company-members-tab.tsx` |
| Yacht form | `src/components/yachts/yacht-form.tsx` |
| Client form | `src/components/clients/client-form.tsx` |
### Infrastructure
| Concern | File(s) |
| ------------------------------------------- | --------------------------------------------- |
| Drizzle config / migrations | `drizzle.config.ts`, `src/lib/db/migrations/` |
| `system_settings` table | `src/lib/db/schema/system.ts:128-147` |
| Permissions / `withAuth` / `withPermission` | `src/lib/api/helpers.ts` |
| Body parsing (always use `parseBody`) | `src/lib/api/route-helpers.ts` |
| Storage backend abstraction | `src/lib/storage/` |
| Logger (pino) | `src/lib/logger.ts` |
---
## Resuming in a fresh session
When you open a new chat, paste this **prompt** to pick up where this
session ended:
```
I'm resuming the 2026-05-08 visual audit. Read
docs/AUDIT-FOLLOWUPS.md first — it has every completed item, every
pending item, and every open question. Then:
1. Skim the "Quick status snapshot" table at the top so you know
what's done.
2. Read the "Open questions for the user" list and ask me question
#N where N is whichever I'll answer first this turn.
3. Wait for my answers; don't start implementing until I confirm.
Key invariants:
- Notes unification model: aggregate-on-read.
- Berth dropdown values: NocoDB SingleSelect canon, sourced from
src/lib/constants.ts (BERTH_*_OPTIONS / _TYPES).
- Power Capacity & Voltage stay numeric inputs; Bow Facing is a
constrained 4-value dropdown despite being SingleLineText in
NocoDB.
- linkedUnit on EditableSpec auto-fills the metric column on save.
- system_settings (key, port_id) is the configuration pattern.
- NocoDB MCP is connected via ~/.claude.json — Berths schema +
records can be pulled live.
- Public berth feed (/api/public/berths) now serves Map Data; 117
berth_map_data rows backfilled in this session.
- Tests: 1185/1185 passing; tsc clean.
The git working tree has 23 modified files + 2 new (no commits yet).
Don't commit anything until I say so.
```
### Resume commands (cheat sheet)
```bash
cd /Users/matt/Repos/new-pn-crm
pnpm dev # Turbopack dev (~1s boot)
# Tests
pnpm exec vitest run # Unit + integration (~7s)
pnpm exec tsc --noEmit # Type check
pnpm exec playwright test --project=smoke # Smoke (~10min)
# NocoDB import (for new berth pulls)
pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run --port-slug port-nimara
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
# DB inspect
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm
# Public-feed sanity check
curl -s http://localhost:3000/api/public/berths | jq '.pageInfo'
curl -s http://localhost:3000/api/public/berths/A1 | jq '.'
```
### Verification checklist before committing this session's work
- [ ] `pnpm exec vitest run` — 1185/1185 pass.
- [ ] `pnpm exec tsc --noEmit` — clean.
- [ ] `pnpm exec playwright test --project=smoke` — passes.
- [ ] Manual: open `/port-nimara/berths`, confirm sort is A1, A2,
A3 … A10, A11 (not lex order).
- [ ] Manual: open a berth detail page, confirm the dock chip reads
e.g. "A Dock", and the Bow Facing / Side Pontoon / Cleat fields
render as `<Select>` not `<Input>`.
- [ ] Manual: pick a country in the user-settings page and confirm
timezone auto-fills if empty; also confirm the country dropdown
scrolls with mousewheel on macOS.
- [ ] Manual: check the mobile More-sheet has no "Inbox" entry, and
"Notification preferences" deep-links to the correct page.
- [ ] Manual: open `/api/public/berths` in the browser and search for
`Map Data` in the response — every row should have it.
---
## Misc tracking notes
- **Backups**: `~/.claude.json.bak.<timestamp>` exists from when the
NocoDB MCP was added. Delete after a session or two if everything's
stable.
- **Turbopack flip**: `next.config.ts` has no custom `webpack()` hook
so reverting `pnpm dev` to plain `next dev` is one line if needed.
Default is now `--turbopack`.
- **Database integrity follow-ups** (separate audit, dated 20:42):
11 findings (5 critical / 6 important). Logged in
`.remember/today-2026-05-08.md`. Cross-cuts the work here in two
spots: (1) `upsertInterestBerth` race could affect the berth
recommender once it's wired into the manual client form (Wave 11.A);
(2) `system_settings` `ON DELETE NO ACTION` will need addressing
before any port-deletion flow ships.

View File

@@ -1,212 +0,0 @@
# Parked questions — needs product / business / design decision
Items from the 33-agent audit that I deliberately did NOT fix automatically, because they need a call from you (or someone in product / legal / design) before code can be written. Each entry: the finding, why it's parked, and the proposed options.
Numbered to match the tiers in `AUDIT-TRIAGE.md`.
---
## P-0.1 — Migration runner: which approach?
**Finding.** `pnpm db:push` silently skips `CREATE INDEX CONCURRENTLY` and `NULLS NOT DISTINCT` constraints, plus the `berths.current_pdf_version_id` circular FK. Production is running without 6 composite indexes from migration 0052.
**Why parked.** Three viable approaches:
- **Drizzle's built-in `migrate()`** — simplest, but doesn't support `CREATE INDEX CONCURRENTLY` (the kit wraps every migration in a transaction, and CONCURRENTLY can't run inside one).
- **A custom tsx script** that reads `0001*.sql``0056*.sql` in order, splits on `--> statement-breakpoint`, runs each statement, special-cases CONCURRENTLY by running it outside a tx, tracks state in a `__drizzle_migrations` table.
- **Adopt a third-party migrator** (graphile-migrate, dbmate, pg-migrate). Best ergonomics, biggest dependency to take on.
**Question.** Which one do you want? If you don't know, my recommendation is **custom tsx script** — keeps the dependency surface tight and matches the rest of the platform's "write a script for it" pattern.
---
## P-0.4 — Resolve-identifier hit-path still echoes real email
**Finding.** Rate-limit + synthetic-miss are in, but on a hit the endpoint still returns the user's canonical email. A guessable-username window still leaks.
**Why parked.** The real fix is to delete the endpoint entirely and have the login form POST `{identifier, password}` to a server-side proxy that resolves + calls Better Auth in one round-trip, never returning the email. That's a noticeable refactor to the login page and possibly the portal-login page too.
**Question.** Do I do the proxy refactor (~30 min) or keep the current rate-limited shape and accept the residual leak?
---
## P-0.5 — Orphan-blob windows in 9+ services
**Finding.** Every `storage.put` runs outside the `db.insert(files)` tx in `documents`, `brochures`, `invoices`, `gdpr-export`, `backup`, `berth-pdf`, `external-eoi`, `document-templates`, `reports`. A comment in one site claims a "reaper handles it" — no reaper exists.
**Why parked.** Two valid patterns, both meaningful work:
- **Compensating delete** — wrap each `storage.put` in a try/catch and `storage.delete()` on tx failure.
- **Saga / 2-phase** — write to a `pending_blobs` table inside the tx, async-confirm after the tx commits, async-reaper for orphans.
Compensating-delete is faster to ship but doesn't catch process-crash gaps. Saga is more robust but is a bigger change.
**Question.** Which pattern? Recommendation: compensating-delete for now + a simple `cron` reaper that lists all blobs not referenced by any `files`/`berth_pdf_versions`/etc. row and deletes them after a grace period.
---
## P-1.1 — GDPR Article-15 export completeness
**Finding.** `gdpr-bundle-builder.ts` is missing ~10 PII-bearing tables — portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions.
**Why parked.** Each table needs (a) FK verification that "row belongs to this client" is unambiguous, (b) whether port-isolation must be enforced, (c) whether to include verbatim PII (email bodies, message contents) or redacted versions. This is a careful per-table audit that benefits from someone who knows the data model intimately.
**Question.** Want me to do a per-table table-by-table follow-up (estimated ~45 min) once you confirm the redaction policy? Or have legal review the scope first?
---
## P-1.2 — Right-to-be-forgotten doesn't actually erase
**Finding.** `client-hard-delete.service.ts` nullifies FKs but verbatim PII survives in `email_messages.body_html`, `files`, `document_sends.recipient_email`.
**Why parked.** **This is a legal decision, not a coding one.** Some jurisdictions (notably France) require true erasure even of email-body content; others accept anonymization. The fix is mechanical once you decide the policy: a `wipeClientPii(clientId)` helper that overwrites every PII column with a tombstone string. But the scope (which fields, which timeline, which audit trail) is yours / legal's.
**Question.** What's the erasure policy? Anonymize (preserve audit trail) or truly delete (loses business records)?
---
## P-1.3 — Activation / reset tokens travel in `?token=` query strings
**Finding.** Browser history, proxy logs, Referer header all see the token.
**Why parked.** Fix is a redesign of the URL scheme — switch to `#token=…` (fragment) or POST-on-load. Both work but require coordinated changes to email templates + the landing pages + Better Auth integration. Estimated 30-45 min.
**Question.** Want me to do the fragment-based redesign?
---
## P-2.1 — `pipelineValueUsd` sums mixed currencies as USD
**Finding.** The dashboard tile labelled "Pipeline Value" sums berth prices in their native currencies but renders the total as USD.
**Why parked.** Three valid UX options:
- **Convert at display time** — fetch each price, convert to port-default-currency via `currency.service`, sum the converted values. Today's rates introduce drift relative to historical reports.
- **Show as port-default-currency totalled** — the dashboard tile labels it as the port's own currency; honest about ambiguity.
- **Show "mixed (X USD, Y EUR, Z GBP)"** — explicit, prevents misreading, but uglier.
**Question.** Which display do you want? My recommendation is **option 2** (show port-default-currency, convert at display) — it's the least visually noisy and lines up with what most CRMs do.
---
## P-2.5 — "Active interest" means 4 different things
**Finding.** Dashboard tiles use `outcome IS NULL OR 'won'`, kanban uses `archivedAt NULL` only (lost cards visible), hot deals uses `outcome IS NULL` (excludes won), PDF reports use `archivedAt NULL` only.
**Why parked.** Need a canonical definition. Recommendation: **active = `archivedAt IS NULL AND outcome IS NULL`** (not yet won, not yet lost, not yet cancelled, not yet archived). But that demotes won deals out of "active" everywhere — affects the kanban "won" column and the dashboard "active deals" tile.
**Question.** Confirm the canonical definition, then I extract an `activeInterestsWhere(portId)` helper and route every site through it.
---
## P-2.6 — Occupancy rate: berths.status vs berth_reservations
**Finding.** KPI tile + PDF use `berths.status` ("occupied"/"available"/etc). Analytics timeline uses `berth_reservations`. Same dashboard, two different numbers.
**Why parked.** Need to know which is the source of truth. Probably `berth_reservations` (richer; supports timeline), but switching the KPI tile changes the displayed number for every port.
**Question.** Which is canonical? I'll switch the other to match.
---
## P-2.7 — Revenue PDF unweighted vs dashboard weighted
**Finding.** Revenue PDF shows gross berth prices per stage. Dashboard revenue-forecast tile multiplies by `pipeline_weights`. They will never reconcile.
**Why parked.** Need PM call on what "Revenue" means in each context. The PDF is probably a board / investor doc and should match dashboard, but maybe they want both.
**Question.** Make the PDF match the dashboard (weighted)? Or leave divergent and label them differently?
---
## P-3.1 — "Interest" / "lead" / "prospect" / "deal" used interchangeably
**Finding.** All four nouns appear in client-facing UI. `berth-detail-header.tsx` literally parenthesises one as a synonym ("the prospect (interest)"). `berth-tabs.tsx` has a "Deal Documents" tab + `/deal-documents` URL path.
**Why parked.** Need a canonical noun. Without one I'd be guessing; with one I can do a codemod across the platform.
**Question.** Which one is canonical? Recommendation: **interest** (matches schema + URL + most code). Then everything else becomes a deprecated alias.
---
## P-3.3 — 16 `window.confirm()` sites for destructive flows
**Finding.** Cancel signing envelope, delete files, archive interest/company/yacht, etc. all use the native browser dialog.
**Why parked.** Mechanical fix once you confirm: each site swaps `window.confirm()` for `<AlertDialog>` from `@/components/ui/alert-dialog`. But there are 16 of them; ~5 min each.
**Question.** OK to do the sweep automatically with the same dialog copy + visual treatment? Or do you want bespoke copy per surface?
---
## P-3.4 — Signing-status labels diverge across 5 surfaces
**Finding.** Hub list, interest-tab, SigningProgress, notification-digest, realtime-toast all use different strings for the same document state.
**Why parked.** Need one canonical mapping. I drafted `PORTAL_SIGNING_LABELS` for the portal but the CRM side has different needs (more granular for reps).
**Question.** Want me to extract a shared `signingStatusLabel()` and route every site through it? If yes, I need a confirmed label map.
---
## P-3.5 — 6× "Save" button variants
**Finding.** "Save", "Save Changes", "Save changes", "Update", "Apply" — plus "Saving..." vs "Saving…".
**Why parked.** Mechanical sweep once you confirm the canonical text. Recommendation: **"Save changes"** for edits, **"Create X"** for new entities, **"Saving…"** (Unicode ellipsis) for the loading state. Trivial codemod but it touches 30+ files.
**Question.** OK to do the sweep with that policy?
---
## P-3.6 — Live Documenso template missing `Berth Range` field
**Finding.** The CRM sends a `Berth Range` form value through `buildDocumensoPayload`, but the live template at Documenso doesn't have that field — Documenso silently drops unknown formValues. Every multi-berth EOI ships with only the primary mooring.
**Why parked.** **Not code — Documenso admin action.** Someone needs to log into the Documenso instance and add a `Berth Range` text field to template id 8. The CRM is ready.
**Question.** Who has Documenso admin access? Can they add the field?
---
## P-4.5 — "Convert to client" prefill qs params unused
**Finding.** The inquiry-inbox triage flow writes `prefill_name/email/phone/inquiry_id/source` query-string params. No consumer reads them. The flow eagerly flips the inquiry to "converted" then drops the operator on a blank form, losing the inquiry_id linkage forever.
**Why parked.** Fix is a wire-up: the create-client form's `useEffect` reads searchParams and hydrates initial values. But it also has to push the `inquiry_id` into the resulting client's `metadata` so the linkage survives. Not difficult; needs ~30 min and design review on what the linkage looks like.
**Question.** Want me to wire it up with the inquiry_id stored on `clients.metadata.source_inquiry_id`?
---
## P-5.1 — `handleDocumentCompleted` TOCTTOU
**Finding.** Two concurrent retries can both pass the idempotency gate, both write the signed PDF blob, both insert duplicate files rows. Webhook + poll-worker race specifically.
**Why parked.** Fix is a `SELECT … FOR UPDATE` on the documents row inside the handler. Mechanical but invasive — touches the hottest path in the signing flow. I want to test before shipping, and that needs a real Documenso webhook replay.
**Question.** OK to ship the FOR UPDATE without a replay test, relying on existing vitest? Or hold until you can replay?
---
## P-5.2 — Zero BullMQ `jobId` usage repo-wide
**Finding.** Every `queue.add` is unkeyed; any double-fire creates a duplicate job. The audit found this is the most pervasive concurrency hazard in the codebase.
**Why parked.** Fix is mechanical: pass a deterministic `jobId` to every `queue.add` call. But "deterministic" varies by surface (webhook deliveries should use the delivery row id, notifications should use a hash of the dedupeKey, etc.). ~20 sites to touch.
**Question.** Want me to do the sweep with per-surface jobId conventions, or batch by surface (webhooks first, then notifications, etc.)?
---
## P-6.2 — Recharts in initial bundle (~80-150KB)
**Finding.** Every dashboard chart imports recharts statically via `widget-registry.tsx`. Initial-page-load bundle includes recharts even if the user has all chart widgets disabled.
**Why parked.** Fix is straightforward (dynamic import each chart widget), but the widget-registry is hot-pathed by the dashboard renderer and by the widget picker UI. Touching it has surface area.
**Question.** OK to ship a `next/dynamic` lazy-import for each chart widget? Adds a loading skeleton flash but kills the bundle bloat.
---
_Everything in `AUDIT-TRIAGE.md` Tier 8 is already shipped. Everything not listed in this file has been fixed without parking — see the commit log on `feat/documents-folders`._

View File

@@ -1,153 +0,0 @@
# Port Nimara CRM — Audit Triage (importance-grouped)
Companion to `AUDIT-2026-05-12.md`. Every line below is a real finding from the 33-agent audit, regrouped strictly by **impact × likelihood of biting you**, not by which domain found it. Tackle tiers top-down.
---
## Tier 0 — Stop-ship: do these in the next session
Anything here is a foot-gun that's actively armed in production right now.
| # | What | Where | Why now |
| --- | ------------------------------------------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0.1 | Build a real `db:migrate` runner | new tsx script | `pnpm db:push` silently skips `CREATE INDEX CONCURRENTLY` (6 indexes in 0052 never created) and skips 2 structural constraints. Every other "migration X exists" claim is unverifiable until this is fixed. |
| 0.2 | `EMAIL_REDIRECT_TO` prod refusal in `src/lib/env.ts` | env zod refine | One stray env value silently funnels every outbound (invites, EOI, portal magic links, contracts) to a single inbox. Only signal today is `logger.debug`. |
| 0.3 | Admin self-target audit-log retention + alerting | audit_logs metadata + retention cron | `audit_logs.metadata` not in `maskSensitiveFields`, no retention cron. PII grows unbounded; rotated-admin compromise is invisible. |
| 0.4 | Resolve-identifier hit-path still echoes the real email | `/api/auth/resolve-identifier/route.ts` | Rate-limit is in (just shipped), but on a hit we still return the canonical email. Replace with a server-side signIn proxy that takes `{identifier, password}` and never returns the email at all. |
| 0.5 | Orphan-blob windows in 9+ services | `documents`, `brochures`, `invoices`, `gdpr-export`, `backup`, `berth-pdf`… | Every `storage.put` runs outside the `db.insert(files)` tx. "Reaper handles it" comment is wrong — no reaper exists. Months of operation = hundreds of orphans. |
| 0.6 | `backup_jobs.storage_path` missing from `TABLES_WITH_STORAGE_KEYS` | `src/lib/storage/migrate.ts:55-60` | Flip the storage backend → silently orphans every pg_dump. Last-resort recovery path goes dark. |
---
## Tier 1 — Compliance / legal liability
Anything here puts the company in a regulator finding or a court case.
| # | What | Where |
| --- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1.1 | GDPR Article-15 export bundle is incomplete | `gdpr-bundle-builder.ts` — missing portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions |
| 1.2 | Right-to-be-forgotten doesn't actually erase | `client-hard-delete.service.ts` — verbatim PII survives in `email_messages.body_html`, `files`, `document_sends.recipient_email` |
| 1.3 | Activation/reset tokens travel in `?token=` URL query strings | portal-auth flow — leaks to browser history, proxy logs, Referer headers |
| 1.4 | `error_events.request_body_excerpt` redacts password/token but not email/phone/name/dob/address | error-classifier sanitizer |
| 1.5 | `audit_logs` no retention cron + IP captured on routine events | `lib/audit.ts` — lawful-basis-questionable |
| 1.6 | S3 backend ships without `ServerSideEncryption` header | `S3Backend.put` — signed contracts, GDPR exports, pg_dumps cleartext at rest unless bucket default is set |
| 1.7 | `audit_logs.metadata` carries raw PII (full emails) at portal-auth, crm-invite, hard-delete, email-accounts service sites | `maskSensitiveFields` skips metadata |
---
## Tier 2 — Money/numbers correctness
Anything where the dashboard or a PDF lies to the user about money.
| # | What | Where |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| 2.1 | `pipelineValueUsd` sums mixed currencies as USD | `dashboard.service.ts:39-51`, KPI cards, pipeline-value tile, revenue forecast |
| 2.2 | Revenue PDF "TOTAL COMPLETED REVENUE" includes lost + cancelled | `report-generators.ts:126-140` — no outcome filter |
| 2.3 | Pipeline PDF crashes because `stageCounts` is missing `.groupBy()` | `report-generators.ts` |
| 2.4 | Hot-deals widget rank ladder uses wrong stage names (`'in_comms'`, `'deposit_10'`) | `dashboard.service.ts:198-208`, `hot-deals-card.tsx:26-36` |
| 2.5 | "Active interest" means **4 different things** across dashboard / kanban / hot deals / PDFs | extract `activeInterestsWhere(portId)` helper |
| 2.6 | Occupancy rate: KPI uses `berths.status`, analytics timeline uses `berth_reservations` — two different numbers on same dashboard | `dashboard.service.ts` |
| 2.7 | Revenue PDF unweighted vs dashboard weighted-by-`pipeline_weights` — will never reconcile | `report-generators.ts` |
| 2.8 | `expenses.amountUsd` snapshot uses edit-time rate not `expenseDate`; nulls when Frankfurter is down | `expenses.service.ts` |
| 2.9 | `convert()` rounds 2dp regardless of currency (JPY broken); invoice math has no rounding (sub-cent drift) | `currency.service.ts`, invoice math |
---
## Tier 3 — Customer-visible polish (embarrassing in front of clients)
| # | What | Where |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| 3.1 | "Interest" / "lead" / "prospect" / "deal" used interchangeably in client-facing UI | `berth-detail-header.tsx`, `berth-tabs.tsx` "Deal Documents", `client-interests-tab.tsx`, `interest-tabs.tsx` |
| 3.2 | Portal renders raw machine enums to clients ("EOI: waiting_for_signatures", "hot lead") | `/portal/interests/page.tsx:80` |
| 3.3 | 16 destructive flows use native `window.confirm()` | cancel signing envelope, delete files, archive interest/company/yacht |
| 3.4 | Signing-status labels diverge across 5 surfaces (Hub / list / interest-tab / SigningProgress / notification-digest / realtime-toast) | normalize through one helper |
| 3.5 | 6× "Save" button variants ("Save" / "Save Changes" / "Save changes") + 6× "Saving..." vs "Saving…" | sweep |
| 3.6 | Live Documenso template missing `Berth Range` field — every multi-berth EOI ships with primary mooring only | Documenso admin |
| 3.7 | URL interpolations in every email template are unescaped (`href="${data.link}"`) — a `"` in any URL breaks out | escape + scheme allow-list in `shell.ts` |
| 3.8 | Admin email-template subject editor silently does nothing on 5 of 8 templates | wire `overrides.subject` |
| 3.9 | `/admin/email` Signature/Footer HTML fields write keys the shell never reads | wire `cfg.footerHtml` or delete fields |
| 3.10 | Mobile scan PWA "Save expense" sits flush against iPhone home indicator | safe-area-inset on ScanShell `<main>` |
---
## Tier 4 — Authz / cross-tenant integrity
| # | What | Where |
| --- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 4.1 | Port admin with only `admin.manage_users` can grant other users any leaf they don't hold themselves (sock-puppet escalation) | permission-overrides PUT + `updateUser` role reassignment — require caller-superset before write |
| 4.2 | `/api/v1/alerts` GET is ungated | add `admin.view_audit_log` |
| 4.3 | Webhooks bypass the platform-error pipeline entirely | `documenso/route.ts``captureErrorEvent` on handler throw, apply to all webhook routes |
| 4.4 | Search graph-expansion writes into all merged buckets without re-checking per-bucket `view` permission | `search.service.ts:1893-1915` — gate each merge call |
| 4.5 | "Convert to client" writes prefill qs params no consumer reads; inquiry_id linkage dropped forever | inquiry-inbox triage flow |
| 4.6 | Inquiry email dedup is case-sensitive (capital-letter resubmits = duplicate client+yacht+interest) | `lower()` on `clientContacts.value === data.email` |
---
## Tier 5 — Concurrency / data races
| # | What | Where |
| --- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 5.1 | `handleDocumentCompleted` idempotency gate is TOCTTOU under webhook+poll race — duplicate files rows + orphan blob | `documents.service.ts:1100-1253``SELECT … FOR UPDATE` or pre-claim transition |
| 5.2 | **Zero BullMQ `jobId` usage repo-wide** — every queue.add is unkeyed, any double-fire creates a duplicate job | every `queue.add` site |
| 5.3 | `advanceStageIfBehind` reads stage outside any lock — parallel DOCUMENT_SIGNED + DOCUMENT_COMPLETED double-run berth rules | wrap in tx |
| 5.4 | `moveFolder` cycle check outside a tx — two concurrent moves can create A↔B cycles | wrap in tx |
| 5.5 | Berth-PDF upload writes blob _before_ acquiring advisory lock — orphans on tx-rollback | reorder |
| 5.6 | `user_email_changes` has no partial unique index on pending rows — spam-email vector | add partial unique |
---
## Tier 6 — Perf / scale (silent today, painful at 10× traffic)
| # | What | Where |
| --- | ----------------------------------------------------------------------------------------------------------- | ---------------------- |
| 6.1 | Documents tab opens with ~50 sequential queries via fetchWorkflowGroupRows | `documents.service.ts` |
| 6.2 | Recharts statically imported in `widget-registry.tsx` — every dashboard chart in initial bundle (~80-150KB) | lazy import |
| 6.3 | `DataTable` rebuilds `allColumns` every render (no useMemo) — resets TanStack internal state | memo |
| 6.4 | `tiptap-to-pdfme.ts` (571 lines) ships to client just to re-export TEMPLATE_VARIABLES | split |
| 6.5 | `listUsers` runs 2 sequential queries with no pagination, returns all super-admins globally | paginate |
| 6.6 | `command-search` invalidates 2 queries every dropdown open — defeats its own 30s staleTime | drop invalidates |
---
## Tier 7 — Build / deploy hardening
| # | What | Where |
| --- | --------------------------------------------------------------------------------------------------------------- | -------------- |
| 7.1 | No `.dockerignore` → 7.6 GB build context, secrets/.env leak risk via `COPY . .` | add |
| 7.2 | `socket.io` + `@socket.io/redis-adapter` not in `serverExternalPackages`; runner stage installs no runtime deps | next.config.ts |
| 7.3 | Prod CSP keeps `'unsafe-inline'` on script-src | tighten |
| 7.4 | `Dockerfile.dev` runs as root | non-root user |
| 7.5 | Compose has no memory/CPU/log-rotation limits | add |
| 7.6 | `@types/node@^25` against Node-20 runtime — type checker greenlights APIs that don't exist | pin to ^20 |
| 7.7 | `node:20-alpine` base image at/past EOL | bump to 22 |
---
## Tier 8 — Already fixed in this session (don't redo)
Already on `feat/documents-folders`:
- Permission-overrides self-target privilege escalation block + canonical allow-list + cross-tenant guard
- `/api/auth/resolve-identifier` rate-limit + synthetic miss email
- Admin email-change updates `account.accountId` + revokes sessions
- Middleware `PUBLIC_PATHS` for email confirm/cancel tokens
- NAV_CATALOG dead-link sweep (10 entries)
- formatRole / formatOutcome / stageLabel applied across user-list, user-card, role-list, sidebar, command-search, realtime-toasts, interest-detail-header, client-columns, yacht-tabs, interest-picker, next-in-line-notify, AI worker, PDF reports
- Optional username sign-in (migration 0054)
- Per-user permission overrides (migration 0055) + UserPermissionMatrix
- UserForm: first/last + admin email change + auto-notify template + PhoneInput
- User disable button
---
## Tier 9 — Nice-to-haves + AI opportunities (not blocking)
Forward-looking (improvements-auditor):
- **AI-where-it-actually-helps:** semantic search across notes + email threads, auto-summarise client history on detail-page open, anomaly detection on expenses paired with existing OCR.
- **What NOT to AI-ify:** legal docs, EOI/contract field merges, money flow, regulatory text.
- **Subtle UX wins:** keyboard shortcuts (j/k list nav, e to edit), smarter defaults (last-used port/currency/source), undo for accidental archives, "what changed since I last looked" digest.
---
_Pick a tier and we open it._

View File

@@ -1,337 +0,0 @@
# Master backlog index
**Single source of truth for everything outstanding.** Start here when
asking "what's left to build/fix?". Items are grouped by source doc;
each entry links back to the original spec for full context.
Last updated: 2026-05-12 (PDF stack overhaul shipped: react-pdf brand
kit + port logo upload + 4 reports + 3 record exports + parent-company
expense + pdfkit brand header + invoice removal + tiptap-to-pdfme
deletion + unpdf for berth-parser tier-2; pdfme deps removed.
Remaining 7 react-email templates ported. browser-image-compression
wired into scan-shell. @axe-core/playwright smoke suite added.).
Documenso phases 2-7 stay back-burnered per user.
---
## A. Documenso build (deferred for later)
**Source:** [`docs/documenso-build-plan.md`](./documenso-build-plan.md) — full phase plan with locked decisions (Q1Q10).
**Tracker delta:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) — what landed in Phase 1.
Phase 1 (EOI generate flow polish + APPROVER-as-CC + per-port settings + signing-URL fix) is **DONE** and committed.
Remaining phases — explicitly back-burnered by the user on 2026-05-07:
| Phase | Scope | Estimate | Notes |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Phase 2** | Webhook handler enhancement: cascading "your turn" emails, on-completion PDF distribution, token-based recipient matching, idempotency lock | ~34h | Schema columns already in place from Phase 1 (`document_signers.invited_at / opened_at / signing_token`, `documents.completion_cc_emails`). |
| **Phase 3** | Custom doc upload-to-Documenso: `custom-document-upload.service.ts` + `POST /api/v1/interests/[id]/upload-for-signing` | ~68h | Depends on Phase 2 webhook UX in anger before locking the upload UX. |
| **Phase 4** | Field placement UI: react-pdf + dnd-kit overlay + auto-detect anchor scanner via pdfjs `getTextContent` | ~1014h | Largest piece. Plan locked in build-plan Phase 4 — regexes, anchors, type-to-bbox sizing all spelled out. Best done in a focused session with the user watching. |
| **Phase 5** | Embedded signing URL emission verification: confirm website's `/sign/<type>/<token>` page handles every signer-role × documentType combination; update `signerMessages` map; apply nginx CORS block from integration audit | ~12h | |
| **Phase 6** | Polish: auto-send delay, audit-log additions, per-document customisation, document expiration, reminder rate-limit display, failed-webhook recovery UI | each ~23h | All deferred until Phases 14 ship. |
| **Phase 7** | Project Director RBAC — UI binding for the developer-user fields. Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`; auto-fill name/email; webhook handler matches against linked user's email for in-CRM signing-status updates. Schema + setting keys (`documenso_developer_user_id`, `documenso_approver_user_id`, `_label`) already in place from Phase 1. | ~1h | Smallest piece; could be picked off independently of Phase 2. |
| **Risk #4** | v2 webhook payload audit against a live v2 instance (`payload.documentId` vs `payload.id`, `recipient.token` vs `recipient.recipientId`) before relying on Phase 2 cascading emails | ~1h | Needs a live v2 instance. |
---
## B. Custom-fields hardening
**Source:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) §7.
-**Merge tokens**`{{custom.<fieldName>}}` validators + resolver shipped 2026-05-08. Tokens expand at template-render time for client/interest/berth contexts via `mergeCustomFieldValues` in `document-sends.service.ts`. Banner updated.
- **Search index** — DEFERRED as design limitation. Adding GIN coverage requires either joining `custom_field_values` per search (slow at scale) or materializing values into a search_text column on the parent (additive maintenance burden). The amber banner documents this.
- **Audit diff** — N/A. Custom-field values live in their own table, not as a JSONB blob on the parent entity. The `setValues()` service-layer call already creates its own audit log entry (custom-fields.service.ts:349-358), so changes ARE audited — just separately from the entity-diff.
-**UI surfacing of `{{custom.…}}` tokens in template-edit pickers** — landed 2026-05-13. Shared `<TemplateTokenPicker>` (`src/components/admin/shared/template-token-picker.tsx`) renders the canonical `MERGE_FIELDS` catalog grouped by scope plus a dynamically-fetched "Custom (port-specific)" group filtered to entityTypes resolvable at send-time (client/interest/berth). Wired into both `sales-email-config-card.tsx` and `document-templates/template-form.tsx` so both pickers share the same surface.
---
## C. Audit-final deferred items
**Source:** [`docs/audit-final-deferred.md`](./audit-final-deferred.md) — pre-merge + post-merge audit findings explicitly carried over.
The 2026-05-07 backlog sweep landed every small/concrete item. Remaining
entries are deferred because they need design decisions, live external
instances, or cross-cutting refactors:
### Deferred — Documenso-related (back-burnered until phases 2-7 land)
- **Documenso webhook does not enforce port_id on document lookups** — `src/app/api/webhooks/documenso/route.ts:96-148`. Bundle with Documenso Phase 2 (webhook handler enhancement) since they touch the same code.
- **Webhook dedup vs per-recipient signed events** — `src/app/api/webhooks/documenso/route.ts:103-110`. Replacing the body-hash dedup with a `(documensoDocumentId, recipientEmail, eventType)` composite unique requires a recipient_email column on `documentEvents`. Bundle with Phase 2.
- **v2 voidDocument endpoint shape verification** — `src/lib/services/documenso-client.ts:450-466`. Needs a live Documenso 2.x instance. Bundle with Phase 5.
### Deferred — pure refactor (no active bug)
- **Public POST routes bypass service layer** — `src/app/api/public/{interests,website-inquiries,residential-inquiries}/route.ts`. The audit's `userId: null as unknown as string` cast was already cleaned up to a proper `userId: null`. Remaining concern is testability: extract a shared `publicInterestService.create(...)`. Pure ergonomics — no active bug or security issue.
### Done in 2026-05-08 sweep (latest)
- ✅ Storage proxy port_id binding: `ProxyTokenPayload` gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. document-sends 24h URLs opt in; other issuers continue working unchanged.
- ✅ system_settings index rebuilt with `NULLS NOT DISTINCT` (migration 0047) — global settings are now uniquely keyed by `key` alone. Surfaced + cleaned 65 duplicate `(storage_backend, NULL)` rows that had accumulated from race-prone delete-then-insert patterns.
- ✅ All 4 read-then-write systemSettings sites converted to true `onConflictDoUpdate` upserts (ocr-config, settings, residential-stages, ai-budget).
- ✅ Response shape standardization: 16 routes converted from `{ success: true }``204 No Content`. CLAUDE.md documents the convention.
-`req.json()``parseBody()` migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url,versions,parse-results}). Portal-auth routes intentionally retained `{ success: true }`.
- ✅ Custom-field merge tokens: validator accepts `{{custom.<fieldName>}}` shape; resolver in `mergeCustomFieldValues` substitutes from per-port custom_field_definitions + per-entity values for client/interest/berth contexts. Banner updated.
-`/api/v1/files` accepts `companyId` and `yachtId` filters. uploadFile service writes both. file-upload-zone component accepts both props.
- ✅ Company Documents tab (CompanyFilesTab) re-enabled and added to company detail tabs.
### Done in 2026-05-07 sweep (commits in this session)
- ✅ Partial archived indexes (migration 0046) — `clients`, `interests`, `yachts`, `residential_clients`, `residential_interests`
-`document_sends` interestId port-verification helper
- ✅ Custom-fields per-entity permission gate (replaces hardcoded `clients.view/edit`)
- ✅ EOI Berth Range warn log (was already in place)
- ✅ v1 `placeFields` retry with backoff (was already in place)
- ✅ S3 bucket-exists check at boot (was already in place)
- ✅ Filesystem dev HMAC fallback warn (was already in place)
- ✅ Storage cache fingerprint documentation comment
- ✅ AI worker cost ledger writes (was already in place)
- ✅ Logger redact paths covering headers, encrypted blobs, two-level nesting (was already in place)
-`loadRecommenderSettings` accepts string `"true"`/`"false"` JSONB booleans
-`renderReceiptHeader` cursor math anchored to captured `baseY`
- ✅ Berth PDF apply: silent-drop logging for non-finite numeric coercions
- ✅ Saved-views: confirmed by-design owner-only (existing inline doc)
- ✅ Alerts ack/dismiss: confirmed by-design port-wide (service correctly bounded)
- ✅ Storage admin migration toasts (already in place)
- ✅ Invoice send/payment toasts + permission gates (already in place)
- ✅ Admin user list edit + remove gates (added remove gate)
- ✅ Email threads list skeleton + empty state (already in place)
- ✅ Scan page error state for OCR failures (already in place)
- ✅ Invoice detail typed (replaced `any` with `InvoiceDetailData` interface)
- ✅ All FK indexes called out in audit doc (already in place — audit was stale)
-`documentSends.sentByUserId` FK (already had `.references(...)`)
### Documented limitations (no action planned)
- **`berths.current_pdf_version_id` lacks Drizzle FK** — `src/lib/db/schema/berths.ts:83`. The in-line comment fully documents why (circular FK between `berths``berth_pdf_versions` makes column-level `.references()` infeasible). FK is enforced via migration 0030. Revisit if Drizzle adds deferred-FK support.
- **`systemSettings` schema declares `uniqueIndex` instead of `NULLS NOT DISTINCT`** — Drizzle's `uniqueIndex` builder doesn't surface the flag. Migration 0047 is the source of truth; `db:push` against an empty DB would skip the flag. Same documented-limitation pattern as `berths.current_pdf_version_id`.
- **One remaining `req.json()` in admin/custom-fields/[fieldId]** — intentional. The handler inspects raw body to detect `fieldType` mutation attempts; parseBody would lose the raw view. Documented inline.
---
## D. Inline TODOs in code (2 remaining)
| File:line | Note | Status |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| ~~`client-yachts-tab.tsx:93`~~ | YachtForm preset owner prop | ✅ landed 2026-05-07 (`initialOwner` prop) |
| ~~`interest-form.tsx:329`~~ | Include company-owned yachts where client is a member | ✅ landed 2026-05-07 (`yachtOwnerFilter` array filter) |
| ~~`interest-form.tsx:330`~~ | "Add new yacht" inline shortcut | ✅ landed 2026-05-07 (Plus button + YachtForm sheet) |
| [`src/lib/queue/scheduler.ts:44`](../src/lib/queue/scheduler.ts#L44) | Per-user reminder schedule (override on top of per-port digest) | Placeholder — per-port digest works; revisit when a customer asks for per-user override |
| [`src/lib/queue/workers/import.ts:13`](../src/lib/queue/workers/import.ts#L13) | CSV/Excel import worker — entire feature surface | Placeholder — nothing currently enqueues `import` jobs (verified) |
---
## E. Hidden / stubbed UI tabs
-**Company Documents tab** — landed 2026-05-08. `/api/v1/files` accepts `companyId`+`yachtId` filters; CompanyFilesTab + uploadZone wired through the storage abstraction.
- **Berth Waiting List + Maintenance Log tabs** — `src/components/berths/berth-tabs.tsx:346`. Removed entirely; revisit if/when product asks.
- **Interest Contract / Reservation tabs** — `src/components/interests/interest-{contract,reservation}-tab.tsx`. Render a "coming soon" friendly card; the real flow is gated on Documenso Phases 26.
---
## G. Dependencies / audit roadmap (post-PDF-overhaul)
**Source:** [`docs/AUDIT-2026-05-12.md`](./AUDIT-2026-05-12.md) §§ 34-36 +
[`docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md`](./superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md).
What's done (2026-05-12 session — all phases shipped):
-**PDF stack overhaul**`@react-pdf/renderer` + brand kit + port logo upload pipeline; 4 reports + 3 record exports + parent-company expense ported; pdfme uninstalled; pdfkit retained for streaming expense PDF (now with shared brand-header). Invoice PDF generation removed (deferred to AcroForm-fill admin-upload). TipTap-to-pdfme bridge (571 LOC) deleted; admin TipTap templates remain as Documenso seed bodies. `unpdf` wired into berth-PDF parser tier-2 (replaced broken tesseract-on-PDF path).
-**react-email templates** — all 7 remaining (crm-invite, document-signing×3, inquiry×2, residential×2, notification-digest, admin-email-change) ported from string templates to React components. Public API surface now `async`. The whole email template directory is uniformly react-email.
-**browser-image-compression** — wired into scan-shell so 4-12 MB phone photos crush to ~500 KB in a WebWorker before tesseract / upload. Massive mobile bandwidth + battery + perceived-latency win.
-**@axe-core/playwright** — smoke spec runs WCAG 2.1 A/AA against 6 main pages; CI fails on new critical/serious violations.
-**ts-pattern in search.service.ts** — converted both switches to `match().with().exhaustive()`; surfaced a real bug along the way (missing `notes` bucket dispatch — `searchNotes()` existed but was never wired into runSingleBucket). The audit flagged 3 other switch sites (client-restore, recently-viewed, custom-fields); those operate on tagged-union internal types where TypeScript already enforces exhaustiveness via control-flow narrowing — converting them adds noise without changing safety. **Done.**
-**p-limit in mass-op services** — bounded fan-outs on the three real unbounded `Promise.all` sites the audit flagged: berth-pdf S3 presigns (20-version berths), custom-fields bulk upserts (50-definition admin scenarios), notifications watcher fan-out (hot pipeline items). Audit also speculatively flagged brochures.service + backup.service — verified neither has an unbounded fan-out. **Done.**
-**formatDate helper** — single source of truth in `src/lib/utils/format-date.ts` backed by `Intl.DateTimeFormat` (no new dep). 9 named presets, TZ-aware via `tz` opt, defensive against null/Invalid Date. `formatDateRange` collapses same-year strings. `formatRelative` via `Intl.RelativeTimeFormat`. 17 unit tests. Sample sweep through 3 high-traffic sites (expense-pdf header, 3 document-template merge tokens); the remaining 93 `.toLocale*` sites can be migrated opportunistically when each file is touched.
-**@tanstack/react-virtual in DataTable** — opt-in `virtual` prop. Existing server-paginated tables unchanged; large client-side lists (admin exports, audit-log archive) now render only viewport rows + small overscan at 60 fps. Pagination wins over virtual when both are passed; mobile card view untouched; sticky header, sort, selection all unchanged.
-**drizzle-zod adoption** — pattern proven in tags.ts + brochures.ts (earlier commit). The remaining ~28 validators include heavy form-input transforms (numeric-string-to-null, refined business rules, partial omits/picks) that drizzle-zod's createInsertSchema doesn't preserve — most are NOT 1:1 with the table shape. Migration is net-wash on LOC and adds no safety. Pattern available for adoption when a validator genuinely matches its table.
-**Tier 2 polish** — surveyed each candidate. `fast-deep-equal` not needed (existing memo comparators work). `use-debounce` package adds no value over the in-tree 13-LOC hook. `@use-gesture/react`, `embla-carousel-react`, `yet-another-react-lightbox`, `react-resizable-panels` all need concrete UX surfaces or product decisions before wiring — added them to the parked list.
-**Pre-commit staged type-check**`scripts/tsc-staged.mjs` (30-LOC shim) replaces the broken `tsc-files` package (which silently no-ops under pnpm). Pre-commit now runs `tsc -p <temp-config>` against staged ts/tsx in ~3s vs ~22s full-project; type errors caught before they hit CI.
**React Compiler safety triage (post-Next-16 bump):**
The Next 15 → 16 upgrade brought `react-hooks` v7 with React Compiler safety rules. Initial sweep surfaced ~89 findings; categorical triage status as of 2026-05-12:
-`react-hooks/purity` (2 → 0) — promoted to `error`. Cleared by pinning `Date.now()` reads to a `useState`-backed `now` ticker in `notes-list.tsx`.
-`react-hooks/set-state-in-render` (5 → 0) — promoted to `error`. `useMemo` mis-used for side effects in `interest-contact-log-tab.tsx`; converted to `useEffect`.
-`react-hooks/immutability` (7 → 0) — promoted to `error`. Mutable `useMemo` value in `documents-hub.tsx` drag counter → `useRef`. `let angle` mutation in `PieChart.tsx` slice loop → `reduce`. Three "function used before declared" hits (load/loadProfile in admin/onboarding-checklist + settings/user-profile + settings/user-settings) → declared inside the calling `useEffect`.
-`react-hooks/refs` (10 → 0) — promoted to `error`. Three `ref.current = x` writes during render moved into a layout-effect (`use-realtime-invalidation.ts`, `settings-form-card.tsx`, `inbox.tsx`). Three search-related `ref.current` reads during render rewritten to backed-by-state (`command-search.tsx`, `mobile-search-overlay.tsx`). Scan shell's `fileRef.current.files[0]` read replaced with a tracked `currentFile` state.
-`react-hooks/incompatible-library` (13 → silenced as `off`) — purely informational ("Compiler skipped this file because of a non-Compiler-safe import"). No action needed.
-`react-hooks/set-state-in-effect` (51 → 0) — promoted to `error` in eslint.config.mjs. All admin-form data-loading hits migrated to TanStack Query (`useQuery`); a small ring of justified eslint-disable comments cover canonical setState-on-subscription patterns (socket-provider, carousel, settings-form-card, etc.). New regressions block CI.
**Data-fetching pattern migration: DONE.** All `useEffect → fetch → setState` sites in admin components migrated to TanStack Query. `set-state-in-effect` is now an ESLint error, so new regressions can't land.
---
Remaining (opportunistic, no concrete trigger):
| Item | Estimate | Notes |
| --------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`.toLocale*` remainder (93 sites)** | ~2-3h opportunistic | Migrate to `formatDate(...)` as you touch each file. Helper already shipped; 17 tests; sweep proven on PDF + template paths. |
| **drizzle-zod remainder (~28 simple validators)** | ~30 min per file | Migrate when a validator file is touched. Pattern proven in tags + brochures. |
| **Wire `<DataTable virtual />`** on big tables | ~15 min per site | Prop is shipped + opt-in. Apply to: admin/audit-log-list (10k rows possible), super-admin port switcher (50+ ports), client export modal preview. None blocking. |
| **Tier 2 polish — when product UX surfaces emerge** | each 30 min 1 h | `embla-carousel-react` + `yet-another-react-lightbox` for berth / yacht photo galleries · `react-resizable-panels` for docs hub sidebar · `@use-gesture/react` for kanban swipe. |
Decisions / parked:
- ~`@upstash/ratelimit`~ — **rejected on inspection.** Audit claimed "4 hand-rolled rate limiters"; actual state is **one** centralized sliding-window Redis limiter (`src/lib/rate-limit.ts`) with 14 named policies + atomic pipeline. Replacement is pure churn.
- ~`@faker-js/faker`~ — **rejected on inspection.** Both seed files (`seed-data.ts`, `seed-synthetic-data.ts`) are hand-curated demo specs (per-pipeline-stage clients with locale-correct names/phones/addresses keyed to test selectors). No fake-data factory exists to replace — adopting faker means WRITING the factory + losing curation. Net add, not net subtract.
- ~`msw`~ — **rejected on inspection.** Integration tests already mock external services via `vi.mock('@/lib/services/documenso-client', ...)` at the module boundary — equivalent determinism, no extra layer. MSW only wins when tests hit `fetch()` directly, which we don't.
- `next-safe-action` — pilot on a new form first (no concrete trigger).
- `@sentry/nextjs` — needs SaaS-dep decision.
- `@tiptap/core` upgrade — needs product decision on rich notes.
- `pdfjs-dist` / `@react-pdf-viewer/core` — in-browser PDF preview in docs hub (paired with Phase 2 docs-hub UX work).
- `next-pwa` / `@serwist/next` — icons already in `public/`; revisit only when we want fuller service-worker integration (offline shell, install prompt UX).
- `next-intl` — no current i18n target.
- `posthog-js` — analytics scope decision.
- `react-virtuoso` — only useful if inbox grows past ~hundreds of items; current `<ScrollArea max-h-[400px]>` handles realistic volumes fine.
- `react-imask` / `react-number-format` — input masks across ~6 forms. Decision pending: hand-rolled formatters work today.
- `type-fest` — opportunistic types; no concrete trigger.
- `partysocket` — Socket.IO-protocol incompatible without significant rework.
Major deferrals from §34 of audit:
- ~**Next 15 → 16**~ — **DONE 2026-05-12**. middleware.ts → proxy.ts via codemod, native flat eslint config, react-hooks v7 Compiler safety rules surfaced + triaged.
- ~**Tailwind 3 → 4**~ — **DONE 2026-05-12**. Official upgrade tool migrated 80 files; tailwind-animate → tw-animate-css; theme moved to @theme directive in globals.css.
- **eslint 9 → 10** — attempted, reverted: `eslint-config-next@16` still has a transitive on `eslint-plugin-react@7` that uses removed eslint-9 context API. Re-attempt when upstream lands eslint-plugin-react@8.
- **archiver 7 → 8** — no `@types/archiver@8` published; skip indefinitely.
---
## H. Grand audit cleanup plan (post-deps)
**Source:** [`docs/AUDIT-2026-05-12.md`](./AUDIT-2026-05-12.md) — 534 findings across 27 domain reports + [`docs/AUDIT-FOLLOWUPS.md`](./AUDIT-FOLLOWUPS.md) + [`docs/AUDIT-TRIAGE.md`](./AUDIT-TRIAGE.md).
Deps work is complete (sections A-G above). Remaining audit cleanup is grouped into focused waves so it's tackleable a chunk at a time. Each wave has clear scope, file pointers, and acceptance criteria.
### Wave 1 — Stop-ship CRITICALs (security + data integrity)
Roughly half-day each; ship in priority order. These are the items from the audit's `## Cross-cutting priority queue` marked `[C]`:
1. **Real `db:migrate` runner**`0052_audit_critical_fixes.sql` uses `CREATE INDEX CONCURRENTLY` which silently never runs under `db:push`. Six composite indexes missing in prod. Build a tsx runner that reads migrations in order, splits on `--> statement-breakpoint`, executes outside a tx, tracks state in `__drizzle_migrations`. ~3-4 h. **(data-model C1)**
2. **`EMAIL_REDIRECT_TO` production guard** — `src/lib/env.ts` should refine to reject when `NODE_ENV === 'production'`; `src/lib/email/index.ts` should `logger.warn` at boot. 5-min change, prevents a very-bad-day class of incident. **(email C1)**
3. **Orphan-blob fix in `handleDocumentCompleted`**`src/lib/services/documents.service.ts:1100-1253`. Wrap `storage.put + files.insert + documents.update` in a transaction (or saga with compensating delete). Current catch-block leaves blob in storage AND marks `status='completed'` with no `signedFileId`. ~2 h. **(services C2)**
4. **Escape URLs in email templates** — every template in `src/lib/email/templates/*` inlines `${data.link}` etc. into `href="…"` and link text without escaping. Add `escapeUrl` helper + http(s) scheme allow-list; route every template through it. ~3 h. **(email C2)**
5. **Replace 16 native `window.confirm()` calls** — destructive flows bypassing `ConfirmationDialog` / `AlertDialog`. ui-ux-auditor's C1 lists the sites (cancel signing, delete files, archive interest/company/yacht…). ~30 min per site = full day. **(ui/ux C1)**
6. **GDPR Article-15 export completeness**`src/lib/services/gdpr-bundle-builder.ts` is missing: portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions. Regulator-finding-level gap. ~half-day. **(gdpr C1)**
7. **Right-to-be-forgotten actually erase**`src/lib/services/client-hard-delete.service.ts` nullifies FKs but leaves verbatim PII in `email_messages.body_html`, `files`, `document_sends.recipient_email`. Add true-wipe path. ~half-day. **(gdpr C2)**
8. **`user_permission_overrides.user_id` FK + `onDelete='set null'`** — data-model H1+H2. Single migration. ~30 min. **(data-model H1+H2)**
9. **Resolve-identifier endpoint replacement** — current rate-limited hit still echoes the real canonical email on a successful username hit. Replace with a server-side signIn proxy that takes `{identifier, password}` together and never returns canonical emails at all. ~2 h. **(security/gdpr crossover)**
### Wave 2 — HIGH-priority security + observability (5-7 days)
10. **`audit_logs.metadata` PII masking** — extend `maskSensitiveFields` to cover `audit_logs.metadata`; add 90-day retention cron mirroring `error_events`. ~2 h. **(gdpr H)**
11. **Webhook → error pipeline**`src/app/api/webhooks/documenso/route.ts` bypasses `captureErrorEvent` on handler crash. Apply to every webhook route. ~2 h. **(observability H)**
12. **Admin email-template subject editor** — 5 of 8 templates ignore `overrides.subject`; admins see "Saved" with zero effect. Wire all 8. ~2 h. **(email H1+H2)**
13. **Admin signature/footer fields**`/admin/email` writes `email_signature_html` + `email_footer_html` which the email shell never reads. Either delete the UI or wire it. ~half-day. **(email H3)**
14. **PII redaction in error pipeline**`error_events.request_body_excerpt` sanitizer redacts password/token but not email/phone/name/dob/address. ~2 h. **(observability H + gdpr)**
15. **Notification email worker XSS**`src/lib/queue/workers/notifications.ts:65-71` interpolates `notif.description` and `notif.link` into HTML unescaped. Apply `escapeHtml` + URL allow-list (the `isomorphic-dompurify` we shipped helps here). ~1 h. **(email H + security)**
### Wave 3 — React Compiler set-state-in-effect cleanup (~40 sites remaining)
Remaining `react-hooks/set-state-in-effect` warnings: **40** (was 41; reduced 2026-05-13). Two patterns established this session as templates:
- **List/load pattern** (`src/components/admin/tags/tag-list.tsx` is the template): `useState([]) + useEffect(fetch+setState)``useQuery({ queryKey, queryFn })`. Mutation paths get `useMutation` with `onSuccess: queryClient.invalidateQueries`. ~10 min per site.
- **Dialog open→reset pattern** (`src/components/clients/hard-delete-dialog.tsx` is the template; new exemplar: `src/components/documents/move-to-folder-dialog.tsx`): inner `<DialogBody key={id} ... />` mounted only while `open`, so `useState` initializers run naturally on each open without an open→reset useEffect. ~15 min per site.
Migrate as a focused day's work (~40 × 10-15 min), then promote `react-hooks/set-state-in-effect` from `warn` to `error` in `eslint.config.mjs` to lock in. **NOTE:** Warnings only — no functional regressions; promotion blocked solely until 0 warnings remain.
### Wave 4 — UI/UX consistency + accessibility (~3-4 days)
-**Raw enum render via `.replace(/_/g, ' ')` (40+ sites)** — extracted to `constants.ts` `formatStage`/`formatStatus`/`formatPriority` helpers (audit-wave-4). **(ui/ux H1)**
-**18 list components missing mobile `cardRender`** — Wave 9.4 covered the 5 actual DataTable consumers without `cardRender` (admin/tags, admin/roles, admin/ports, admin/document-templates, admin/custom-fields). **(ui/ux H2)**
-**Berth status pills using ad-hoc Tailwind colors** — swapped to shared `StatusPill` in Wave 9.2. **(ui/ux M1)**
-**UserList "Active"/"Disabled" badge** — aligned to `StatusPill` in Wave 9.2; also `PortList` in Wave 9.4. **(ui/ux M2)**
-**Drawer vs Sheet usage drift** — single offender (`client-interests-tab`) swapped to Sheet; doctrine documented in CLAUDE.md (Wave 9.1). **(ui/ux M11)**
-**Decorative icons missing `aria-hidden`** — Wave 10.4 mechanical sweep added `aria-hidden` to 444 self-closing single-line Lucide icons across 267 .tsx files. **(ui/ux M10)**
-**Hard-coded "border-amber-300 bg-amber-50" callouts (15+ sites)**`<WarningCallout>` shipped in Wave 4. **(ui/ux L5)**
-**Dashboard route `loading.tsx` coverage** — default `[portSlug]/loading.tsx` plus tailored detail-page skeletons (Wave 9.5). **(ui/ux M3)**
### Wave 5 — Performance + reliability (~2-3 days)
-**Concurrency races** — Wave 10.3 closed the CRITICAL + tractable HIGH items: `handleDocumentCompleted` concurrent-retry TOCTOU via SELECT FOR UPDATE re-check (C-1), `moveFolder` cycle-check race via per-port pg_advisory_xact_lock (H-1), `upsertInterestBerth` 23505 → ConflictError (H-3), username uniqueness 23505 → ConflictError (M-2). Wide-impact items (BullMQ jobId plumbing — C-2) remain deferred. **(concurrency C, H)**
-**Postgres FTS for `search.service.ts`** — migration `0057_search_fts_indexes.sql` shipped in Wave 5. **(audit 36.K.1)**
-**`useEffect → fetch → setState` data-loading** — covered by Wave 3.
### Wave 6 — Email + Documenso depth (~2-3 days)
- **Documenso integration depth** (documenso-auditor report) — full v1/v2 audit, recipient signing URL handling, redirect URL per-port, sequential signing flag.
- **Email deliverability** (email-auditor report) — subject editor wire-up (Wave 2 #12), signature/footer wire-up (Wave 2 #13), bounce monitoring sanity check, attachment threshold UX.
### Wave 7 — Reporting + recommender quality (~half-week)
- **Reporting math correctness** (reporting-auditor) — verify revenue, pipeline funnel, occupancy math against hand-computed truth set.
- **Berth recommender quality** (recommender-auditor) — tier ladder edge cases, heat-score weight calibration.
### Wave 8 — Long tail (whenever)
-**PDF + brand asset correctness** (pdf-auditor) — Wave 9.6: wrong-port brand fallback (`'Port Nimara'``(port)`/throw), AcroForm field-drift warnings, EOI form flatten, PDF metadata, sha256 pinning of `assets/eoi-template.pdf`, berth-range warning noise. Items C-2/C-3 (tiptap-to-pdfme bugs) were eliminated by the 2026-05-12 PDF stack overhaul.
-**Customer-facing copy + terminology** (copy-auditor) — Wave 9.7: centralized `lib/labels/document-status.ts` (C3), portal `leadCategory` chip removed (C2), `Save Changes``Save changes` + `Saving...``Saving…` codemod (H1, M3), envelope → signing request (M1), `Linked prospect``Linked interest`, `Deal Documents``Interest Documents`, `Hot Lead``Hot lead` (M5).
-**Onboarding + first-run UX** (onboarding-auditor) — Wave 9.8: fixed wrong setting keys in checklist auto-checks (C1), broken `forms` href (C2), compound gate for Documenso EOI readiness (C3), catch-and-log around `ensureSystemRoots` (C4), fresh-port berth empty state (H5), admin-sections-browser description (M4).
-**Type-safety + drizzle leak audit** (types-auditor) — Wave 10.1: `Tx` type exported (C-1), berth-detail `useQuery<any>` replaced with `BerthDetailData` (C-2), parseBody adopted across 7 portal/public routes (C-3), `toAuditJson<T>` helper removed 21 `as unknown as Record<…>` casts (H-5). Drizzle leak check came back clean (no `$inferSelect` crossing the API boundary).
-**Build + deploy + prod readiness** (build-auditor) — Wave 10.2: socket.io + 6 other native deps added to `serverExternalPackages` + COPY-in-Dockerfile (C-3), `NEXT_PUBLIC_APP_URL` validation (H-2), healthcheck PORT templatization (H-5), `NODE_ENV=production` in builder (M9), image-level HEALTHCHECK (M7). CSP `'unsafe-inline'` (H-1) deferred pending nonce middleware infrastructure.
-**Wave 11 — unaddressed-dossier sweep + cross-cutting infra**:
- **BullMQ jobId plumbing** (concurrency C-2): stable per-entity jobIds added across `invoices` (send-invoice, invoice-overdue-notify), `gdpr-export`, `webhook-dispatch`, `expenses`, `webhooks.service`, `notifications`, `inquiry-notifications`, `reports` (generate-report).
- **CSP nonce middleware** (build-auditor H-1): per-request nonce in `src/proxy.ts:buildCspWithNonce` with `'self' 'nonce-<n>' 'strict-dynamic'` in prod; `next.config.ts` fallback header kept for static assets / API JSON.
- **Error UX** (error-ux-auditor): `apiFetch` synthesizes a client-side correlation id for non-JSON 5xx (C3); `checkRateLimit` fails open on Redis outage so auth doesn't lock (C4); `StorageTimeoutError extends Error` with `name='TimeoutError'` for classifier hints (H2); `errorResponse()` adopted across `/api/storage/[token]`, `/api/public/website-inquiries`, Documenso webhook body cleaned (H5); 17 `toast.error(err.message)` sites swept to `toastError(err, …)` (C2).
- **Outbound webhooks** (outbound-webhook-auditor): Stripe-style `HMAC(secret, "${ts}.${body}")` + `X-Webhook-Timestamp` header (C1); dead-letter when secret is null (C3); retry policy `8 attempts × 30s base exponential` (H2); SSRF denylist gains Oracle Cloud `192.0.0.192` (M1); dispatch-time `https://` assertion (M2).
- **Storage-pathing** (storage-pathing-auditor): berth-PDF presigned-upload key prefixed with `${portSlug}/` + `portSlug` passed to `presignUpload` (H1); `presignDownloadUrl` infers the slug from the key's first segment when callers don't pass it explicitly — engages the filesystem-proxy port-binding `p` token verifier across every download site (H2).
- **Search** (search-auditor): dead `void wantEmail; void wantPhone;` + unused `looksLikeEmail` helper removed (H3).
- **Maintainability** (maintainability-auditor M2): swept seven `void <symbol>` abandoned-scaffolding markers and their dead imports across `clients/bulk`, `interests/bulk`, `admin/email-templates`, `admin/website-submissions`, `alert-rules`, and `notes.service`.
### Wave 11 — explicitly deferred items (revisited 2026-05-13, deferred again)
Each was flagged by the audit but assessed as not-yet-needed for production correctness. Listed here so future-you doesn't re-research them.
**Engineering refactors deferred:**
- **Orphan-blob reaper** (storage-pathing C2, ~4-6h) — `handleDocumentCompleted` already has compensating delete for the only frequent orphan path. Other paths (gdpr-export, backup, etc.) are low-frequency. Revisit when storage costs grow.
- **Webhook deliveries reaper** (outbound-webhook C2, ~2-3h) — `webhook_deliveries` table grows unbounded on high-volume events. Zero active webhook subscribers today; revisit when customers actually subscribe.
- **DNS-rebind TOCTOU** (outbound-webhook H1, ~2h) — Requires admin AND DNS control on the target host. Defense-in-depth on already-low-risk vector. Revisit before exposing webhooks to external integrators.
- **Streaming pass on backup/migrator/email-compose** (storage-pathing H3+H4, ~4-6h) — pg_dump OOM at multi-GB. DB is ~10s of MB today. Revisit when DB grows 100x.
- **Webhook circuit-breaker** (outbound-webhook H3, ~3-4h) — Auto-disable webhooks after N consecutive dead-letters. Saturating worker slots requires active webhook subscribers; none today.
**Mechanical service splits deferred:**
- `documents.service.ts` split (1982 lines → 4 files, ~3-4h)
- `search.service.ts` split (2163 lines → per-bucket files, ~4-6h)
- `notes.service.ts` dedup → dispatch table (1121 → ~500 lines, ~3-4h)
- `interest-tabs.tsx` split (959 lines → 3 files, ~2-3h)
- `expense-pdf.service.ts` split (987 → 3 files, ~2h)
- `command-search.tsx` split (1177 → 5 files, ~3-4h)
Pure code-hygiene work. The files are large but functional. Splitting touches hundreds of imports, risks regression, delivers zero user value. Revisit if/when navigation friction becomes a real bottleneck.
### How to use this section
- Pick a wave; pick an item; read the linked audit section for full context.
- Each item closes with a commit in the `fix(audit-<wave>): ...` format so it's trivially greppable.
- Mark items DONE inline in this section as they ship.
- Audit-FOLLOWUPS.md tracks Wave 1-10 from an earlier sweep — items there may already be done or supplanted by AUDIT-2026-05-12.
Future PDF-related work (carry-over from §A of the PDF overhaul spec):
- **AcroForm-fill admin-uploaded PDF templates** (~1 week solo): new `pdf_templates` table + admin upload UI + field-mapping editor + generalize `fill-eoi-form.ts` into a reusable `fillAcroForm()` utility. Reinstates the invoice PDF path (and any future customer-facing standardized doc).
- **Port brand color tokens** (~2 h): admin sets brand color → flows into the PDF brand kit accent.
- **Optical receipt-photo rotation/deskew** (~half day): auto-rotate phone-upload receipts that EXIF misses.
---
## F. Historical audit docs (mostly resolved)
These dossiers drove the audit-fix commit waves on 2026-05-05/06. Items
not surfaced in §C above were resolved via the `fix(audit): …` commits
(`588f8bc`, `94331bd`, `a8c6c07`, `5fc68a5`, `da7ede7`, `c5b41ca`,
`b4fb3b2`, `0f648a9`, `c312cd3`, `0a5f085`, `1a87f28`, `f3143d7`,
`05babe5`). Keep for historical context:
- [`audit-comprehensive-2026-05-05.md`](./audit-comprehensive-2026-05-05.md) — pre-merge audit (1 CRIT + 18 HIGH at start)
- [`audit-comprehensive-2026-05-06.md`](./audit-comprehensive-2026-05-06.md) — post-merge audit (1 CRIT + 7 HIGH + 10 MED + 7 LOW)
- [`audit-frontend-2026-05-06.md`](./audit-frontend-2026-05-06.md) — frontend-only sweep
- [`audit-missing-features-2026-05-06.md`](./audit-missing-features-2026-05-06.md) — admin-promised-but-unwired features (V1V12)
- [`audit-permissions-2026-05-06.md`](./audit-permissions-2026-05-06.md) — permission-gate gaps
- [`audit-reliability-2026-05-06.md`](./audit-reliability-2026-05-06.md) — transactional integrity / TOCTOU
- [`berth-feature-handoff-prompt.md`](./berth-feature-handoff-prompt.md) — berth recommender handoff (shipped, kept as reference)
- [`berth-recommender-and-pdf-plan.md`](./berth-recommender-and-pdf-plan.md) — berth recommender + per-berth PDF plan (Phases 08 shipped)
- [`documenso-integration-audit.md`](./documenso-integration-audit.md) — Documenso integration spec (drives §A)
- [`website-refactor.md`](./website-refactor.md) — public website cutover plan

View File

@@ -1,305 +0,0 @@
# Post-Audit Fix Plan
Generated 2026-05-14 from two rounds of deep Playwright + API audit on `feat/documents-folders``main`.
**Total findings:** 24 fixes + 1 new feature. Grouped by priority. Each entry has impact, file pointer, and effort estimate.
---
## TIER 0 — Already Applied in Working Tree (uncommitted)
Status: **fixed in code, not yet committed**. Commit + push to ship.
### F1. `/api/v1/bootstrap/*` proxy allow-list (task #22)
- **Impact:** Cold-start VPS deploy can't bootstrap its first super-admin. `/setup` page calls `/api/v1/bootstrap/status` which 401s; setup form never renders.
- **File:** `src/proxy.ts` — added to `PUBLIC_PATHS`.
- **Effort:** XS.
### F2. Interest detail page 500s on every visit (task #25)
- **Impact:** Sales workflow non-functional. Raw `Date` passed to postgres-js `sql\`${col} >= ${dateVar}\`` template crashes the Bind step.
- **File:** `src/lib/services/interests.service.ts:566` — switched to `gte(col, date)`.
- **Effort:** XS.
---
## TIER 1 — Pre-Deploy Blockers (P1)
Ship before any real client touches the system.
### F3. GDPR export 500s — BullMQ rejects job IDs with colons (task #51)
- **Impact:** GDPR Article 15 right-to-access non-functional. Legal/compliance gate.
- **File:** `src/lib/services/gdpr-export.service.ts:113` — change `jobId: \`gdpr-export:${row.id}\`` → `jobId: \`gdpr-export-${row.id}\``.
- **Effort:** XS (one char).
### F4. Redis eviction policy is `allkeys-lru` but BullMQ requires `noeviction` (companion to F3)
- **Impact:** Under memory pressure, Redis will evict BullMQ keys; jobs disappear silently.
- **File:** production Redis config (`maxmemory-policy noeviction`) + the docker-compose redis service.
- **Effort:** XS (config).
### F5. `deleteBerth()` hard-deletes rows instead of soft-archiving (task #65)
- **Impact:** Permanent data loss on accidental delete. Junction tables CASCADE-vanish. Audit log points to non-existent rows. Public feed could 404 mid-customer-inquiry.
- **Files:**
- `src/lib/services/berths.service.ts:673-685` — replace `db.delete()` with `set archivedAt = now(), archivedBy = userId, archiveReason = input.reason`.
- Add filter `isNull(berths.archivedAt)` to all default berth queries (recommender, public feed, list, dashboard heat).
- Add restore endpoint `POST /api/v1/berths/[id]/restore` mirroring the interests pattern.
- Require `reason` (min 5 chars) before destructive call.
- **Effort:** M.
### F6. Weak input validation on `/api/v1/clients` (task #50)
- **Impact:** Email format not validated (bounces silently); whitespace-only names accepted (blank chips everywhere); XSS payload stored verbatim (depends on every render path being safe).
- **Files:**
- `src/lib/validators/clients.ts` — add `.email()` refinement on contacts where `channel === 'email'`; trim+min(1) on `fullName`; regex-strip control chars + zero-width chars.
- Audit every fullName render path for `dangerouslySetInnerHTML` / pdfme / react-pdf / email template merges and ensure escaping.
- Apply similar hardening to yachts, companies, interests, notes, berths, reminders (audit all string fields).
- **Effort:** S for the obvious zod tweaks, M for the full audit.
### F7. No rate limiting on login (task #68)
- **Impact:** Brute force is wide open. 20 wrong-password attempts in a row all returned 401 with no lockout.
- **Files:**
- `src/lib/auth/` — add a `rateLimit` block to the better-auth config: `{ window: 60, max: 5 }` per IP+email.
- Optionally: Redis sliding window via existing ioredis client.
- Optionally: per-user lockout table (`auth_lockouts`) after 5 failures, locked 15min.
- **Effort:** S.
### F8. postgres-js pool corruption causes CONNECT_TIMEOUT (task #46)
- **Impact:** During the audit the dev server twice entered a stuck state where every query 500'd with `CONNECT_TIMEOUT` while the DB was healthy (1/100 connections used). Production VPS will hit this under load.
- **Files:**
- `src/lib/db/index.ts` — add `connect_timeout: 5`, `max_lifetime: 60 * 60`, `idle_timeout: 30`.
- Wrap critical-path queries in retry-on-CONNECT_TIMEOUT logic (one retry, then 503).
- Consider pgbouncer in front of postgres for production multi-process deployments.
- **Effort:** S for the postgres-js options, M for full pgbouncer.
---
## TIER 2 — High Impact Architectural / UX
Not strictly deploy-blocking, but each one breaks the UX in observable ways every day.
### F9. Layout-wide duplicate mobile/desktop DOM rendering (task #26)
- **Impact:** Single highest leverage UX bug. EVERY page mounts BOTH responsive layouts; both Radix Tabs providers are concurrently active with `data-state="active"`. Half my click attempts on tabs/filters/popovers went to the wrong layer. Doubled network requests, doubled component state, doubled a11y landmarks.
- **Files:** the responsive shell (likely `src/components/layout/*-shell.tsx` and detail-page wrappers).
- **Fix options:** use `useMediaQuery` to mount only one tree; or hoist `<Tabs>` to a single provider and let both layouts consume context.
- **Effort:** L (architectural refactor across multiple pages).
### F10. Archiving a client doesn't cascade-archive their interests (task #66)
- **Impact:** Orphan refs. Archived clients have active interests; active queries surface them with broken breadcrumbs / silent 404s on drill-in.
- **Files:** `src/lib/services/clients.service.ts:archiveClient()` — wrap in transaction, archive open interests too. OR extend `activeInterestsWhere()` to filter on `client.archived_at IS NULL`.
- **Effort:** S.
---
## TIER 3 — Standard Fixes (P3)
UX polish + missing entry points. Each is small, but the sum matters.
### F11. "Mark as won" dialog still says "moves to Completed" (task #27)
- **Impact:** Stale copy from before the 7-stage refactor. Misleads users.
- **File:** `src/components/interests/won-dialog.tsx` (or similar) — update copy to "marks Won; stage stays at <current>".
- **Effort:** XS.
### F12. Activity feed + tab count concatenation (task #23)
- **Impact:** "Test Person 1interest", "Interests0", "Click Test Co.company" — unprofessional.
- **Files:** `src/components/dashboard/activity-feed.tsx` (entity name + type), every detail-page tab count render. Audit log FTS `search_text` should also include entity names.
- **Effort:** S.
### F13. Bulk-add berths wizard has no UI entry point (task #28)
- **Impact:** Feature built for new-port setup, but invisible. Operator must know the URL.
- **Files:** Add a "Bulk add" button next to "New berth" on `/[portSlug]/berths`. Add link on `/admin` landing card.
- **Effort:** S.
### F14. Audit Log page has no UI entry point (task #49)
- **Impact:** Feature built, no nav link. Discovery requires URL knowledge.
- **Files:** Sidebar Admin section — add "Audit Log" entry under `documents` settings or as its own item, gated by `audit_log.view` permission.
- **Effort:** S.
### F15. New Yacht dialog only lists clients in owner picker (task #44)
- **Impact:** Data model supports `'client' | 'company'` ownership; UI only lets you pick clients. Cannot create company-owned yacht via UI.
- **Files:** `src/components/yachts/new-yacht-dialog.tsx` — add owner-type segmented control (Client / Company) above the owner picker; switch data source.
- **Effort:** S.
### F16. InlineTagEditor "Add tag" focus + create flow (task #45)
- **Impact:** Typing in the tag widget set the CONTACT LABEL instead. Plus no "Create new tag" affordance for new tag names.
- **Files:** `src/components/shared/inline-tag-editor.tsx`. Fix focus target; surface "Create new: X" as a popover item; orchestrate POST /api/v1/tags then PUT .../tags.
- **Effort:** S.
### F17. Cross-port (and 404) detail URLs silently render list shell (task #48)
- **Impact:** User pastes a wrong-port URL → API 404s correctly but UI silently shows the list shell. No explicit "not found" message.
- **Files:** every entity-detail client component — render `<EmptyState title="Not found" />` when GET returns 404. Apply to clients, interests, yachts, companies, berths.
- **Effort:** M (apply pattern to each detail page).
### F18. Recommender `limit` param ignored (task #69)
- **Impact:** Request with `{"limit": 3}` returned 8 berths. Either param name mismatch or no clamp.
- **Files:** `src/lib/services/berth-recommender.service.ts` + the recommend-berths validator.
- **Effort:** XS.
---
## TIER 4 — Polish & UX Reductions (P4)
The `UX EFFICIENCY` list (task #24). Each is small, mostly copy/flow improvements.
### F19. New Client form — primary contact default trap
- Default-checked "Primary contact" with empty email silently rejects on submit. Either don't pre-add OR drop empty contacts on save.
### F20. New Interest dialog — redirect to detail page on create
- Currently returns to the list. Add `router.push('/interests/' + newId)` to land on the workflow page immediately.
### F21. Stage-transition error toast leaks developer language
- "yachtId is required before leaving stage=enquiry" → "Yacht is required before leaving the Enquiry stage."
- Audit ALL ValidationError + ConflictError + service error messages for user-readable copy.
### F22. Stage menu uses unicode emoji `⚑` as prereq-blocked indicator
- Per user preference (memory: avoid decorative emoji), replace with a Lucide icon (`Lock`, `AlertCircle`, or `FlagOff`).
### F23. Blocked-stage UX — show prereq picker inline
- Clicking a blocked stage currently dismisses with a toast. Better: open the prereq picker inline ("Pick a yacht to leave Enquiry" with combobox right there).
### F24. New Client form — "Country" optional but prominent
- Drop from quick-path OR move to a "More details" disclosure.
### F25. Documents Hub — folder navigation doesn't update URL
- Drilling into a folder updates "Current location" but doesn't change `location.search`. Can't deep-link, browser-back broken, refresh resets to root.
### F26. "Reopen" outcome action silent — no toast
- After clicking Reopen, no feedback. Add `toast.success('Outcome cleared')` or similar.
### F27. Same-stage write returns full body — should be 204
- PATCH /stage with same stage = current stage returns 200 + full interest. Should be 204 No Content (no-op).
### F28. Recommender empty-result UI
- 300ft yacht returns `data: []` — UI Recommendations tab silently shows blank. Should render "No berths match — try relaxing constraints."
### F29. Inbox first-load "Loading..." stuck
- First navigation to /inbox shows "Loading..." indefinitely; subsequent reload renders fine. TanStack Query cache initialization issue.
### F30. Berths in default queries should filter `archivedAt IS NULL`
- Companion to F5 — once soft-delete lands, every default list query must filter archived rows.
---
## NEW FEATURE — Manual Berth Status Catch-Up Workflow (task #67)
User-requested. Foundation already exists (column `berths.status_override_mode` is in schema but never written).
### Phase 1 — Wire the status_override_mode field
- `updateBerthStatus()` sets `status_override_mode = 'manual'` when called via the user-facing API.
- `berth-rules-engine.ts` triggers set `status_override_mode = 'automated'`.
- When a backing interest is successfully created and links the berth, clear `status_override_mode` back to null in the same transaction; set `status_last_changed_reason` to "Reconciled via interest [id]".
- **Effort:** S.
### Phase 2 — Visual indicator
- On berth list rows: small chip "Manual" next to the status badge when `status_override_mode = 'manual'` AND no active interest is linked.
- On berth detail page header: badge + tooltip showing last reason, user, when.
- On dashboard "Berth Heat" widget: filter or annotate the manual rows.
- **Effort:** S.
### Phase 3 — Reconciliation Queue page
- New page `/[portSlug]/admin/berths/reconcile`.
- Lists every berth where `status_override_mode = 'manual'` and no active interest. Sortable by `status_last_modified DESC`.
- Each row links to the catch-up wizard.
- Sidebar Admin section gets a link with the queue count badge.
- **Effort:** S.
### Phase 4 — Catch-Up Wizard (the core piece)
- Multi-step modal. Steps:
1. **Pick or create client** — combobox + inline quick-create (name + email only).
2. **Pick or create yacht** — optional if pre-EOI; quick-create with name + dimensions.
3. **Pick the matching stage** — based on current berth status:
- `under_offer` → enquiry / qualified / nurturing / eoi (default eoi)
- `sold` → contract + outcome=won
- Allow override.
4. **Upload existing docs** — EOI PDF, contract PDF, reservation form. Each auto-filed to the right entity folder.
5. **Optional payments** — if status=sold, prompt for deposit/full amount.
6. **Review + submit.** On submit, transaction:
- Create/select client + yacht
- Create interest at chosen stage with `assigned_to = current user`
- Upsert `interest_berths(is_primary=true, is_specific_interest=true, is_in_eoi_bundle=true)`
- Upload + attach files
- Insert payments
- Set `berth.status_override_mode = null` + `status_last_changed_reason = 'Reconciled via interest [id]'`
- Audit log single "reconcile" event linking berth + new interest.
- **Effort:** M (wizard) + S (transaction service) + S (API endpoint). Total M-L.
### Phase 5 — Entry points
- Berth list row menu → "Catch up..."
- Berth detail page next to manual badge → "Catch up"
- Dashboard widget "Manual statuses awaiting reconciliation" (count + link)
- Sidebar link
- **Effort:** S.
### Total feature effort: M-L (2-3 dev days).
---
## What I Tested in Round 2 (15 deep journeys, all passed structural validation)
| Journey | Result |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- |
| State machine — stage skipping | ✓ Rejects forward/backward jumps with friendly copy + override path |
| Double outcome write | ⚠ Allowed (won→lost flips freely); audit log just says "update" — should tag outcome change |
| Cascade — delete with dependents | ✗ Inconsistent: clients soft-archive, **berths HARD-delete**, companies soft-archive |
| Manual berth status without backing interest | ✗ Foundation column exists, never written |
| Unicode (emoji/RTL/zero-width) | ⚠ Emoji + RTL OK; zero-width chars NOT stripped (search blind spot) |
| Storage / file upload magic-byte | ✓ Rejects JPEG/HTML disguised as PDF |
| Documenso webhook idempotency | ✓ Timing-safe + rate-limited bad-secret check |
| Berth recommender edge cases | ⚠ Empty dims OK; extreme dims return empty; **limit param ignored** |
| Email body XSS via markdown | ✓ Escape-first-then-rules, javascript: URLs stripped |
| Public berth feed correctness | ✓ Port allow-list, archive filter, status enum validation |
| Rate limiting / abuse | ✗ Login: no rate limit; public feed: CDN-cached |
| Health check + dependency probes | ✓ Anonymous minimal payload, secret-mode for website-intake |
| Direct ID enumeration | ✓ Uniform 404 — no leak |
| Cross-port API access | ✓ 404 at API; **silent at UI** |
| CSRF — fake Origin | ✓ Prod-only protection — dev intentionally skips |
---
## Recommended Commit Sequence
1. **Squash-commit T0 fixes** (F1 + F2) — these are deploy-blockers already applied. Push to main.
2. **T1 batch commit** (F3, F4, F5, F6, F7, F8) — pre-deploy blockers. Single commit per fix for clean review.
3. **T2** (F9, F10) — schedule for next sprint (F9 is architectural).
4. **T3** (F11-F18) — knock out in a few hours. Quick polish wave.
5. **T4** (F19-F30) — UX list. Bundle into a single PR over a few sessions.
6. **NEW FEATURE — Catch-Up Workflow** — 2-3 dev days. Higher business value than T2; prioritize after T1.
---
## Risk Notes
- The audit polluted the dev DB with test entities: `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `Robert'; DROP TABLE clients`, `François 🏄 المعتمد`, `محمد عبد الله`, `CSRF Test`, etc. Also **hard-deleted berth A1 in port-amador** + soft-archived Test Person 1. Consider `pnpm db:reseed:synthetic` before the next clean run.
- The Smoke Test Client interest had `outcome=lost_other` set during the won-then-lost test (R2-B). Audit log preserved both transitions but with action="update" not action="outcome_change".

View File

@@ -1,243 +0,0 @@
# Pre-deploy plan — locked 2026-05-14
Source of truth for everything between today and initial VPS deployment.
Captures every decision reached in the 2026-05-14 planning session, plus
the implementation order, deferred items, and operator checklist.
If a future agent or session resumes this work, **start here** — do not
re-litigate the decisions below without checking the transcript context
that produced them.
---
## 1. Decisions
### 1.1 Hot-path correctness (numbers users see)
| # | Item | Decision | File(s) impacted |
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 1 | Pipeline value mixed-currency | Convert each `berths.price` to the port-default currency at display time via `currency.service`, then sum. | `src/lib/services/dashboard.service.ts`, `src/components/dashboard/*` |
| 2 | "Active interest" definition | `archivedAt IS NULL AND outcome IS NULL` (strictest). Won deals are CLOSED, not active. Extract single `activeInterestsWhere(portId)` SQL helper; route every site through it. | Sweep target — see § 2.1 for list. |
| 3 | Occupancy source of truth | `berth.status = 'sold'`. KPI tile + revenue PDF + analytics timeline all derive from this one source. | `src/lib/services/dashboard.service.ts`, `src/lib/services/analytics.service.ts`, `src/lib/services/report-generators.ts` |
| 4 | Revenue PDF shape | Two side-by-side cards on the same page: "Completed revenue (won, gross)" + "Forecast revenue (pipeline-weighted)". Stacks gracefully on portrait. | `src/lib/services/report-generators.ts` |
| 4.5 | Multi-berth EOI mooring rendering | Populate the existing Documenso `Berth Number` form field with `eoiBerthRange` for both single- and multi-berth EOIs (single-berth output is identical to today via `formatBerthRange(['A1']) === 'A1'`). Drop the unused `Berth Range` payload key + AcroForm field + merge token. No Documenso admin action needed. | `src/lib/services/documenso-payload.ts`, `src/lib/pdf/fill-eoi-form.ts`, `src/lib/templates/merge-fields.ts`, `CLAUDE.md` |
### 1.2 Security / deploy gates
| # | Item | Decision |
| --- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 5 | Portal activation + password-reset token URLs | Switch `?token=ABC``#token=ABC` (URL fragment). Fragment never hits server logs, proxies, or `Referer` header. Touches email templates + `/portal/activate` + `/portal/reset-password` + the `set-password` page reader. |
### 1.3 Email infrastructure refactor
| # | Item | Decision |
| --- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 6 | Admin "Signature HTML" field | **Delete** it. Currently writes `email_signature_html` to settings; `shell.ts` only reads `emailFooterHtml`. Footer covers brand sign-off; signatures are semantically per-user (separate future feature if asked). |
| 7 | Per-category send-from routing | New admin matrix on `/admin/email`: each email category (account activation, password reset, notification digest, EOI signing request, brochure send, berth-PDF send, signed-doc completion, sales send-out, manual rep compose) gets a sender dropdown (`noreply` / `sales`). Sales option auto-disabled when sales SMTP/IMAP creds aren't set. |
| 8 | Bounce monitoring | Per-port admin-configurable IMAP polling of one or more sender mailboxes. Parses DSN bounce notifications via `mailparser`. Writes to new `email_bounces` table, flags the original `document_send` / `notification` / `email_thread` message as bounced, and emits an in-app notification to the assigned sales rep when a _client_ email bounces. |
| 9 | Attachment threshold compose UI | On the manual-compose dialog (brochure send, berth-PDF send, rep custom email), show a banner on any attached file above `email_attach_threshold_mb` that says "will be sent as a 24h signed-link download instead of inline attachment". Also audit current default threshold (10MB) against typical SMTP provider caps. |
### 1.4 Schema additions
| # | Item | Decision |
| --- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 10 | `berths.archived_at` column | Add `archived_at` (timestamp, nullable) + partial index on `(port_id) WHERE archived_at IS NULL`. Filter `/api/public/berths` to exclude archived. Add `<ArchiveBerth>` action in berth detail header (soft-delete with audit log). |
| 11 | `clients.metadata.source_inquiry_id` | Add field for inquiry → client linkage so the conversion funnel chart can attribute won deals back to the originating inquiry. |
| 12 | `email_bounces` table | Bounce monitoring storage — see #8. Columns: `id`, `port_id`, `mailbox_address`, `bounced_address`, `original_send_type` (enum: `document_send` / `notification` / `email_thread`), `original_send_id`, `dsn_status`, `dsn_action`, `dsn_diagnostic`, `received_at`, `raw_message`. |
| 13 | Bulk-berth UX | 2-step wizard for new-port setup. Step 1: pick dock letter + range + tenure (only genuinely-standard defaults). Step 2: editable table with "apply to selected" multi-row actions + Excel-style drag-fill on numeric columns. Step 3 from earlier rounds folded in. |
### 1.5 UX features
| # | Item | Decision |
| --- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 14 | "Mark as signed externally" action | On contract / reservation tabs: new action that records the document as signed without uploading a file. Captures optional reason in a warning modal. Advances pipeline + writes audit log. UI shows "⚠ No file on record — signed externally" indicator. Reps can later upload the file if they obtain a copy. |
| 15 | Contract paper-upload endpoint | Clone the existing EOI `external-eoi` upload flow into `external-contract` and `external-reservation` endpoints. Mirrors the current EOI ergonomics. |
| 16 | Inquiry P-4.5 wire-up | Make `/clients/new?prefill_*&inquiry_id=...` hydrate the create-client form from the searchParams **and** persist `inquiry_id` to `clients.metadata.source_inquiry_id`. Conversion funnel chart depends on this linkage. |
| 17 | Quick brochure/PDF download | Add "Download" buttons on client detail header, interest detail header, berth detail header. Each downloads the current brochure (port-default) / berth PDF / signed contract from storage so the rep can attach to their own email or messenger app. |
| 18 | Per-user reminder digest schedule | Build the simple version of `scheduler.ts:44` placeholder. User-settings dropdown for digest time + days-of-week. Falls back to port-default when unset. |
| 19 | Documents tab N+1 batch fix | Replace the 4-call sequential walk in `listFilesAggregatedByEntity` (direct + company + yacht + client) with a single UNION query keyed by entity-relationship. Target: opening Documents tab on a busy client ≤500ms. |
### 1.6 Investor dashboard charts (toggleable widgets)
Priority order. Each chart ships as a separate widget integrated into the existing widget-customization system; disabled by default for reps, enabled by default for admins.
| # | Widget | Notes |
| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 20 | Total pipeline value of all berths | Single big number (port-default currency, conversion at display). Weekly-change sparkline below. Re-uses the #1 currency-conversion helper. |
| 21 | Berth interest heatmap + ranked-table view | Heatmap shows pier-style grid colored by active-interest count per berth. Paired with a sortable ranked-table view of the same data — table is what exports cleanly to PDF/CSV. Both views toggleable. |
| 22 | Pipeline velocity over time | Stacked area chart: count of interests in each pipeline stage, weekly. Investors see whether deals are advancing or stalling. |
| 23 | Conversion funnel by lead source | Enquiry → qualified → EOI → contract → won, broken down by `lead_source`. Depends on #16 (inquiry → client linkage) for full attribution. |
### 1.7 Mechanical sweeps
| # | Item | Decision |
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 24 | "Deal" → "interest" terminology sweep | Full sweep. Updates: admin description copy (`/admin/qualification-criteria`, `/admin/documenso`), `bulk-archive-wizard.tsx` placeholders, `smart-archive-dialog.tsx`, `client-columns.tsx` comments, and the API route path `/api/v1/berths/[id]/deal-documents``/api/v1/berths/[id]/interest-documents`. Route rename includes caller updates + a 301 redirect on the old path for any external integrations. |
---
## 2. Implementation order
Branch: **`main`** (feat/documents-folders has been fast-forwarded into main; new work continues on main directly).
Test strategy: TDD-where-meaningful (services with behavioral changes — active-interest helper, currency converter, DSN parser). UI and mechanical sweeps covered by full vitest + tsc + lint + playwright smoke at the end.
### 2.1 Step 1 — Money math sweep (highest leverage)
Extract `activeInterestsWhere(portId)` helper. Sweep these call sites:
- `dashboard.service.ts` (already self-consistent, replace inline `isActiveInterest`)
- `client-archive-dossier.service.ts:266-267`
- `client-restore.service.ts:189-190, 215`
- `client-archive.service.ts:214-215`
- `reminders.service.ts:424`
- `berths.service.ts:173-174` (recommender feasibility check — verify semantics still match)
- `interests.service.ts:1161-1162, 196, 361`
- `report-generators.ts:63, 85, 121`
Then:
- Pipeline value currency conversion (`dashboard.service.ts:35-47`)
- Occupancy: switch analytics timeline to `berths.status = 'sold'` (`analytics.service.ts:195`)
- Revenue PDF: two-card layout, weighted forecast + won-gross side-by-side (`report-generators.ts:109-150`)
Estimated effort: ~half day. Single coherent commit set tagged `feat(reporting): canonical active-interest + occupancy + currency-aware pipeline value`.
### 2.2 Step 2 — Email infrastructure refactor
- Drop `email_signature_html` setting + admin field (~10 min)
- Per-category send-from routing matrix (~3-4h)
- Bounce monitoring infrastructure (~6-8h): `email_bounces` table migration, IMAP poller worker, DSN parser, in-app notification on bounce, admin UI for sender configuration
- Attachment threshold compose banner + threshold default audit (~1h)
Estimated effort: ~1 day. Multi-commit.
### 2.3 Step 3 — Schema additions
Single migration + service work:
- `0065_pre_deploy_schema.sql`: `berths.archived_at`, `clients.metadata` (already JSONB — convention update only), `email_bounces` table.
- Services + admin UI for archive berth + filter on public feed.
Estimated effort: ~2h.
### 2.4 Step 4 — UX features
- Externally-signed mark (contract + reservation tabs) + audit log + UI indicator
- Contract + reservation paper-upload endpoints (clone EOI flow)
- Inquiry P-4.5 wire-up (prefill form + persist inquiry_id)
- Quick brochure/berth-PDF download buttons (3 surfaces)
- Per-user reminder digest schedule
- Documents tab N+1 batch query fix
Estimated effort: ~1 day. Multi-commit.
### 2.5 Step 5 — Bulk-berth wizard
Dedicated commit. New `/admin/berths/bulk-add` route + 2-step wizard component + smart-helpers (apply-to-selected, drag-fill). ~half day.
### 2.6 Step 6 — Investor dashboard charts
Four toggleable widgets, each its own commit. ~1 day total. Depends on Step 1 (currency converter) and Step 3 (inquiry linkage).
### 2.7 Step 7 — Terminology sweep
Mechanical. Run last to minimize merge churn. ~2h.
### 2.8 Step 8 — Portal token fragment switch
Dedicated commit. Email template URL builder, page-side fragment readers, Better Auth integration test. ~1h.
### 2.9 Step 9 — NocoDB inspection complete: simulator DEFERRED
NocoDB `Interests` carries only the current `Sales Process Level`
single-select + a handful of point-in-time event timestamps
(`EOI Time Sent`, `Time LOI Sent`, `clientSignTime`,
`developerSignTime`, `EOI_Completed_At`, `finalized_document_sent_at`)
scattered as text fields. There is **no dedicated stage-change
history table** — only the most recent stage value survives.
The recommender simulator's tier-ladder + heat-score logic depends on
"how long did this deal sit at each stage" and "which stage did past
deals make it furthest to before falling through." Without an
advancement timeline that's not recoverable: every imported interest
collapses to one data point.
**Decision (2026-05-14):** defer the simulator until production
accumulates ~10+ won deals under the new pipeline — then the simulator
can replay against real CRM history. The existing per-port heat-weight
tuning UI in `/admin/berth-recommender` is sufficient for v1 launch.
---
## 3. Deferred items (will not block deploy)
### 3.1 External / operator actions (your side)
- **Coordinate website cutover env vars**: generate shared secret with `openssl rand -hex 32`, set `CRM_INTAKE_SECRET` on the website and `WEBSITE_INTAKE_SECRET` on the CRM, wire website's berth-map fetch + inquiry-submit + health probe per `docs/website-cutover-runbook.md`.
- **Legal review of right-to-be-forgotten scope** — anonymize vs true-delete decision. Mechanical fix once policy is set.
- **Documenso v2 endpoint audit against live v2 instance** — verify `/api/v2/envelope/delete` shape, webhook payload (`documentId` vs `id`), `recipientId` vs `token`. Needs a live v2 instance.
### 3.2 Deferred indefinitely (no current trigger)
- Bulk import queue worker (`src/lib/queue/workers/import.ts`) — superseded by bespoke migration scripts. Delete placeholder when the comprehensive NocoDB migration ships.
- Auto-calibration of berth-recommender weights — depends on accumulating ≥10 won deals in the new system before it produces meaningful results.
### 3.3 Comprehensive NocoDB → CRM migration
**Separate workstream** — its own multi-session project. Scope:
1. Pull every row from legacy NocoDB via MCP.
2. Audit messy MinIO storage; tie loose signed PDFs to client/interest/yacht where ownership is recoverable.
3. Carry over historical Documenso documents (per-port API key + envelope IDs).
4. Map legacy schema → current schema; fill obvious data gaps where the right answer is unambiguous.
5. Dry-run + apply against prod DB at initial startup.
Not on the pre-deploy checklist below — handled as a dedicated planning session before the first port-data import.
---
## 4. Pre-deploy operator checklist
In rough order. Tick as completed.
### 4.1 External (operator side)
- [ ] Generate `WEBSITE_INTAKE_SECRET` via `openssl rand -hex 32`; configure both CRM and website to use it.
- [ ] Coordinate website-cutover plan with website repo per `docs/website-cutover-runbook.md`.
- [ ] Provision IMAP credentials for `noreply@portnimara.com` (and `sales@portnimara.com` if applicable) so bounce monitoring works at boot.
- [ ] Provision SMTP credentials for both sender addresses; verify each can actually send.
- [ ] DNS + SSL for the CRM domain.
- [ ] Decide RTBF policy (anonymize vs true-delete) with legal; document in `docs/runbooks/`.
### 4.2 CRM side (run after code work is complete)
- [ ] `pnpm exec vitest run` — all pass.
- [ ] `pnpm exec tsc --noEmit` — clean.
- [ ] `pnpm exec eslint .` — clean.
- [ ] `pnpm exec playwright test --project=smoke` — passes.
- [ ] `pnpm db:migrate` against a fresh prod-shaped DB — runner ships in commit `544b129`; verify it actually runs `CREATE INDEX CONCURRENTLY` statements.
- [ ] `pnpm tsx scripts/migrate-storage.ts` if switching from filesystem → s3 storage backend.
- [ ] Verify `MULTI_NODE_DEPLOYMENT=true` is set if web + worker run on separate nodes (filesystem backend refuses to start otherwise).
- [ ] Confirm `EMAIL_REDIRECT_TO` is **unset** in production (`src/lib/env.ts:110` refuses to start otherwise).
- [ ] Confirm `DOCUMENSO_API_URL` is bare host (no `/api/v1` suffix) and matches the live Documenso version's `DOCUMENSO_API_VERSION`.
- [ ] Verify `/api/public/health?X-Intake-Secret=...` returns 200 with `checks: { db: 'ok', redis: 'ok' }`.
---
## 5. What's NOT in this plan
Items explicitly out of scope for this deploy:
- IMAP-based two-way email sync — feature scope decision, anti-automation stance.
- AI features (semantic search, auto-summarize, anomaly detection) — anti-automation stance.
- `.toLocale*``formatDate()` sweep (93 sites) — opportunistic as files are touched.
- `drizzle-zod` adoption for the remaining ~28 validators — opportunistic.
- Reports system + admin-composable report templates (`audit-followups Wave 11.C`) — post-deploy feature work.
- Manual client form expansion (`Wave 11.A`) — post-deploy feature work.
- Inquiry triage auto-classification (`Wave 11.F`) — post-deploy feature work.
- Per-port email branding admin UI (`Wave 11.G`) — post-deploy feature work.
---
_Last updated: 2026-05-14._

View File

@@ -1,196 +0,0 @@
# Admin / settings UX backlog — STATUS
Living tracker for the admin/UX backlog. Items are marked DONE or
REMAINING based on what landed in the autonomous-push session.
---
## DONE in the autonomous push
### Foundations
- **Currency API verified end-to-end**. `scripts/test-currency-api.ts`
fetches live Frankfurter rates → upserts → reads back → converts.
Inverse-rate drift confirmed at ≤0.001.
- **Storage abstraction audit complete**. Every byte path
(signed EOIs, contracts, brochures, berth PDFs, files, avatars,
branding logos) goes through `getStorageBackend()`. `/api/ready`
and the system-monitoring health probe now check the active
backend (S3 or filesystem) instead of always probing MinIO.
### User settings
- Country + Timezone selectors with cross-defaulting + auto-detect
banner ("Looks like you're in Europe/Paris — Update?")
- Email change with verification flow (`user_email_changes` table,
`/api/v1/me/email/confirm/<token>`, `/api/v1/me/email/cancel/<token>`)
- Password reset triggered via better-auth `requestPasswordReset`
- Profile photo upload + crop (square 256×256) via shared
`<ImageCropperDialog>` + `/api/v1/me/avatar`
### Branding
- Logo upload + crop modal in admin/branding (uses the same shared
cropper, persists via `/api/v1/admin/settings/image` → storage backend)
- Email header/footer HTML defaults injectable via "Insert default" button
- Brand colour picker, app-name field, logo URL all in one card
### Storage admin
- New layout: S3 config form FIRST, swap action SECOND
- Test connection button before any switch
- Two-button switch: "Switch + migrate" vs "Switch only" with warning modal
- `runMigration()` honours `skipMigration` flag
### Backup management
- Real `/admin/backup` page driven by new `backup_jobs` table
- `runBackup()` service spawns `pg_dump --format=custom`, streams to
active storage backend, records size + path
- Download button presigns the .dump for offline restore
- Super-admin gated
### AI admin panel
- Dedicated `/admin/ai` page consolidating master switch +
monthly token cap + provider credentials
- Per-feature settings (OCR, berth-PDF parser, recommender)
linked from the same page
### Onboarding
- Real `/admin/onboarding` page with auto-checked steps
- Reads each setting key + lists endpoint (roles / users / tags) to
decide completion
- Manual checkboxes for steps without an auto-detect signal
- Progress bar + "Mark done"/"Mark incomplete" buttons
- State persisted in `system_settings.onboarding_manual_status`
### Residential parity (full)
- New `residential_client_notes` + `residential_interest_notes`
tables (mirror marina-side shape)
- Polymorphic `notes.service.ts` extended with two new entity types
through verifyParent + listForEntity + create + update + delete
- New `<NotesList>` accepts `residential_clients` /
`residential_interests` entity types
- Activity endpoints: `/api/v1/residential/clients/[id]/activity` +
`/api/v1/residential/interests/[id]/activity`
- Notes endpoints: 4 new routes covering GET/POST/PATCH/DELETE
- `residential-client-tabs.tsx` + `residential-interest-tabs.tsx`
built using the marina-side `DetailLayout` pattern (Overview +
Notes + Activity tabs, Interests tab on the client)
- Detail header components mirror the marina-side strip
- `useBreadcrumbHint` wired into both detail components
### Residential pipeline stages — configurable
- New `residential-stages.service.ts` with list/save + orphan-check
- `/api/v1/residential/stages` GET/PUT
- `/admin/residential-stages` admin UI with reassign-on-remove
modal (select new stage per affected interest before save)
- Validators relaxed from `z.enum(...)` to `z.string()` so any
admin-defined stage id round-trips
### Documenso Phase 1 (EOI generate flow polish)
- Schema migrations applied:
`document_signers.invited_at / opened_at / last_reminder_sent_at / signing_token`,
`documents.completion_cc_emails / auto_reminder_interval_days`
- `transformSigningUrl()` now maps SignerRole → URL segment correctly
(approver→cc, witness→witness) so emails don't land on `/sign/error`
- New `POST /api/v1/documents/[id]/send-invitation` endpoint with
next-pending-signer auto-pick
- Per-port settings added: `documenso_developer_label`,
`documenso_approver_label`, `documenso_developer_user_id`,
`documenso_approver_user_id` (Phase 7 RBAC binding fields)
### Misc UI/UX
- Sidebar collapse removed (always expanded)
- Audit log filter inputs sized + dates widened
- Custom Settings section got a long-form description
- Reminder digest timezone uses `TimezoneCombobox`
- Port form: currency dropdown + timezone combobox + brand color
- Permissions count badge opens a modal with granted/denied
- Role names display-normalized via `prettifyRoleName`
- Sales email config: token list + tooltips on threshold + body fields
- Custom Fields page: amber heads-up about non-integration with
search / recommender / audit / merge tokens
- Tag form: native `<input type="color">`
- FilterBar Select crash fixed (no empty-string item values)
---
## REMAINING — large pieces that didn't fit this push
### 1. Documenso Phase 2 — Webhook handler enhancement (~3-4 hours)
Cascading "your turn" emails when each signer completes; on-completion
PDF distribution; token-based recipient matching; idempotency lock.
File to extend: `src/app/api/webhooks/documenso/route.ts`. The
schema columns are already in place (Phase 1).
### 2. Documenso Phase 3 — Custom doc upload-to-Documenso (~6-8 hours)
Backend service `custom-document-upload.service.ts` + endpoint
`POST /api/v1/interests/[id]/upload-for-signing`. Accepts a PDF +
recipient list + field-placement JSON, calls `createDocument`
`placeFields``sendDocument` on the per-port Documenso client.
Persists a row in `documents` table.
### 3. Documenso Phase 4 — Field placement UI (~10-14 hours)
The biggest piece. Needs:
- 4a: Recipient configurator dialog (~2-3h)
- 4b: PDF rendering with `react-pdf` (~3-4h)
- 4c: Auto-detect anchor scanner via `pdfjs-dist.getTextContent` (~4-6h)
- 4d: Drag-drop overlay using `dnd-kit` (~3-4h)
- 4e: Send button → calls Phase 3 endpoint (~1h)
Plan locked in `docs/documenso-build-plan.md` Phase 4 — the
field-detector regexes, the anchor patterns, and the type-to-bbox
sizing table are all spelled out.
### 4. Documenso Phase 5 — Embedded signing URL emission verification (~1-2 hours)
Verify the website's `/sign/<type>/<token>` page handles every signer
role + every documentType combination. Update website's
`signerMessages` map keyed on `(documentType, role)`. Apply the
nginx CORS block from `docs/documenso-integration-audit.md`.
### 5. Documenso Phase 6 — Polish items (deferred)
Auto-send delay, audit-log additions, per-document customisation,
document expiration, reminder rate-limit display, failed-webhook
recovery UI. Each ~2-3 hours; all deferred until Phases 1-4 ship.
### 6. Project Director — UI binding for the developer-user fields
Schema + setting keys are now in place
(`documenso_developer_user_id`, `documenso_approver_user_id` +
`documenso_developer_label` / `_approver_label`). The remaining
work is: add a "Linked to CRM user" dropdown in
`/admin/documenso/page.tsx` that lists port users; when bound,
auto-fill name/email from the user profile and mark name/email
fields read-only. Webhook handler can then match against the
linked user's email for in-CRM signing-status updates.
### 7. Custom-fields hardening (~ongoing)
Remediation paths for the heads-up banner concerns:
- **Search index**: extend the GIN tsvector to include
customFieldValues content
- **Audit diff**: extend `diffEntity` to walk the
customFieldValues blob
- **Merge tokens**: add `{{custom.<fieldName>}}` handling at
template-render time, plus surface them in the merge-tokens UI
### 8. Documenso v2 webhook payload audit (small)
Risk #4 from `docs/documenso-build-plan.md` — confirm v2 payload
shape (`payload.documentId` vs `payload.id`, recipient.token vs
`recipient.recipientId`) against a live v2 instance before relying
on Phase 2 cascading emails.

View File

@@ -1,117 +0,0 @@
# Comprehensive Playwright Audit — 2026-05-15
Scope: full coverage of admin, sales-rep, viewer, portal, catch-up wizard, single-tree responsive shell, plus spot-checks on yacht / interest / berth detail surfaces.
## Setup
- Dev server: localhost:3000 (running)
- Users:
- super_admin: `admin@portnimara.test` / `SuperAdmin12345!`
- sales_agent: `agent@portnimara.test` / `SalesAgent12345!`
- viewer: `viewer@portnimara.test` / `ViewerUser12345!`
- Port slug: `port-nimara`
## Verified working (positive findings)
- ✅ super-admin login + dashboard renders, all 34 admin pages return 200
- ✅ Recent commits' workflow features:
- F22 AlertTriangle icon on override-required stages
- F23 inline yacht-prereq picker fires when leaving Enquiry without a yacht (confirmed end-to-end: "A yacht must be linked before leaving Enquiry. Pick one below to move to Qualified.")
- F25 documents-hub folder selection persists in `?folder=root` querystring
- F44 OwnerPicker has Client/Company tabs visible in popover (just hidden by Select trigger summary)
-**#67 catch-up workflow end-to-end**: manually flipped berth A2 → reconciliation queue picked it up → wizard quick-created client + interest + cleared override + reason stamped "Reconciled via interest <id>" + redirected to interest detail
-**#26 single-tree shell**: at viewport 390px only mobile shell mounts (1 nav, no desktop sidebar); at 1440px only desktop shell mounts; clean swap on resize
- ✅ Permission gating: viewer + sales-agent get no "New Client"/admin nav; viewer POST to /clients returns 403
- ✅ Audit log captures all writes (tag create, berth update, interest create, client create) including the reconcile event with `reconciledInterestId` metadata
## Findings
### A1 — Dashboard Recent Activity surfaces raw `permission_denied` rows with no label
- `/api/v1/dashboard/activity` returns entries with `action: "permission_denied"` and `label: null`. The activity feed renders just the action badge with nothing beside it. From earlier audits, 6 of these are stacked at the top of the dashboard for the super-admin.
- Fix options: filter `permission_denied` out of the feed, OR map them to readable copy ("Permission denied: tried to view audit log (denied)") using `metadata.attemptedAction`.
- Effort: XS.
### A2 — Activity feed renders legacy 9-stage enum values
- `pipelineStage: "deposit_10pct"` and `"contract_sent"` still appear in `oldValue` / `newValue` for historical rows. These should map to the 7-stage labels at render time so the feed reads as `Eoi → Deposit Paid` not `eoi_signed → deposit_10pct`.
- The mapping table lives in seed-synthetic-data.ts (`details_sent→enquiry` etc.) — pull it into a shared `LEGACY_STAGE_REMAP` helper for activity-feed read paths.
- Effort: S.
### A16 — File upload to documents hub root fails with validation error
- Repro: open `/documents`, click "Upload file", drop any file in. POST to `/api/v1/files/upload` returns 400 with field errors on `clientId`, `yachtId`, `companyId`, `category`, `entityType`, `entityId` — all "expected string, received null".
- Root cause: the client sends `null` for unset optional fields; the validator expects them either absent or strings. Mismatch.
- Fix: either make the zod schema accept `.nullable()` on those fields OR strip nulls in `FileUploadZone` / `FolderDropZone` before POST.
- Effort: XS.
### A17 — `/api/v1/admin/ports` requires X-Port-Id but is the bootstrap port-resolver
- Symptom: as sales-agent, every page load fires a 400 to `/api/v1/admin/ports` ("Port context required"). Repeats on every apiFetch call because `apiFetch` calls this endpoint to resolve port-slug→port-id.
- Bigger problem: the endpoint is gated to super-admin (`requireSuperAdmin`). Sales-reps and viewers will NEVER get a ports list from this endpoint, so the bootstrap path always falls through to the Zustand store. The 400 noise is wasted work + log spam.
- Fix: add a `/api/v1/me/ports` endpoint that returns the caller's accessible ports without the super-admin gate, and have `client.ts` use it. OR seed the PortProvider context into a `__INITIAL_PORTS__` window global on first paint and skip the fetch entirely.
- Effort: S.
### A18 — `/api/v1/users` returns 404 vs `/api/v1/admin/audit` returns 403 (inconsistent perm denials)
- Both endpoints reject sales-agent access but use different status codes. Pick one — either always 404 (hide existence) or always 403 (acknowledge but deny). The 403/404 split is the kind of inconsistency a pentester probes to map permissions.
- Effort: XS sweep.
### A4 — F19 empty-contact filter never runs because zod-validation rejects first
- Repro: open New Client dialog, fill Full Name + one valid email, click "Add Contact" to insert an empty row, click Create Client. Nothing happens (no toast, no submit, no POST in network).
- Root cause: my F19 fix put the empty-row prune in the **mutationFn**, but `handleSubmit(zodResolver)` validates the form FIRST. The empty contact's `value: z.string().min(1)` fails silently — handleSubmit short-circuits without surfacing an error on the empty row (the field has no `errors.contacts[1].value` rendered because the schema-level message attaches to the array path).
- Fix: prune empty contact rows in a custom onSubmit wrapper BEFORE handleSubmit/zod sees them, OR change the field-array schema to allow empty rows and let the mutationFn prune.
- Effort: XS.
### A19_b — Portal `/portal/login` shows "Client portal unavailable"
- The portal is gated by a per-port `client_portal_enabled` system setting. The route layout renders a friendly message but no admin path is obvious to a fresh-eyes operator.
- Two distinct problems:
- **Discoverability**: the admin landing card for "System Settings" doesn't surface a "Enable client portal" toggle prominently. A new operator would have to know the setting key.
- **Portal scope**: the portal currently only has activation + reset password + sign-in surfaces. Once the rep logs the client in, they land on... what? Worth a separate scoping session to flesh out: their interests, their documents, their signing queue, payment history, message thread.
- Recommendation: spec a "Phase 0 portal MVP" (read-only views of own interests + documents + signed-PDF download) before promoting it to clients. Treat the rest as v1.3 backlog.
- Effort: portal MVP S-M depending on scope.
### A3 — Dev-only CSP error spam from react-grab
- `react-grab` dev script tries to load `fonts.googleapis.com/css2?family=Geist` and triggers a CSP block on every page load (2 console errors). Cosmetic since react-grab isn't loaded in prod, but the dev console gets noisy.
- Fix: either drop the react-grab include or extend dev CSP `style-src` to allow `https://fonts.googleapis.com`.
- Effort: XS.
### A5 — Socket.IO WebSocket repeatedly fails to connect in dev
- Console floods with "WebSocket is closed before the connection is established" — at least 6 occurrences per page in this session. Socket-io server endpoint at /socket.io/ isn't reachable from the Next dev server.
- Likely root cause: Socket.IO server runs as a sidecar in compose but `pnpm dev` only starts Next, so the realtime channel is permanently broken in dev. Realtime invalidation features (interest/folder updates) silently never fire.
- Fix: either start the socket server alongside `pnpm dev` (concurrently script), gate the SocketProvider behind a feature flag in dev, or stub the client to no-op when the endpoint 404s the first handshake.
- Effort: S.
### A6 — Some DialogContent missing aria-describedby
- React warnings: `Missing 'Description' or 'aria-describedby={undefined}' for {DialogContent}`. At least one Dialog opens without a DialogDescription.
- Fix: audit Dialog usages and either add a DialogDescription or pass `aria-describedby={undefined}` explicitly where genuinely no description is needed.
- Effort: S.
### A8 — Legacy `statusOverrideMode = "auto"` values still in seed data
- Berth A1 (and likely others) has `statusOverrideMode: "auto"` from the NocoDB legacy import. The new code writes 'manual' | 'automated' | null; 'auto' is unrecognized.
- Treated as "not manual" by the reconcile-queue filter so it's benign today, but the column should be normalized — either migrate legacy 'auto' → null in a migration, or treat 'auto' explicitly in the read paths.
- Effort: XS.
### A9 — Catch-up wizard pipeline stage default doesn't match berth status
- Open the wizard on a berth where status=under_offer; the stage picker defaults to "New Enquiry" instead of "EOI" (the most common manual-flip case).
- Root cause in `catch-up-wizard.tsx`: the default-stage logic only fires when the initial state isn't in the allowed set; 'enquiry' IS in the allowed set for under_offer, so it stays. Should default to EOI on first open via a `useEffect` keyed on `berth?.data.status`.
- Effort: XS.
### A19 — F27 same-stage write still returns 200 + body instead of 204
- Spec said "same-stage write → 204 No Content (no-op)". The service early-returns `existing` correctly (no audit log emitted), but the route handler wraps it in `{ data: existing }` and returns 200.
- Fix: have the service return a discriminated result like `{ kind: 'no-op' } | { kind: 'updated', interest }`, and the route handler returns 204 for the no-op branch.
- Effort: XS (route handler tweak).
### A20 — F44 OwnerPicker — toggle hidden until popover opens (minor UX)
- The yacht-create form shows just "Select owner..." with no visible indication that it supports both clients AND companies. The Client/Company toggle pills only appear once the popover is open.
- Fix option: surface "Owned by: Client | Company" as a segmented control above the picker, OR add a hint chip "Client/Company" next to the label.
- Effort: XS.

File diff suppressed because it is too large Load Diff

View File

@@ -1,753 +0,0 @@
# Comprehensive Audit — 2026-05-06
Conducted directly after the smart-archive / hard-delete / bulk-wizard /
audit-overhaul / synthetic-seed batches landed (commits `d07f1ed`
through `9890d06`). Prior comprehensive audit:
`docs/audit-comprehensive-2026-05-05.md`.
Findings are sorted by severity. Each has a concrete file:line, a
scenario, and a fix recommendation.
---
## CRITICAL
### C1. 5 of 10 BullMQ workers are never imported (production + dev)
**Files:** `src/worker.ts:13-17`, `src/server.ts:72-76`
`src/worker.ts` (production) and `src/server.ts` (dev fallback) both
import only:
- `emailWorker`
- `documentsWorker`
- `notificationsWorker`
- `importWorker`
- `exportWorker`
**Missing:** `aiWorker`, `bulkWorker`, `maintenanceWorker`, `reportsWorker`, `webhooksWorker`.
Because BullMQ workers are constructed at the top of each worker
module and only "start" when the module is imported, never importing
them means:
- **Webhooks never deliver.** `webhooksWorker` is what processes the
`webhooks` queue; the admin "Replay" button we just shipped enqueues
jobs that pile up in `pending` forever.
- **All maintenance crons silently no-op.** `maintenanceWorker` handles
`database-backup`, `backup-cleanup`, `session-cleanup`,
`currency-refresh`, `gdpr-export-cleanup`, `ai-usage-retention`,
`error-events-retention`, `website-submissions-retention`,
`alerts-evaluate`, `analytics-refresh`, `calendar-sync`,
`temp-file-cleanup`, `form-expiry-check` — none run.
- **Scheduled reports never generate.** `reportsWorker` handles
`report-scheduler` (every minute).
- **Bulk jobs never process** (the synchronous bulk endpoints work, but
any deferred-bulk path is dead).
- **AI usage features never run.**
**Impact:** Production CRM has been silently shedding webhook
deliveries, never running retention/cleanup, never sending scheduled
reports.
**Fix:**
```ts
// Append to src/worker.ts AND the inline section of src/server.ts:
import { aiWorker } from '@/lib/queue/workers/ai';
import { bulkWorker } from '@/lib/queue/workers/bulk';
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
import { reportsWorker } from '@/lib/queue/workers/reports';
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
const workers = [
emailWorker,
documentsWorker,
notificationsWorker,
importWorker,
exportWorker,
aiWorker,
bulkWorker,
maintenanceWorker,
reportsWorker,
webhooksWorker,
];
```
After fix, run `pnpm dev` and watch `/admin/webhooks/{id}` deliveries
go from `pending``success` to confirm.
---
## HIGH
### H1. Hard-delete request endpoints have zero rate limiting
**Files:**
- `src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37`
- `src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32`
Each call writes a fresh code to Redis and emails it to the operator's
address. No `withRateLimit(...)`. An attacker who has compromised an
admin account (or even just the new `permanently_delete_clients`
permission) can:
1. Email-bomb the admin's own inbox (every request → email).
2. Probe whether arbitrary client IDs exist (200 + `sentToMaskedEmail`
vs 404 `client not found` is a UID oracle).
3. Burn SMTP quota.
**Fix:** add `withRateLimit('auth', ...)` or a new dedicated bucket
(e.g. 5 per hour per user). Pattern is already in
`src/app/api/v1/clients/[id]/gdpr-export/route.ts`.
### H2. Audit-page view fires on every paginated reload (log spam)
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
I added a "watch the watchers" `view` audit row for first-page audit
fetches. That's the right idea, but the page also re-fires the request
on every filter change (severity, source, action, date range, search).
A diligent admin filtering through the inspector for an investigation
will write dozens of `view` audit rows per minute — making it harder to
find the actual events they're looking for.
**Fix:** dedupe in Redis with a 60-second per-user TTL key, only emit
if the key didn't exist. Or only fire when no filters are active.
### H3. Hard-delete error messages distinguish "no code" vs "wrong code"
**File:** `src/lib/services/client-hard-delete.service.ts:166-174`
```ts
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
if (!safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Confirmation code is incorrect');
}
```
The two messages let an attacker distinguish "you've never requested a
code" (so spam the request endpoint to open the window) from "wrong
code" (so brute-force more codes). 4-digit space is only 10,000 — with
distinguishable feedback an attacker can confirm code validity in
≤5,000 attempts on average.
**Fix:** collapse to a single `'Invalid or expired code'` message; the
operator already has the email open and knows what they typed.
### H4. Synthetic seed leaves `super_admin` linked-port-roles empty
**File:** `src/lib/db/seed-bootstrap.ts:147-160`
The bootstrap creates the `userProfiles` row with
`isSuperAdmin: true` for `super-admin-matt-portnimara`, but doesn't
create `userPortRoles` rows. The actual real `user` rows (admin@,
agent@, viewer@) are only created via the Playwright global-setup.
Anyone running `pnpm db:seed:synthetic` then `pnpm dev` and trying to
log in via the UI hits an unauthenticated state until they also run
playwright setup or sign up via better-auth manually.
**Fix:** either document this in `CLAUDE.md` Quick Reference, or add a
`pnpm db:seed:dev-users` companion script that signs up the three
test users + links roles. Today's synthetic-seed flow felt clean
because the playwright setup was still applied; in a fresh clone it
will surprise.
### H5. Documenso bad-secret 200 response is correct, but enables enum oracle
**File:** `src/app/api/webhooks/documenso/route.ts:67-86`
The route returns `200 ok=false error=Invalid secret` for a wrong
secret. That's webhook best-practice (don't leak signal to attackers),
but combined with the new audit row that captures
`metadata.providedLen`, an attacker can probe secret-length over time
without being detected (just a "warning" row per attempt). On an admin
inspector with 1000s of rows, a slow-rate probe is invisible.
**Fix:** add per-IP rate limit (5/min) to `/api/webhooks/documenso/`
when secret check fails. Don't block real Documenso traffic — it
shouldn't fail the secret check.
### H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond `admin.view_audit_log`
**File:** `src/app/api/v1/admin/audit/route.ts:31`
Audit log has the most sensitive cross-cutting data in the system
(every login attempt with attempted email, every secret-regenerate,
every hard-delete). It's gated only by `admin.view_audit_log`. The
seed grants this to `director` AND `super_admin`. Consider:
- making the page super-admin-only for production, OR
- adding a secondary confirmation when viewing rows that contain
attempted emails / IP ranges (PII).
**Fix:** change `withPermission('admin', 'view_audit_log', ...)` to
add `if (!ctx.isSuperAdmin) check sensitive_audit_view`. Or accept
the current model but document it in the role docs.
### H7. Three "coming soon" stubs in production UI
**Files:**
- `src/components/clients/client-tabs.tsx:276` — "File attachments coming soon."
- `src/components/clients/client-reservations-tab.tsx:41` — "History is coming soon."
- `src/components/berths/berth-tabs.tsx:327` — "{label} coming soon"
Visible to every user on every client / berth detail page. Either ship
the feature or hide the tab.
**Fix:** for `client-tabs.tsx` line 276 (Files), the `files` table
already exists and supports clientId — ship a list view.
For `berth-tabs.tsx` line 327 — find the calling tab labels and
either implement or remove from the tabs array.
For `client-reservations-tab.tsx` line 41 — query past reservations
when the user toggles a "show history" filter.
---
## MEDIUM
### M1. `attachWorkerAudit` recurring job names list duplicates scheduler.ts (drift risk)
**File:** `src/lib/queue/audit-helpers.ts:23-46`
The 20 recurring job names are hardcoded in the audit helper; the
scheduler also has its own list. If someone adds a new cron without
updating both, the cron_run audit row never fires for that job.
**Fix:** export the list from `scheduler.ts` and import it in
`audit-helpers.ts`. Single source of truth.
### M2. `client-merge-log.surviving_client_id` deleted by hard-delete (history loss)
**File:** `src/lib/services/client-hard-delete.service.ts:200-202`
Hard-delete drops every `client_merge_log` row whose surviving id
matches. Those rows are the audit trail of WHO was merged INTO this
client. Once deleted, you've lost evidence of the prior merge.
**Fix:** replace `delete` with a column nullification, or move the row
to a `client_merge_log_archive` table. Audit trail per GDPR Article 5
should outlive the data.
### M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)
**File:** `src/lib/services/client-hard-delete.service.ts:382-396`
For a 100-client bulk delete, the function writes 100 single-client
codes to Redis just to satisfy `hardDeleteClient`'s expectation. Each
write is a round-trip; on a Redis hiccup mid-loop, you can end up
with a half-deleted batch.
**Fix:** refactor `hardDeleteClient` so the inner deletion can be called
without the per-client code check (extract `_doHardDelete()` private
helper used by both single and bulk paths). Keeps Redis clean.
### M4. Smart-restore wizard has dead reversal applier for `berth_released`
**File:** `src/lib/services/client-restore.service.ts:360-372`
The `applyReversal` switch case for `'berth_released'` does nothing —
it just leaves the berth available. The wizard surfaces this as
"auto-reversible" if the berth is still free, but the actual restore
doesn't re-attach the berth to any interest. Operator clicks Restore
expecting their berth back; nothing changes on the berth.
**Fix:** either (a) at archive time, persist the original interestId
in the decision metadata so we can re-link, or (b) update the wizard
copy to make clear the berth is "available for re-attach" rather than
"will be re-attached."
### M5. Several services use `void createAuditLog(...)` without `.catch()`
**Files:** widespread; e.g. `src/lib/services/client-hard-delete.service.ts:127-136, 230-240`,
`src/lib/services/portal-auth.service.ts:269-276`
`createAuditLog` is documented as never-throwing (catches internally),
but defense-in-depth: a `void` Promise that throws produces an
unhandled rejection event. Most paths are fine because the helper
catches; if anyone refactors `createAuditLog` and removes the catch,
this becomes a process-killer.
**Fix:** convention rule: every `void someAsync()` must have a `.catch()`.
Codify with a custom ESLint rule, or wrap at call sites:
`void createAuditLog({...}).catch(() => undefined);`
### M6. Hard-delete audit metadata leaks client `fullName`
**File:** `src/lib/services/client-hard-delete.service.ts:241-247`
After the hard-delete the audit row carries
`metadata: { fullName: client.fullName }`. The client record itself is
gone but their name lives on in the audit log. For a GDPR data subject
who exercised their right-to-erasure, this is technically a retention
of personal data in audit history. Not necessarily wrong (audit logs
have a legitimate-interest basis), but should be conscious.
**Fix:** decide policy: either (a) keep as-is and document, (b) replace
with a hash of the name, or (c) substitute a tombstone identifier.
### M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects
**File:** `src/lib/services/webhooks.service.ts:282-326`
Replaying a successful webhook (operator presses Replay on a delivery
that already had `status: 'success'`) re-fires the same payload to the
recipient. If the recipient's idempotency check is weak, you've just
caused a duplicate. The replay payload includes `retried_from` /
`retried_at` markers, which is good — but most recipients won't honor
them.
**Fix:** disable the Replay button when `status === 'success'`. The UI
already gates on `'failed' || 'dead_letter'` — verify it stays that
way (`webhook-delivery-log.tsx:118-131` looks correct; double-check
no regressions).
### M8. `audit_logs` table has no DELETE permission gate
**Files:** schema and routes
There's no admin endpoint to delete audit rows (good). But there's no
DB-level guard either. A super_admin who runs `db:reset` wipes audit
history. Audit retention should be enforced at the schema level so
even a misconfigured operator can't blow away the trail.
**Fix:** create a `audit_logs_no_delete_role` postgres role that lacks
DELETE on the table; document that the app's DB user should not have
DELETE on `audit_logs` in production deployments.
### M9. Documenso void worker uses dynamic import every time
**File:** `src/lib/queue/workers/documents.ts:25`
```ts
const { voidDocument } = await import('@/lib/services/documenso-client');
```
Dynamic import inside a hot per-job path is fine the first time but
slows every subsequent call slightly. Move to top-of-file import
unless there's a deliberate reason (circular dep?).
**Fix:** test moving to top-level import; if it works (no circular
deps), keep it there.
### M10. Bulk archive wizard "blocked" reason copy truncates at first line
**File:** `src/components/clients/bulk-archive-wizard.tsx:153-163`
The wizard shows `b.blockers[0]` for blocked clients. If the dossier
has multiple blockers, only the first is shown. Operators may fix the
first one, retry, and discover a second.
**Fix:** show all blockers (joined with `·`) or a "+N more" badge
with click-to-expand.
---
## LOW
### L1. `next-in-line-notify.service.ts` could double-fire on archive retry
**File:** `src/app/api/v1/clients/[id]/archive/route.ts:114-135`
If the smart-archive request succeeds at the DB transaction level but
the response upload-side fails (network blip, browser closes), the
operator may retry. Each retry re-fires the next-in-line notification
to all sales recipients. The `dedupeKey: berth-released:{berthId}`
inside the notification helper deduplicates within a cooldown window —
so this is mitigated, but worth verifying the cooldown is set and
not 0.
### L2. `interests.berth_id` reference in `seed-data.ts` (legacy seed)
**File:** `src/lib/db/seed-data.ts:973`
The realistic seed inserts `berthId: ...` on the interests table. Per
`CLAUDE.md`, that column was dropped in migration 0029 and replaced
with `interest_berths` junction. The synthetic seed uses the junction
correctly. The realistic seed will FAIL at insert time if anyone
tries to run it on a freshly-migrated DB.
**Fix:** rewrite `seed-data.ts:969-982` to insert into `interests`
without `berthId`, then insert the junction rows separately (mirror
the synthetic seed's pattern).
### L3. Audit log entry for failed login uses `entityId = attemptedEmail` (unbounded)
**File:** `src/app/api/auth/[...all]/route.ts:53-68`
If the entityId is very long (a 500-char "email"), it goes into the
DB column. The column is `text` (unbounded) so no DB error, but FTS
search-text may bloat.
**Fix:** truncate attempted email to 256 chars before using as
entityId.
### L4. The "watch the watchers" audit fires for filtered queries too
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
(See H2 above for the page-spam variant.) Even on a single search,
an audit row containing the search term is written. If the search
term itself is sensitive (e.g. an admin searches for a specific
client's name in audit logs), it's now in the audit log of audit-log
viewing. Acceptable but worth documenting.
### L5. Import worker is a stub
**File:** `src/lib/queue/workers/import.ts:13`
`// TODO(L2): implement import job handlers` — the worker is wired
into the queue and registered, but does nothing. If anyone enqueues
an `import:*` job, it returns immediately. Either ship the feature
or remove the queue.
### L6. `interest-form.tsx` two TODOs about company-yacht filter + add-yacht inline
**File:** `src/components/interests/interest-form.tsx:332-333`
Real product gaps. When creating an interest for a client who's a
member of a company, you can't pick a yacht owned by that company.
And there's no inline "Add yacht" shortcut in the form.
### L7. `berth-spec-template.ts` defaults to `'Price: TBD'` when price is null
**File:** `src/lib/pdf/templates/berth-spec-template.ts:128`
Generated berth-spec PDFs say "Price: TBD" for any berth without a
price. Cosmetic — verify whether sales considers this an acceptable
fallback or wants to suppress the line entirely.
---
## Things checked and found OK (so we don't re-audit)
- Tenant isolation on hard-delete (`portId` filter on every query and
inside the tx).
- `withPermission` gates on every new route (bulk-archive-preflight,
hard-delete-_, bulk-hard-delete-_, redeliver).
- Audit log: no public DELETE endpoint, no PATCH endpoint.
- Sidebar nav properly gates marina sections from `residential_partner`
via `hasMarinaAccess`.
- Auth wrapper rebuilds the request body correctly so the upstream
better-auth handler can re-read it (no body-already-consumed bug).
- Webhook outbound SSRF guard with DNS rebinding protection still
intact.
- 1175/1175 vitest suite passing as of last run.
---
## Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)
See **"Triage list" at the end** of this document — combined ranking
across both audit rounds.
---
## Round 2 — focused agents (added 2026-05-06 evening)
After the original synthesis above, four scoped agents (smaller blast
radius, hard finding caps) successfully audited their domains and
produced dedicated docs. Findings are linked here with `R2-`-prefixed
IDs. Detail in:
- [audit-reliability-2026-05-06.md](audit-reliability-2026-05-06.md) — 11 findings
- [audit-frontend-2026-05-06.md](audit-frontend-2026-05-06.md) — 12 findings
- [audit-permissions-2026-05-06.md](audit-permissions-2026-05-06.md) — 9 findings
- [audit-missing-features-2026-05-06.md](audit-missing-features-2026-05-06.md) — 12 findings
### Round 2 — CRITICAL
**R2-C1. Bulk archive discards post-commit side effects** ([reliability C1](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:68-134`
- The bulk wizard's `runBulk` callback discards the return value from
`archiveClientWithDecisions`. **Documenso envelopes marked
`void_documenso` are never queued for void; "next-in-line" sales
notifications never fire**. The CRM ends up showing `documents.status='cancelled'`
while the live envelope is still out for signature — a signer can
legally complete a doc the CRM thinks is voided.
- Same severity tier as the original C1 (worker-imports).
**R2-C2. Frontend: Restore icon hovers destructive-red on archived clients** ([frontend C1](audit-frontend-2026-05-06.md))
- File: `src/components/clients/client-detail-header.tsx:174-186`
- Conditional `hover:text-destructive` is overridden by an unconditional
`hover:text-foreground` earlier in the class string. Result: the
Restore button on archived clients hovers blood-red, signalling
"destructive" on a fully reversible action. Users hesitate to click.
Promoted to "critical UX" because it's directly misleading on every
archived client view.
### Round 2 — HIGH
**R2-H1. Smart-restore wizard's `berth_released` reversal is a no-op but the audit log claims success**
([reliability H1](audit-reliability-2026-05-06.md))
- File: `src/lib/services/client-restore.service.ts:359-372`
- Already noted as M4 in the original synthesis. Round-2 reliability
agent escalated to HIGH because the wizard counter increments and
the audit log records "1 auto-reversed" — operator believes the berth
was re-attached when nothing happened. Same fix path: persist the
original `interestId` in the decision detail and re-link on restore.
**R2-H2. Smart-archive berth status update has TOCTOU race**
([reliability H2](audit-reliability-2026-05-06.md))
- File: `src/lib/services/client-archive.service.ts:191-207`
- Berth row read outside tx, mutated inside tx without `for update`
lock. Concurrent archive + sale of the same berth can race: the
archive flow flips a freshly-sold berth back to `available`. Add
`select … for update` on `berths` before the status flip.
**R2-H3. Bulk archive can pick the wrong interest for berth release**
([reliability H3](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:95-103`
- Lookup by `primaryBerthMooring` falls back to `dossier.interests[0]?.interestId ?? ''`.
Empty-string `interestId` reaches the delete and silently matches
zero rows; the link is silently retained while the audit log claims
it was removed.
**R2-H4. External EOI runs five operations outside a transaction**
([reliability H4](audit-reliability-2026-05-06.md))
- File: `src/lib/services/external-eoi.service.ts:67-155`
- Storage upload + 4 DB writes are independent. Mid-flight failure
leaves orphan PDFs in S3/MinIO and partial DB state.
**R2-H5. Bulk wizard double-submit treats `ConflictError('already archived')` as a per-row error**
([reliability H5](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:68-120`
- No idempotency key on the bulk endpoint. A double-submit (network
retry, double click) makes the second response look like all rows
failed even though the first succeeded.
**R2-H6. Webhook replay button has no UI permission gate (403 toast spam)**
([permissions H1](audit-permissions-2026-05-06.md))
- File: `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
- Replay button renders for any user who can load the page. Server gates
on `admin.manage_webhooks`. Non-admins see enabled buttons; clicking
surfaces a generic 403 toast.
**R2-H7. Bulk Archive bulk action exposed to roles without `clients.delete`**
([permissions H2](audit-permissions-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:182-190`
- `sales_agent` and `viewer` see the Archive bulk action; clicking
surfaces a 403 from preflight. Mirror the `canHardDelete` pattern:
`const canBulkArchive = can('clients', 'delete');`
**R2-H8. Bulk add_tag / remove_tag exposed to viewer**
([permissions H3](audit-permissions-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:165-181`
- Same pattern as R2-H7 — no UI gate; server gates on `clients.edit`.
**R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute**
([permissions H4](audit-permissions-2026-05-06.md))
- File: `src/lib/services/client-hard-delete.service.ts:377`
- `if (!c) continue;` swallows any client that was archived/restored/
deleted by another operator between preflight and execute. Operator
sees a `deletedCount` lower than requested and no signal which IDs
were skipped.
**R2-H10. Frontend: `webhook-delivery-log` and `audit-log-list` swallow fetch errors silently**
([frontend H3, H4](audit-frontend-2026-05-06.md))
- Files: `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`,
`src/components/admin/audit/audit-log-list.tsx:150-175`
- Both wrap fetches in `try/finally` with no `catch`. Failed loads show
spinner forever or stale data; user has no signal that anything
failed. Surface via `toast.error` + inline retry banner.
**R2-H11. Frontend: `audit-log-card` renders as `<a href="#">` — page-jumps on mobile tap**
([frontend H5](audit-frontend-2026-05-06.md))
- File: `src/components/admin/audit/audit-log-card.tsx:96`
- Card view rows on mobile insert `#` in URL on tap (back-button trap).
Render as button or div, or link to a useful destination.
**R2-H12. Frontend: `smart-archive-dialog` doesn't invalidate the dossier or single-client query**
([frontend H6](audit-frontend-2026-05-06.md))
- File: `src/components/clients/smart-archive-dialog.tsx:197-212`
- Detail page header keeps showing client as un-archived after a
successful archive until hard reload. Add
`qc.invalidateQueries({queryKey: ['clients', clientId]})` and
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})`.
**R2-H13. Frontend: bulk tag mutation uses `alert()` and lacks `onError`**
([frontend H2](audit-frontend-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:88-106`
- Native `alert()` blocks the page on partial failure; pure network
failure shows nothing. Replace with `toast.warning` / `toast.error`.
**R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates**
([missing-features V1](audit-missing-features-2026-05-06.md))
- Files: `src/components/admin/email-templates-admin.tsx:24-72` (UI),
`src/lib/services/portal-auth.service.ts:120,332` (only consumers)
- Admin sees an "Overridden" badge after saving a custom subject for
CRM invite, inquiry confirmation, residential templates, etc. — but
the senders ship the hardcoded subject regardless. Wire
`loadSubjectOverride(portId, key)` into the 6 missing senders.
**R2-H15. Branding admin saves 5 settings that nothing reads**
([missing-features V2](audit-missing-features-2026-05-06.md))
- Files: `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx`,
`src/lib/services/port-config.ts:240-272`
- Logo URL, app name, primary color, header HTML, footer HTML all
dead-end. `getPortBrandingConfig` has zero callers. **Multi-tenant
promise broken — every port's emails ship Port Nimara's branding.**
**R2-H16. Reminder admin saves digest defaults that no scheduler applies**
([missing-features V3](audit-missing-features-2026-05-06.md))
- Files: `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx`,
`src/lib/services/port-config.ts:284-306`
- Sales reps think they configured a daily digest at 09:00 in their
TZ; they get fire-as-they-hit notifications instead. The digest
scheduler doesn't exist.
### Round 2 — MEDIUM (selected highlights)
**R2-M1. Portal "My Memberships" tile is a dead-end** ([missing-features V4](audit-missing-features-2026-05-06.md))
- Tile on `/portal/dashboard` has no `href`; route doesn't exist. Either
ship `/portal/memberships` or remove the tile.
**R2-M2. Company detail Documents tab is a "Coming soon" stub** ([missing-features V5](audit-missing-features-2026-05-06.md))
- `src/components/companies/company-tabs.tsx:230-234`. Same problem
as the three already-noted "coming soon" stubs but on a different
entity.
**R2-M3. Onboarding page is a static checklist not the wizard it advertises** ([missing-features V6](audit-missing-features-2026-05-06.md))
- The page literally says "what this page will become". Either build
the wizard or relabel the landing card.
**R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports"** ([missing-features V7](audit-missing-features-2026-05-06.md))
- Once C1 (worker imports) is fixed, the existing `database-backup`
job is reachable; small lift to wire a "Take backup now" button.
**R2-M5. Inquiry inbox has zero triage actions** ([missing-features V8](audit-missing-features-2026-05-06.md))
- No "Convert to client", no "Resolve", no "Assign". `website_submissions`
table is permanent; sales has to copy-paste emails into client forms.
**R2-M6. external-eoi grants only `documents.upload_signed` but mutates interest state** ([permissions M1](audit-permissions-2026-05-06.md))
- A custom role with `documents.upload_signed:true` + `interests.edit:false`
can flip an interest to "signed" via the external-EOI route.
**R2-M7. `InlineStagePicker` never sends `override:true` — `override_stage` permission unreachable from the most-used UI path** ([permissions M2](audit-permissions-2026-05-06.md))
- Users with the perm have to fall back to the modal `InterestStagePicker`
to actually use it.
**R2-M8. `sales_agent` granted `interests.override_stage:true` — likely copy-paste from sales_manager** ([permissions M3](audit-permissions-2026-05-06.md))
- All other trust-elevated flags are stripped from sales_agent. Needs a
product decision; either flip to false or document intent.
**R2-M9. `bulk-archive-preflight` leaks dossier-loader error text in `blockers`** ([permissions M4](audit-permissions-2026-05-06.md))
- An attacker enumerating UUIDs can distinguish "doesn't exist" vs
"exists but you can't see it". Replace with generic "Could not load
dossier".
**R2-M10. Documenso void worker has no max-retry alert hook** ([reliability M2](audit-reliability-2026-05-06.md))
- A persistent 401/403 retries forever. On exhaustion, write back to
`documents` (`cancellation_failed=true`) and notify admin.
**R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics** ([missing-features V9](audit-missing-features-2026-05-06.md))
- Mobile users have zero path to entire feature domains. Add to
`MORE_ITEMS`.
**R2-M12. Portal has no profile / change-password surface** ([missing-features V10](audit-missing-features-2026-05-06.md))
- Forces every portal user to use the forgot-password flow even when
they remember their old password. Ship `/portal/profile`.
**R2-M13. Portal invoices show amounts but no PDF download** ([missing-features V11](audit-missing-features-2026-05-06.md))
- Documents page does have downloads; mirror the pattern.
(Plus several more medium/low items in the dedicated docs; see those
for the full set.)
---
## TRIAGE LIST (combined Round 1 + Round 2)
### Ship now — CRITICAL
1. **C1** — wire the 5 missing BullMQ workers (`worker.ts`, `server.ts`)
— 5-line fix; every webhook + cron flow is currently dead.
2. **R2-C1** — make bulk archive enqueue Documenso voids + next-in-line
notifications (return value plumbing in `bulk/route.ts`).
3. **R2-C2** — fix the destructive-red hover on the Restore button
(`client-detail-header.tsx`). Trivial CSS fix.
### Ship this week — HIGH (security/UX with concrete user impact)
4. **H1** — rate-limit the hard-delete-request endpoints.
5. **H3** — collapse "no code" vs "wrong code" into one error message.
6. **H7** — three "coming soon" stubs in client/berth tabs.
7. **R2-H1** — fix smart-restore's silent `berth_released` no-op (or
reclassify as `reversibleWithPrompt`).
8. **R2-H2** — add `for update` lock on the smart-archive berth status
flip (TOCTOU race).
9. **R2-H3** — bulk-archive's wrong-interest fallback — empty-string
interestId silently no-ops.
10. **R2-H6, R2-H7, R2-H8** — three permission UI-gate misses on
bulk actions and the webhook-replay button. ~30 lines total.
11. **R2-H10, R2-H12, R2-H13** — frontend swallowed errors + missing
invalidation + alert() instead of toast. Small fixes, immediate UX
win.
12. **R2-H11**`audit-log-card` `href="#"` mobile back-button trap.
13. **R2-H14** — wire 6 missing email-subject overrides through their
senders.
### Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)
14. **R2-H4** — wrap external-EOI in a transaction.
15. **R2-H5** — bulk-archive idempotency key + treat already-archived as
success in bulk.
16. **R2-H9** — bulk hard-delete should return `skipped: string[]`.
17. **R2-H15, R2-H16** — branding + reminder admin pages save settings
nothing reads (silently broken multi-tenancy).
18. **H2** — audit-page-view de-dupe (don't spam on every filter change).
19. **H4** — synthetic seed needs documented dev-user setup or its own
bootstrap script.
20. **H5** — Documenso bad-secret rate-limit per IP.
21. **R2-M1 through R2-M5** — portal memberships dead-end, company
Documents stub, onboarding wizard, backup page, inquiry inbox triage.
### Backlog — MEDIUM/LOW + remaining items
22. The remaining MEDIUM/LOW from both rounds — see the dedicated docs.
---
## Headline numbers (combined)
- **3 CRITICAL** (worker imports, bulk-archive side-effects, restore-button hover)
- **22 HIGH** (security + UX with concrete impact)
- **~15 MEDIUM** (operational hygiene, multi-tenancy gaps, unfinished features)
- **~10 LOW** (cleanup, defensive)
Round 1 was a manual synthesis after agent-pool stalls; Round 2 was
four focused agents with hard finding caps that all completed inside
the watchdog window. Every finding is grounded in code references.

View File

@@ -1,278 +0,0 @@
# Final audit deferred findings
> **Status update (audit-v3 round)**: most of the v2 deferred items have
> now landed. Items struck through below are completed. The remaining
> open items are bigger refactors (custom-fields per-entity routes,
> systemSettings PK reconciliation, Documenso v2 voidDocument verification,
> partial-vs-composite archived index conversion, storage-proxy port_id
> claim, Documenso webhook port_id enforcement, response-shape
> standardization, berths.current_pdf_version_id Drizzle FK).
The pre-merge audit on `feat/berth-recommender` produced ~30 findings. The
critical + high-severity items were fixed in-branch. The items below are
medium / low severity and deferred to follow-up issues so the merge isn't
held up. Each entry is self-contained — pick one off and ship it.
## Cross-cutting integration
- **EOI in-app pathway silently swallows missing `Berth Range` AcroForm field**
`src/lib/pdf/fill-eoi-form.ts:93`. `setText(form, 'Berth Range', ...)`
is wrapped in a try/catch that succeeds silently when the field is
absent. CLAUDE.md already warns ops about needing to add the field to
the live Documenso template; this code change would make the deployment
gap observable. Fix: when `context.eoiBerthRange` is non-empty AND the
field is absent, log at warn level + surface a structured response field.
- **Email body merge expansion happens after token validation** —
`src/lib/services/document-sends.service.ts:399-403`. If a merge value
contains a `{{token}}` substring (e.g. a client name like
`"Acme {{discount}} Inc."`), the expanded body will contain a token
the unresolved-check missed and ships with literal braces. Fix: HTML-
escape merge values before expansion, OR run a second
`findUnresolvedTokens` against the expanded body.
- **Filesystem dev-fallback HMAC secret can drift across processes** —
`src/lib/storage/filesystem.ts:328-331`. The dev-only fallback derives
the HMAC secret from `BETTER_AUTH_SECRET`. Two CRM processes running
with different secrets (web vs worker) reject each other's tokens.
Fix: assert `BETTER_AUTH_SECRET` is set when filesystem backend is
active in non-prod, or document the requirement loudly.
- **Berth PDF apply path: numeric column nulling silently drops** —
`src/lib/services/berth-pdf.service.ts:473-475`. When
`Number.isFinite(n)` is false the apply loop `continue`s without
pushing to `applied` and without warning. Combined with the
"no appliable fields supplied" check (only fires when ALL drop), partial
silent drops are invisible. Fix: collect dropped keys and surface them.
## Multi-tenant isolation hardening
- **document_sends row stores `interestId` without verifying port match** —
`src/lib/services/document-sends.service.ts:422`. Audit-log pollution
rather than data exposure (the recipient lookup is port-checked already).
Fix: when `recipient.interestId` is set, fetch with
`and(eq(interests.id, ...), eq(interests.portId, input.portId))` and
throw if missing.
- **Storage proxy token does not bind to port_id** —
`src/lib/storage/filesystem.ts:73-84`. ProxyTokenPayload is `{k, e, n,
f?, c?}` with a global HMAC. The current "issuer always checks port
first" relies on every issuer being correct in perpetuity. Fix: add a
`p` (portId) claim and have the proxy route resolve key→owner row +
assert `owner.portId === payload.p` before streaming.
- **Documenso webhook does not enforce port_id on document lookups** —
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch by
global `documensoId`. If two ports' documents were ever issued the
same Documenso ID (replay across staging/prod, forwarded webhook from
a foreign instance), the wrong port's interest could be mutated. The
per-body `signatureHash` dedup is partial mitigation. Fix: either
(a) include the originating Documenso instance/team in the lookup, or
(b) verify `documents(documenso_id)` has a unique index port-wide.
## Recent expense work polish
- **renderReceiptHeader cursor math drifts after multi-step writes** —
`src/lib/services/expense-pdf.service.ts:854`. After
`doc.text(...)` with auto-flow, `doc.y` advances. Using `doc.y -
headerH + 10` after the rect+stroke block computes against the
post-rect position; works only because pdfkit's text-after-rect
hasn't moved y yet. Headers may misalign on the first receipt page
after a soft page break. Fix: capture `const baseY = doc.y` before
drawing the rect and compute all subsequent offsets relative to it.
## Settings parsing
- **`loadRecommenderSettings` rejects string-shaped JSONB booleans** —
`src/lib/services/berth-recommender.service.ts:116`. Postgres returns
JSONB `true/false` as JS booleans, but if an admin saves `"true"`
via a UI that wraps the value as a string, `asBool` returns null and
the per-port override silently falls through to defaults. Not a
security bug; a tuning footgun. Fix: accept `"true"`/`"false"` string
forms in `asBool`.
# Audit-final v2 (post-merge platform-wide pass) deferred findings
A second comprehensive audit (security, routes, DB, integrations, UI/UX)
ran after the merge. The high-impact items landed in commit
`fix(audit-final-v2): platform-wide hardening` (or similar). Items below
are deferred follow-ups.
## Routes / API
- **Saved-views routes lack `withPermission`** —
`src/app/api/v1/saved-views/[id]/route.ts:4-5` and
`src/app/api/v1/saved-views/route.ts:24`. Convention is
`withAuth(withPermission(...))`. Verify the service applies
`(ctx.userId, ctx.portId)` ownership filtering, then add either an
explicit owner-only comment or wrap with a benign permission gate.
- **Custom-fields permission resource hardcoded to `clients`** —
`src/app/api/v1/custom-fields/[entityId]/route.ts:15,29`. Custom fields
attach to client / yacht / interest / berth / company, but the route
always checks `clients.view` / `clients.edit`. A user with
`companies.view` can read confidential company custom-field values via
this endpoint (the service-level `customFieldDefinitions.portId` filter
prevents cross-tenant access but not cross-resource within a tenant).
Fix: split into per-entity routes, OR resolve `entityType` and gate on
the matching permission inline.
- **`alerts/[id]/acknowledge|dismiss` ungated** —
`src/app/api/v1/alerts/[id]/acknowledge/route.ts:6` etc. only `withAuth`,
no `withPermission`. Verify the service requires user ownership; if
not, gate on `reports.view_dashboard` or similar.
- **Public POST routes bypass service layer** —
`src/app/api/public/interests/route.ts`, `…/website-inquiries/route.ts`,
`…/residential-inquiries/route.ts`. These do extensive `tx.insert(...)`
with hand-rolled audit logs (`userId: null as unknown as string`).
Extract a `publicInterestService.create(...)` so the same code path is
unit-testable and port-id discipline is uniform. Verify
`audit_logs.user_id` is nullable (the cast pattern signals it is, but
enforce in schema if not).
- **Inconsistent response shapes** — most endpoints return `{ data: ... }`,
but `notifications/[notificationId]` returns `{ success: true }`,
`website-inquiries` returns `{ id, deduped }`. Document a convention in
CLAUDE.md and migrate.
- **`req.json()` without `parseBody` helper** — admin custom-fields
routes use `await req.json(); schema.parse(body)` directly instead of
the project's `parseBody(req, schema)` helper. Migrate for uniform
400 error shapes.
## Documenso integration
- **v2 voidDocument endpoint may not match real API** —
`src/lib/services/documenso-client.ts:450-466`. The audit flagged that
Documenso 2.x exposes envelope deletion as
`POST /api/v2/envelope/delete` with `{ envelopeId }` body, not
`DELETE /api/v2/envelope/{id}`. The unit test mocks fetch so it can't
catch the real shape. Verify against a live Documenso 2.x instance
(`pnpm exec playwright test --project=realapi`) before flipping any
port to v2.
- **Webhook dedup vs per-recipient signed events** —
`src/app/api/webhooks/documenso/route.ts:103-110`. The top-level
`signatureHash` (sha256 of raw body) blocks exact replays, but a
duplicate webhook delivery for a multi-recipient document with a
re-encoded body will go through the per-recipient loop. Make
`documentEvents.signatureHash` unique cover the suffixed values OR add
a composite unique index `(documensoDocumentId, recipientEmail, eventType)`.
- **v1 `placeFields` per-field POST has no retry** —
`src/lib/services/documenso-client.ts:374-398`. A single transient 500
mid-loop leaves the document with a partial field set. Add 3-attempt
exponential backoff on 5xx + voidDocument on final failure.
## Storage
- **S3 backend has no startup bucket-exists check** —
`src/lib/storage/s3.ts:100-111`. A typo'd bucket name surfaces as a
500 inside a user-facing request rather than at boot. Add
`await client.bucketExists(bucket)` in `S3Backend.create` with a clear
error message.
- **Storage cache fingerprint includes encrypted secret** —
`src/lib/storage/index.ts:158-159`. After a key rotation the old
cached client survives until `resetStorageBackendCache()` is called
(already called via the settings-write hook). Document the
invariant or fingerprint on a content-hash that excludes encrypted
material.
- **Filesystem dev HMAC silent fallback** —
`src/lib/storage/filesystem.ts:309-332`. Two dev nodes started with
different `BETTER_AUTH_SECRET` derive different secrets and reject
each other's tokens. Log a one-line warn at backend boot in non-prod.
## DB schema
- **`berths.current_pdf_version_id` lacks Drizzle FK** —
`src/lib/db/schema/berths.ts:83`. The FK exists in migration 0030
but not in the schema source-of-truth, so `pnpm db:push` against an
empty DB skips the constraint. Either add the FK with a deferred
declaration or document that `db:push` is unsupported.
- **Missing indexes on FK columns** — `berthReservations.interestId`,
`berthReservations.contractFileId`, `documents.fileId`,
`documents.signedFileId`, `documentEvents.signerId`,
`documentTemplates.sourceFileId`, `formSubmissions.formTemplateId`,
`formSubmissions.clientId`, `documentSends.brochureId`,
`documentSends.brochureVersionId`, `documentSends.sentByUserId`. Add
`index(...)` declarations to avoid full-scan FK checks on parent
delete.
- **`systemSettings` PK / unique-index drift** —
`src/lib/db/schema/system.ts:119-133`. Schema declares only a
`uniqueIndex` on `(key, port_id)` but the migration uses `key` as PK.
`port_id` is nullable so `(key, port_id)` cannot serve as a PK with
default NULLs-not-equal semantics. Reconcile: declare
`primaryKey({ columns: [table.key, table.portId] })` (after making
`portId` non-null with a sentinel) OR use partial unique indexes for
global + per-port settings.
- **Composite vs partial archived indexes** — many tables use
`index('idx_*_archived').on(portId, archivedAt)` when the dominant
query is `WHERE port_id = ? AND archived_at IS NULL`. Convert to
`index(...).on(portId).where(sql\`archived_at IS NULL\`)` partial
indexes for smaller storage + faster planner choice.
- **`documentSends.sentByUserId` ungated FK** —
`src/lib/db/schema/brochures.ts:118` is `notNull()` but has no FK
reference. If a user is hard-deleted (rare; we soft-delete), an
orphan id remains. Add `.references(() => users.id, { onDelete: 'set null' })`
and make the column nullable. Same audit-trail rationale as the
other documentSends FK fixes (commit 0035).
## UI/UX
- **Storage admin migration mutation lacks toasts** —
`src/components/admin/storage-admin-panel.tsx:61-72`. Add `onSuccess`
toast with row count + `onError` toast.
- **Invoice detail send/payment mutations lack error feedback + gates** —
`src/components/invoices/invoice-detail.tsx:93-99,152-167`. Add
`onError: (e) => toast.error(...)` and wrap mutating buttons in
`<PermissionGate resource="invoices" action="send">` /
`record_payment`.
- **Admin user list edit button ungated** —
`src/components/admin/users/user-list.tsx:114`. Wrap in
`<PermissionGate resource="admin" action="manage_users">`.
- **Email threads list missing skeleton** —
`src/components/email/email-threads-list.tsx:29-45`. Use `<Skeleton>`
rows during load + `<EmptyState>` for the empty case.
- **Scan page mutations swallow OCR errors** —
`src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx:67-87`. Add an
inline error state for `scanMutation.isError` (the upload-side
already does this).
- **Invoice detail uses `any` for query data** — strict-mode escape
hatch. Define a proper response type matching the API contract.
## Security defense-in-depth
- **Storage proxy token does not bind to port_id** —
`src/lib/storage/filesystem.ts:73-84`. Token's HMAC is global. Fix:
add `p` (portId) claim and have the proxy resolve key→owner row +
assert `owner.portId === payload.p`.
- **Documenso webhook does not enforce port_id** —
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch
by global `documensoId`. Verify `documents(documenso_id)` is unique
port-wide OR include the originating instance/team in the lookup.
- **EOI in-app pathway silently swallows missing `Berth Range` field** —
`src/lib/pdf/fill-eoi-form.ts:93`. Log warn when
`context.eoiBerthRange` is non-empty AND the field is absent so the
Documenso template deployment gap is observable.
- **AI worker has no cost-tracking ledger write** —
`src/lib/queue/workers/ai.ts:122-177`. Persist token usage to the
`ai_usage` ledger after every call.
- **Logger redact paths miss nested credentials** —
`src/lib/logger.ts:5-19`. Extend redact list to cover
`*.headers.authorization`, `**.token`, `secretKeyEncrypted`, etc.

View File

@@ -1,223 +0,0 @@
# Frontend audit — 2026-05-06
Scope: new archive/restore/hard-delete dialogs, bulk archive wizard, client
detail header, audit log inspector, webhook delivery log, client list bulk
section. Companion to `docs/audit-comprehensive-2026-05-06.md` (does NOT
re-flag the Files-tab / reservations / berth-tab "coming soon" stubs already
covered there).
---
## Critical
### C1 — `client-detail-header` opens restore dialog from the Archive icon for archived clients
**File:** `src/components/clients/client-detail-header.tsx:174-186`
**Scenario:** On an archived client the icon button still renders `<Archive>`
when `isArchived` is true (`isArchived ? <RotateCcw /> : <Archive />` is
correct), BUT both states use the same `setArchiveOpen(true)` handler and
the conditional below routes `<SmartRestoreDialog>` vs `<SmartArchiveDialog>`
off of `isArchived`. That part is fine. The real problem: the destructive
hover colour `hover:text-destructive` is applied via
`isArchived ? 'hover:text-foreground' : 'hover:text-destructive'` — but the
preceding class string already sets `hover:text-foreground` unconditionally,
so the conditional is dead and the restore button hovers red the same as
archive. Misleading colour signal on a reversible action; users hesitate to
click it.
**Fix:** Drop the always-applied `hover:text-foreground` from the base class
list and let the conditional own the hover colour, or just colour the
restore icon emerald to differentiate.
---
## High
### H1 — `bulk-archive-wizard` lets users skip the reasons step by clicking Continue while preflight is loading then Cancel/reopen
**File:** `src/components/clients/bulk-archive-wizard.tsx:253-267, 80-107`
**Scenario:** In the `preflight` stage the Continue button is only disabled
when `archivable.length === 0 || preflight.isLoading`. But `archivable` is
derived from `items = preflight.data ?? []`. While loading, `archivable` is
`[]` so Continue is disabled — good. After load with all-blocked selection,
`archivable.length === 0` so still disabled — good. However, the
`reasonsByClientId: reasons` payload is sent verbatim, so a user who advances
to "reasons", types into one client's box, then uses the carousel back arrow
and edits another, can submit reasons for clients NOT in `archivable` (e.g.
if the preflight is refetched on stale-time). Reasons for blocked or removed
client IDs are forwarded to the API. Minor data-quality issue.
**Fix:** Filter `reasons` to `archivable` IDs before mutating:
`reasonsByClientId: Object.fromEntries(Object.entries(reasons).filter(([id]) => archivable.some(a => a.clientId === id)))`.
### H2 — `client-list` bulk tag mutation uses `alert()` for partial failures and has no `onError`
**File:** `src/components/clients/client-list.tsx:88-106`
**Scenario:** User bulk-adds a tag to 50 clients; backend returns 200 with
`{succeeded: 30, failed: 20}` → user sees a native browser `alert()` blocking
the page. If the request itself errors (network drop, 500), there is no
`onError` so the dialog closes via `onSettled` and the user sees nothing —
silent failure. Inconsistent UX vs. every other mutation in this audit which
uses `toast`.
**Fix:** Replace `alert(...)` with `toast.warning(...)`, add an
`onError: (err) => toast.error(...)` branch matching the pattern used in
`bulk-archive-wizard.tsx` and `bulk-hard-delete-dialog.tsx`.
### H3 — `webhook-delivery-log` swallows fetch errors silently
**File:** `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`
**Scenario:** Admin opens a webhook detail page while the API is down or the
webhook was just deleted. `load()` catches and discards the error
(`} catch { /* ignore */ }`). UI shows "Loading deliveries…" forever on the
first load, or stays on the last successful page on subsequent loads, with
no indication that anything failed. No error state, no toast, no retry.
**Fix:** Surface errors via `toast.error` and show an inline error state
("Couldn't load deliveries — Retry") instead of swallowing.
### H4 — `audit-log-list` first-page fetch swallows errors and shows no error state
**File:** `src/components/admin/audit/audit-log-list.tsx:150-175`
**Scenario:** Filter form is fully interactive, user changes a date — request
fires, server 500s. The `try/finally` has no `catch`, so the rejected promise
becomes an unhandled rejection. The list shows whatever was previously
loaded (or empty state), and the user has no idea their filter didn't apply.
Same applies to `loadMore`.
**Fix:** Add `catch` blocks that set an error state and render an inline
error banner above the table, with a Retry button.
### H5 — `audit-log-card` renders as a link to `href="#"` — clicking jumps the page
**File:** `src/components/admin/audit/audit-log-card.tsx:96`
**Scenario:** On mobile / card view the audit log entries become clickable
cards with `href="#"`. Tapping any card scrolls the page to top and inserts
`#` in the URL (back-button trap). There's no detail view to navigate to.
**Fix:** Either render a non-link wrapper (button or div) when no detail
target exists, or link to a useful destination like
`/{portSlug}/{entityType}/{entityId}` when the entity is resolvable.
### H6 — `smart-archive-dialog` `archiveMutation` doesn't invalidate the dossier or single-client query
**File:** `src/components/clients/smart-archive-dialog.tsx:197-212`
**Scenario:** User archives a client successfully. The dialog invalidates
`['clients']`, `['berths']`, `['interests']` but NOT
`['client-archive-dossier', clientId]` nor `['clients', clientId]`. If the
parent screen (e.g. detail page) keeps the client query mounted, the
detail header continues to show the client as un-archived until a hard
reload. The Restore icon won't appear.
**Fix:** Add `qc.invalidateQueries({queryKey: ['clients', clientId]})` and
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})` so a
re-open re-fetches a fresh dossier (e.g. if user re-archives after restoring
in the same session).
---
## Medium
### M1 — `smart-archive-dialog` derives `interestId` from a name match against `primaryBerthMooring` — wrong key
**File:** `src/components/clients/smart-archive-dialog.tsx:158-167`
**Scenario:** When building per-berth decisions the code does
`dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId`.
Multiple interests can share the same primary mooring (rare, but possible
historically), and worse, when no interest has this berth as primary it
falls back to `dossier.interests[0]?.interestId` regardless of which berth
is being decided. The wrong interest gets credited with the release, which
is then audit-logged.
**Fix:** Have the dossier API return `interestId` per berth row (it already
joins `interest_berths`), or look up by membership not by primary flag.
### M2 — `hard-delete-dialog` doesn't reset state when switching from intent → confirm if request fails midway
**File:** `src/components/clients/hard-delete-dialog.tsx:39-46, 64-79`
**Scenario:** User submits hard delete with wrong code → backend returns 400
→ toast fires, but the dialog stays on `confirm` stage with the bad code
still in the input and no clear cue. If the user then closes (X) and
reopens, the `useEffect` resets correctly. But if the email code expired
(10 min) and they request a fresh one, there's no "Resend code" button —
they must cancel and start over from intent. Minor.
**Fix:** Add a "Send a new code" link in the confirm stage that calls
`requestCode.mutate()` again and clears `code`.
### M3 — `bulk-hard-delete-dialog` doesn't refetch / invalidate after partial failure shows totals
**File:** `src/components/clients/bulk-hard-delete-dialog.tsx:64-85`
**Scenario:** Bulk delete returns `{deletedCount: 7}` for 10 selected; toast
warns but `qc.invalidateQueries({queryKey: ['clients']})` is invalidated
unconditionally — fine. However, the dialog closes immediately
(`onOpenChange(false)`), so the user can't see WHICH 3 failed. The toast
just says "see audit log". For a destructive bulk op this is too sparse;
users will repeat the action thinking it didn't work.
**Fix:** Stay open on partial failure and render a list of failed IDs (the
API likely already returns per-item results — if not, return them).
### M4 — `audit-log-list` doesn't validate that `dateFrom <= dateTo`
**File:** `src/components/admin/audit/audit-log-list.tsx:142-146`
**Scenario:** User picks From=2026-06-01, To=2026-05-01. Query fires with an
empty result range; user sees "No audit log entries found" and assumes
their data isn't there. No client-side validation hint.
**Fix:** Show an inline warning "From date must be before To date" and skip
the request when invalid.
### M5 — `bulk-archive-wizard` `Cancel` during `archiveMutation.isPending` discards mutation tracking
**File:** `src/components/clients/bulk-archive-wizard.tsx:248-251, 293-307`
**Scenario:** User clicks "Archive 50" → mutation in flight (10s) → user
clicks Cancel. The dialog closes; the mutation continues server-side and
its onSuccess fires later, showing a toast for an action the user thought
they cancelled. Worse, the dialog is gone so they can't tell which clients
got archived.
**Fix:** Disable Cancel while `archiveMutation.isPending`, or relabel to
"Cancel (won't stop in-progress)" and keep the mutation visible.
---
## Low
### L1 — `audit-log-list` filter row overflows on narrow viewports
**File:** `src/components/admin/audit/audit-log-list.tsx:321-467`
**Scenario:** 8 filter controls (`Search` 288px, `Entity` 144px, `Action`
176px, `Severity` 128px, `Source` 128px, `User id` 176px, `From` 144px,
`To` 144px, total ~1330px) sit in a single `flex-wrap` row. At <1280px
viewports they wrap onto multiple lines pushing the table down 200+px;
at <640px (mobile) each control wraps onto its own line and the "Clear"
button (`ml-auto`) lands on the wrong row.
**Fix:** Collapse rarely-used filters (User id / Severity / Source) into a
"More filters" Popover for sm: viewports.
### L2 — `audit-log-card` action map missing entries silently fall back to grey "Activity" icon and grey badge
**File:** `src/components/admin/audit/audit-log-card.tsx:27-44, 46-52`
**Scenario:** New webhook/cron/job actions are in `audit-log-list.tsx`
ACTION_COLORS but absent from `audit-log-card.tsx` ACTION_BADGE_COLORS and
ACTION_ACCENT. Card view of these entries looks identical to a generic
"unknown" entry — visual loss vs. table view.
**Fix:** Sync the two maps; consider extracting to a shared module so they
can't drift.

View File

@@ -1,405 +0,0 @@
# Missing-Features Audit — 2026-05-06
Focused pass on **features that look done in the UI but aren't fully
wired through the service layer**, plus **admin settings exposed to
users that no code reads**. Companion to
`docs/audit-comprehensive-2026-05-06.md` — the three "coming soon" stubs
already documented there (client Files tab, client reservations history,
berth tabs), the import-worker stub, the two interest-form TODOs, and
the EOI "Price: TBD" finding are NOT re-flagged here.
Hard cap: 12 findings. Severity tiers below.
---
## VISIBLE-BROKEN (admin sees a control, click is a no-op or wrong)
### V1. 6 of 8 admin-editable email subject overrides are silently ignored at send time
**Files:**
- `src/components/admin/email-templates-admin.tsx:24-72` (UI)
- `src/lib/email/template-catalog.ts:16-25` (catalog of 8 keys)
- `src/lib/services/portal-auth.service.ts:120-127, 332-339` (the only
consumers of `loadSubjectOverride`)
The `/admin/email-templates` page lets an admin override the subject
line on **eight** transactional templates:
`portal_activation`, `portal_reset`, `portal_invite_resend`,
`crm_invite`, `inquiry_client_confirmation`,
`inquiry_sales_notification`, `residential_inquiry_client_confirmation`,
`residential_inquiry_sales_alert`. The save endpoint persists each one
to `system_settings` (`email_template_<key>_subject`).
Only **two** of those eight are ever read at send time —
`portal_activation` and `portal_reset` in `portal-auth.service.ts`.
A repo-wide search for `loadSubjectOverride` / `settingKeyForSubject`
returns no other consumers. The other six templates use their hardcoded
subject regardless of the admin override.
**Impact:** sales/ops teams will customize an inquiry confirmation
subject, hit Save, see the "Overridden" badge, and silently ship the
default subject to every prospect.
**Fix:** small per template — call `loadSubjectOverride(portId, key)`
in each sender (`crm-invite.service.ts`, the inquiry sender, the
residential inquiry sender, the portal-invite-resend path) and pass the
result through as the email subject.
**Scope:** small (5 callsites + tests).
---
### V2. Branding admin (logo / app name / primary color / email header & footer HTML) saves to settings but no code reads them
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx:7-46` — UI
with five fields.
- `src/lib/services/port-config.ts:240-272``getPortBrandingConfig()`
resolves the five `branding_*` settings into a typed config.
- Repo-wide: `getPortBrandingConfig` has **zero callers** outside its
declaration. The five `SETTING_KEYS.branding*` constants are only
read inside `getPortBrandingConfig` itself.
The admin panel is functional end-to-end (write hits the settings API,
"Reset to default" works), and the email-templates module hardcodes
`s3.portnimara.com/...` for the logo URL plus a fixed table layout.
None of the email-rendering helpers (`renderEmail`, the template
modules in `src/lib/email/templates/`) call `getPortBrandingConfig`,
and the `<BrandedAuthShell>` component sources its logo + colors from
constants too.
**Impact:** every multi-tenant assumption made about branding is
broken. A second port wired into this CRM will see Port Nimara's logo
- colors in every transactional email and on the auth pages, even
after their admin "configures branding" successfully.
**Fix:** plumb `getPortBrandingConfig(portId)` through the email
renderer (header/footer HTML + primary button color), and through
`<BrandedAuthShell>` via a server-fetched prop.
**Scope:** medium (touches every transactional email + auth shell).
---
### V3. Reminder admin page configures defaults that no service applies
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx:7-50` — UI
for default-enabled, default-days, digest-enabled, digest-time,
digest-timezone.
- `src/lib/services/port-config.ts:284-306`
`getPortReminderConfig()` defines the schema.
- Repo-wide: the keys (`reminder_default_*`, `reminder_digest_*`) and
`getPortReminderConfig` have **zero callers**.
Same pattern as V2. The admin sets "enable reminders by default on new
interests" → toggles to true → save succeeds → newly-created interests
still default to `reminderEnabled=false`. The digest-time +
timezone fields go nowhere — there is no scheduler that batches
pending reminders into a daily digest.
**Impact:** the entire reminder UX is decorative. Sales reps think
they configured a daily digest at 09:00 Europe/Warsaw, get
fire-as-they-hit notifications instead.
**Fix:** wire `getPortReminderConfig` into (a) the interest-create
service (defaults), (b) the maintenance/notifications worker that
fires reminders (digest batching + delivery window). The `digest`
behavior didn't exist before this audit — needs a new scheduled job.
**Scope:** medium (defaults are small, digest job is new code).
---
### V4. Portal dashboard "My Memberships" tile has no link, no destination page, and isn't reachable from nav
**Files:**
- `src/app/(portal)/portal/dashboard/page.tsx:58-63` — `<PortalCard
title="My Memberships" ... icon={Building2} />` — note no `href`
prop.
- `src/components/portal/portal-nav.tsx:8-15` — six nav entries, no
memberships.
- Filesystem: `src/app/(portal)/portal/memberships/` does not exist.
The dashboard shows a count of "memberships" (companies the portal
user belongs to) but the tile is non-clickable and there is no
`/portal/memberships` route. A user with 3 memberships sees the tile,
clicks → nothing happens.
**Impact:** dead-end on the portal home for any client tied to a
company (the residential and yacht-ownership use-cases).
**Fix:** ship `/portal/memberships/page.tsx` listing the companies
returned by the existing `companyMemberships` query (already
aggregated in `getPortalDashboard`), and add it to `PortalNav`. Or
pull the tile if memberships isn't a portal feature.
**Scope:** small.
---
### V5. Company detail page Documents tab is a "Coming soon" stub
**File:** `src/components/companies/company-tabs.tsx:230-234`
```ts
{
id: 'documents',
label: 'Documents',
content: <EmptyState title="Documents" description="Coming soon" />,
},
```
Visible alongside the working Notes / Activity / Addresses / Members
tabs on every company detail page. NOT covered by the existing audit
doc's H7 (which lists clients, client reservations, and berths).
**Impact:** the same UX problem H7 calls out for clients.
**Fix:** mirror what client-Files-tab needs — query `documents` joined
to a polymorphic billing-entity = company link, render a list, ship a
download button. Or hide the tab.
**Scope:** small to medium.
---
## HALF-WIRED (the page works but the surrounding promise overstates it)
### V6. "Onboarding" admin page is a static checklist, not the wizard the page itself promises
**File:** `src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx`
The page renders 8 stepwise links and explicitly says (lines 71-72,
98-110): "The future onboarding wizard will track progress per port…",
"What this page will become", "The wizard will record completion per
port in `system_settings`, gate the public marketing-site cutover…".
The admin landing card describes it as the "Initial-setup wizard for
fresh ports" — admins clicking through expect a wizard, get a static
table of contents.
**Impact:** the only "fresh port" workflow doesn't exist; cutover
gating logic mentioned in the page body is also unimplemented.
**Fix:** either (a) build the wizard with progress in `system_settings`
- banner integration, or (b) re-label both this page and the admin
landing card to "Setup checklist" so expectations match reality.
**Scope:** large for the wizard; tiny for the relabel.
---
### V7. Backup & Restore admin page is informational only — admin landing card promises actions
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/backup/page.tsx`
- `src/app/(dashboard)/[portSlug]/admin/page.tsx:148` — landing card
description: "Database snapshots and on-demand exports."
The landing card sells "on-demand exports". The actual page renders a
two-card explainer: "Current backup posture" (read-only) and "What
this page will become" (the entire interactive surface — list
snapshots, "Take backup now" button, per-port logical export, restore
preview, GDPR per-client export). None of those exist.
**Impact:** the "Backup & Restore" tile is functionally a docs page.
Compliance officers / users expecting a self-serve GDPR export
button have to file a support ticket.
**Fix:** match the language on the landing card to the page reality
("Backup posture" → docs only) until the snapshot/export buttons
ship. The maintenance worker already runs `database-backup` (per
`docs/audit-comprehensive-2026-05-06.md` C1 — though that worker isn't
imported), so wiring "Take backup now" against the existing job is
small once C1 is fixed.
**Scope:** small (doc tweak) or medium (button + per-port export
endpoint).
---
### V8. Inquiry inbox is read-only — no "Convert to Client" / "Mark resolved" / "Assign" actions
**File:** `src/components/admin/inquiry-inbox.tsx` (entire file, 207
lines, ends at the View payload toggle)
The inbox lists website-form submissions (berth_inquiry,
residence_inquiry, contact_form) with filter chips and a
"View payload" expand. There is no action to:
- create a client/interest from the submission,
- assign the inquiry to a sales rep,
- mark it resolved / triaged,
- reply directly,
- archive or trash the row,
- export.
The `website_submissions` table appears to be permanent — every
inquiry ever received remains in the inbox forever, with no triage
state. Sales has to manually copy the email into a new client form
and back-reference the original submission.
**Impact:** the inquiry-to-pipeline conversion step isn't supported in
the CRM. The marketing-site cutover (per the user's
`project_email_ownership_at_cutover.md` memory) will increase volume
on this surface and make the missing triage UX painful.
**Fix:** add a per-submission "Convert" action that prefills the
client + interest forms with the payload, plus a `triage_state`
column (open / converted / dismissed) and a default filter that hides
non-open rows.
**Scope:** medium.
---
## MOBILE PARITY
### V9. Mobile More-sheet is missing several real top-nav destinations
**File:** `src/components/layout/mobile/more-sheet.tsx:38-50`
`MORE_ITEMS` lists 11 entries. The dashboard route directory has at
least these top-level segments not represented anywhere in the mobile
bottom-tabs OR more-sheet:
- `residential` — exists at `/[portSlug]/residential/...`
- `notifications` — exists at `/[portSlug]/notifications/...`
- `berth-reservations` — exists at `/[portSlug]/berth-reservations/...`
- `documents` — exists as a top-level page (separate from the bottom
tab `documents`, which IS in mobile-bottom-tabs)
- `website-analytics` — exists at `/[portSlug]/website-analytics/...`
A mobile-only user has no path to any of them. The Documents bottom
tab does cover the doc list, but residential is an entire feature
domain (per the `(dashboard)/.../residential` directory) with no
mobile entry point.
**Impact:** anyone using the mobile chrome to triage on the go can't
reach residential clients/interests, alerts (`alerts` IS in the
sheet), or notifications.
**Fix:** add the missing segments to `MORE_ITEMS`. If the grid feels
too dense, reorganize into sections.
**Scope:** small.
---
### V10. Portal has no "Profile" / "Change password" surface
**Files:**
- `src/components/portal/portal-nav.tsx:8-15` — six tabs, no profile.
- Filesystem: no `src/app/(portal)/portal/profile/` directory.
A portal user who wants to change their email, phone, mailing address,
or password has no UI. The portal sign-in flow goes through the
better-auth session but the app exposes zero account-management
controls. The "Need assistance?" card on the dashboard tells the user
to contact the port team — which is the explicit answer for data
edits, but does not cover password changes (a security expectation,
not a per-port-staff burden).
**Impact:** every portal user who forgets their password (after
already activating) has to use `/portal/forgot-password` even if they
remember the old one. There's no proactive password rotation. A user
who changes their phone number has to email the port to update it.
**Fix:** ship `/portal/profile` with at minimum: read-only PII view +
"Change password" form (re-uses the existing reset-password endpoint
or a new `change-password` endpoint that takes the current pw).
Phone/address editing is a longer fix because of the audit-trail
implications.
**Scope:** small for password; medium with PII edits.
---
### V11. Portal invoices page lists invoices but offers no view/download — even though documents do
**File:** `src/app/(portal)/portal/invoices/page.tsx:53-99`
Each invoice row shows number, status, due/paid dates, amount, and a
small payment-status caption. There is no link, no PDF view, no
download. By contrast, the portal Documents page (peer route) ends
each row with a `<DocumentDownloadButton documentId={doc.id} />` that
fetches a signed S3 URL.
Compare to admin/CRM where invoices have a full PDF render flow
(invoice service generates the PDF + signed URL).
**Impact:** a portal user can see they owe money and cannot retrieve
the actual invoice document. They have to email the port to ask for a
PDF copy.
**Fix:** add an invoice-PDF endpoint under `/api/portal/invoices/[id]/
download` mirroring the documents one, and a download button on each
row. The invoice PDF generator already exists (`src/lib/services/
invoices.ts`).
**Scope:** small.
---
## DEV-NOTES (legitimately staged-for-later, calling out so they're not forgotten)
### V12. Email-templates admin only edits subject lines — body editing is a documented "next iteration"
**Files:**
- `src/components/admin/email-templates-admin.tsx:78-79` —
"Customize the subject line of transactional emails per port. Body
editing is the next iteration; for now the layout and HTML stay
locked to the default template."
- `src/lib/email/template-catalog.ts:5-9` — same statement in the
catalog header.
The page is honest about the limitation, so this isn't a "broken"
finding. But it's a notable shipped-without-the-killer-feature gap:
the multi-tenant promise of per-port email customization can't deliver
the body changes that ports actually want (logo placement, signature,
language). Combined with V2 (branding HTML fragments aren't read at
all), there is currently NO way for a non-super-admin per-port admin
to customize the email body in any way.
**Impact:** confined to admin expectations — most ports will assume
"Email templates" = "edit the email", click in, see only a subject
field, and request the missing body editor.
**Fix:** scope a body-editing flow that reuses the
`merge_fields.ts` token catalog (the validator already exists for
document templates) for safety. Until that's built, V2 + this finding
together mean a "rebrand the emails" task is single-tenant only.
**Scope:** large (HTML editor + token validator + per-port override
storage + render-side composition).
---
## Summary
12 findings, four severity tiers:
- **Visible-broken (V1-V5):** five admin/portal controls produce no
effect. V1 (email overrides) and V2 (branding) are the highest
impact — both silently break the multi-tenant promise.
- **Half-wired (V6-V8):** three pages where the surrounding wrapper
oversells what's there. V8 (inquiry inbox) is the largest scope.
- **Mobile parity (V9-V11):** mobile users can't reach several real
features; portal users have no profile/password surface and can't
download invoices.
- **Dev-notes (V12):** documented limitations called out for the
roadmap.
The two highest-leverage quick wins are **V1** (wire 6 missing
template subject overrides — a few hours) and **V11** (portal invoice
download — small, fixes a real customer pain point).

View File

@@ -1,266 +0,0 @@
# Per-role permission audit — 2026-05-06
Focused review of UI/server permission divergence on the new endpoints
shipped during the smart-archive / hard-delete / bulk-wizard /
external-EOI / webhook-replay work bundle. Skips items already covered
in `docs/audit-comprehensive-2026-05-06.md` (audit-log gating H6,
residential_partner sidebar nav).
The pattern hunted for: `<PermissionGate>` (or `usePermissions().can`)
on the UI side hides a control under permission **X**, while the
matching API route gates on permission **Y** (or doesn't gate at all,
or gates strictly — producing 403 toast spam for users who can see the
button but can't use it).
Scope: 8 routes + 5 components + the seed permission matrix. Hard cap
of 10 findings, ranked by impact. Critical/High/Medium/Low.
---
## CRITICAL
_None._ The four new hard-delete endpoints all gate on
`admin.permanently_delete_clients` on both layers (UI hides the button
via `<PermissionGate resource="admin" action="permanently_delete_clients">`
in `client-detail-header.tsx:162` and via `canHardDelete = can('admin',
'permanently_delete_clients')` in `client-list.tsx:53`; the four routes
all wrap with `withPermission('admin', 'permanently_delete_clients', …)`).
The webhook-replay route gates on `admin.manage_webhooks` — see H1 below
for the matching UI gap.
---
## HIGH
### H1. Webhook replay button has no UI permission gate (403 toast for non-admins)
- **UI:** `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
— the Replay `<Button>` renders for any user who can load the page,
with no `<PermissionGate>` wrapper and no `usePermissions().can('admin',
'manage_webhooks')` check.
- **Server:** `src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts:15`
`withPermission('admin', 'manage_webhooks', …)`.
**Divergence:** A `sales_manager` / `sales_agent` / `viewer` who
somehow lands on `/admin/webhooks/{id}` (e.g. via a deep link from a
shared message) sees enabled Replay buttons. Clicking surfaces a
generic 403 toast — the user has no signal that the action is
restricted, just that "Replay failed".
**Fix:** wrap the Replay `<Button>` in
`<PermissionGate resource="admin" action="manage_webhooks">…</PermissionGate>`,
or skip rendering the entire "Replay" column when
`!can('admin', 'manage_webhooks')`. The page-level guard on
`/admin/webhooks` should prevent non-admins from reaching the route in
the first place, but defense-in-depth is cheap and the toast UX is
poor.
---
### H2. Bulk-archive bulk action exposed to roles without `clients.delete`
- **UI:** `src/components/clients/client-list.tsx:182-190` — the
"Archive" entry in `bulkActions` is unconditionally rendered (only
the "Permanently delete" entry checks `canHardDelete`).
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — gates
`archive` action on `clients.delete`. Also
`src/app/api/v1/clients/bulk-archive-preflight/route.ts:30`
`withPermission('clients', 'delete', …)`.
**Divergence:** `sales_agent` (`clients.delete:false`,
seed-permissions.ts:246) and `viewer` (`clients.delete:false`,
seed-permissions.ts:323) both see the Archive bulk action. Selecting
clients and pressing it fires the `BulkArchiveWizard`, which calls
`bulk-archive-preflight` (returns 403) followed by `bulk` archive
(also 403). The wizard surfaces this as an opaque error.
**Fix:** mirror the `canHardDelete` pattern — compute
`const canBulkArchive = can('clients', 'delete');` near
`client-list.tsx:53` and conditionally include the Archive entry.
---
### H3. Bulk add_tag / remove_tag exposed to viewer (clients.edit:false)
- **UI:** `src/components/clients/client-list.tsx:165-181` — the "Add
tag" / "Remove tag" bulk actions render with no permission check.
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — both gate
on `clients.edit`.
**Divergence:** A `viewer` can multi-select rows, click "Add tag" or
"Remove tag", pick a tag in the dialog, hit "Apply", and receive a 403. The standalone bulk tag dialog has no inline gating to prevent
this.
**Fix:** the bulk action menu entries should gate on
`can('clients', 'edit')`. (Sales agent and above pass; only `viewer`
and `residential_partner` see the bug.)
---
### H4. `client-merge-log.surviving_client_id` enforcement absent from per-row port check on bulk hard-delete
- **Server:** `src/lib/services/client-hard-delete.service.ts:269-272`
The bulk preflight loads **every** row in the port
(`db.select(...).from(clients).where(eq(clients.portId, args.portId))`)
into memory, then validates the requested `clientIds` against that map.
That's correct for tenant isolation — a foreign-port id can't appear in
the map — but the inner loop at lines 364-389 then re-fetches each
client by `(id, portId)` and **silently skips** rows where the second
fetch returns nothing (line 377: `if (!c) continue;`). If a client is
archived between preflight and execute by another operator, the bulk
delete reports `deletedCount` lower than the requested set with no
error — the operator has no way to tell which ids were skipped.
**Divergence (perm-adjacent):** the per-row gate is enforced for
tenancy but the failure mode masquerades as success. Combined with
the route's all-or-nothing `withPermission` at the top, a
`permanently_delete_clients`-bearing operator can quietly under-delete.
**Fix:** when `c` is null, push the id into a `skipped: string[]`
array and return it in the response so the UI can surface "3
deleted, 1 skipped (not archived / removed by another user)".
---
## MEDIUM
### M1. `external-eoi` upload allows any role with `documents.upload_signed` regardless of `interests.edit`
- **UI:** `src/components/interests/interest-detail-header.tsx:382-395`
`<PermissionGate resource="documents" action="upload_signed">`.
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8`
`withPermission('documents', 'upload_signed', …)`.
**Divergence:** UI and server agree on the permission, but the seed
matrix has `documents.upload_signed:true` for `sales_agent` (line 264) AND any custom role with that flag — uploading an externally
signed EOI mutates the **interest** (it's the operative `signedDocument`
that flips the interest into a "signed" state inside
`uploadExternallySignedEoi`). The user only needs `documents.upload_signed`,
not `interests.edit`. A custom role with `documents.upload_signed:true`
- `interests.edit:false` can mutate the interest's effective state.
**Fix:** add a second gate inside the route handler:
`if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) throw new ForbiddenError(...)`.
Rationale: signing a doc against an interest is an interest-state
change, not just a document upload. Mirror the same check in
`<PermissionGate>` (use `<PermissionGate resource="interests" action="edit">`
nested inside the `documents.upload_signed` gate).
---
### M2. `change_stage` UI doesn't expose override checkbox in `InlineStagePicker` — server still accepts override
- **UI:** `src/components/interests/inline-stage-picker.tsx:52-58`
the inline picker (used in the detail header at
`interest-detail-header.tsx:221`) sends only
`{ pipelineStage, reason }` and never sets `override:true`. Users
with `override_stage` get no UI affordance to actually use the
permission from the inline picker; they have to open the modal
`InterestStagePicker` (which does expose the checkbox at line 137).
Worse, when a user picks a stage that isn't a legal forward
transition, the inline picker just shows the toast from the server's
`ConflictError` — instead of "you need override; toggle this box".
- **Server:** `src/app/api/v1/interests/[id]/stage/route.ts:14-22`
reads `body.override` and re-checks `interests.override_stage`
permission.
**Divergence:** UI and permission map diverge in the affordance, not
the gate. End-result: the `override_stage` permission is partially
unreachable from the inline picker. Sales managers / agents can
override only via the modal picker.
**Fix:** when the inline picker sees a transition that isn't allowed
by `canTransitionStage(currentStage, newStage)`, check
`can('interests', 'override_stage')` and either auto-set
`override:true` (with a confirmation) or surface a "Use override"
secondary action. Keep the inline picker UX; just don't let the
override permission be silently inaccessible from the most-used
path.
---
### M3. `sales_agent` granted `interests.override_stage:true` — possible copy-paste from sales_manager
- **Seed:** `src/lib/db/seed-permissions.ts:253``SALES_AGENT_PERMISSIONS.interests.override_stage = true`.
This is identical to `SALES_MANAGER_PERMISSIONS.interests.override_stage = true`
at line 176. The same `sales_agent` block has `delete:false` for
clients/interests/yachts/companies/files/etc — all the other
"trust-elevated" flags are explicitly stripped from sales_agent. The
ability to bypass the pipeline-stage transition table is a meaningful
trust elevation: it lets an agent skip prerequisites (e.g. mark an
interest as `eoi_signed` without an actual signed doc) which has
downstream implications for the public berths feed (`Under Offer`
status), the recommender's tier ladder, and the EOI bundle.
**Divergence:** likely intent vs. permission map. Worth confirming
with a product owner; if intentional, leave a code comment. If
unintentional, flip to `false`.
**Fix:** product decision. If demoted, also update
`src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`
(noted in the file header at seed-permissions.ts:9) so the UI
default for new roles matches.
---
### M4. `bulk-archive-preflight` returns dossier even when client is in another port (defense-in-depth)
- **Server:** `src/app/api/v1/clients/bulk-archive-preflight/route.ts:33-62`
The route loops through `ids` and calls `getClientArchiveDossier(id, ctx.portId)`
for each. If a `clientId` belongs to another port, `getClientArchiveDossier`
throws and the route catches it (line 52-61) and returns a fallback row
with `blockers: ['<error message>']`. This leaks **the existence of an
unknown client id** — an attacker enumerating UUIDs can distinguish
"client doesn't exist" from "client exists but you can't see it" by
parsing the blocker text. The bulk hard-delete route has the same
shape but returns `NotFoundError`.
**Divergence (perm-adjacent):** the preflight route doesn't enforce a
per-id port check before falling through to the dossier service, and
the catch block leaks the failure mode in the response.
**Fix:** in the catch block, replace the dossier error message with a
generic `'Could not load dossier'` blocker. The operator already
selected these ids so they know the count; they don't need the inner
error.
---
## LOW
### L1. `external-eoi` route doesn't enforce `interests.edit` defense-in-depth on the interest port
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8-14`
The route receives `interestId` from the URL and passes it +
`ctx.portId` into `uploadExternallySignedEoi`. The service is
expected to enforce port isolation, but the route itself does no
upfront `(interestId, portId)` existence check before reading the
multipart body — meaning a cross-port id will fully process the
upload (read the file into memory) before the service rejects.
**Divergence:** not strictly a permission divergence; it's resource
waste from missing early port-ownership check. Low because the
service-level reject does close the security hole.
**Fix:** add a one-row `select` on `interests` matching `id` + `portId`
before parsing form data, throw `NotFoundError` on miss.
---
## Summary
- 0 critical
- 4 high (H1H4)
- 4 medium (M1M4)
- 1 low (L1)
Top recommendation: H1 (webhook-replay UI gate) is a
ten-line fix that closes a 403-toast UX bug. H2 + H3 (bulk-archive +
bulk-tag UI gates) are also trivial and remove the same class of bug
across the bulk actions menu. M3 (sales_agent override_stage) needs a
product decision, not code; flag it before shipping the audit.

View File

@@ -1,220 +0,0 @@
# Reliability audit — 2026-05-06 (focused, post-batch deltas)
Scope: NEW services from the recent archive/restore/hard-delete/external-EOI batches.
Out of scope (already covered in `docs/audit-comprehensive-2026-05-06.md`):
worker imports, rate limits, hard-delete error message UX, smart-restore
dead reversal applier, bulk hard-delete redis loop, audit log spam.
---
## Critical
### C1. Bulk archive enqueues zero post-commit side effects
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-134`
- **Scenario:** When the bulk wizard archives 100 clients with high-stakes
reasons, `archiveClientWithDecisions` returns `externalCleanups` and
`releasedBerths` arrays per-client, but `runBulk` discards the return
value. Documenso envelopes that the wizard marked `void_documenso`
never get queued, and "next-in-line" notifications never fire. The
database is left in `documents.status='cancelled'` with the live
Documenso envelope still out for signature — the signer can complete
a legally-binding envelope that the CRM thinks is voided.
- **Fix:** Make the per-row callback return the result, then loop over
`results` after `runBulk` to enqueue Documenso voids and fire
next-in-line notifications (mirroring the single-client route).
Defaulting `documentDecisions` to `'leave'` (line 113-116) hides the
symptom for the bulk wizard but isn't enough — the single-client
service can still surface this if the bulk path is ever generalized.
---
## High
### H1. Restore wizard silently drops every released berth
- **File:** `src/lib/services/client-restore.service.ts:359-372`
- **Scenario:** `applyReversal` for `berth_released` is a no-op with a
comment saying "v1 leaves the berth available". But the dossier (line
122-129) classifies these as `autoReversible` and the UI tells the
operator "still available — re-attaching to the restored client". The
wizard increments `autoReversed` and the audit log records a
successful auto-reverse — but nothing actually happens. Operator
thinks restore re-linked their berth; it didn't.
- **Fix:** Either (a) actually re-link by persisting the original
`interestId` in the `berth_released` decision detail (it's already
there, line 211) and re-inserting an `interestBerths` row + flipping
the berth status back to `under_offer`, or (b) reclassify these as
`reversibleWithPrompt` with copy that says "berth left available —
re-add via the interest detail page".
### H2. Smart-archive berth status update has TOCTOU race
- **File:** `src/lib/services/client-archive.service.ts:191-207`
- **Scenario:** Berth row is read via `dossier.berths` (read outside the
tx) and modified inside the tx without a `for update` lock on
`berths`. Two concurrent flows — e.g. operator A archives client X
while operator B sells berth A1 to client Y — can race: A reads
`berth.status === 'sold' → false`, B's tx commits sold, A's tx then
flips it back to `available`. The "still under offer" subselect
doesn't catch this because berth.status is the source of truth, not
interest_berths.
- **Fix:** Add `tx.select(...).from(berths).where(eq(berths.id, d.berthId)).for('update')`
before the status flip and re-check `status !== 'sold'` against the
locked row.
### H3. Bulk archive can pick the wrong interest for berth release
- **File:** `src/app/api/v1/clients/bulk/route.ts:95-103`
- **Scenario:** When a client has multiple interests linked to the same
berth, the bulk wizard picks `dossier.interests.find((i) =>
i.primaryBerthMooring === b.mooringNumber)` and falls back to
`dossier.interests[0]?.interestId ?? ''`. The fallback to the
first-interest-or-empty-string can hand `archiveClientWithDecisions`
an `interestId` that was never linked to that berth — so the
`delete from interest_berths where berthId=… and interestId=…`
matches zero rows and the link is silently retained. Worse: an empty
string `''` reaches the delete, which still matches zero rows but
leaves the berth status check believing the link was removed.
- **Fix:** Build the berth→interest map from `interestBerthRows` (the
authoritative join) rather than guessing by `primaryBerthMooring`,
and skip berths with no resolvable interest rather than emitting an
empty-string interestId.
### H4. External EOI runs four writes outside a transaction
- **File:** `src/lib/services/external-eoi.service.ts:67-155`
- **Scenario:** `getStorageBackend().put()`, `files.insert`,
`documents.insert`, `documentEvents.insert`, and the interests
update happen as five independent operations. If any one fails after
the storage upload, you're left with an orphan PDF in S3/MinIO and
partial DB state. If the documents insert fails after the file
insert, the file row points to a storage key with no document
referencing it — and the interest never advances.
- **Fix:** Wrap files/documents/documentEvents/interests in a single
`db.transaction`. Storage upload stays outside (S3 isn't
transactional) but on tx failure, schedule a cleanup job that deletes
the orphan storage object, or accept the orphan and add a janitor.
### H5. Bulk wizard double-submit re-archives the same client and racy errors
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-120` +
`src/lib/services/client-archive.service.ts:165-173`
- **Scenario:** The single-client `archiveClientWithDecisions` locks
the row and throws `ConflictError('Client is already archived')` on
re-entry — good. But `runBulk` swallows the error string and returns
it as `{ok:false, error:"Client is already archived"}` for that
client. If the bulk wizard double-submits (network retry, double
click), partial successes from the first request now look like
per-client failures in the response, confusing the operator. There's
no idempotency key on the bulk submit.
- **Fix:** Treat `ConflictError('already archived')` as success in the
bulk per-row handler (the desired end state is reached). Or add an
idempotency-key header on the bulk endpoint that short-circuits a
duplicate request with the cached response.
---
## Medium
### M1. Hard-delete `clientMergeLog.surviving_client_id` deletes audit history
- **File:** `src/lib/services/client-hard-delete.service.ts:209`
- **Scenario:** The comment says "merged records remain in the log
because mergedClientId has no FK", but the delete is wider than
needed: it removes every merge-log row where this client was the
survivor. If client X (being deleted) previously absorbed clients
A/B/C, the audit trail of those merges is lost on X's deletion. The
surviving rows that remain (`mergedClientId = X`) are now
inconsistent — they reference a survivor that no longer exists.
- **Fix:** Either preserve the survivor rows by setting
`surviving_client_id = NULL` (requires column nullable) or keep the
current behavior but document it more visibly. At minimum, log the
deleted merge-log row count so operators can investigate gaps.
### M2. Documenso void worker has no max-retry guard for non-404 errors
- **File:** `src/lib/queue/workers/documents.ts:19-37`
- **Scenario:** `voidDocument` throws `CodedError` on non-404 failures
(auth error, network blip, Documenso 500). BullMQ retries with
backoff, but there's no per-job idempotency check — the second
retry hits the same envelope, voidDocument's 404 short-circuit only
kicks in if Documenso has actually voided it on the first retry
before the API call returned an error. A persistent 401 / 403 will
retry forever (until BullMQ exhausts attempts) and the documents row
stays `cancelled` in the CRM with the envelope still live in
Documenso. The DLQ is mentioned in the comment but the worker
doesn't surface a DLQ alert hook.
- **Fix:** On exhaustion, write back to `documents` (e.g.
`cancellation_failed=true`) and emit an admin notification so the
envelope can be voided manually.
### M3. Next-in-line notification fan-out unhandled rejection
- **File:** `src/lib/services/next-in-line-notify.service.ts:75-87`
- **Scenario:** Each `void createNotification(...)` is a fire-and-forget
promise with no `.catch` handler. If `notifications.service`
dispatches to a DB that's transiently down, the unhandled rejection
will surface in the Node process with no recipient context (the
closure captured `userId` is in the stack but pino won't include it
unless explicitly logged). Process-level handlers will log it but
individual recipients silently lose their notification.
- **Fix:** `.catch((err) => logger.warn({err, userId, berthId:
input.berthId}, 'next-in-line notification failed'))`.
### M4. Restore service uses `any` for transaction type
- **File:** `src/lib/services/client-restore.service.ts:354-355`
- **Scenario:** `applyReversal(tx: any, ...)` defeats Drizzle's type
safety. A future schema rename (e.g. `yachts.status` enum change)
won't fail at compile time inside this function. Combined with the
documented v1 no-op for `berth_released`, the function looks
innocuous but carries the most risk.
- **Fix:** Use the proper Drizzle tx type — `Parameters<Parameters<typeof
db.transaction>[0]>[0]` or a named type alias from
`@/lib/db/types.ts` if one exists.
### M5. interests.changeInterestStage milestones write outside tx
- **File:** `src/lib/services/interests.service.ts:630-648`
- **Scenario:** The override path (and normal path) writes
`pipelineStage` in one update and milestone fields
(`dateEoiSent`, `dateContractSigned`, etc.) in a second update. If
the process crashes between the two, the stage advances but the
milestone is never recorded. Funnel/conversion math then under-
counts that interest. Over-the-wire this is rare but the audit log
fires before the milestone update succeeds, so the audit trail
claims a complete transition that's actually half-applied.
- **Fix:** Combine both into a single update statement, computing the
milestone fields in JS and merging them into the `set({...})` clause.
---
## Low
### L1. Smart-archive coalesces invoice notes via SQL string concat
- **File:** `src/lib/services/client-archive.service.ts:288-291`
- **Scenario:** `notes: sql\`coalesce(${invoices.notes}, '') || ${...}\``embeds`new Date().toISOString()`and the action label inside a
parameterized string. The values are bound, so it's not an injection
risk, but the`\n[archive ...]` marker is appended unconditionally —
re-running the archive on a not-yet-committed client would double
the marker. Combined with H5 (no idempotency on bulk), a retry could
bloat invoice notes with duplicate markers.
- **Fix:** Append only when the marker isn't already present, or rely
on the `clients.archivedAt is null` precheck (which already guards
re-entry) and accept the duplicate as theoretically impossible.
### L2. Hard-delete `requestHardDeleteCode` reveals client existence pre-archive
- **File:** `src/lib/services/client-hard-delete.service.ts:77-85`
- **Scenario:** A user without `admin.permanently_delete_clients`
shouldn't reach this service, so this is theoretical, but the
ConflictError "Client must be archived" leaks the existence of an
unarchived client to anyone who can reach the route. The audit doc
flagged hard-delete error messages already (out of scope), but this
specific error path isn't covered there.
- **Fix:** Same as the audit-doc finding for the symmetric path —
return a generic `NotFoundError` instead of distinguishing
"not found" from "not archived" externally; log the distinction
internally only.

View File

@@ -1,147 +0,0 @@
# Handoff prompt for new Claude Code session
Copy everything below the `---` line into the new chat as your first message.
---
I'm continuing work on a comprehensive multi-feature push that was fully designed in a prior session but not yet implemented. The complete plan lives at `docs/berth-recommender-and-pdf-plan.md` (~1030 lines). **Read that file end-to-end before doing anything else — every design decision, schema change, edge case, and confirmed answer to a product question is captured there.** Don't re-litigate decisions; if something seems unclear, the answer is almost certainly in the plan.
## What the project is
A multi-tenant marina/port-management CRM at `/Users/matt/Repos/new-pn-crm`. Next.js 15 App Router, React 19, TypeScript strict, Drizzle ORM on Postgres, MinIO for files, BullMQ on Redis, better-auth, shadcn/ui, Tailwind. See `CLAUDE.md` for the conventions.
## What we're building (high level)
The plan bundles 8 capabilities into one branch (`feat/berth-recommender`):
1. **/clients + /interests list-column fix** (the original bug — list views show `-` everywhere because the service didn't join contacts/yachts)
2. **Full NocoDB Berths import** + seeding + mooring-number normalization (current CRM has `A-01..E-18`; canonical is `A1..E18`)
3. **Schema refactor** to many-to-many `interest_berths` with role flags (`is_primary`, `is_specific_interest`, `is_in_eoi_bundle`)
4. **Berth recommender** (SQL ranking, tier ladder, heat scoring, UI panel) — no AI; pure SQL
5. **EOI bundle** support (multi-berth EOIs + range formatter for the Documenso PDF: `["A1","A2","A3","B5","B6"]``"A1-A3, B5-B6"`)
6. **Pluggable storage backend** (s3-compatible OR local filesystem) so admins can run without MinIO if they want
7. **Per-berth PDFs** (versioned uploads, OCR-based reverse parser, conflict-resolution diff dialog)
8. **Sales send-out emails** (berth PDF + brochure) with full audit + size-aware fallback to download links
## Phase ordering (from plan §2)
```
Phase 0: Full NocoDB berth import + mooring normalization + 5 new pricing columns
Phase 1: /clients + /interests list column fix
Phase 2: M:M interest_berths schema refactor + desired dimensions on interests
Phase 3: CRM /api/public/berths endpoint + website cutover
Phase 4: Recommender SQL + tier ladder + heat + UI panel
Phase 5: EOI bundle + range formatter
Phase 6a: Pluggable storage backend + migration CLI + admin UI
Phase 6b: Per-berth PDF storage (versioned) + reverse parser
Phase 7: Sales send-outs + brochure admin + email-from settings
Phase 8: CLAUDE.md updates + final validation
```
**Start with Phase 0**.
## Working tree state at handoff
- Branch: `main` (you'll create `feat/berth-recommender` from here)
- Recent commits (already pushed):
- `8699f81 chore(style): codebase em-dash sweep + minor layout polish`
- `d62822c fix(migration): NocoDB import safety + dedup helpers + lead-source backfill`
- `089f4a6 feat(receipts): upload guide page + scanner head-tag fix`
- `77ad10c feat(dashboard): custom date range + KPI port-hydration gate`
- `e598cc0 feat(layout): unified Inbox + UserMenu extraction`
- `f5772ce feat(analytics): Umami integration with per-port admin settings`
- `49d34e0 feat(website-intake): dual-write endpoint + migration chain repair`
- Untracked / uncommitted at handoff:
- `docs/berth-recommender-and-pdf-plan.md` (the plan — read this first)
- `docs/berth-feature-handoff-prompt.md` (this file)
- `berth_pdf_example/` (two reference files — see below)
- `.env.example` (modified — adds `WEBSITE_INTAKE_SECRET=`; pre-commit hook blocks `.env*` files so user adds this manually)
- Dev DB state:
- 245 clients (210 with no `nationality_iso` — Phase 1 backfills from primary phone's `value_country`)
- 4 test rows in `website_submissions` (from a previous live audit; safe to ignore)
- 90 berths with `mooring_number` in `A-01` format (Phase 0 normalizes to `A1`)
- vitest: 956 tests passing
- tsc: clean (one pre-existing issue in `scripts/smoke-test-redirect.ts` that's unrelated)
## Reference files
- `berth_pdf_example/Berth_Spec_Sheet_A1.pdf` (358 KB) — sample per-berth PDF. **0 AcroForm fields** (confirmed via pdf-lib) so OCR with positional heuristics is the primary parser tier; the AcroForm tier is built defensively. Plan §9.2 captures the layout structure.
- `berth_pdf_example/Port-Nimara-Brochure-March-2025_5nT92g.pdf` (10.26 MB) — sample brochure. Sized so it ships as an attachment under the 15 MB threshold. Plan §11.1 covers brochure handling.
## NocoDB access
You have `mcp__NocoDB_Base_-_Port_Nimara__*` tools available. Tables you'll touch most:
- `mczgos9hr3oa9qc` — Berths (Phase 0 imports from here; mooring numbers are stored as `A1..E18`)
- `mbs9hjauug4eseo` — Interests (the combined client+deal table the old system used)
## Branch & commit conventions
- Create the branch: `git checkout -b feat/berth-recommender`
- Commit messages match recent history style: `<type>(<scope>): <subject>` lowercase, terse subject, body explains why not what.
- **Pre-commit hook blocks any `.env*` file** including `.env.example`. If you need to update `.env.example`, leave it staged and tell the user to commit manually with `--no-verify` (they're aware of this).
- **Don't push without explicit user permission.** Commits are fine; pushes need approval.
- **Don't run `git rebase`, `git push --force`, or anything destructive without checking.** The branch is solo-owned but the repo's `main` is shared.
## User communication preferences (from prior session)
- Direct, no fluff. If something is a bad idea, say so — don't sycophant.
- When proposing changes, include trade-offs explicitly.
- For multi-question decisions, use `AskUserQuestion` rather than long bulleted lists.
- Run validation (vitest + tsc) at logical checkpoints. Don't ship a commit with regressions.
- The user prefers small focused commits over mega-commits. Within Phase 0 alone there will probably be 2-3 commits (e.g. mooring normalization, schema additions, NocoDB import script).
## Critical rules (from plan §14)
Eleven 🔴 critical items requiring tests before their phase ships:
1. NocoDB mooring collisions → unique constraint + ON CONFLICT
2. Non-PDF disguised upload → magic-byte check
3. Recipient email typos → pre-send confirmation
4. XSS in email body markdown → DOMPurify + payload tests
5. SMTP credentials silently failing → loud error + failed `document_sends` row
6. Wrong-environment `CRM_PUBLIC_URL` → health-check env match
7. Mooring format drift breaking `/berths/A1` URLs → Phase 0 normalization gates Phase 3
8. Multi-port isolation in recommender → explicit `port_id` filter + cross-port test
9. Permission escalation on SMTP creds → per-port admin only, no rep visibility
10. Filesystem backend in multi-node deployment → refuse to start; documented + health-check enforced
11. Path traversal via storage key in filesystem mode → strict regex validation + path realpath check
## Pending items (from plan §9)
These are non-blocking but worth knowing:
- Sample brochure already provided (the 10.26 MB file above).
- SMTP app password for `sales@portnimara.com` — not yet obtained; expected close to production cutover. Phase 7 ships the admin UI immediately and the credential gets entered when available.
- `CRM_PUBLIC_URL` confirmed as `https://crm.portnimara.com` once live; configurable via env.
- GDPR cascade behavior for `document_sends` (delete vs. anonymize-PII vs. keep) — left `OPEN` in §14.10, default lean: anonymize-PII. Revisit when Phase 7 schema lands.
## Scope reminder
- **No prod data depends on the current CRM schema** — refactors don't need backwards-compatibility shims. But every schema change still ships as a Drizzle migration with `pnpm db:generate`.
- **Pluggable storage** rejects Postgres `bytea` as an option (§4.7a). The two backends are s3-compatible (MinIO/AWS/B2/R2/etc.) and local filesystem. Filesystem is single-node only.
## What to do first
1. Read `docs/berth-recommender-and-pdf-plan.md` end-to-end. Don't skim. The edge-case audit in §14 alone is critical context.
2. Confirm you've understood the plan by stating back the 8-phase outline and the 11 critical items, then ask the user if they want to proceed with Phase 0.
3. Once approved, create `feat/berth-recommender` and start Phase 0.
Phase 0 deliverables (per plan):
- One commit normalizing existing CRM mooring numbers from `A-01``A1` form (via `regexp_replace` migration). Delete the offending `scripts/load-berths-to-port-nimara.ts`.
- One commit adding the 5 new berth columns (`weekly_rate_high_usd`, `weekly_rate_low_usd`, `daily_rate_high_usd`, `daily_rate_low_usd`, `pricing_valid_until`, `last_imported_at`). Run `pnpm db:generate`. Verify `meta/_journal.json` prevId chain stays contiguous.
- One commit adding `scripts/import-berths-from-nocodb.ts` — the idempotent NocoDB import (handles updates, preserves CRM-side edits via `last_imported_at vs updated_at` check, `pg_advisory_lock`, dry-run flag, etc. per §4.1 and §14.1).
- Update `src/lib/db/seed-data.ts` with the imported berth set so fresh installs get them.
- Final vitest + tsc validation at the end of Phase 0.
## Don't
- Don't push to remote during this session (user will batch the push later).
- Don't commit `.env*` files (hook blocks them anyway).
- Don't edit `.gitignore` to exclude generated artifacts; the repo's existing ignores are correct.
- Don't add documentation files unless the plan asks for them — the plan itself is the doc.
- Don't add features not in the plan. If something seems missing, ask.
- Don't use AI for the recommender (plan §1 + §13). Pure SQL ranking.
Once you've read the plan and confirmed understanding, ask me whether to proceed with Phase 0.

File diff suppressed because it is too large Load Diff

View File

@@ -1,722 +0,0 @@
# Documenso signing-flow build plan
Captures every Documenso-related piece that isn't shipped yet, in attack order. A fresh session should be able to pick this up without re-reading the whole conversation.
**Companion docs:**
- [docs/documenso-integration-audit.md](./documenso-integration-audit.md) — what's already built, v1/v2 endpoint mapping, nginx CORS block
- Old system reference: [client-portal/server/api/eoi/generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [client-portal/server/api/webhooks/documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [client-portal/server/services/documenso-notifications.ts](../client-portal/server/services/documenso-notifications.ts), [Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)
---
## Locked design decisions (from user, do NOT re-ask)
| Q | Decision |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Embedded signing host | `portnimara.com/sign/<role>/<token>` (marketing website hosts the embed page; CRM emits URLs in this format) |
| Initial "please sign" email | **Per-port admin setting** `eoi_send_mode`: `auto` = send branded email immediately on generate; `manual` = generate + show URL + Send button |
| Contract / Reservation generation | **Upload-and-place-fields per deal only.** EOI is the only template-driven flow. (Resolved Q6 — template-fallback dropped.) |
| Reminder cadence | **Manual by default.** Rep clicks "Send reminder" button. Per-doc opt-in for auto-reminders at upload time. (Resolved Q1) |
| Document expiration | **Never expire.** No `expiresAt` UI in v1. (Resolved Q2) |
| Approver vs CC | **Two concepts**: `APPROVER` = real Documenso recipient that gates signing; `Completion CC` = passive recipient that only receives the signed PDF. (Resolved Q4) |
| Witness | **First-class signer role.** Configurable per-document; full reminder/tracking flow. (Resolved Q7) |
| Per-port developer label | **Configurable** via `documenso_developer_label` / `documenso_approver_label`. (Resolved Q8 bonus) |
| Multi-port template config | All Documenso settings are per-port via `/[portSlug]/admin/documenso` (already wired) |
| Documenso API version | Both v1 + v2 supported. Per-port config picks. v1 is prod (1.32) — primary. v2 unlocks embed + envelope |
| nginx CORS | User applies manually. Block is in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Supports multi-origin via `set $cors_origin` regex |
| Signer override | **Hybrid** — template docs (EOI) keep template-fixed signers (per-port settings fill the slots). Custom-uploaded docs (contract, reservation) get full per-deal signer customization. |
| Multi-berth | EOI keeps existing bundle support. Contract/reservation are custom-uploaded PDFs — no PDF form-fill, just Documenso signature/initials/date fields |
| Test mode | Reuse `EMAIL_REDIRECT_TO` env var (already redirects every outbound email + Documenso recipient) |
| Regenerate handling | Match old system: 3 retries to delete prior Documenso doc with 2-second wait. **Plus** a confirm modal: "Retain old EOI? (default no)" |
| Field placement strategy | **Auto-detect (anchor text scanner) + manual drag-drop UI as safety net.** Auto-detect populates the initial state; rep can drag/delete/reassign before sending. |
---
## What's already shipped (foundation)
Files in place; do NOT rebuild:
- `src/lib/services/port-config.ts` — extended with: `documenso_developer_name/email`, `documenso_approver_name/email`, `eoi_send_mode`, `embedded_signing_host`, `documenso_contract_template_id`, `documenso_reservation_template_id`
- `src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx` — admin UI exposes every Documenso knob across 5 cards
- `src/lib/email/templates/document-signing.ts``signingInvitationEmail`, `signingCompletedEmail`, `signingReminderEmail` with per-port branding
- `src/lib/services/document-signing-emails.service.ts``sendSigningInvitation`, `sendSigningReminder`, `sendSigningCompleted`. Includes `transformSigningUrl(rawUrl, host, role)` for embed URL wrapping
- `src/lib/services/documenso-client.ts` — extended `DocumensoFieldType` to all 11 types: SIGNATURE, FREE_SIGNATURE, INITIALS, DATE, EMAIL, NAME, TEXT, NUMBER, CHECKBOX, DROPDOWN, RADIO. Plus typed `DocumensoTextFieldMeta`/`NumberFieldMeta`/`ChoiceFieldMeta` interfaces and `fieldTypeNeedsMeta(type)` helper
- `src/components/interests/interest-eoi-tab.tsx` — EOI workspace with active-doc hero, signing progress, paper-signed upload, history strip
- `src/components/interests/interest-contract-tab.tsx` — Contract workspace shell with paper-signed upload + "send for signing" placeholder dialog
- `src/components/interests/interest-reservation-tab.tsx` — Reservation workspace shell (clone of Contract)
- `src/components/interests/interest-tabs.tsx` — stage-conditional visibility wired
What works today end-to-end: generate EOI → Documenso template path → manual link sharing (rep copies URL out of UI). What does NOT yet work: auto-send branded invitation, cascading "your turn" emails, custom-doc upload-to-Documenso, embedded signing URL emission to the website, on-completion PDF distribution.
---
## Phase 1 — EOI generate flow polish (~3 hours)
> **Updated for Q1, Q4, Q6, Q8 resolutions.** Adds manual-reminder endpoint, two new per-port label settings, drop of contract/reservation template settings, schema columns for completion CCs + auto-reminder. Also folds in webhook-secret hardening (Risk #7 Option A) and `transformSigningUrl` role mapping (Risk #5 fix).
**Why first**: Smallest surface area, validates the per-port `eoi_send_mode` setting works end-to-end, gets the cascading-email mental model in place before tackling the bigger pieces.
### Tasks
1. **Auto-send wiring**: in `src/components/documents/eoi-generate-dialog.tsx`, after `handleGenerate()` succeeds:
- Fetch port's `eoi_send_mode` (already on `getPortDocumensoConfig(portId)`)
- If `auto`: server-side already sent the doc to Documenso with `sendEmail: false`. Now call new endpoint `POST /api/v1/documents/[id]/send-invitation` (build it) which:
- Looks up the document's signers
- Calls `sendSigningInvitation()` for the first signer (the client; signing order 1)
- Stores `sent_at` timestamp on the signer row
- If `manual`: do nothing. Surface the signing URL in the EOI tab + a "Send invitation" button that hits the same endpoint.
2. **Regenerate confirm modal**: when EOI tab's "Generate EOI" button is clicked AND a Documenso doc already exists for this interest (`activeDoc !== null`):
- Show a `<Dialog>` asking: "There's already an EOI in flight. Regenerating will create a new document and the existing one will be cancelled."
- Two buttons: "Cancel" (default), "Regenerate" (destructive)
- Below the buttons, a checkbox: "Keep the previous EOI in Documenso (don't delete)" — defaults UNCHECKED
- On confirm: if checkbox unchecked, call `voidDocument(oldId, portId)` with 3 retries + 2-second wait between (mirror old system's `generate-quick-eoi.ts` lines 110-162). Then run the normal generate flow.
3. **Send-invitation endpoint**: new file `src/app/api/v1/documents/[id]/send-invitation/route.ts`:
```ts
POST /api/v1/documents/[id]/send-invitation
Body: { recipientId?: string } // optional — defaults to first unsigned recipient
```
- Loads the document + signers
- Resolves the target recipient (passed-in or first unsigned in signing order)
- Resolves port's documenso config + the recipient's signing URL from the document_signers row
- Calls `sendSigningInvitation` from the email service
- Updates `document_signers.invited_at` (need to add column — see schema migration below)
4. **Schema migration**: add `invited_at` and `last_reminder_sent_at` columns to `document_signers`:
```sql
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
```
The webhook handler updates these (Phase 2). Apply via psql then restart dev server (per CLAUDE.md migration note).
### Acceptance criteria
- Setting `eoi_send_mode=auto` in admin → generating an EOI fires off our branded HTML email to the client immediately
- Setting `eoi_send_mode=manual` → no email fires; "Send invitation" button in EOI tab hits the endpoint
- Clicking Generate when an active EOI exists → confirm dialog with checkbox; default deletes prior doc with retries
---
## Phase 2 — Webhook handler enhancement (~3-4 hours)
**Why second**: Once invitations are flowing (Phase 1), the webhook needs to track the lifecycle and fire the cascading "your turn" emails as each signer completes. Without this, the system goes silent after the initial invite.
### Tasks
1. **Extend `src/app/api/webhooks/documenso/route.ts`** to handle `DOCUMENT_OPENED`, `DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED` (DOCUMENT_OPENED currently ignored).
2. **For `DOCUMENT_SIGNED`** (fires when one recipient signs, can fire multiple times per doc):
- Resolve the (port, document, signer) — existing per-port secret lookup already does this
- Update `document_signers.signed_at` for the matching signer
- Find the next unsigned signer in signing order
- If next signer exists AND we haven't already invited them: call `sendSigningInvitation()` with the next signer + their signing URL + role='developer' (or 'approver' depending on signing order). Mark `document_signers.invited_at` for them.
- This is the cascading "your turn" flow that mirrors `client-portal/server/services/documenso-notifications.ts`
3. **For `DOCUMENT_OPENED`**:
- Update `document_signers.opened_at` for the matching recipient (matched by token in payload)
- Used for analytics later ("12% of clients open within an hour")
4. **For `DOCUMENT_COMPLETED`** (fires once when all signers have signed):
- Update document `status='completed'`, `completed_at=...`
- Download signed PDF: `await downloadSignedPdf(documensoId, portId)` (existing)
- Store in storage backend via the file ingestion flow — this creates a `files` row
- Update the document row to point at the signed file (`signed_file_id`)
- Call `sendSigningCompleted()` with all signers + the signed file's id
- Update the linked interest's pipeline stage:
- If document type = `eoi` → `eoi_signed`
- If document type = `contract` → `contract_signed`
- If document type = `reservation_agreement` → leave stage; reservation is post-deal-close anyway
5. **Recipient-token matching**: webhooks include `payload.recipients[]` with each recipient's `token`. Use the token to match against `document_signers.signing_token` (need to add the column if not already). Old system's webhook does this via email match — fragile when the same email serves multiple roles. Token match is robust.
6. **Idempotency**: webhook can fire duplicates. Old system's `acquireWebhookLock` + signature comparison pattern is good. Port that logic.
### Schema migration
```sql
-- Add fine-grained tracking columns to document_signers
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text; -- index this
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
```
### Acceptance criteria
- Client signs → developer receives our branded "your turn" email within seconds
- Developer signs → approver receives the same
- All signed → all three recipients receive the signed PDF as attachment
- Interest's pipeline stage advances to `eoi_signed` automatically
- Re-firing of duplicate webhooks is no-op
---
## Phase 3 — Custom document upload-to-Documenso (~6-8 hours)
**Why third**: Backend foundation for contract + reservation flows. Without this, the "Upload draft for signing" CTA on those tabs is a placeholder.
### Tasks
1. **New service** `src/lib/services/custom-document-upload.service.ts`:
```ts
export async function uploadDocumentForSigning(args: {
interestId: string;
portId: string;
documentType: 'contract' | 'reservation_agreement';
pdfBuffer: Buffer;
filename: string;
title: string;
recipients: Array<{
name: string;
email: string;
role: 'SIGNER' | 'APPROVER' | 'CC';
signingOrder: number;
}>;
fields: DocumensoFieldPlacement[]; // from auto-detect or manual placement
}): Promise<{ documentId: string; signingUrls: Record<string, string> }>;
```
Steps:
- Convert pdfBuffer → base64
- Call `createDocument(title, base64, recipients, portId)` — existing client function
- Call `placeFields(docId, fields, portId)` — existing client function (handles v1 + v2)
- Call `sendDocument(docId, portId)` — existing
- Return doc ID + per-recipient signing URLs
- Mirror the timing-safe URL extraction from old system's generate-quick-eoi (recipients[].signingUrl)
- Insert a row into our `documents` table with the new doc_id + signers + interest link
- If port's `eoi_send_mode === 'auto'`: kick off `sendSigningInvitation()` to first signer
2. **API endpoint**: `POST /api/v1/interests/[id]/upload-for-signing`
- Accepts multipart: `file` (the PDF), `documentType`, `title`, `recipients` (JSON), `fields` (JSON)
- Validates: file is PDF (magic-byte check, see berth-pdf flow), recipients ≥ 1, fields ≥ 1
- Calls service
- Returns 201 with the new document row
3. **Update Contract + Reservation tab placeholders** to open a real upload dialog (see Phase 4).
### Acceptance criteria
- Endpoint accepts a PDF + recipients + fields and returns a Documenso doc ID
- Document appears in the Documents tab with status `sent`
- v1 and v2 paths both work (same code path; client chooses based on per-port config)
---
## Phase 4 — Recipient configurator + Field placement UI (~10-14 hours)
**Why fourth**: This is the BIG visual piece. Don't start until Phase 3 backend is proven via curl.
### Sub-phase 4a: Recipient configurator (~2-3 hours)
UI inside a new `<UploadForSigningDialog>` component:
- File picker (drag-drop + click)
- Title input (defaults to filename minus extension)
- Recipients list:
- Add row → name + email + role (SIGNER/APPROVER/CC) + signing order (number, auto-increments)
- Drag to reorder (uses `dnd-kit`, already in deps)
- Delete row
- Defaults: client (signing order 1) prefilled from interest's linked client; developer + approver prefilled from port settings
- "Configure fields →" button advances to sub-phase 4b
### Sub-phase 4b: PDF rendering (~3-4 hours)
- Install: `pnpm add react-pdf` (uses pdfjs-dist under the hood; pdfme already pulls pdfjs-dist so no new dep weight)
- Render the uploaded PDF page-by-page using `<Document>` + `<Page>` from react-pdf
- Page navigation (prev/next, page picker)
- Zoom controls (50%, 75%, 100%, 125%, 150%)
### Sub-phase 4c: Auto-detect scanner (~4-6 hours)
New file `src/lib/services/document-field-detector.ts`:
```ts
export interface DetectedField {
type: DocumensoFieldType;
pageNumber: number;
pageX: number; // 0-100 percent
pageY: number;
pageWidth: number;
pageHeight: number;
/** Confidence 0-1 — how sure the scanner is. */
confidence: number;
/** Original anchor text (for debugging / display). */
anchorText?: string;
/** Inferred recipient (from nearby labels). null = unassigned. */
inferredRecipientLabel?: string | null;
}
export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>;
```
Implementation:
- Use `pdfjs-dist` to extract text per page with `getTextContent()` — gives `{str, transform: [a,b,c,d,e,f]}` per text item where `e,f` is position in PDF user space, plus `width/height`
- Anchor patterns:
- `SIGNATURE`: `/signature[:\s_-]+/i`, `/sign\s*here[:\s_-]*/i`, `/X\s*_{4,}/i`, `/signed\s*by[:\s]+/i`
- `INITIALS`: `/initials?[:\s_-]+/i`
- `DATE`: `/dated?[:\s_-]+/i`, `/date\s+of\s+signature/i`
- `NAME`: `/(printed?\s*)?name[:\s_-]+/i`, `/full\s+name[:\s_-]+/i`
- `EMAIL`: `/email[:\s_-]+/i`
- Catch-all: `/_{8,}/` → if not preceded by name/email/date keyword, default to TEXT
- For each match: place field bounding box immediately AFTER the matched text (offset 5pt right), with type-appropriate width:
- SIGNATURE: 150pt × 30pt
- INITIALS: 50pt × 30pt
- DATE: 80pt × 20pt
- NAME: 150pt × 20pt
- EMAIL: 200pt × 20pt
- TEXT: 200pt × 20pt
- Convert to PERCENT (divide by page width/height)
- Recipient inference: scan ±100pt of the field for labels like "Buyer", "Seller", "Client", "Developer", "Witness", "Notary". Map to recipient by role.
### Sub-phase 4d: Drag-drop overlay (~3-4 hours)
- Overlay absolute-positioned divs on top of the PDF viewer for each field
- Each field shows: type icon + recipient color + delete (×) handle + drag affordance
- Use `dnd-kit` to enable drag — update `pageX/pageY` in state on drop
- Field palette toolbar: 11 buttons (one per Documenso field type) — click to enter "place mode" → next click on the PDF places a new field at that coord
- Side panel for selected field:
- Type changer (dropdown)
- Recipient assignment (dropdown of configured recipients)
- Required toggle
- Per-type config (TEXT label, NUMBER min/max, CHECKBOX/DROPDOWN/RADIO options) — drives `fieldMeta`
- Width/height inputs
- Delete button
### Sub-phase 4e: Send (~1 hour)
"Send for signing" button:
- Validates: ≥1 recipient, ≥1 field, every field has a recipient assigned
- POSTs to `/api/v1/interests/[id]/upload-for-signing` (Phase 3)
- On success, closes dialog and refreshes the Contract/Reservation tab
### Acceptance criteria
- Upload a draft PDF → auto-detect runs → fields appear overlaid in their detected positions
- Rep can drag any field to reposition (state updates, persists to backend on send)
- Rep can change a field's type, recipient, or metadata via side panel
- Rep can add new fields by clicking palette button + clicking on PDF
- Rep can delete fields they don't want
- Click Send → fields ship to Documenso, signing flow starts, Contract tab shows the active doc
---
## Phase 5 — Embedded signing URL emission verification (~1-2 hours)
**Why later**: The Vue page on the marketing website already exists. This phase is a verification + documentation pass, not a code build.
### Tasks
1. **Verify URL transformation matches website expectations**:
- Website route: `/sign/[type]/[token]` where `type ∈ {client, cc, developer}`
- Our `transformSigningUrl()` emits `/sign/<role>/<token>` where role can be `client | developer | approver | witness | other`
- Mismatch: website only handles `client | cc | developer`. Our email service may emit `approver` (which the website doesn't route).
- **Fix**: either (a) update website's `[type].vue` to accept `approver` (and `witness | other` if needed), OR (b) map our role names to the website's expected names in `transformSigningUrl()`.
2. **For contract + reservation document types**: the website's `signerMessages` map only covers EOI-specific copy. When a contract goes out for signing and the recipient hits `portnimara.com/sign/client/<token>`, the page would show "Sign Your Expression of Interest" — wrong copy.
- **Fix**: add document-type to the URL too: `/sign/<docType>/<role>/<token>`. Update website's signerMessages to be keyed on `(docType, role)`.
3. **Webhook callback URL**: website POSTs to `client-portal.portnimara.com/api/webhook/document-signed` after signing. The new CRM is at a different domain. Update website's `handleDocumentSigned` to POST to the new CRM's webhook (a thin "client confirmed sign" notification, separate from Documenso's own webhook).
4. **Apply nginx CORS block** — already documented in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Apply via ssh when user grants access.
### Acceptance criteria
- Embedded URL points at a working website page that loads the right Documenso embed for any document type / role combo
- Post-sign callback updates our document_signers row (redundant with the Documenso webhook but useful as a real-time UI signal)
---
## Phase 6 — Polish & deferred items (~2-3 hours each, do as needed)
- **`auto` send mode delay**: optional per-port `eoi_send_delay_minutes` setting. When set, the auto-send fires after N minutes (BullMQ scheduled job) so the rep can review + cancel during the window. Default 0 (immediate).
- **Audit log entries**: every Documenso-related action (generate, send, remind, cancel, sign-event-received) writes to `audit_logs` with structured metadata. Mostly already there for the existing flow; extend to cover Phase 1-3 additions.
- **Per-document customization of email copy**: rep can override the default signing-invitation body before send. New textarea in the upload dialog. Stored as `documents.invitation_message`.
- **Document expiration**: Documenso supports `expiresAt`. Surface as a per-document field in the upload dialog.
- **Reminder rate-limit display**: surface "next reminder available in X days" on each unsigned signer in the signing-progress UI.
- **Failed-webhook recovery UI**: admin page showing webhooks that errored, with a "Replay" button. Old system has the foundation; CRM doesn't.
---
## Phase 7 — Project Director role + RBAC layer (~6-8 hours)
> **Surfaced from Q8 conversation.** The `developer` signer slot is conceptually the "Project Director" — the person at the port who countersigns deals on behalf of the port. Today every CRM user is either a sales rep or admin; there's no Project Director user role. Attack alongside the Documenso build because (a) the Documenso developer-label setting is meaningless if no user actually has the role, and (b) a few permissions naturally cluster around it.
### What a Project Director needs (vs sales rep)
| Capability | Sales rep | Project Director | Admin |
| -------------------------------------------------------- | --------- | ---------------- | ----------------------------- |
| Generate EOI / contract / reservation | ✓ | ✓ | ✓ |
| Approve / sign as the "developer" recipient on Documenso | — | ✓ | — (unless also designated PD) |
| View own deals | ✓ | ✓ | ✓ |
| View other reps' deals | — | ✓ | ✓ |
| View audit logs (read-only) | — | ✓ | ✓ |
| Trigger CSV / report exports | — | ✓ | ✓ |
| Re-assign deals between reps | — | ✓ | ✓ |
| Edit per-port settings | — | — | ✓ |
| Manage users + invitations | — | — | ✓ |
| Manage Documenso config | — | — | ✓ |
So Project Director sits between sales rep and admin: read-everywhere + a few action capabilities (re-assign, export, sign-as-PD), but no settings/user management.
### Tasks
1. **Add `project_director` to the role enum** in `src/lib/db/schema/users.ts` (or wherever port_roles enum lives). Existing role values (sales, admin, super_admin) stay; this is additive.
2. **Permission flags**: extend the per-port permissions matrix (`src/lib/auth/permissions.ts` or equivalent) with new flags:
- `viewAllDeals` — true for project_director, admin, super_admin
- `viewAuditLogs` — true for project_director, admin, super_admin
- `exportReports` — true for project_director, admin, super_admin
- `reassignDeals` — true for project_director, admin, super_admin
- `signAsProjectDirector` — true for project_director only (admin can sign as PD only if also assigned the role on this port)
These flags get checked in the relevant API handlers via the existing `withPermission()` middleware.
3. **Documenso developer-slot binding**: per-port admin UI gets a "Project Director user" dropdown alongside the existing developer-name/email free-text inputs. When a real CRM user is selected, the admin UI:
- Populates `documenso_developer_name/email` from the user's profile (read-only when bound)
- When that user signs an EOI/contract via Documenso, the webhook handler can match by user-email and update the in-CRM signing UI in real time (signer chip turns green for them specifically)
- Free-text fallback stays for ports without a CRM-PD user yet
4. **User invitations + role selection**: extend `src/components/admin/invite-user-dialog.tsx` to surface "Project Director" alongside Sales / Admin as a selectable role at invitation time.
5. **Audit-log access**: surface a new `/[portSlug]/admin/audit-log` route (or extend the existing one's permission gate) so Project Directors can read but not write. Hide write controls for non-admins.
6. **Reports page permission gate**: existing `/[portSlug]/reports` (or wherever exports live) checks `exportReports` permission flag instead of admin-only.
7. **Re-assign deals UI**: add a "Re-assign owner" action on the interest detail page, gated by `reassignDeals`. Writes to `interests.owner_user_id` (or whatever the assigned-rep field is) and audit-logs the change.
### Schema migration
```sql
-- Add project_director as a valid role; depends on how roles are stored.
-- If port_roles uses an enum:
ALTER TYPE port_role ADD VALUE 'project_director';
-- Or if it's a text column with check constraint:
ALTER TABLE port_roles DROP CONSTRAINT port_roles_role_check;
ALTER TABLE port_roles ADD CONSTRAINT port_roles_role_check
CHECK (role IN ('sales', 'admin', 'super_admin', 'project_director'));
-- Optional: link the per-port Documenso developer slot to a real user
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS user_id text REFERENCES users(id) ON DELETE SET NULL;
-- (Used for the documenso_developer_user_id setting; null for free-text fallback)
```
### Acceptance criteria
- A user invited as `project_director` can view all deals across the port (not just their own), read audit logs, trigger exports, and re-assign deals — but cannot edit settings or invite users
- Admin can bind a CRM user to the per-port Documenso developer slot; the user's name + email auto-populate in invitations and emails
- Non-PD users cannot trigger PD-only actions (server returns 403; UI hides the controls)
- Existing sales / admin / super_admin permissions are unchanged
### Why attack at the same time as the Documenso build
- Both touch `port-config.ts` and `admin/documenso/page.tsx` — fewer rebases if done in one push
- The `documenso_developer_label` setting (Q8 bonus) and the PD-user binding overlap; doing them together avoids re-touching the same admin card twice
- The Documenso webhook's per-signer matching benefits from having a real `users.email` to bind against, not just a free-text developer name
### Out of scope (defer to a later RBAC pass)
- Custom permission templates (e.g. "PD with no audit-log access")
- Per-deal ACLs (sharing a single interest with another rep)
- Time-bound role grants
- Cross-port role overrides for super_admin
---
## Risks + decisions (resolved through code review)
Each entry below was checked against the current code. The original "open question" form is preserved in italics for traceability; the **Decision** is what the next session should implement.
---
### 1. `fieldMeta` on Documenso v1.32
_Q: Does v1.32 silently ignore unknown properties, or does it reject the request?_
**Decision: not a risk in current code.** [src/lib/services/documenso-client.ts:491-501](../src/lib/services/documenso-client.ts#L491) shows the v1 path constructs its own body containing only `recipientId, type, pageNumber, pageX/Y/Width/Height` — `fieldMeta` is never sent on v1. The code comment at [line 341-344](../src/lib/services/documenso-client.ts#L341) is misleading — update it. Action for next session: change the comment to "v1 does not receive `fieldMeta` (we never send it). v1 renders TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO as blank inputs; if the per-port admin chose v1 the field UI should warn 'Configurable field types require Documenso v2'." The placement UI in Phase 4d should disable the meta-config side panel when the resolved port is on v1.
### 2. PDF dimension extraction (non-A4 contracts)
_Q: How do we get real page dimensions on the v1 path?_
**Decision: parse the PDF with pdf-lib in the upload service before calling `placeFields()`.** pdf-lib is already a transitive dep via the EOI form-fill flow ([src/lib/pdf/fill-eoi-form.ts](../src/lib/pdf/fill-eoi-form.ts)). Concrete change for Phase 3:
```ts
// In src/lib/services/custom-document-upload.service.ts
import { PDFDocument } from 'pdf-lib';
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pageDims = pdfDoc.getPages().map((p) => {
const { width, height } = p.getSize();
return { width, height };
});
// Pass to placeFields as a per-page dimension map override
```
Then extend `placeFields` signature to accept an optional `pageDimensionsOverride?: DocumensoPageDimensions[]` (one entry per page). When provided, the v1 path uses `pageDimensionsOverride[fieldPageIndex]` instead of [`getPageDimensions()`'s A4 default](../src/lib/services/documenso-client.ts#L427). Falls back to A4 when override is missing — keeps the EOI template path (which IS A4) unchanged.
### 3. Multi-page signature blocks not picked up by auto-detect
_Q: What's the recovery path if the scanner misses a signature block on the last page?_
**Decision: not a risk — by design.** Phase 4d's drag-drop overlay + field palette is the explicit fallback. Auto-detect populates initial state; rep MUST be able to add fields manually. The acceptance criterion at the end of Phase 4 already covers this. Demoted from "risk" to "design note": every page must be reachable in the PDF viewer (Phase 4b's page navigation) and the field palette must be enabled even on auto-detected pages.
### 4. Webhook payload differences v1 vs v2
_Q: Does our webhook handler decode both v1 and v2 payload shapes correctly?_
**Decision: partially confirmed; finish the audit in Phase 2.** Confirmed working today:
- Secret transport: identical (`X-Documenso-Secret` plaintext) — see [route.ts:53](../src/app/api/webhooks/documenso/route.ts#L53)
- Event names: both versions send the uppercase Prisma enum (`DOCUMENT_SIGNED`); CLAUDE.md note documents this. The route also normalizes lowercase-dotted variants for forward-compat.
- Top-level shape `{ event, payload: { id, ... } }`: same on both versions
Still unverified (defer to Phase 2 implementation):
- v2 may rename `payload.id` → `payload.documentId` and `recipient.id` → `recipient.recipientId` (mirrors the API-response rename — see [src/lib/services/documenso-client.ts](../src/lib/services/documenso-client.ts) `normalizeDocument()`). Apply the same dual-field read pattern in the webhook handler: `const docId = payload.documentId ?? payload.id`.
- v2 may include `payload.envelopeId` instead of `payload.id` for envelope-level events (DOCUMENT_COMPLETED). Read both.
- Recipient token field: v1 uses `recipient.token`; v2 may differ. Phase 2's token-based matching (step 5) needs to handle both.
Test with a v2 instance during Phase 2; until then keep the per-port API version setting on v1 only.
### 5. `approver` role → `cc` URL mapping
_Q: How do we keep the website's signing page (which only routes `client | cc | developer`) working when our `SignerRole` includes `approver | witness | other`?_
**Decision: confirmed bug in current code; fix in Phase 5.** [Website route validation](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue#L175) explicitly redirects to `/sign/error` for any `signerType` not in `['client', 'cc', 'developer']`. Our [transformSigningUrl()](../src/lib/services/document-signing-emails.service.ts#L106) emits `${host}/sign/${signerRole}/${token}` with the raw `SignerRole` value. Today, an `approver` invite would land on `/sign/error`.
Concrete fix in `transformSigningUrl()`:
```ts
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer'> = {
client: 'client',
developer: 'developer',
approver: 'cc', // legacy: approver showed as "EmbeddedSignatureLinkCC"
witness: 'cc', // route through cc page; copy needs a witness override (Phase 5)
other: 'cc',
};
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
return `${host}/sign/${urlRole}/${token}`;
```
Two follow-ups for Phase 5:
- Add the mapping above to `transformSigningUrl()` — DO this in Phase 1 already since Phase 1 fires the first invitation email.
- Update website's `signerMessages` (currently EOI-specific) to be keyed on `(documentType, signerType)` so contract+reservation invites get the right copy — see Phase 5 task 2.
### 6. Storage backend for signed PDFs
_Q: Does the on-completion download in Phase 2 use the pluggable storage backend?_
**Decision: confirmed — pattern already established, just follow it.** [`getStorageBackend()`](../src/lib/storage/index.ts) is used by 9 services in the codebase (berth-pdf, brochures, expense-pdf, invoices, gdpr-export, reports, document-templates, document-sends, email-compose). The [`documents` schema](../src/lib/db/schema/documents.ts) already has the `signedFileId` column with index `idx_docs_signed_file_id`. Phase 2 step 4 is just: `const buffer = await downloadSignedPdf(docId, portId); const file = await ingestFile({ buffer, portId, ... }); await db.update(documents).set({ signedFileId: file.id })...`. Demoted from "risk" to "implementation note" inside Phase 2.
### 7. Cross-port webhook secret collision
_Q: Can two ports happen to share the same webhook secret?_
**Decision: real risk — fix at write-time, not schema.** [system_settings](../src/lib/db/schema/system.ts#L137) is unique on `(key, port_id)`, so the same key+port combo is enforced unique, but there's no global uniqueness on the _value_. The [webhook handler](../src/app/api/webhooks/documenso/route.ts#L62) iterates all configured secrets and breaks on first match — if two ports paste the same secret, the second port's webhooks get attributed to the first. Three options, in preference order:
**Option A (recommended): generate, never paste.** Replace the textbox in [admin/documenso/page.tsx](<../src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx>) for `documenso_webhook_secret` with a "Generate secret" button that calls `crypto.randomBytes(32).toString('base64url')` server-side and writes it. Display once, mask after. Collision probability is negligible. Admin still has a "Regenerate" button for rotation.
**Option B: warn at write.** Keep the textbox but on PUT to the setting, query `system_settings WHERE key='documenso_webhook_secret' AND value=?` and fail with a 409 if any other port has this value. Cheap, defensive, but exposes that a value exists somewhere.
**Option C: schema-level enforcement.** Add a partial unique index `CREATE UNIQUE INDEX system_settings_documenso_secret_unique ON system_settings (value) WHERE key = 'documenso_webhook_secret'`. Strongest, but requires careful ordering during port-clone or restore-from-backup operations.
Pick Option A. Add to Phase 1 as a polish item — small change, eliminates the risk class.
---
## Open questions — RESOLVED 2026-05-07
All 10 questions plus the bonus role-label question have user-locked answers. Implementation must follow these decisions; do not re-litigate.
### Q1. Reminder cadence — RESOLVED
**Decision**: **Manual reminders by default.** Rep clicks a "Send reminder" button in the EOI/Contract tab. Per-document opt-in: rep can configure auto-reminders on a specific doc at send time (e.g. "remind every 7 days until signed").
**Implications**:
- No port-wide reminder schedule setting needed.
- Phase 1 / 2: skip the BullMQ scheduled-reminder job for now. Add a `POST /api/v1/documents/[id]/send-reminder` endpoint that calls `sendSigningReminder()` for the next-pending signer. Track `last_reminder_sent_at` to enforce Documenso's 24h rate limit on the UI ("Next reminder available in X").
- Phase 4a (upload dialog): add an optional "Auto-reminder schedule" field — None (default) / Every 3d / Every 7d. When set, store on `documents.auto_reminder_interval_days`; a once-daily worker iterates unsigned documents and fires due reminders.
### Q2. Document expiration — RESOLVED
**Decision**: **Never expire by default.** No expiration UI in v1. Skip Documenso's `expiresAt` entirely.
**Reasoning**: link expiration doesn't help the regenerate flow (regen already voids+recreates). Adding the UI is overhead with no immediate user benefit.
**Implications**:
- Phase 3 `uploadDocumentForSigning`: don't expose `expiresAt`.
- Phase 4a recipient configurator: no expiration field.
- Phase 6 deferred-items list: drop the "Document expiration" item.
### Q3. Auto-detect confidence threshold — RESOLVED
**Decision**: **Default ≥0.8 silent / 0.50.8 flagged / <0.5 drop**, with the drag-drop overlay (Phase 4d) as the universal fix mechanism — rep can reposition or delete any auto-placed field.
**Implications**:
- Phase 4c scanner: emit `DetectedField.confidence`; threshold checks live in the UI layer (Phase 4d) so they're easy to tune.
- Phase 4d overlay: flagged fields render with a yellow border + "?" badge; rep can click to confirm-as-correct (clears the badge) or drag/delete.
### Q4. Approver semantics — RESOLVED
**Decision**: **TWO concepts, not one.**
1. **APPROVER** = real Documenso `APPROVER` recipient. Gates signing flow (e.g. client signs → approver approves → developer signs). Configured per-port (existing `documenso_approver_name/email` settings).
2. **Completion CC** = passive recipient. Does NOT participate in signing. Receives only the final signed PDF as attachment when the doc completes. Set per-document by the rep at send time.
**Implications**:
- Phase 3 `uploadDocumentForSigning` recipients: support `role: 'SIGNER' | 'APPROVER' | 'CC'`. CCs are NOT created as Documenso recipients — they're stored on `documents.completion_cc_emails` (text array) and emailed by our own service when DOCUMENT_COMPLETED webhook fires.
- Phase 4a recipient configurator: split into two sections:
- **Signing recipients**: name + email + role (Signer / Approver) + signing order
- **Copy on completion** (CC): just email addresses, comma-separated
- Phase 2 step 4 (on-completion email distribution): include `documents.completion_cc_emails` recipients with the signed PDF. Dedup by email (see Q5).
- Schema migration: `ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];`
### Q5. On-completion PDF distribution — RESOLVED
**Decision**: **All signing recipients + rep who generated + per-deal CC**, deduplicated by email address.
**Implications**:
- Phase 2 step 4: build the recipient list as union of (a) all `document_signers` for this doc, (b) the user who created the doc (`documents.createdBy` → `users.email`), (c) `documents.completion_cc_emails`. Lowercase + dedupe before calling `sendSigningCompleted`.
- Common case (rep IS the approver): one email, not two.
- Per-port distribution list (originally proposed) is NOT needed — the per-deal CC field covers it. If a port wants `legal@portnimara.com` on every deal, the rep types it once per doc; if it's truly always-on, add a port-default later (deferred to Phase 6).
### Q6. `documenso_contract_template_id` / `documenso_reservation_template_id` — RESOLVED
**Decision**: **DROP both settings. EOI is the only template-driven flow.** Contracts and reservations are custom-uploaded per deal — no template fallback.
**Implications**:
- Remove `documenso_contract_template_id` and `documenso_reservation_template_id` from `port-config.ts` `SETTING_KEYS` and `PortDocumensoConfig` type.
- Remove the corresponding fields from `admin/documenso/page.tsx`. Card title becomes "Templates" with just the EOI template ID field.
- Phase 3: contract/reservation tabs go straight into the upload dialog — no `if (templateId) { ... }` branch.
- Locked design decisions table at top of this doc: update the "Contract / Reservation generation" row to remove the template-fallback option.
### Q7. Witness role — RESOLVED
**Decision**: **First-class. Configurable per-document at generation time.** Witness goes through the full invitation/reminder/tracking flow same as any other signer; signs the document attesting to having witnessed.
**Implications**:
- Keep `witness` in `SignerRole`.
- Phase 4a recipient configurator: "Witness" is a selectable role in the role dropdown (alongside Signer / Approver / CC).
- Phase 5 website edit: add witness copy to `signerMessages` map ("Witness this signing of…"). Add `witness` to the validated role list at line 175 of `[type]/[token].vue` — currently `['client', 'cc', 'developer']`, becomes `['client', 'cc', 'developer', 'witness']`.
- Risk #5 mapping in `transformSigningUrl()`: `witness → 'witness'` (NOT mapped to `cc`). Update the role-to-URL-segment table accordingly.
- Witness gets the same reminder/auto-reminder support as any signer — no special-casing.
### Q8. Multiple developers/approvers per port — RESOLVED (with rename)
**Decision**: **Stay single per port** for the standard `developer` and `approver` slots. If a port needs more on a custom doc, the rep adds extra signers via the upload-for-signing dialog (Phase 4a recipient configurator).
**Plus the bonus**: the per-port "developer" label IS configurable via a new `documenso_developer_label` setting (default: "Developer"). Used in email subjects, signer chips, and signing-progress UI. Backend type-name stays `developer` so no schema churn.
**Implications**:
- Add `documenso_developer_label` and `documenso_approver_label` to `SETTING_KEYS` + `PortDocumensoConfig`.
- Admin UI in `documenso/page.tsx` Signers card: each signer card gets a "Display label" input next to name/email.
- Email templates in `document-signing.ts`: read the label from the per-port branding config and use it in copy ("Your Project Director, {{name}}, has signed…").
- **Open follow-up (out of scope for Documenso build)**: the user mentioned the project-director user MIGHT need different CRM permissions/access from a sales rep (e.g. exclusive audit-log access, more prominent reports). That's a separate RBAC initiative — note it on the audit backlog and don't action here.
### Q9. Field placement draft persistence — RESOLVED
**Decision**: **No persistence.** If the rep closes the dialog mid-placement, state is lost.
**Implications**:
- Phase 4 architecture: keep all placement state in React component state. No localStorage, no DB drafts table.
- Add a confirm-close on the dialog if the rep has placed any fields ("Discard placement work?").
### Q10. Embedded signing host fallback — RESOLVED
**Decision**: **Send raw Documenso URLs** when host is unset. The Documenso API already returns a working signing URL per recipient (e.g. `https://signatures.portnimara.dev/sign/<token>`); `transformSigningUrl()` returns this raw URL untouched when `embeddedSigningHost` is null/empty (current behaviour, see [document-signing-emails.service.ts:106-117](../src/lib/services/document-signing-emails.service.ts#L106)).
**Implications**:
- Phase 1: no behaviour change in `transformSigningUrl()`. The current null-host short-circuit IS the fallback.
- Add a banner in the EOI/Contract tab when port has unset `embedded_signing_host` and at least one outstanding doc: "Signing emails currently link to signatures.portnimara.dev directly. Configure an embedded host in admin for branded signing pages."
- No new env var. No blocking-on-send.
---
## Schema migration summary (resolved)
Combining all resolved decisions, the migrations needed are:
```sql
-- Phase 1 (also covers Phase 2's lifecycle tracking)
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text;
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
-- Phase 1 / Q4 (completion CCs are per-document)
ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];
-- Phase 1 / Q1 (auto-reminder opt-in per document)
ALTER TABLE documents ADD COLUMN auto_reminder_interval_days integer;
```
## Settings to add / remove (resolved)
**Add to `SETTING_KEYS` + `PortDocumensoConfig`:**
- `documenso_developer_label` (text, default "Developer") — Q8 bonus
- `documenso_approver_label` (text, default "Approver") — Q8 bonus
**Remove from `SETTING_KEYS` + `PortDocumensoConfig`:**
- `documenso_contract_template_id` — Q6
- `documenso_reservation_template_id` — Q6
**Remove from admin UI** (`admin/documenso/page.tsx`):
- Contract template ID input — Q6
- Reservation template ID input — Q6
**Add to admin UI:**
- Display-label inputs next to developer + approver name/email pairs — Q8 bonus
---
**Status**: Plan is now fully resolved. Phase 1 can start without further clarification.
---
## Quick file reference
**Existing — modify in place:**
- `src/lib/services/documenso-client.ts` (extend createDocument for v2; add recipient management functions)
- `src/lib/services/port-config.ts` (no changes expected)
- `src/lib/email/index.ts` (consider: add raw-Buffer attachment option to skip MinIO round-trip for one-off PDFs)
- `src/app/api/webhooks/documenso/route.ts` (Phase 2 — major rewrite)
- `src/components/interests/interest-contract-tab.tsx` (replace ComingSoonDialog with UploadForSigningDialog in Phase 4)
- `src/components/interests/interest-reservation-tab.tsx` (same)
- `src/components/documents/eoi-generate-dialog.tsx` (Phase 1 — add regenerate confirm)
**New files to create:**
- `src/lib/services/custom-document-upload.service.ts` (Phase 3)
- `src/lib/services/document-field-detector.ts` (Phase 4c)
- `src/components/documents/upload-for-signing-dialog.tsx` (Phase 4)
- `src/components/documents/pdf-field-canvas.tsx` (Phase 4b/4d)
- `src/components/documents/recipient-configurator.tsx` (Phase 4a)
- `src/components/documents/field-palette-toolbar.tsx` (Phase 4d)
- `src/components/documents/field-config-side-panel.tsx` (Phase 4d)
- `src/app/api/v1/documents/[id]/send-invitation/route.ts` (Phase 1)
- `src/app/api/v1/interests/[id]/upload-for-signing/route.ts` (Phase 3)
- DB migrations for `document_signers.invited_at` etc. (Phase 1, Phase 2)

View File

@@ -1,252 +0,0 @@
# Documenso integration audit
Reference for the multi-port Documenso signing pipeline in this CRM. Mirrors the legacy client portal's flow ([generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [documeso.ts](../client-portal/server/utils/documeso.ts), [documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [website /sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) but rewired for multi-tenant + better-auth + Drizzle.
---
## Per-port configuration
All Documenso settings live in `system_settings` keyed by `(key, port_id)` and are read via [`getPortDocumensoConfig(portId)`](../src/lib/services/port-config.ts). Falls back to env vars when no per-port row exists. Surfaced in the admin UI at `/[portSlug]/admin/documenso`.
| Setting key | Type | Purpose |
| ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| `documenso_api_url_override` | string | Per-port Documenso instance URL. Falls back to `DOCUMENSO_API_URL` env. |
| `documenso_api_key_override` | string | API key. Stored plaintext. |
| `documenso_api_version_override` | `'v1' \| 'v2'` | Different ports may run different Documenso versions. |
| `documenso_eoi_template_id` | int | Template ID for EOI generation. |
| `documenso_client_recipient_id` | int | Template recipient slot — client (signing order 1). |
| `documenso_developer_recipient_id` | int | Template recipient slot — developer (signing order 2). |
| `documenso_approval_recipient_id` | int | Template recipient slot — approver (signing order 3). |
| `documenso_developer_name` | string | Display name for developer signer (legacy hardcoded "David Mizrahi"). |
| `documenso_developer_email` | string | Developer signer email. |
| `documenso_approver_name` | string | Approver display name. |
| `documenso_approver_email` | string | Approver email. |
| `documenso_webhook_secret` | string | Per-port webhook secret. Receiver tries each enabled secret with timing-safe equal. |
| `eoi_default_pathway` | `'documenso-template' \| 'inapp'` | Which path is used when EOI is generated without explicit choice. |
| `eoi_send_mode` | `'auto' \| 'manual'` | Auto = send branded invitation email immediately; manual = rep clicks Send. |
| `embedded_signing_host` | string | Public host that wraps Documenso URLs into `{host}/sign/<type>/<token>`. |
| `documenso_contract_template_id` | int (optional) | Optional template for sales contracts. Blank = upload-and-place-fields per deal. |
| `documenso_reservation_template_id` | int (optional) | Optional template for reservation agreements. Same logic as contract. |
---
## Document type matrix
| Type | Generation flow | Signers | Field placement |
| --------------- | ----------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |
| **EOI** | Documenso template (`eoi_template_id`) + form-fill values | Static: client, developer, approver (per-port) | Templated — fields baked into Documenso template |
| **Contract** | Per-deal upload (drafted custom). Template fallback if configured | Custom per deal — rep specifies | Per-deal placement — default footer-anchored fallback |
| **Reservation** | Per-deal upload OR template if configured | Custom per deal | Per-deal placement |
## Documenso field types
Custom-uploaded documents (contracts, reservations) need a per-deal field placement step — different documents need different mixes. The CRM exposes the full Documenso-supported field palette so reps can place whatever the document calls for without code changes.
| Field type | Use case | Needs `fieldMeta`? | What goes in meta |
| ---------------- | ------------------------------------------------------- | ------------------ | --------------------------------------------------- |
| `SIGNATURE` | Drawn signature — almost every signing flow | No | — |
| `FREE_SIGNATURE` | Type-or-draw signature variant | No | — |
| `INITIALS` | Per-page initials block | No | — |
| `DATE` | Auto-fills the date when the recipient signs | No | — |
| `EMAIL` | Auto-fills the recipient's email | No | — |
| `NAME` | Auto-fills the recipient's name | No | — |
| `TEXT` | Free text input (e.g. address, notes, place of signing) | Yes | `{ text?, label?, required?, readOnly? }` |
| `NUMBER` | Numeric input with optional min/max | Yes | `{ numberFormat?, min?, max?, required? }` |
| `CHECKBOX` | Boolean / single checkbox | Yes | `{ values: [{ checked, value }], validationRule? }` |
| `DROPDOWN` | Pick from a fixed list | Yes | `{ values: [{ value }], defaultValue? }` |
| `RADIO` | Mutually-exclusive options | Yes | `{ values: [{ checked, value }] }` |
Helper: [`fieldTypeNeedsMeta(type)`](../src/lib/services/documenso-client.ts) returns true for the configurable types so the placement UI knows when to surface a config side-panel.
`fieldMeta` is forwarded verbatim by [`placeFields()`](../src/lib/services/documenso-client.ts) on the v2 path. v1 silently ignores the property — fields render as blank inputs. Configurable behaviour (validation, defaults) only fires on v2 instances.
---
## Documenso v1 vs v2 endpoint mapping
The [`documenso-client.ts`](../src/lib/services/documenso-client.ts) abstracts both. Each function picks v1 or v2 from `getPortDocumensoConfig(portId).apiVersion`.
| Operation | v1 (1.131.32) | v2.x |
| ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- |
| Create document from upload | `POST /api/v1/documents` (body: `{ title, document, recipients }`) | `POST /api/v2/envelope` |
| Generate document from template | `POST /api/v1/templates/{id}/generate-document` | (template-from-envelope path) |
| Send for signing | `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/{id}/send` |
| Place a field | `POST /api/v1/documents/{id}/fields` (PIXEL coords, one at a time) | `POST /api/v2/envelope/field/create-many` (PERCENT, bulk) |
| Get document state | `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{id}` |
| Send reminder to one recipient | `POST /api/v1/documents/{id}/recipients/{rid}/remind` | `POST /api/v2/envelope/{id}/recipient/{rid}/remind` |
| Download finalized PDF | `GET /api/v1/documents/{id}/download``{ downloadUrl }` then GET that URL | `GET /api/v2/envelope/{id}/download` (same shape) |
| Cancel / void | `DELETE /api/v1/documents/{id}` | `DELETE /api/v2/envelope/{id}` |
| Healthcheck | `GET /api/v1/health` | (v1 path used) |
**Field key rename in v2 responses**: `id``documentId` and recipient `id``recipientId`. Our [`normalizeDocument()`](../src/lib/services/documenso-client.ts) handles both shapes.
---
## Signing-flow lifecycle
```
[rep clicks Generate] (CRM)
buildEoiContext(interestId, portId) service
generateAndSign(templateId, ctx, signers) creates Documenso doc
POST /documents/{id}/send {sendEmail:false} Documenso starts the chain;
it does NOT email signers
extract signing URLs from response service
transformSigningUrl(url, host, role) wrap as {host}/sign/<role>/<token>
if eoi_send_mode === 'auto':
sendSigningInvitation(client) our branded HTML email goes out
else:
UI shows the URL + Send button rep dispatches manually
```
When the client signs:
```
Documenso fires DOCUMENT_SIGNED webhook ──► /api/webhooks/documenso
verify x-documenso-secret (per-port lookup)
update document_signers row: status='signed', signedAt=...
if next signer in chain has not been notified:
sendSigningInvitation(developer) cascading "your turn" email
```
When the document reaches fully-signed:
```
Documenso fires DOCUMENT_COMPLETED webhook
download signed PDF from Documenso
store in storage backend → creates files row
update document: status='completed', completedAt=...
sendSigningCompleted([client, developer, approver], pdfFileId)
all parties get the signed PDF
update interest: pipelineStage='eoi_signed' (or contract_signed, etc)
```
---
## Embedded signing on the marketing website
The CRM emits signing URLs in the form `{embeddedSigningHost}/sign/<role>/<token>`. The marketing website ([Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) hosts the page, embeds Documenso via `@documenso/embed-vue`'s `<EmbedSignDocument>`, and POSTs back to the CRM webhook on completion.
For the embed to work, the Documenso instance MUST send `Access-Control-Allow-Origin` headers permitting the website origin.
### nginx CORS block to apply on `signatures.portnimara.dev`
Add to the relevant `server { ... }` block:
```nginx
location / {
# CORS for embedded signing — allow the marketing-website origin
# to load the Documenso signing iframe.
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
# ... your existing proxy_pass block to Documenso
}
```
To support multiple website origins (e.g. Port Amador hosted on a different domain), use a regex:
```nginx
set $cors_origin "";
if ($http_origin ~* "^https://(portnimara\.com|portamador\.com)$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
```
---
## What's deferred vs landed in this build
**Landed:**
- Per-port admin settings — every Documenso config knob is exposed at `/admin/documenso`
- Branded invitation, completion, and reminder email templates
- `transformSigningUrl()` for `{host}/sign/<role>/<token>` URL wrapping
- Documenso v1 + v2 dual-version client (existing)
- Webhook handler with timing-safe per-port secret resolution (existing)
- Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder
- Stage-conditional tab visibility for EOI / Contract / Reservation
**Landed in Phase 2-4 (2026-05-13):**
- **Phase 2** — Webhook cascade + on-completion PDF distribution. `handleRecipientSigned` now finds the next pending signer and fires `sendSigningInvitation`; `handleDocumentCompleted` calls `sendSigningCompleted` to all recipients with the signed PDF attached (resolved via `getStorageBackend()` so MinIO + filesystem backends both work). Recipient matching prefers the Documenso recipient `token` captured at send-time (`document_signers.signing_token`); falls back to email match.
- **Phase 3** — `lib/services/custom-document-upload.service.ts` + `POST /api/v1/interests/[id]/upload-for-signing`. Magic-byte verifies the PDF, stores via `getStorageBackend`, inserts the `documents` row, runs the full Documenso round-trip (`createDocument → sendDocument → placeFields`), captures recipient tokens, auto-sends invitation when port `sendMode === 'auto'`.
- **Phase 4** — `<UploadForSigningDialog>` (`src/components/documents/upload-for-signing-dialog.tsx`). Three-step state machine (file → recipients → fields). Auto-detect runs server-side via `lib/services/document-field-detector.ts` (pdfjs text-extraction + anchor patterns); rep can drag/place/delete fields via native DOM events. Wired into the Contract + Reservation tabs.
- **Phase 7** — Project Director RBAC binding. Admin UI exposes `documenso_developer_user_id` / `approver_user_id` / `_label` settings; webhook cascade fires an in-CRM `document_signing_your_turn` notification for linked users alongside the email.
**Phase 5 — Embedded signing URL emission verification:**
- `transformSigningUrl()` validated via 10 unit tests in `tests/unit/services/document-signing-urls.test.ts`. Maps signer-role → URL segment as:
- `client → /sign/client/<token>`
- `developer → /sign/developer/<token>`
- `approver → /sign/cc/<token>` — funnels through the CC page with passive copy
- `witness → /sign/witness/<token>` — website must handle this segment
- `other → /sign/cc/<token>` — same as approver
- Hardened to reject malformed source URLs: the function now uses `extractSigningToken()` (rejects tails <8 chars or with non-URL-safe punctuation), so a bare `https://sig.example.com` is returned untouched rather than producing the malformed `<host>/sign/<role>/sig.example.com`.
**Phase 5 — coordination on the marketing-website side (NOT in this repo):**
These are tracked here so the CRM stays the source of truth on the contract — the actual edits land in the website repo.
1. **Website `/sign/[type]/[token].vue` must handle `type ∈ {client, cc, developer, witness}`.** The CRM emits `cc` for both `approver` and `other` roles, and `witness` for explicit witness signers. Anything else lands on the website's `/sign/error` fallback.
2. **`signerMessages` map must be keyed on `(documentType, role)`** so a contract recipient hitting `/sign/client/<token>` sees "Sign Your Sales Contract" rather than the EOI default. Until the website is updated, the URL emits `(role, token)` only; the website can resolve documentType from the Documenso embed payload.
3. **Post-sign callback** — the legacy portal POSTed to `client-portal.portnimara.com/api/webhook/document-signed`. The CRM no longer needs this — the Documenso webhook at `/api/webhooks/documenso` handles all state updates server-side. The website's POST is now optional; if it's still in place, point it at the CRM's webhook receiver as a real-time UI signal.
4. **Apply the nginx CORS block above** on the prod Documenso instance.
**Genuinely deferred (Phase 6 polish):**
- Auto-send delay (`eoi_send_delay_minutes` per-port setting + scheduled BullMQ job).
- Document expiration toggle (`documents.expires_at` + Documenso `expiresAt` passthrough).
- Per-document custom invitation message (textarea on the upload dialog → `documents.invitation_message`).
- Reminder rate-limit display ("next reminder available in X days" badge on each unsigned signer in the signing-progress UI).
- Failed-webhook recovery admin surface — the BullMQ webhook DLQ exists; needs an admin page with a Replay button.
- Per-field metadata side panel for DROPDOWN/RADIO option lists in the Phase 4 dialog.
- Pinch-zoom + zoom-out controls on the field-placement canvas.
- Recipient drag-reorder via dnd-kit (current UI uses an order number input).
**Manual ops work for you:**
- Apply the nginx CORS block above on your prod Documenso instance.
- Decide whether to upgrade prod Documenso to v2 (would unlock cleaner field placement + better envelope semantics).
- Configure each port's developer/approver names and template IDs at `/[portSlug]/admin/documenso`.

View File

@@ -1,81 +0,0 @@
# Documenso EOI Template — Field Mapping
**Purpose:** This doc is the canonical reference for mapping the Documenso EOI template's `formValues` keys to the new data model's `EoiContext` shape. It drives `buildDocumensoPayload()` (Task 11.2), the in-app Standard EOI HTML tokens (Task 11.3), and the Spec 2 importer's yacht/company hydration.
## Source
The legacy field list comes from `client-portal/server/api/eoi/generate-quick-eoi.ts`, specifically the POST body sent to `POST /api/v1/templates/{templateId}/generate-document` (Documenso template 8). The relevant lines in that file are around the `createDocumentPayload.formValues` object.
## Documenso template `formValues` keys
Documenso template IDs and recipient IDs are configured via env vars:
- `NUXT_DOCUMENSO_TEMPLATE_ID` (default: `8`)
- `NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID` (default: `192`) — signing order 1
- `NUXT_DOCUMENSO_DEVELOPER_RECIPIENT_ID` (default: `193`) — signing order 2
- `NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID` (default: `194`) — APPROVER, signing order 3
The template exposes eight text fields (`formValues` keys) and two boolean checkboxes.
## Field mapping
The legacy template (Documenso template `8`, configured in production) auto-fills exactly the fields below. All eight text fields + two booleans are populated by `buildDocumensoPayload()` from the resolved `EoiContext`. Anything else on the form (signature, date, terms acknowledgment) is filled in by the client inside Documenso.
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. Empty string when no yacht is linked yet. |
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Boat dimension. Send as string. Documenso doesn't enforce numeric format. Empty string when not applicable. |
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | The interest's PRIMARY berth (resolved via `interest_berths.is_primary=true`). Empty string when no primary set. |
| `Berth Range` | text | (new) | `context.eoiBerthRange` | **NEW IN PHASE 5** — compact range string for multi-berth EOIs (e.g. `"A1-A3, B5-B7"`) covering every junction row marked `is_in_eoi_bundle=true`. Empty string when the bundle is empty. **The live Documenso template (id `8`) does NOT yet have this field. Add a `Berth Range` text field to the template before multi-berth EOIs render the range; until then Documenso silently drops the value and only `Berth Number` (the primary mooring) renders.** |
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
**Backwards-compatibility guarantee**: every legacy `formValues` key is still emitted with the same name and type. The only addition is `Berth Range` (Phase 5). Documenso silently ignores unknown formValues keys, so old templates that don't have `Berth Range` will simply not render it — single-berth EOIs continue to work identically. No template changes are required for legacy use.
## Document `meta` fields (non-`formValues`)
| Documenso key | Type | Legacy source | New source |
| ------------------------- | ---- | ---------------------------------------- | ----------------------------------------------------------------- |
| `meta.message` | text | `Dear ${interest['Full Name']}...` | `Dear ${context.client.fullName}, ...port name interpolated` |
| `meta.subject` | text | `"Your LOI is ready to be signed"` | Same — constant. |
| `meta.redirectUrl` | text | `"https://portnimara.com"` | `context.port.redirectUrl` if per-port; otherwise global app URL. |
| `meta.distributionMethod` | text | `"NONE"` | Same — constant. We use manual send flow (Documenso webhook). |
| `title` | text | `` `${interest['Full Name']}-EOI-NDA` `` | `` `${context.client.fullName}-EOI-NDA` `` |
| `externalId` | text | `` `loi-${interestId}` `` | Same. |
## Recipients (non-`formValues`)
| Recipient | Role | Name | Email | Signing order |
| ------------------- | -------- | ------------------------- | ----------------------------- | ------------- |
| Client (signer) | SIGNER | `context.client.fullName` | `context.client.primaryEmail` | 1 |
| Developer (signer) | SIGNER | `"David Mizrahi"` | `"dm@portnimara.com"` | 2 |
| Approval (approver) | APPROVER | `"Abbie May"` | `"sales@portnimara.com"` | 3 |
The Developer and Approval recipients are currently hardcoded in the legacy flow. In the new system these should eventually come from port-level settings (e.g., `ports.settings.eoi.developerName` + email). For Task 11.2, keep them hardcoded as the legacy system does — tracking as TODO: "Replace hardcoded Developer/Approval recipients with port-level configuration."
## Company-owned yacht handling
The legacy flow has no concept of company ownership — the signer is always the interest's client. In the new system:
- If `context.yacht.ownerType === 'client'`: behavior unchanged.
- If `context.yacht.ownerType === 'company'`: the interest's point-of-contact client still signs (they're the representative of the yacht's owning company), but an extra block should appear in the message body: `"On behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner)."`. This isn't a separate Documenso field — it's woven into `meta.message`.
Tracking this in the mapping doc rather than as a hard TODO because company-owned EOIs were rare in the legacy system and need product input before committing to the final wording.
## Deprecated fields (no longer sourced from `clients`)
The legacy system read these fields from the client row. They are now sourced elsewhere:
| Legacy source | New source |
| ------------------------- | --------------------------------------------------- |
| `client.yachtName` | `yachts.name` via `interest.yachtId` |
| `client.yachtLengthFt` | `yachts.lengthFt` via `interest.yachtId` |
| `client.yachtWidthFt` | `yachts.widthFt` via `interest.yachtId` |
| `client.yachtDraftFt` | `yachts.draftFt` via `interest.yachtId` |
| `client.companyName` | `companies.name` via polymorphic owner resolution |
| `client.berthSizeDesired` | Removed. Berth is picked via reservation, not text. |

View File

@@ -1,188 +0,0 @@
# Error handling
## Overview
Every authenticated request runs inside an `AsyncLocalStorage` frame
that carries a `requestId` (UUID) plus the resolved `portId` / `userId`
/ HTTP method / path / start time. The id surfaces:
- as `X-Request-Id` on every response header (success or failure)
- inside every pino log line emitted during the request
- in the JSON error body returned to the client (`requestId` field)
- as the primary key of the `error_events` row written when a 5xx fires
A user who hits a failure can copy the **Reference ID** from the toast
and a super admin can paste it into `/<port>/admin/errors/<requestId>`
to see the full request context, sanitized body, error stack, and a
heuristic "likely culprit" hint.
## Throwing errors from a service
Use `CodedError` with a registered code:
```ts
import { CodedError } from '@/lib/errors';
if (!hasReceipts && !ack) {
throw new CodedError('EXPENSES_RECEIPT_REQUIRED');
}
```
The code drives:
- the HTTP status (defined in `src/lib/error-codes.ts`)
- the **plain-text user-facing message** (no jargon — written for the
rep on the phone with a customer)
- the stable identifier the user can quote to support
For more verbose internal context — admin-only — use `internalMessage`:
```ts
throw new CodedError('CROSS_PORT_LINK_REJECTED', {
internalMessage: `interest ${a.id} (port ${a.portId}) ↔ berth ${b.id} (port ${b.portId})`,
});
```
The `internalMessage` lands in the `error_events` row and the admin
inspector but **never** reaches the client.
## Adding a new error code
1. Open `src/lib/error-codes.ts`.
2. Add an entry to the `ERROR_CODES` map. Convention: `DOMAIN_REASON`
in SCREAMING_SNAKE_CASE.
```ts
FOO_INVALID_BAR: {
status: 400,
userMessage: 'That bar value is no good. Please try another.',
},
```
3. Use it: `throw new CodedError('FOO_INVALID_BAR')`.
4. The code, status, and message are now contractually stable —
never rename a code once it has shipped. Documentation, UI, and
external integrations may pin to it.
## Plain-text message guidelines
User-facing messages should:
- Avoid internal jargon (no "constraint violation", "FK", "row lock").
- Be written for a rep on the phone with a customer.
- Include the suggested next action when natural ("Ask an admin if you
think you should").
- Not include any technical detail that doesn't help the user — the
request id + error code carry that.
Verbose technical detail belongs in `internalMessage` (admin-only).
## Client side
In a `useMutation`, render errors with the shared helper:
```ts
import { toastError } from '@/lib/api/toast-error';
const mutation = useMutation({
mutationFn: () => apiFetch('/api/v1/foo', { method: 'POST', body: { ... } }),
onSuccess: () => { ... },
onError: (err) => toastError(err),
});
```
The toast renders three lines:
```
{plain-text message}
Error code: EXPENSES_RECEIPT_REQUIRED
Reference ID: 8f3c-ab12-… [Copy ID]
```
The "Copy ID" action puts the request id on the clipboard so the
user can paste it into a support ticket.
## Admin inspector
`/<port>/admin/errors` lists captured 5xx errors:
- Status badge + method + path
- "Likely culprit" badge (heuristic — Postgres SQLSTATE, error name,
stack-path patterns, message keywords)
- Truncated error name + message
- Timestamp + reference id
Click any row for `/<port>/admin/errors/<requestId>` which shows:
- Request shape (method / path / when / duration / port / user / IP / UA)
- Likely culprit + plain-English hint + subsystem tag
- Full error name, message, stack head (first 4 KB)
- Sanitized request body excerpt (max 1 KB; sensitive keys redacted)
- Raw metadata (Postgres SQLSTATE codes, internalMessage, etc.)
Permission: `admin.view_audit_log`. Super admins see every port's
errors; regular admins are scoped to their active port.
## What gets persisted
| Status | error_events row? | Toast shows code? |
| ------ | ----------------- | ----------------- |
| 4xx | No | Yes |
| 5xx | **Yes** | Yes |
4xx errors are user-action mistakes (validation, not-found, permission
denied). They're visible in the audit log but not the error inspector
— that table is reserved for platform faults.
5xx errors hit the `errorEvents` table via `captureErrorEvent` inside
`errorResponse`, which:
1. Reads the request context from ALS.
2. Sanitizes + truncates the body (1 KB cap, sensitive keys redacted).
3. Pulls Postgres `code` / `severity` / `cause.code` if the underlying
error is a `postgres` driver error.
4. Truncates the stack to 4 KB.
5. Inserts one row keyed on `requestId` with `ON CONFLICT DO NOTHING`.
Failure to persist NEVER throws — the user is already getting an
error response; we don't want a logging-pipeline failure to mask it.
## Likely-culprit classifier
`src/lib/error-classifier.ts` runs four passes against an
`error_events` row, first match wins:
1. **Postgres SQLSTATE** (from `metadata.code`): 23502 NOT NULL,
23503 FK, 23505 unique, 23514 CHECK, 42703 schema drift, 42P01
missing table, 40001 serialization, 53300 connection limit, …
2. **Error class name**: `AbortError`, `TimeoutError`, `FetchError`,
`ZodError`.
3. **Stack path**: `/lib/storage/`, `/lib/email/`, `documenso`,
`openai|claude`, `/queue/workers/`.
4. **Message free-text**: `econnrefused`, `rate limit`, `timeout`,
`unauthorized|invalid api key`.
Returns `null` when nothing matches; the inspector renders
"Uncategorized" in that case. Adding a new heuristic is a one-line
edit to the relevant array.
## Pruning
`error_events` rows are dropped after 90 days by the maintenance
worker (TODO: confirm the worker has the deletion path; if not, add
a periodic job that runs `DELETE FROM error_events WHERE created_at <
now() - interval '90 days'`).
## Migration path for legacy throws
Existing `NotFoundError` / `ForbiddenError` / `ConflictError` /
`ValidationError` / `RateLimitError` still work — the user-facing
messages on these classes have been rewritten to plain-text defaults.
Migration to `CodedError` happens opportunistically: when touching a
service to fix something else, swap the throw site for a registered
code.
A follow-up audit pass should walk `git grep "throw new ValidationError"`
and migrate the user-impactful ones to specific codes.

View File

@@ -1,123 +0,0 @@
# Outbound communications safety net
**Last reviewed:** 2026-05-03
**Owner:** matt@portnimara.com
This doc enumerates every channel through which the CRM can produce
outbound communication (email, document signing, webhooks) and describes
how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a
single environment flip pauses **all** outbound traffic, so a production
data import, dedup migration dry-run, or staging environment can run
against real data without anyone getting paged or spammed.
> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address,
> all outbound communication is rerouted there or short-circuited. Unset
> it in production.
---
## Channels
### 1. Direct email (`sendEmail`)
**Path:** `src/lib/email/index.ts``sendEmail()` → nodemailer SMTP transport.
**Safety:** YES — covered.
When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header
to the redirect address and prefixes the subject with
`[redirected from <orig>]`. The original recipient is logged.
**Call sites** (all flow through `sendEmail`, so all are covered):
- `src/lib/services/portal-auth.service.ts` — portal activation + reset
- `src/lib/services/crm-invite.service.ts` — CRM user invitations
- `src/lib/services/document-templates.ts` — template-generated PDFs sent
as attachments (the PDF body is generated locally; the email itself
goes through SMTP)
- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed
in the in-app UI
- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery
### 2. Documenso e-signature recipients
**Path:** `src/lib/services/documenso-client.ts``createDocument()` /
`generateDocumentFromTemplate()` → Documenso REST API.
**Safety:** YES — covered as of 2026-05-03.
Documenso's own server sends the signing-request email on our behalf.
We can't intercept that at the SMTP layer because it's external. The
fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set,
`createDocument` rewrites every recipient's email to the redirect
address and prefixes the recipient name with `(was: <orig email>)` so
the doc is still traceable to its intended recipient.
`generateDocumentFromTemplate` does the same for both shapes the
template-generate endpoint accepts (v1.13 `formValues.*Email` keys and
v2.x `recipients` array).
The redirect happens **before** the API call, so even if Documenso has
its own retry logic the original email never leaves our process.
### 3. Webhooks (outbound to user-configured URLs)
**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`.
**Safety:** YES — covered as of 2026-05-03.
When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits
before the HTTP call. The delivery row is marked `dead_letter` with a
human-readable reason so it's still visible in the deliveries listing.
The SSRF guard remains in place independently.
### 4. WhatsApp / phone deep-links
**Path:** `<a href="https://wa.me/...">` and `<a href="tel:...">` in
client / interest detail headers.
**Safety:** N/A — user-initiated only.
These are deep links the user explicitly clicks. No automated dispatch.
A deep link click opens the user's WhatsApp / phone app, which is the
intended interaction. No safety net needed.
### 5. SMS
Not implemented. The `interests.preferredContactMethod` enum includes
`'sms'` as a value but no sending path exists. If/when SMS is added (e.g.
via Twilio), the new send function should respect `EMAIL_REDIRECT_TO`
the same way `sendEmail` does — log the original number, drop the
message, or reroute to a configurable `SMS_REDIRECT_TO` env.
---
## Verification checklist before importing real data
- [ ] `.env` has `EMAIL_REDIRECT_TO=<my-address>` set.
- [ ] Restart dev server (or worker) so the new env is picked up — env
vars are read at import time in some paths.
- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts`
or similar. Confirm subject is prefixed with `[redirected from ...]`.
- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso
shows the redirect address as recipient (not the real client email).
- [ ] If any webhooks are configured, trigger an event that fires one and
confirm the delivery is recorded as `dead_letter` with the
"EMAIL_REDIRECT_TO is set" reason.
- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the
`--apply` step is what creates real records but emails/webhooks are
still gated by the redirect env.
## Production cutover
When ready to go live:
1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set
to a sandbox address.
2. Verify the snapshot looks right (counts, client coverage).
3. Unset `EMAIL_REDIRECT_TO` in the production env.
4. Restart the app + worker.
5. Run the migration with `--apply`. From this point forward, real
recipients will receive real comms.
If you ever need to re-pause outbound (e.g. handling a security incident,
re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again.

View File

@@ -1,199 +0,0 @@
# Backup and restore runbook
This runbook documents what gets backed up, how often, where it lands, and
the exact commands to restore the system from a cold start. The goal is
that any operator who has the off-site backup credentials can bring the
CRM back up on a clean host without help.
## Scope of a "full backup"
The CRM has three stateful surfaces. All three must be captured for a
restore to be useful.
| Surface | Holds | Risk if missing |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| **PostgreSQL** (`port_nimara_crm`) | Every relational record: clients, yachts, companies, interests, reservations, invoices, audit log, GDPR exports, AI usage ledger, Documenso webhook receipts, etc. | Total data loss — site is unrecoverable. |
| **MinIO bucket** (`MINIO_BUCKET`, default `crm-files`) | Receipts, signed contracts, EOI PDFs, GDPR export ZIPs, document attachments. | Files reachable by row references in Postgres become 404s. |
| **`.env` + secrets** | DB password, MinIO keys, Documenso webhook secret, SMTP creds, encryption key (`ENCRYPTION_KEY`). | OCR API keys re-resolve from `system_settings` (encrypted at rest), but **without the original `ENCRYPTION_KEY` they're unreadable**. |
The Redis instance is not backed up. It only holds queue state, rate-limit
counters, and Socket.IO presence — all reconstructable. Stop the workers
during a restore so the queue starts clean.
## Backup schedule
Defaults are tuned for a single-port deployment with O(10k) clients. Bump
on the producing side as scale demands.
| Job | Frequency | Retention | Where |
| ---------------------------------- | -------------------- | ----------------------------- | -------------------------------------------------------------------- |
| `pg_dump` (custom format, gzipped) | Hourly | 7 days hourly + 30 days daily | `${BACKUP_BUCKET}/pg/<host>/<UTC date>/<hour>.dump.gz` |
| MinIO mirror | Hourly (incremental) | 30 days versions | `${BACKUP_BUCKET}/minio/` |
| `.env` snapshot (encrypted) | On change (manual) | Forever | Password manager / secrets vault — **never the same bucket as data** |
The hourly cadence is the right answer for this workload — invoices and
contracts cluster around business hours, and an hour of lost work is the
worst-case data loss window most clients will tolerate. Promote to 15-min
WAL streaming if a customer demands tighter RPO.
## Required environment variables
The scripts below read these. Store them in a CI secret store, not the
host's bash profile.
```
# Source (the running CRM database)
DATABASE_URL=postgresql://crm:<pw>@<host>:<port>/port_nimara_crm
# MinIO (source bucket — the live one)
MINIO_ENDPOINT=minio.letsbe.solutions
MINIO_PORT=443
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=<live key>
MINIO_SECRET_KEY=<live secret>
MINIO_BUCKET=crm-files
# Backup destination (a *separate* MinIO/S3 endpoint or a different bucket
# with no IAM overlap with the live keys)
BACKUP_S3_ENDPOINT=https://s3.eu-west-1.amazonaws.com
BACKUP_S3_REGION=eu-west-1
BACKUP_S3_BUCKET=portnimara-backups-prod
BACKUP_S3_ACCESS_KEY=<dedicated read+write key for this bucket only>
BACKUP_S3_SECRET_KEY=<...>
# Optional: encrypts dumps at rest with a passphrase. Cuts a wider blast
# radius if the backup bucket itself is compromised.
BACKUP_GPG_RECIPIENT=ops@portnimara.com
```
## Provisioning the backup destination
1. Create a dedicated S3-compatible bucket in a **different account** from
the live infra. AWS S3, Backblaze B2, or a separately-credentialed
MinIO instance all work.
2. Apply object-lock or versioning so an attacker who steals the backup
write key still can't permanently delete history.
3. Generate IAM credentials scoped to `s3:PutObject`, `s3:GetObject`,
`s3:ListBucket` on this bucket only. Inject them as
`BACKUP_S3_*` above. Do not reuse the live `MINIO_*` keys.
4. Set a 90-day lifecycle rule that transitions objects older than 30
days to cold storage and deletes them at 90 days. Past 90 days it's
cheaper to restart from a snapshot taken outside the system.
## The scripts
Three scripts in `scripts/backup/`:
- `pg-backup.sh` — runs `pg_dump`, gzips, optionally GPG-encrypts, uploads
- `minio-mirror.sh``mc mirror` of the live bucket → backup bucket
- `restore.sh` — interactive restore (DB + MinIO) given a snapshot path
Make them executable and wire them into cron / GitHub Actions / your
scheduler of choice. Sample crontab on the worker host:
```cron
# Hourly DB dump at minute 7
7 * * * * /opt/pncrm/scripts/backup/pg-backup.sh >> /var/log/pncrm-backup.log 2>&1
# Hourly MinIO mirror at minute 17 (offset so the two don't fight for I/O)
17 * * * * /opt/pncrm/scripts/backup/minio-mirror.sh >> /var/log/pncrm-backup.log 2>&1
# Weekly restore drill (smoke-test to a throwaway DB on Sunday at 03:00)
0 3 * * 0 /opt/pncrm/scripts/backup/restore.sh --drill >> /var/log/pncrm-restore-drill.log 2>&1
```
## Restoring from cold
These steps have been rehearsed against the dev environment; expect them
to take 1530 minutes for a typical port. **The drill (last cron line
above) ensures the runbook stays correct — if the drill fails, the
real restore will too.**
### 0. Stop everything that writes
```bash
docker compose -f docker-compose.prod.yml stop web worker scheduler
# Leave postgres + minio + redis up; we'll point them at restored data.
```
### 1. Restore PostgreSQL
```bash
# Find the dump you want. Prefer the most recent successful hour.
mc ls "$BACKUP_S3_BUCKET/pg/$(hostname)/" | tail
SNAPSHOT="2026-04-28/14.dump.gz"
# Pull it.
mc cp "$BACKUP_S3_BUCKET/pg/$(hostname)/$SNAPSHOT" /tmp/
# Decrypt if BACKUP_GPG_RECIPIENT was set on the producer side.
gpg --decrypt /tmp/14.dump.gz.gpg > /tmp/14.dump.gz
# Drop & recreate the database. The 'restrict' FK from gdpr_exports.requested_by
# to user means we restore in the right order — pg_restore handles this.
psql "$DATABASE_URL" -c 'DROP DATABASE IF EXISTS port_nimara_crm WITH (FORCE);'
psql "$DATABASE_URL" -c 'CREATE DATABASE port_nimara_crm;'
gunzip -c /tmp/14.dump.gz | pg_restore --no-owner --no-privileges \
--dbname "$DATABASE_URL"
```
### 2. Restore MinIO
```bash
# Sync the backup bucket back over the live one. --overwrite handles
# files that were modified between snapshots.
mc mirror --overwrite \
"$BACKUP_S3_BUCKET/minio/" \
"live/$MINIO_BUCKET/"
```
### 3. Restore secrets
The `.env` file is **not** in object storage. Pull it from the password
manager / secrets vault. Verify `ENCRYPTION_KEY` matches the value used
when the database was last running — if it doesn't, rows in
`system_settings` (OCR API keys, etc.) decrypt to garbage and the OCR
"Test connection" button will return an opaque error. There is no
recovery path; the keys must be re-entered through the admin UI.
### 4. Bring services back up
```bash
docker compose -f docker-compose.prod.yml up -d
# Watch the worker logs; expect a flurry of socket reconnections, then quiet.
docker compose -f docker-compose.prod.yml logs -f worker
```
### 5. Verify
Tail through the smoke checklist, in order:
1. **DB up**`psql "$DATABASE_URL" -c 'SELECT count(*) FROM clients;'`
matches the producer-side count from the snapshot's hour.
2. **MinIO up** — open any client with attachments in the CRM, click a
receipt thumbnail; verify the signed URL serves the file.
3. **Documenso webhooks** — re-trigger one in the Documenso admin and
confirm `audit_logs` records the receipt.
4. **Email** — send a portal invite to a real address.
5. **Realtime** — open two browser windows, edit a client in one, watch
the other update via Socket.IO.
6. **AI usage ledger**`SELECT count(*) FROM ai_usage_ledger;`
non-empty if AI was being used. Old rows survive but the budget gates
reset alongside the period boundary at month rollover.
## Drill schedule
The weekly drill (cron line above) runs `restore.sh --drill` against a
throwaway database and a sandbox MinIO bucket. It must produce zero diff
between the restored row counts and the live row counts (modulo the
hour-or-so the drill takes to run).
Failure modes the drill catches before they bite production:
- New tables added without inclusion in `pg_dump`'s `--schema=public` (we
use the default, which captures everything in `public` — but a future
developer adding a `tenant_X` schema will silently lose it).
- MinIO bucket-policy changes that block the backup-side `s3:GetObject`
on certain prefixes.
- GPG passphrase rotation that wasn't propagated to the restore host.
- A `pg_restore` version skew with the producer-side `pg_dump`.

View File

@@ -1,186 +0,0 @@
# Email deliverability runbook
The CRM sends transactional email through three different surfaces. Each
has a different failure mode when it lands in spam. This runbook covers
how to diagnose, fix, and verify each path.
## What email the CRM sends
| Surface | Trigger | Template | Default `from` |
| ----------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------- |
| Portal activation / password-reset | Admin invites a client to the portal | `src/lib/email/templates/portal-auth.ts` | per-port `email_settings.from_address` or `SMTP_FROM` |
| Inquiry confirmation + sales notification | Public website POSTs to `/api/public/interests` or `/api/public/residential-inquiries` | `inquiry-client-confirmation.ts`, `inquiry-sales-notification.ts` | same |
| GDPR export ready | Staff requests an export with `emailToClient=true` | inline in `gdpr-export.service.ts` | same |
| Documenso reminders | Cadence job fires for an unsigned signer | `documenso/reminders/*` | same |
Documenso _itself_ sends signing requests with its own `from` address —
those don't flow through this codebase. SPF/DKIM for the Documenso
sender is the Documenso operator's problem, not yours.
## DNS records
For every domain that appears in a `from:` header you must publish:
### 1. SPF
A single TXT record at the apex authorizing whichever provider is
sending. Multiple SPF records on the same name **break SPF entirely**
combine into one.
```
v=spf1 include:_spf.google.com include:amazonses.com -all
```
The `-all` (hardfail) is correct for transactional mail. Switch to `~all`
(softfail) only as a temporary diagnostic when migrating providers.
### 2. DKIM
Each provider publishes its own selector. Common shapes:
- Google Workspace: `google._domainkey` → 2048-bit RSA pubkey (rotate every 12 months).
- Amazon SES: `xxxx._domainkey`, `yyyy._domainkey`, `zzzz._domainkey` (three CNAMEs SES gives you).
- Postmark / Resend / Mailgun: one CNAME per selector.
Verify alignment — the `d=` value in the DKIM signature must match the
`From:` domain (relaxed alignment is fine, strict is overkill).
### 3. DMARC
Start at `p=none` while you build deliverability data, then upgrade.
```
_dmarc 14400 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@portnimara.com; ruf=mailto:dmarc@portnimara.com; fo=1; adkim=r; aspf=r; pct=100"
```
`rua` (aggregate reports) is the diagnostic feed — set it before the
first send so the first weekly report has data.
### 4. MX (only if you also receive)
The CRM's IMAP probe (`scripts/dev-imap-probe.ts`) and the inbound thread
sync rely on a real mailbox. Whoever runs that mailbox publishes the MX
records — typically Google Workspace or a dedicated provider. Don't add
an MX pointing at the CRM host; it doesn't accept SMTP IN.
## Per-port overrides
Each port can override `from_address`, `from_name`, and SMTP creds via
the admin email-settings page. When set, `getPortEmailConfig()` returns
those values and `sendEmail()` uses them in preference to the global
`SMTP_*` env. **The override domain still needs SPF / DKIM / DMARC** on
its own DNS — without them, every send from that port lands in spam.
When a customer reports "our portal invite didn't arrive":
1. Pull the port's email settings from the admin UI. Check `from_address`.
2. Run `dig TXT <from-domain>` and `dig TXT _dmarc.<from-domain>`.
Confirm SPF includes the SMTP provider's domain and DMARC exists.
3. Send a probe through `mail-tester.com`: paste the address into a
test send, click the score breakdown.
4. Score < 8/10 → fix whatever's flagged before doing anything else in
this runbook.
## Diagnosing a "didn't arrive" report
Order matters — go top-down, stop when one of these is the answer.
### Step 1: Was the send attempted?
```bash
# Tail the worker logs for the recipient address.
docker compose logs worker | grep '<recipient>'
```
You'll see one of three patterns:
- **Nothing**: The job didn't run. Check that BullMQ actually queued it.
`redis-cli LLEN bull:email:waiting` — if non-zero, the worker is dead.
`docker compose logs scheduler | tail` to see why.
- **`Email sent`** with a message-id: The provider accepted it. Move to
Step 2.
- **`SendError`**: Provider rejected. The error string says why
(auth, rate limit, blocked recipient).
### Step 2: Is `EMAIL_REDIRECT_TO` set?
In dev/test we set `EMAIL_REDIRECT_TO=ops@portnimara.com` so seeded fake
clients don't get real email. **It must be unset in production.**
```bash
# On the production host:
docker exec pncrm-web printenv EMAIL_REDIRECT_TO
# Should print nothing.
```
If it's set, every email is going to the redirect target with the
original recipient prefixed in the subject — the customer never sees it.
### Step 3: Did it land but get filtered?
Ask the recipient to check:
- Spam / Junk folder
- Gmail "Promotions" tab
- Outlook "Other" folder (vs Focused)
- The Quarantine console if they're on M365 with anti-spam enabled
If found in a spam folder: the email arrived; the recipient's filter
classified it. SPF/DKIM/DMARC alignment is suspect — re-run the
mail-tester probe from above.
### Step 4: Was the recipient on a suppression list?
Some providers (SES, Postmark) maintain a suppression list — once a
domain bounces from an address, future sends are dropped silently.
```bash
# SES example:
aws ses list-suppressed-destinations --region eu-west-1
```
If the recipient is suppressed, remove them and ask them to retry. The
CRM doesn't track suppression locally; that's the provider's job.
## When migrating SMTP providers
1. Add the new provider's DKIM CNAMEs alongside the old ones.
2. Add the new provider's `include:` to the existing SPF record.
3. Wait 48 hours for DNS to propagate and DMARC reports to confirm both
providers align.
4. Switch `SMTP_*` env to the new provider on a single staging host.
5. Send through the staging host for a week. Watch DMARC reports.
6. Cut production over.
7. Wait two weeks before removing the old provider's DNS — undelivered
bounce reports keep arriving for a while.
## Testing a deliverability fix
There's no automated test for "did this email reach the inbox" — that's a
property of the recipient's filter, which we don't control. The closest
proxy is the realapi suite:
```bash
pnpm exec playwright test --project=realapi
```
It runs `tests/e2e/realapi/portal-imap-activation.spec.ts` which sends a
real portal-invite email through SMTP, then polls the configured IMAP
mailbox for the activation link. If it appears within 30 seconds, the
SMTP→DKIM→DMARC chain is alive end-to-end. If the test times out, work
backwards through this runbook.
The realapi suite needs `SMTP_*` and `IMAP_*` env vars — see the
"Optional dev/test-only env vars" block in `CLAUDE.md`.
## Bounce handling
The CRM doesn't currently process bounces. If you start seeing volume:
- Set up the provider's webhook (SES → SNS → Lambda; Postmark → webhook
URL) to POST bounce events to a new `/api/webhooks/email-bounce` route.
- Persist the bounced address into a `email_suppressions` table.
- Have `sendEmail()` consult that table before each send.
That work isn't in scope yet; this runbook just flags it as the next
deliverability gap.

View File

@@ -1,56 +0,0 @@
# Permission Matrix Audit
Scanned 182 route files under `src/app/api/v1/`.
**No violations.** Every internal v1 handler is permission-gated.
**Allow-listed:** 46 handler(s) intentionally skip `withPermission`.
| File | Method | Reason |
| ---------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- |
| `src/app/api/v1/admin/alerts/run-engine/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/connections/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/errors/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/health/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/route.ts` | PUT | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/test/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts` | DELETE | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/users/options/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/ai/email-draft/[jobId]/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/email-draft/route.ts` | POST | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/interest-score/bulk/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/interest-score/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/alerts/[id]/acknowledge/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). |
| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. |
| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. |
| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. |
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | GET | TODO: needs custom_fields:\* permission. PUT path internally validated. |
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | PUT | TODO: needs custom_fields:\* permission. PUT path internally validated. |
| `src/app/api/v1/expenses/export/parent-company/route.ts` | POST | Internally gated by isSuperAdmin inside the handler. |
| `src/app/api/v1/me/route.ts` | GET | Self-endpoint — auth is sufficient. |
| `src/app/api/v1/me/route.ts` | PATCH | Self-endpoint — auth is sufficient. |
| `src/app/api/v1/notifications/[notificationId]/route.ts` | PATCH | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/preferences/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/preferences/route.ts` | PUT | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/read-all/route.ts` | POST | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/unread-count/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/saved-views/[id]/route.ts` | PATCH | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/[id]/route.ts` | DELETE | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/route.ts` | GET | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/route.ts` | POST | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/search/recent/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
| `src/app/api/v1/search/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
| `src/app/api/v1/settings/feature-flag/route.ts` | GET | Public read of feature-flag bool — no PII; auth is sufficient. |
| `src/app/api/v1/tags/options/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
| `src/app/api/v1/tags/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
| `src/app/api/v1/users/me/preferences/route.ts` | GET | User-self preferences — caller is the resource owner. |
| `src/app/api/v1/users/me/preferences/route.ts` | PATCH | User-self preferences — caller is the resource owner. |

View File

@@ -1,489 +0,0 @@
# Prod-Readiness Audit — feat/documents-folders
**Date:** 2026-05-11
**Branch:** `feat/documents-folders` (67 commits ahead of `main`; 34 from this session's documents-hub-split work + 33 from Wave 11.B)
**Scope:** 17 parallel domain audits (data-structure & sales-process completeness appended at bottom)
**Test posture at audit time:** 1287/1287 unit + integration pass. TypeScript clean (4 pre-existing errors: 1 stale `.next/` build artifact, 3 in a Wave 11.B-era `InMemoryBackend` test stub).
## Headline
**~28 Critical, ~38 Important, ~36 Minor findings across 17 domains.** (Original 16-domain count was 23/32/30; Audit 17 added 5/6/6.)
A handful of the Criticals are real bugs in this session's work that need to be fixed on this branch before merging to `main`. A few are long-standing gaps that survived multiple iterations (storage migration script, `.env.example` URL) and should be fixed independently of this branch but before any prod cutover. Several are mobile/a11y issues that were never going to be caught without a running dev server, which the implementation pass didn't have.
**Recommendation:** fix the 23 Criticals before merging this branch. Triage Importants into "fix-before-prod" vs "follow-up-on-main". Minors → backlog.
Estimated effort to clear Criticals: 6-10 hours of focused work.
---
## Critical findings
Grouped by remediation domain. Each entry: brief rationale + file:line ref + fix sketch.
### A. Core feature regressions in this session's work
**A1. `handleDocumentCompleted` is not idempotent — Documenso retries duplicate `files` rows + orphan blobs**
`src/lib/services/documents.service.ts:1115`
`resolveWebhookDocument` returns the doc regardless of `status`. Two webhook deliveries (Documenso retries on 5xx) can both pass through and both insert `files` rows; the second `UPDATE documents SET signedFileId` clobbers the first and the first blob is permanently orphaned in storage with no DB row.
**Fix:** `if (doc.status === 'completed' && doc.signedFileId) return;` immediately after `resolveWebhookDocument`. Standard idempotency gate for this pattern.
**A2. Realtime hookup dropped by hub rebuild — multi-rep stale data**
`src/components/documents/hub-root-view.tsx`, `src/components/documents/entity-folder-view.tsx`
The pre-rebuild hub consumed `document:*` and `file:*` Socket.IO events via `useRealtimeInvalidation`. After the rebuild, both `HubRootView` and `EntityFolderView` have no realtime subscription at all. The remaining hook lives inside `FlatFolderListing`, which is torn down when navigating away. Result: rep A on `Clients/Smith/` will not see rep B's upload until manual refresh; webhook-completed signatures don't appear in the Signing-in-progress section.
**Fix:** lift `useRealtimeInvalidation` up to `DocumentsHub` with both `document:*` and `file:*` events targeting the prefix keys `['files']` and `['documents']`. TanStack Query prefix matching will invalidate the aggregated keys.
**A3. LEFT JOIN port_id in ON clause defeats `idx_docs_signed_file_id`**
`src/lib/services/files.ts:544`
```sql
LEFT JOIN documents d ON d.signed_file_id = f.id AND d.port_id = $portId
```
Planner picks `idx_docs_port` and applies `signed_file_id = f.id` as a residual filter. At scale this is 20 × N comparisons per page load instead of 20 point lookups. Same pattern in `documents.service.ts:1915` for the workflow projection.
**Fix:** drop `AND d.port_id = portId` from the ON clause and add `AND (d.port_id = portId OR d.id IS NULL)` to the outer WHERE. Or add a composite `(signed_file_id, port_id)` index. `files.port_id` is already scoped, so cross-port leak risk is zero.
**A4. Importer doesn't set `files.folder_id` — imported files invisible to folder queries**
`scripts/import-organized-documents.ts:196-208`
The `documents` row gets `folderId` correctly (line 216) but the companion `files` row does not. `files.folder_id` is a separate column. The backfill won't rescue these — it only acts on files with entity FKs set, and the importer sets none of those either.
**Fix:** copy `folderId` into the `files.values(...)` block alongside the document insert.
**A5. `chk_system_folder_shape` has NULL escape — corrupted system rows persist**
`src/lib/db/migrations/0051_documents_hub_split.sql:22-28`
`NOT system_managed OR entity_type = 'root' OR (...)` evaluates to `NULL` (not `false`) when `entity_type IS NULL` and `system_managed = true`. Postgres treats NULL as "not false" so the constraint passes. Confirmed by direct insert test.
**Fix:** add `entity_type IS NOT NULL` to the constraint, or restructure as `CHECK (NOT system_managed OR (entity_type IS NOT NULL AND (entity_type = 'root' OR (entity_type = ANY(...) AND entity_id IS NOT NULL))))`.
**A6. `document-folders.service.ts` has zero log lines — silent failures across the entire folder service**
`src/lib/services/document-folders.service.ts` (no `logger` import)
Orphan rows in `listTree` are silently dropped (line 83-84). The 50-attempt suffix-loop exhaustion throws `ConflictError` with no log. `ensureSystemRoots` "missing root after upsert" throws raw `Error`. At 3am you would have no diagnostic for folder-related failures.
**Fix:** `import { logger } from '@/lib/logger'`. Add `logger.warn` on orphan-detection, retry-exhaustion (both `ensureEntityFolder` and `syncEntityFolderName`), and the missing-root invariant in `ensureSystemRoots`.
**A7. `demoteSystemFolderOnEntityDelete` is not wired into `client-hard-delete.service.ts`**
`src/lib/services/document-folders.service.ts:650` (exported but zero callers)
`client-hard-delete.service.ts` exists. It clears entity FKs on `files` and `documents` inside its transaction but never demotes the system folder. After hard-delete: folder retains `system_managed=true` + the dead `entity_id`. The partial unique index `uniq_document_folders_entity` permanently blocks any future client folder that would get the same display name. Also a GDPR right-to-be-forgotten gap.
**Fix:** call `demoteSystemFolderOnEntityDelete(portId, 'client', clientId)` inside `hardDeleteClient`'s transaction (or as a post-commit hook with audit log). Confirm whether `companies`/`yachts` have analogous hard-delete services that also need wiring.
### B. Accessibility blockers (WCAG 2.1 AA failures)
**B1. Unlabeled search input**
`src/components/documents/documents-hub.tsx:265`
`<Input placeholder="Search by title..." />` — placeholder is not a label. Fails WCAG 1.3.1 / 4.1.2.
**Fix:** `aria-label="Search documents by title"`.
**B2. No `aria-pressed` on type-filter chips**
`src/components/documents/documents-hub.tsx:276-299`
Active state is purely visual. Screen readers can't tell which chip is selected. Fails WCAG 4.1.2.
**Fix:** `aria-pressed={typeFilter === t}` on each chip.
**B3. No `aria-expanded` on tree chevrons; folder-row labels lack context**
`src/components/documents/folder-tree-sidebar.tsx:125, 135-155`
The expand button has `aria-label="Collapse"` / `"Expand"` with no folder name, so SR users hear "Expand button, Expand button…" with no differentiation. And it lacks `aria-expanded` so the open/closed state is invisible.
**Fix:** `aria-expanded={open}`, `aria-label={\`${open ? 'Collapse' : 'Expand'} ${node.name}\`}`. Same pattern in `documents-hub.tsx:210-217` for the per-row signer expand.
**B4. `aria-label` on Lock SVG becomes part of button's accessible name**
`src/components/documents/folder-tree-sidebar.tsx:150-154`
`<Lock aria-label="System folder" />` inside the folder-select `<button>` produces accessible name "Smith System folder" rather than a separate badge announcement.
**Fix:** `aria-hidden="true"` on the SVG + `<span className="sr-only"> (system folder)</span>` after the folder name.
### C. Mobile blockers
**C1. FolderTreeSidebar stacks above main panel with no collapse toggle**
`src/components/documents/folder-tree-sidebar.tsx:32` — `w-full sm:w-60`
On mobile the entire folder tree renders above the document list. With any non-trivial tree, reps scroll past it to reach content. Every other secondary-nav page uses a Sheet or Collapsible.
**Fix:** wrap in a Sheet drawer (default closed on mobile) with a "Show folders" trigger button.
**C2. `border-r` on wrong axis at mobile breakpoint**
`src/components/documents/folder-tree-sidebar.tsx:32`
Right border draws on full-width-stacked element instead of bottom separator.
**Fix:** `border-b sm:border-r border-r-0`.
**C3-C7. 5 tap-target violations below WCAG 44×44px minimum**
- C3: chevron expand button (`folder-tree-sidebar.tsx:125`) — 20×20px
- C4: row expand chevron (`documents-hub.tsx:210-216`) — no sizing
- C5: "view signing details" (`entity-folder-view.tsx:82-89`) — ~20px tall
- C6: "Show all (N)" (`aggregated-section.tsx:101-108`) — ~18px tall
- C7: type-filter chips (`documents-hub.tsx:277-297`) — `py-0.5` gives ~24px
**Fix:** `min-h-[44px]` + `py-2` (or `py-1.5`) on each. Or wrap in `<Button size="sm">` where the visual change is acceptable.
### D. Long-standing infra gaps (independent of this branch, must fix before prod)
**D1. `migrate-storage.ts` migrates zero files — silent footgun**
`src/lib/storage/migrate.ts:40-43`
`TABLES_WITH_STORAGE_KEYS` is an empty array. The comment says "Phase 6a ships an empty list" — never followed up. Running `pnpm tsx scripts/migrate-storage.ts` flips the active backend but migrates nothing. Existing blobs in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports`, `report_snapshots` become unreachable.
**Fix:** populate the table list with all five tables + their `storagePath`/`storageKey` columns. The `copyAndVerify` SHA-256 round-trip already works; it just needs entries to act on.
**D2. `.env.example` DOCUMENSO_API_URL has `/api/v1` baked in → double-path URLs**
`.env.example`
Current value: `DOCUMENSO_API_URL=https://documenso.example.com/api/v1`. The client appends `/api/v1/documents` etc., producing `https://documenso.example.com/api/v1/api/v1/documents`. Anyone copying the example file gets 404s from Documenso with no diagnostic. Applies to both v1 and v2 deployments.
**Fix:** change to `DOCUMENSO_API_URL=https://documenso.example.com` (bare host). Update the admin UI placeholder to match.
### E. Test theatre — assertions never run
**E1. Smoke spec `test.skip()` guards mask infrastructure failures**
`tests/e2e/smoke/04-documents-hub-aggregated.spec.ts:99-104`
`tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts:41, 129, 153, 165`
When the API setup step (client create, file upload, file list) returns non-2xx, the test calls `test.skip(true, ...)` and proceeds no further. Playwright reports skipped tests as passed — a green CI run hides whether the actual assertion would have succeeded.
**Fix:** convert skip-on-non-ok to `expect.fail()` so a 401 on setup becomes a real test failure. Skip should only fire when the precondition is genuinely "this scenario doesn't apply", not "the infrastructure broke".
### F. Webhook event coverage gap (with v1 + v2 support in scope)
**F1. `DOCUMENT_DECLINED` has no handler**
`src/app/api/webhooks/documenso/route.ts:146-214`
v2 distinguishes Decline (recipient refuses) from Reject (admin cancels). The switch handles `DOCUMENT_REJECTED` only. A v2-declined document leaves the CRM document in `sent` status indefinitely; the poller doesn't catch it either (only checks `COMPLETED` and `EXPIRED`).
**Fix:** add a `DOCUMENT_DECLINED` case to the switch. Behaviorally mirror `DOCUMENT_REJECTED` initially; product can refine if Decline vs Reject should differentiate downstream.
---
## Important findings (fix before prod, or as follow-up on `main`)
Listed by audit domain. Each has a file:line ref in its source audit; I'll quote the highlights here for triage.
### Security
- **`storagePath` + `storageBucket` exposed via aggregated files API** (`files.ts:533-534`) — internal storage paths reach authenticated rep clients via `GET /api/v1/files?entityType=X`. Auditors flagged this from both Security and Integration angles. Sanitize at service layer.
- **Missing `portId` on UPDATE in folder-move route** (`api/v1/documents/[id]/folder/route.ts:41-44`) — pre-flight read scopes by portId so no current exploit, but defense-in-depth gap that breaks if pre-flight is ever refactored.
- **Signer emails exposed to all `documents.view` holders** — confirm with product whether read-only roles should see signatory email addresses or get them redacted.
### Database / Migration
- **`uniq_document_folders_entity` doesn't cover `entity_type = NULL`** — rows with NULL entity_type but non-NULL entity_id can duplicate. Closes when CHECK constraint is tightened (A5 above).
- **Backfill transaction holds advisory lock across N `ensureEntityFolder` calls** — at 10k files the lock is held for minutes. Batch in chunks of 500.
- **`CREATE INDEX` without `CONCURRENTLY`** in migration 0051 — blocks writes briefly. Quantify: short-duration on small tables, moderate on prod-sized. Split for zero-downtime if needed.
### Concurrency / Error Paths
- **Storage blob orphaned on DB-insert failure** in `handleDocumentCompleted` — `storage.put` before `db.insert(files)`. No janitor. Long-standing tradeoff; document explicitly.
- **`ensureSystemRoots`/`ensureEntityFolder` outside backfill transaction** — folder rows persist if the wrapping tx rolls back. Idempotent so re-run heals.
- **`syncEntityFolderName` 50-attempt cap with concurrent renames to same target** — silent log + stale folder name. Accepted divergence.
### Performance
- **N+1 grows with linked entities** — leasing company with 50 yachts = 110 queries per page load. Worst case (5 companies + 100 yachts) = 216. Acceptable for now; future optimization: single CTE with grouping.
- **Count queries can collapse via window function** — `count(*) OVER ()` halves round-trip count at scale.
- **Missing composite indexes `(port_id, client_id)` / `(port_id, company_id)` / `(port_id, yacht_id)` on `files`** — same for `documents`. Add before prod backfill at scale.
- **`listDocuments` calls `listTree()` twice when `includeDescendants=true`** — pass already-fetched tree into `hydrateDocumentsWithDownloadUrl`.
### Data migration (importer)
- **System-root collision risk** — bucket folders named `Clients`/`Companies`/`Yachts` silently merge into auto-created system roots. Add a pre-flight check that warns when any top-level segment matches a system root name.
### Observability
- **Archive/restore hooks missing `portId` in log context** (`companies.service.ts:215`, `yachts.service.ts:193`) — clients has it; companies and yachts don't.
- **Backfill CLI has no row-count telemetry** — only "Backfill complete" on success. Want files-processed / folders-created / FKs-propagated counts.
- **No log on empty aggregated projection** — `assertEntityInPort` returning false produces a silent empty result. Log warn with `portId + entityType + entityId`.
- **`handleDocumentCompleted` outer catch loses `portId`** (line 1197).
### UI/UX
- **Em-dash in `SigningDetailsDialog` description** (line 62) — user-facing copy.
- **Em-dashes baked into aggregated group labels** (`FROM COMPANY — ACME CORP`) — rendered on every entity folder view. `files.ts:335`, `documents.service.ts:1877`. Replace with colon or slash.
- **Mixed `Loading...` (ASCII) and `Loading…` (Unicode ellipsis)** across components. Normalize.
- **Raw `partially_signed` status in `HubRootView`** — no StatusPill or underscore replacement. Apply `StatusPill` or at minimum `replace(/_/g, ' ')`.
- **"view signing details" button too subtle** — inline-text in a tight muted cluster, blends into the date. Consider `<Button variant="ghost" size="sm">`.
### Integration conformance (with v1 + v2 support)
- **Documenso poll worker double-fire of `handleDocumentCompleted`** writes a second blob + second `files` row and overwrites `signedFileId`. Confirmed by both concurrency and integration audits. Resolved by A1's idempotency gate.
- **Poll worker omits `portId`** when calling `handleRecipientSigned` / `handleDocumentCompleted` — multi-port correctness risk.
- **MinIO operations have no socket timeout** — TCP blackhole stalls workers indefinitely. `fetchWithTimeout` doesn't cover the minio client's `putObject`/`getObject`. Wrap with an external timeout (`AbortController` or `Promise.race`).
- **No 0-byte check on `downloadSignedPdf` result** — a 0-byte response from Documenso writes a permanent corrupt `signedFileId` with no recovery path.
- **`DOCUMENSO_API_VERSION` env defaults to `v1`** with no documentation in `.env.example` that v2 is supported. A v2-pointed deployment that misses the env var fires v1 code paths against a v2 instance.
- **`DOCUMENT_DECLINED` event handler** — already listed as Critical F1; mentioned again here because the integration audit captured it under v2-specific gaps.
- **`RECIPIENT_VIEWED` / `RECIPIENT_SIGNED`** v2 event aliases — currently silently dropped. Confirm whether v2 actually fires these or maps to `DOCUMENT_OPENED` / `DOCUMENT_SIGNED` like v1. If v2 fires them, add handlers.
### Realtime / Socket.IO
- **`useRealtimeInvalidation` is inside `FlatFolderListing`, not `DocumentsHub`** — torn down when navigating away. Lifting to DocumentsHub closes this and unblocks A2 cleanly.
- **`['document-folders']` query key has no realtime invalidation path** — rep B renaming a folder takes up to 30s `staleTime` to surface for rep A. Add a folder-rename socket emit + invalidate.
### Audit log completeness
- **`createFolder` has no audit log** (line 102-136) — inconsistent with rename/move/delete which all audit.
- **`handleDocumentCompleted` file insert has no audit** (line 1163-1180) — signed PDFs created with no audit trail.
- **`syncEntityFolderName` ignores `_userId`** — folder renames driven by entity rename leave no audit trail.
- **Archive/restore suffix helpers no audit** — parent entity action audits, but folder mutation doesn't.
### Type-safety
- **`entityType as 'client'|'company'|'yacht'`** in `documents-hub.tsx:134` — no runtime guard. Fix with `ENTITY_TYPES.has()`.
- **`INFLIGHT_STATUSES as unknown as string[]`** — replace with `[...INFLIGHT_STATUSES]`.
- **Loose `files?/workflows?` union + unconstrained `T`** in `AggregatedSection` — refactor to discriminated union + `T extends { id: string }`.
### Test quality
- **`mapWorkflowStatus` `partially_signed` fix has no regression test**.
- **`applyEntityRestoredSuffix` "restore without prior archive" path not tested**.
- **`folderId="" → null` validator transform has zero test coverage**.
- **`syncEntityFolderName` collision beyond `(2)` untested** — if `isSiblingNameConflict` ever mis-classifies the error shape, retries never fire and the test wouldn't notice.
### Mobile
- **DocumentsHub sets no `useMobileChrome`/`setChrome` title** — falls back to URL-segment title-casing.
- **FolderActionsMenu trigger overrides to 28×28px** — should use default `size="icon"` (44×44).
- **SigningDetailsDialog signer email no `truncate`** — long emails overflow on narrow viewports.
- **Breadcrumb tap targets too small** (`folder-breadcrumb.tsx:41-60`) — no padding.
---
## Minor (backlog)
Approximately 30 minor findings across all domains. Highlights:
- **Em-dashes in `CLAUDE.md`** (29 in prose bullets, all in pre-existing content; no new em-dashes added in commit `ab79894`) — backlog cleanup pass.
- **`@radix-ui/react-icons` unused** — safe to remove from `package.json`.
- **`@hookform/resolvers`, `zod`, `tailwindcss`** all have major-version updates available — DO NOT upgrade pre-cutover (breaking changes).
- **Sonnet color contrast on `muted-foreground/70` opacity variant** (`aggregated-section.tsx:94`) — ~3.2:1 fails WCAG AA for normal text. Drop the `/70` tint.
- **`<header>` element inside `<div>` not under a sectioning element** (`aggregated-section.tsx:92`) — wrong landmark scope; use `<div>` or `<h6>`.
- **`h3` → `h5` jump in SigningDetailsDialog** (skipped heading level).
- **`renameFolder` `updatedAt` test uses 10ms `setTimeout`** — fragile but `toBeGreaterThan` is OK; can drop the sleep entirely.
- **`MINIO_AUTO_CREATE_BUCKET`** bypasses zod env schema; undocumented in `.env.example`.
- **`DOCUMENSO_TEMPLATE_ID_EOI` + recipient ID vars absent from `.env.example`** with Port-Nimara-specific hardcoded defaults.
- **`voidDocument` raw `FetchTimeoutError` propagation** — no `CodedError('DOCUMENSO_TIMEOUT')` wrap. Both call sites handle gracefully; cosmetic.
---
## Audit-by-audit completion log
| # | Audit | Status | Critical | Important | Minor |
| --- | ------------------------------------------- | ------ | -------- | --------- | ----- |
| 1 | Security & multi-tenant isolation | ✓ | 0 | 3 | 0 |
| 2 | Database & migration safety | ✓ | 1 | 3 | 3 |
| 3 | Concurrency, idempotency, error paths | ✓ | 1 | 3 | 3 |
| 4 | Performance & query plans | ✓ | 1 | 3 | 3 |
| 5 | Data migration from old system | ✓ | 1 | 1 | 3 |
| 6 | Production observability | ✓ | 2 | 4 | 3 |
| 7 | UI/UX | ✓ | 0 | 5 | 4 |
| 8 | Integration conformance (Context7) | ✓ | 0 | 0 | 3 |
| 9 | Dependency audit | ✓ | 0 | 0 | ~10 |
| 10 | Accessibility (WCAG 2.1 AA) | ✓ | 4 | 5 | 4 |
| 11 | Test quality & coverage | ✓ | 2 | 6 | 3 |
| 12 | Realtime / Socket.IO | ✓ | 3 | 2 | 1 |
| 13 | Audit log completeness | ✓ | 0 | 4 | 4 |
| 14 | Type-safety | ✓ | 0 | 3 | 3 |
| 15 | Mobile / responsive | ✓ | 6 | 5 | 3 |
| 16 | Integration holes (MinIO + Documenso) | ✓ | 2 | 5 | 5 |
| 17 | Data structure & sales process completeness | ✓ | 5 | 6 | 6 |
---
## Suggested remediation order
**Pre-merge (block this branch):**
1. A1 (concurrency idempotency) — 1 line, 5 minutes.
2. A2 (realtime hookup) — ~30 min: lift one hook up two layers in component tree.
3. A4 (importer folder_id) — 1 line in scripts/import-organized-documents.ts.
4. A5 (CHECK NULL escape) — 1-line migration patch + re-apply.
5. A6 (folder service logger) — add `import { logger }` + 3 warn calls.
6. A7 (demote on hard-delete) — 1 line in client-hard-delete.service.ts.
7. B1-B4 (a11y) — ~30 min combined: aria attributes only.
8. C1-C7 (mobile) — ~1-2 hours: Sheet wrap + tap-target padding.
9. E1 (test theatre) — convert skips to fails.
10. F1 (DOCUMENT_DECLINED) — add case to switch.
**Pre-prod cutover (independent of branch):**
- A3 (LEFT JOIN port_id) — performance fix.
- D1 (storage migration table list) — populate TABLES_WITH_STORAGE_KEYS.
- D2 (.env.example URL) — strip `/api/v1`.
- All Important security findings.
- 0-byte signed PDF check.
- MinIO socket timeout wrapper.
- DOCUMENSO_API_VERSION documentation + v2 event audit.
**Post-prod (backlog on main):**
- Important UI/UX (em-dashes, loading state consistency, status pill on HubRootView).
- Important audit log completeness.
- Important type-safety tightening.
- All Minor.
---
## Notes on session vs. pre-existing findings
Several Criticals (D1 storage migration script, D2 .env.example, A3 LEFT JOIN port_id, parts of the audit-log gaps and observability gaps) are long-standing — they survived multiple iterations of the codebase, sometimes since Phase 6a. Fixing them on this branch is fine but they're not regressions introduced by this session.
The session's actual regressions are: A1 (idempotency), A2 (realtime), A5 (CHECK NULL), A6 (folder service has no logger), A7 (demote not wired), B1-B4 (a11y missed during the UI rebuild), C1-C7 (mobile never tested), E1 (test theatre).
The dependency, integration-conformance (Context7), and type-safety audits are clean of Critical findings — your dep posture is solid and the implementation follows published specs.
---
## Audit 17 — Data structure & sales process completeness
**5 Critical, 6 Important, 6 Minor.** This audit walked the entire entity graph and the sales-process pipeline end-to-end. Most findings are not regressions from this session — they are gaps in the sales-process plumbing that pre-date the documents-hub-split work but matter for prod cutover. C-1 and C-3 are session-introduced; C-2, C-4, C-5 are long-standing.
### Critical (data graph + sales pipeline)
**G-C1. `deleteFolderSoftRescue` re-parents documents but not files — split delete behavior**
`src/lib/services/document-folders.service.ts:268-282`
The soft-rescue transaction `UPDATE`s `documents.folderId = newParent`, then deletes the folder row. The schema cascade on `files.folderId` is `ON DELETE SET NULL` (not `SET DEFAULT newParent`) — so any files in the deleted folder land at **root**, while documents in the same folder correctly land at the deleted folder's **parent**. A folder containing both will scatter on delete.
Fix: inside the transaction, between the documents UPDATE and the folder DELETE:
```ts
await tx
.update(files)
.set({ folderId: newParent })
.where(and(eq(files.folderId, folderId), eq(files.portId, portId)));
```
**G-C2. Client hard-delete blocked by `scratchpadNotes.linkedClientId` RESTRICT FK**
`src/lib/services/client-hard-delete.service.ts:190-218` + `src/lib/db/schema/system.ts:180`
`scratchpadNotes.linkedClientId references clients.id` with no `onDelete` → defaults to RESTRICT. The hard-delete service nullifies six nullable FKs (files, documents, formSubmissions, emailThreads, reminders, documentSends) but skips `scratchpadNotes`. Any rep who scratchpad-linked a note to a client → hard-delete throws an FK violation and aborts the transaction.
Fix: add to the nullification block:
```ts
await tx
.update(scratchpadNotes)
.set({ linkedClientId: null })
.where(eq(scratchpadNotes.linkedClientId, args.clientId));
```
**G-C3. Client hard-delete leaves ghost system folder with stale `entityId`**
`src/lib/services/client-hard-delete.service.ts:214-218`
The unique index `uniq_document_folders_entity` on `(portId, entityType, entityId)` enforces a singleton system folder per entity. Hard-delete removes the client row but does not call `demoteSystemFolderOnEntityDelete`. The folder persists with `systemManaged=true, entityType='client', entityId=<deleted-id>` — invisible in the sidebar but holding the unique slot.
Fix: after the client delete, fire-and-forget the demote:
```ts
void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch(logger.error);
```
(This is the same wire-up A7 in the main report flagged — confirmed missing on the hard-delete pathway specifically.)
**G-C4. Five of seven berth-rule triggers are defined but never called**
`src/lib/services/berth-rules-engine.ts:37-44` vs `src/lib/services/documents.service.ts:798,894,1234`
`DEFAULT_RULES` defines triggers for `eoi_sent`, `eoi_signed`, `deposit_received`, `contract_signed`, `interest_archived`, `interest_completed`, `berth_unlinked`. Only `eoi_sent` and `eoi_signed` are passed to `evaluateRule` anywhere in the codebase.
Concrete consequences:
- Deposit received (invoice paid) → no berth state change. Should auto-mark berth as Sold.
- Contract signed → no berth state change.
- Interest archived → no "berth available" suggestion fires.
- Interest marked Won/Lost → no rule trigger.
- Interest unlinked from berth → no rule trigger (off-by-default, but configurable and silently dead).
Fix sketches:
- `invoices.ts:741` (after `advanceStageIfBehind('deposit_10pct')`):
```ts
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
void evaluateRule('deposit_received', updated.interestId, portId, meta);
```
- `interests.service.ts:archiveInterest` after `softDelete`: fetch primary berth via `getPrimaryBerth`, then `void evaluateRule('interest_archived', ...)`.
- `interests.service.ts:setInterestOutcome` after the outcome write: `void evaluateRule('interest_completed', ...)`.
- `interest-berths.service.ts:removeInterestBerth` after delete: `void evaluateRule('berth_unlinked', ...)`.
**G-C5. `contract_sent` and `contract_signed` pipeline stages have zero auto-advancement triggers**
`src/lib/services/documents.service.ts` (absent)
`STAGE_TRANSITIONS` defines `contract_sent` and `contract_signed` and they render in the Kanban/funnel UI, but no code path calls `advanceStageIfBehind(..., 'contract_sent')` or `advanceStageIfBehind(..., 'contract_signed')`. Sending a reservation agreement → no stage advance. Completing one (signed PDF arrives, `contractFileId` set in `handleDocumentCompleted` ~line 887) → no stage advance.
Effect: deals stall at whatever stage they hit when the reservation agreement was sent, until a rep manually drags them in the Kanban.
Fix: in `documents.service.ts`:
- `sendDocument` pathway (~line 798): if `doc.documentType === 'reservation_agreement'`, fire `advanceStageIfBehind(..., 'contract_sent', meta, 'Reservation agreement sent')`.
- `handleDocumentCompleted` (~line 887, where `contractFileId` is set): fire `advanceStageIfBehind(..., 'contract_signed', meta, 'Reservation agreement signed')` and `evaluateRule('contract_signed', ...)`.
### Important (cross-entity gaps)
**G-I1. Portal email uniqueness is global, not per-port**
`src/lib/db/schema/portal.ts:40` — `uniqueIndex('idx_portal_users_email_unique').on(table.email)`
A client who has dealt with two ports under this deployment can only ever have one portal account. The second `createPortalUser` will throw a unique-constraint violation. Make per-port (`.on(table.email, table.portId)`) if multi-port is a real deployment scenario, or document as single-port-only.
**G-I2. `archiveInterest` skips `interest_archived` rule and `notifyNextInLine`**
`src/lib/services/interests.service.ts:985-1014`
Archive does the audit log + socket emit but does not (a) trigger the berth-availability rule, (b) notify the waiting list for the primary berth. The waiting-list code is only fired when the **client** is archived, not the **interest**.
Fix after `softDelete`: fetch primary berth → `evaluateRule('interest_archived', ...)` + `notifyNextInLine(primaryBerth.berthId, portId, meta.userId)`.
**G-I3. Yacht/company `restore` paths missing `applyEntityRestoredSuffix`**
`src/lib/services/yachts.service.ts:178` + `src/lib/services/companies.service.ts:200`
Archive sides call `applyEntityArchivedSuffix`. Restore paths do not exist for yachts/companies at all today — but when they are added (or if the entity-restoration logic moves to the `clients/archive` parity routes), `applyEntityRestoredSuffix` must be wired. `clients.service.ts:596` already does this correctly.
**G-I4. `berthRecommendations.interestId` has no FK constraint**
`src/lib/db/schema/berths.ts:134` — column comment says "references interests.id" but `.references()` is omitted.
If an interest is hard-deleted (currently only possible via `db:studio` or future migrations), stale `berthRecommendations` rows persist and skew the recommender's tier aggregates. Add `.references(() => interests.id, { onDelete: 'cascade' })` and generate a migration.
**G-I5. Portal invoices invisible for company-billed deals**
`src/lib/services/portal.service.ts:232`
`getClientInvoices` matches on `billingEmail in client.emails`. Invoices with `billingEntityType='company'` (the most common B2B pattern: client is an individual buying through their company) are not surfaced even when the client is the company's director. Extend the query to OR-in invoices where `billingEntityType='company' AND company.directorClientId = portalUser.clientId`.
**G-I6. `hub-counts` API endpoint is orphaned**
`src/app/api/v1/documents/hub-counts/route.ts:5-10` + `getHubTabCounts` in `documents.service.ts:397`
The hub rebuild on this branch removed the component that called this endpoint. Service function + route are dead code. Either wire a KPI strip back into `HubRootView` (the spec does call for this) or delete the route + service function.
### Minor
- **G-M1.** Website inquiry → client conversion is fully manual; `prefill_*` query params are hints only. `inquiry-inbox.tsx:119`.
- **G-M2.** Polymorphic array columns (`photoFileIds`, `attachmentFileIds`) have no FK protection. Files deleted via any future hard-purge path silently orphan these arrays.
- **G-M3.** `berthReservations.interestId` RESTRICT default (notNull, no `onDelete`) — intent (preserve history vs oversight) undocumented.
- **G-M4.** `setInterestOutcome` to `won` does not fire berth-sold; downstream of G-C4.
- **G-M5.** `advanceStageIfBehind` silently no-ops when `yachtId` is null at `open` stage. Walk-in EOIs (vessel not yet identified) stall invisibly at `open`.
- **G-M6.** `removeInterestBerth` emits socket + webhook but skips `evaluateRule('berth_unlinked')`. Downstream of G-C4.
### Impact on cutover gate
- **G-C2** is the most pressing for cutover: it is a hard error on a foreseeable action (any rep deleting a client with a linked scratchpad note → 500). Fix before any team testing.
- **G-C4 + G-C5** mean the berth-map status and Kanban columns will drift visually for every deal that progresses past EOI. This is not data corruption, but it will erode rep trust quickly during initial team testing. Fix before cutover.
- **G-C1** is a UX correctness issue; will surprise reps but won't lose data. Same-branch fix.
- **G-C3** is data-integrity hygiene; no immediate user-visible effect but pollutes the unique-folder slot. Same-branch fix.
### Updated headline
With Audit 17 folded in, the corrected count is **~28 Critical, ~38 Important, ~36 Minor** across 17 domains. The new Criticals (G-C2, G-C4, G-C5) are long-standing pre-existing gaps in the sales pipeline — they don't block this branch's merge to `main`, but they block prod cutover. G-C1 and G-C3 are this-branch issues and should be folded into the same fix pass as A1-A7.
### Suggested remediation order — addendum
After the A/B/C/D/E/F block from the main report:
1. **G-C1** — files folder UPDATE in `deleteFolderSoftRescue` transaction (1-line addition).
2. **G-C2** — nullify `scratchpadNotes.linkedClientId` in `clientHardDelete` (1-line addition).
3. **G-C3** — call `demoteSystemFolderOnEntityDelete` after client hard-delete (1-line addition).
4. **G-C4 + G-C5** — wire 6 missing berth-rule + pipeline-advance triggers (~30 min total, spread across invoices.ts, interests.service.ts, interest-berths.service.ts, documents.service.ts).
Total addendum effort: ~1 hour for G-C1/G-C2/G-C3, ~30 min for G-C4/G-C5, plus 1 migration regen for I-4 if you choose to fix it now.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,201 +0,0 @@
# Inquiry Notifications System Design
Migrates the ActivePieces-powered inquiry notification flow into the CRM. When a client registers interest via the Port Nimara website, the system sends a confirmation email to the client and notifies the sales team -- all using the CRM's own database and email infrastructure instead of NocoDB + ActivePieces.
## Scope
- Expand the public interest API to accept all website form fields
- Add client address storage (multi-address with primary flag)
- Send branded confirmation email to the client
- Send notification to sales team (CRM users + optional external recipients)
- Make notification recipients and contact email configurable by admins
## Database Changes
### New table: `client_addresses`
| Column | Type | Notes |
| ---------------- | ----------------- | ---------------------------------------------------------------- |
| `id` | uuid PK | `crypto.randomUUID()` |
| `client_id` | uuid FK → clients | cascade delete |
| `port_id` | uuid FK → ports | cascade delete |
| `label` | text | e.g., "Home", "Office", "Billing" |
| `street_address` | text | |
| `city` | text | |
| `state_province` | text | |
| `postal_code` | text | |
| `country` | text | |
| `is_primary` | boolean | default `true`, one-primary-per-client enforced in service layer |
| `created_at` | timestamp | default `now()` |
| `updated_at` | timestamp | default `now()` |
Schema file: `src/lib/db/schema/clients.ts` (alongside existing client tables).
Relations: added to `src/lib/db/schema/relations.ts` (client has many addresses).
### No changes to existing tables
- `clients.preferred_contact_method` already exists -- we populate it from the form.
- `interests.berth_id` already exists -- we resolve `mooringNumber` to a berth and link it.
- `notifications.type` already has `new_registration` -- we fire it.
## Public API Changes
### `POST /api/public/interests`
Expanded request schema:
```typescript
// Required
firstName: string; // max 100
lastName: string; // max 100
email: string; // email format
phone: string;
// Optional
preferredContactMethod: 'email' | 'phone' | 'sms';
mooringNumber: string; // e.g., "A3" -- resolved against berths.mooring_number
companyName: string;
yachtName: string;
yachtLengthFt: number;
yachtWidthFt: number;
yachtDraftFt: number;
preferredBerthSize: string;
notes: string; // max 2000
address: {
street: string;
city: string;
stateProvince: string;
postalCode: string;
country: string;
}
// Backward compatibility
fullName: string; // accepted if firstName/lastName not provided
```
Backward compatibility: if `fullName` is provided without `firstName`/`lastName`, it is used as-is for `clients.full_name`. If `firstName`+`lastName` are provided, they are concatenated.
### Behavior after record creation
1. Resolve `mooringNumber` against `berths.mooring_number` for the port. Link `interests.berth_id` if found; leave null if not.
2. Store `address` in `client_addresses` with `is_primary: true` and `label: 'Primary'`.
3. Set `clients.preferred_contact_method` from the form value.
4. Queue client confirmation email (see Email Templates below).
5. Fire `new_registration` notifications to sales team (see Notification Flow below).
6. Return `201 { data: { id, message } }` unchanged.
Rate limiting remains 5 requests/hour per IP.
## Email Templates
Located in `src/lib/email/templates/`. Each exports a function that accepts a typed data object and returns `{ subject: string, html: string, text: string }`.
### `inquiry-client-confirmation.ts`
Sent to the client who submitted the form.
**Input data:**
- `firstName` -- for the greeting
- `mooringNumber` -- berth identifier (nullable)
- `contactEmail` -- from `inquiry_contact_email` system setting
**Subject:** "Thank You for Your Interest in Berth {mooringNumber}" or "Thank You for Your Interest in a Port Nimara Berth" if no berth.
**Body:** Greeting with first name, confirmation their interest is registered, mention they'll be contacted by preferred method, link to the contact email address.
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces client confirmation template.
### `inquiry-sales-notification.ts`
Sent to CRM users and optional external recipients.
**Input data:**
- `fullName`
- `email`
- `phone`
- `mooringNumber` (nullable, defaults to "None")
- `crmUrl` -- link to the interest detail page in the CRM (built from port slug + interest ID)
**Subject:** "New Interest - Port Nimara"
**Body:** Notifies that a new interest has been registered, shows client details and berth selected, links to the CRM.
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces admin notification template.
Both templates include a plain-text fallback.
## Notification & Delivery Flow
### Client confirmation email
1. After record creation, queue a `send-inquiry-confirmation` job on the `email` BullMQ queue.
2. Email worker renders the `inquiry-client-confirmation` template with the interest data.
3. Sends via system SMTP (`src/lib/email/index.ts`).
4. No in-app notification (client is not a CRM user).
### Sales team notification
1. Query all users on the port who have `interests` read permission via their role.
2. For each user, call `createNotification()` with type `new_registration`.
- The existing notification service checks `user_notification_preferences` (in-app / email / both / neither).
- Creates in-app notification + Socket.IO push if `in_app: true`.
- Queues `send-notification-email` job if `email: true`.
3. Fetch `inquiry_notification_recipients` system setting for the port.
4. For each external email, queue a `send-inquiry-sales-notification` job on the `email` queue (bypasses notification preferences since these are not CRM users).
### Independence
Client confirmation and sales notifications are independent -- a failure in one does not block the other. The `201` response returns immediately after record creation, before any emails are sent.
## Admin Configuration
Two new system settings, managed via the existing admin settings UI:
### `inquiry_contact_email` (string, per-port)
The reply-to / contact email shown in client confirmation emails.
- Default: `sales@portnimara.com`
- Displayed as a mailto link in the client confirmation email.
### `inquiry_notification_recipients` (JSON array of strings, per-port)
Additional external email addresses that receive the sales team notification.
- Default: `[]` (empty)
- Only CRM users with interests permissions are notified by default.
- External recipients receive the sales notification email directly.
### Existing infrastructure (no changes needed)
- **Which CRM users get notified**: controlled by roles/permissions.
- **How each user receives notifications**: `user_notification_preferences` table.
- **Admin settings UI**: already supports custom key-value pairs in `system_settings`.
## Files to Create or Modify
### New files
- `src/lib/db/schema/client-addresses.ts` -- (or added to `clients.ts`)
- `src/lib/email/templates/inquiry-client-confirmation.ts`
- `src/lib/email/templates/inquiry-sales-notification.ts`
### Modified files
- `src/lib/db/schema/clients.ts` -- add `clientAddresses` table export
- `src/lib/db/schema/index.ts` -- re-export new table
- `src/lib/db/schema/relations.ts` -- add client addresses relations
- `src/lib/validators/public-interest.ts` (or wherever `publicInterestSchema` lives) -- expand schema
- `src/app/api/public/interests/route.ts` -- berth resolution, address storage, notification + email triggers
- `src/lib/queue/workers/email.ts` -- handle `send-inquiry-confirmation` and `send-inquiry-sales-notification` jobs
- `src/lib/services/interests.service.ts` -- helper to find users with interests permissions on a port
- `src/app/(dashboard)/[portSlug]/admin/settings/settings-manager.tsx` -- register the two new setting keys
## Out of Scope
- Editing email templates from the admin UI (templates are in code).
- Supplemental forms for collecting missing info (separate feature using existing `form_templates` / `form_submissions` infrastructure).
- Documenso EOI integration with address merge fields (separate feature).
- Changes to the Port Nimara website form itself (website team wires the form to our API).

View File

@@ -1,663 +0,0 @@
# Data-Model Refactor: Yachts and Companies as First-Class Entities
**Status:** Draft — awaiting final review
**Date:** 2026-04-23
**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint)
## Overview
This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express.
The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment:
- A client owns multiple yachts (a common marina scenario)
- A person is a broker or director of multiple companies
- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock
- A yacht changes hands between owners and the marina needs chain-of-title
The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking.
This spec also fixes two existing schema gaps that surface during the refactor:
- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth
- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities
## Scope boundaries
### In scope (this spec)
- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables
- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables
- New `berth_reservations` table with partial-unique-index exclusivity enforcement
- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities
- Removal of yacht, company, and proxy columns from `clients`
- New services, API routes, permissions, and socket/webhook events
- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms
- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder
- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression
- Seeder with realistic multi-cardinality dummy data
### Explicitly out of scope
- **Importing NocoDB records and MinIO documents** → Spec 2
- **Client merge endpoint** → Spec 3
- Yacht survey / class-cert document categorization
- Company hierarchy (holding → subsidiary)
- Line-item-level yacht references on invoices
- Auto-renewal flow for berth reservations
- Per-yacht row-level permissions
- Portal branding per company
## Decisions and rationale
| Topic | Decision | Why |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) |
| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity |
| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast |
| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk |
| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level |
| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves |
| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them |
| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily |
| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency |
| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked |
| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers |
## Schema design
### New tables
```
yachts
id text PK
portId text NOT NULL FK → ports.id
name text NOT NULL
hullNumber text
registration text
flag text
yearBuilt integer
builder text
model text
hullMaterial text
lengthFt numeric
widthFt numeric
draftFt numeric
lengthM numeric
widthM numeric
draftM numeric
currentOwnerType text NOT NULL -- 'client' | 'company'
currentOwnerId text NOT NULL
status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away'
notes text
archivedAt timestamptz
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_yachts_port on (portId)
idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId)
idx_yachts_name on (portId, name)
yacht_ownership_history
id text PK
yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE
ownerType text NOT NULL -- 'client' | 'company'
ownerId text NOT NULL
startDate date NOT NULL
endDate date -- NULL = currently active
transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other'
transferNotes text
createdBy text NOT NULL
createdAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_yoh_yacht on (yachtId)
idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL
yacht_notes -- mirrors client_notes shape
id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt
yacht_tags
yachtId, tagId composite PK; tagId references system.tags.id
companies
id text PK
portId text NOT NULL FK → ports.id
name text NOT NULL
legalName text
taxId text
registrationNumber text
incorporationCountry text
incorporationDate date
status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved'
billingEmail text
notes text
archivedAt timestamptz
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_companies_port on (portId)
idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive
idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL
company_memberships
id text PK
companyId text NOT NULL FK → companies.id ON DELETE CASCADE
clientId text NOT NULL FK → clients.id ON DELETE CASCADE
role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other'
roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker"
startDate date NOT NULL
endDate date -- NULL = active
isPrimary boolean NOT NULL DEFAULT false
notes text
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_cm_company on (companyId)
idx_cm_client on (clientId)
idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL
unique_cm_exact UNIQUE on (companyId, clientId, role, startDate)
company_addresses -- mirrors client_addresses shape with companyId FK
company_notes -- mirrors client_notes shape with companyId FK
company_tags
companyId, tagId composite PK
berth_reservations
id text PK
berthId text NOT NULL FK → berths.id
portId text NOT NULL FK → ports.id
clientId text NOT NULL FK → clients.id -- contract holder
yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip
interestId text FK → interests.id -- nullable link back to originating interest
status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled'
startDate date NOT NULL
endDate date -- NULL = open-ended
tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal'
contractFileId text FK → files.id
createdBy text NOT NULL
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_br_berth on (berthId)
idx_br_client on (clientId)
idx_br_yacht on (yachtId)
idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active'
```
### Modified tables
```
clients
DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt,
yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired
DROP COLUMN companyName
DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes
(retains: fullName, nationality, preferredContactMethod, preferredLanguage,
timezone, source, sourceDetails, archivedAt, createdAt, updatedAt)
interests
ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open'
ADD INDEX idx_interests_yacht on (yachtId)
berth_waiting_list
ADD COLUMN yachtId text FK → yachts.id
invoices
ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company'
ADD COLUMN billingEntityId text NOT NULL
(clientName column kept as immutable snapshot — must never auto-update)
ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId)
files
ADD COLUMN yachtId text FK → yachts.id -- nullable
ADD COLUMN companyId text FK → companies.id -- nullable
(existing clientId stays nullable; a file links to one of: client, yacht, or company)
documents
ADD COLUMN yachtId text FK → yachts.id -- nullable
ADD COLUMN companyId text FK → companies.id -- nullable
```
### DB-level invariants
| # | Invariant | Enforced by |
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` |
| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` |
| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction |
| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` |
| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` |
### Service-layer invariants (not DB-enforceable due to polymorphic columns)
| # | Invariant | Enforced by |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| 6 | `yacht.currentOwnerType='client'``currentOwnerId` references an existing row in `clients`; same for `'company'``companies` | Zod validator + service-layer lookup before insert/update |
| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 |
| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 |
| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update |
### Drizzle relations (`relations.ts`)
All new tables wire into the relations map. Notable additions:
- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many)
- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many)
- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many)
- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile`
## Service layer and API
### New services (`src/lib/services/`)
| File | Key functions |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns |
| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) |
| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` |
| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` |
### Modified services
| File | Change |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths |
| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` |
| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer |
| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) |
| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId |
| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` |
| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes |
| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations |
### New REST endpoints
```
# Yachts
GET /api/v1/yachts
POST /api/v1/yachts
GET /api/v1/yachts/:id
PATCH /api/v1/yachts/:id
DELETE /api/v1/yachts/:id — archive (soft delete)
POST /api/v1/yachts/:id/transfer — ownership transfer
GET /api/v1/yachts/:id/ownership-history
GET /api/v1/yachts/autocomplete?q=…
# Companies
GET /api/v1/companies
POST /api/v1/companies
GET /api/v1/companies/:id
PATCH /api/v1/companies/:id
DELETE /api/v1/companies/:id — archive
GET /api/v1/companies/autocomplete?q=…
# Company memberships
GET /api/v1/companies/:id/members
POST /api/v1/companies/:id/members
PATCH /api/v1/companies/:id/members/:mid
DELETE /api/v1/companies/:id/members/:mid — sets endDate
# Berth reservations
GET /api/v1/berths/:id/reservations
POST /api/v1/berths/:id/reservations — create pending
PATCH /api/v1/berth-reservations/:id — state transitions
```
### Modified endpoints
- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations`
- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields
- `POST /api/v1/interests` — requires `yachtId`
- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId`
- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary).
### Permissions (new keys)
```
yachts:view
yachts:write
yachts:transfer — higher-stakes operation, separate from :write
yachts:delete — archive permission
companies:view
companies:write
companies:delete
memberships:write — covers both directions of company_memberships
reservations:view
reservations:write
```
Existing role updates:
- `admin` — all new keys
- `team_lead``yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write`
- `front_desk` — all `:view` keys
### Socket / webhook events (new)
```
yacht.created
yacht.updated
yacht.ownership_transferred
yacht.archived
company.created
company.updated
company.archived
company_membership.added
company_membership.ended
berth_reservation.created
berth_reservation.activated
berth_reservation.ended
berth_reservation.cancelled
```
Webhook event map in `src/lib/services/webhooks.ts` gains the same list.
## EOI template strategy (dual-path)
Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback.
### Shared payload builder
```ts
// src/lib/services/eoi-context.ts
export async function buildEoiContext(interestId: string): Promise<EoiContext>
type EoiContext = {
client: { fullName; nationality; primaryEmail; primaryPhone; address; }
yacht: { name; lengthFt; widthFt; draftFt; hullNumber; flag; yearBuilt; } // via interest.yachtId
company: { name; legalName; taxId; billingAddress } | null // if yacht owner is a company
owner: { type: 'client' | 'company'; name; } // polymorphic current owner
berth: { mooringNumber; area; lengthFt; price; priceCurrency; tenureType; }
interest: { stage; leadCategory; dateFirstContact; notes; }
port: { name; defaultCurrency; legalEntity; }
date: { today; year }
}
```
Both paths consume this. Guarantees the two rendering engines see the same data and stay in sync as schema evolves.
### Path A — Documenso template
- Documenso hosts the template, referenced by ID via env var `DOCUMENSO_TEMPLATE_ID` (matches the old system's `NUXT_DOCUMENSO_TEMPLATE_ID` pattern — a single global template ID; per-port templates are a future extension if needed)
- Payload builder flattens `EoiContext` into Documenso's field-name format, POSTs to `/api/v1/templates/{id}/generate-document`
- Signing flow unchanged: Documenso emails signers, webhook updates status in our DB
- Mitigation for "Documenso's template expects specific field names": one-time audit mapping every field name expected by `templateId=8` (from the old system) to a source in the new schema
### Path B — In-app PDF template
- Seed a "Standard EOI" HTML template into `document_templates` table on first boot. Template references tokens: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, etc.
- `resolveTemplate()` substitutes tokens from `EoiContext`
- `pdfme` renders the resolved HTML to PDF
- **Signing**: generated PDF is uploaded to Documenso via existing `documensoCreate` + `documensoSend` — Documenso supports signing ad-hoc PDFs (not just its own templates). Signing experience identical to Path A from the signer's perspective.
- **Fallback**: if Documenso is unavailable, the PDF can be emailed to the signer via `nodemailer` as a manual fallback (flag in UI, not auto-fallback)
### UI picker
Generate-EOI dialog adds a Template dropdown:
```
Template: [ Documenso — Standard EOI v ]
[ Documenso — Standard EOI ]
[ In-app — Standard EOI ]
[ In-app — (any custom template user authored) ]
```
Explicit picker chosen over automatic fallback: misconfiguration is visible, not silently masked — important for handoff.
## UI impact
### New pages
| Route | Purpose |
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
| `/[portSlug]/yachts` | List view: name, dimensions, current owner, status. Filters by owner type, size, status |
| `/[portSlug]/yachts/[yachtId]` | Detail — Tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags |
| `/[portSlug]/companies` | List view: name, legal name, # members, # owned yachts |
| `/[portSlug]/companies/[companyId]` | Detail — Tabs: Overview, Members, Owned Yachts, Addresses, Documents, Notes, Tags |
### Modified pages
| Page | Change |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `client-form` | Remove yacht / companyName / proxy fields. Becomes a clean "person" form. Yacht and company associations managed from detail page, not here |
| `client-detail` | Add tabs: Yachts (owned + represented), Companies (active memberships), Reservations |
| `client-columns` | Replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships marked `isPrimary`) |
| `interest-form` | New required field: yacht picker, constrained to client's yachts (with inline "Add new yacht" option) |
| `interest-detail` | Display yacht prominently; berth recommendations match against yacht dimensions |
| `berth-detail` | New tab: Reservations. Shows active reservation + history. "Reserve this berth" button opens reservation dialog |
| `invoice-form` | New billing-entity picker (client or company toggle + autocomplete); `clientName` snapshot populates automatically |
| `eoi-generate-dialog` | New template-picker dropdown (per dual-path strategy) |
| Global search | Extended to yachts and companies |
| Sidebar | Adds "Yachts" and "Companies" entries. Reservations lives inside the Berths page |
| `/api/public/interest` form (new interest submission) | Captures yacht + company sub-forms; creates new trio on submission |
### Portal pages
- Dashboard: shows owned + represented yachts, active memberships, active reservations
- New "My Yachts" tab — read-only yacht detail scoped to ones user owns or represents
- New "My Reservations" tab
- Authenticated interest submissions create yacht row linked to the portal user (not anonymous)
### New components (`src/components/`)
```
yachts/
yacht-form.tsx
yacht-detail.tsx
yacht-detail-header.tsx
yacht-tabs.tsx
yacht-columns.tsx
yacht-picker.tsx
yacht-ownership-history.tsx
yacht-transfer-dialog.tsx
companies/
company-form.tsx
company-detail.tsx
company-detail-header.tsx
company-tabs.tsx
company-columns.tsx
company-picker.tsx
company-members-tab.tsx
company-owned-yachts-tab.tsx
add-membership-dialog.tsx
reservations/
reservation-form.tsx
reservation-list.tsx
berth-reserve-dialog.tsx
shared/
owner-picker.tsx — polymorphic client|company autocomplete
billing-entity-picker.tsx
```
All follow existing `shadcn/ui` + CVA + react-hook-form + zod pattern.
### Seeder (`src/lib/db/seed.ts`) — rewrite
Produces realistic multi-cardinality fixtures:
- 3 companies (two with multiple members, one dissolved with an `endDate` on all memberships)
- 8 clients (some personal-only, some with company memberships, at least one representing multiple companies)
- 12 yachts (mix of client-owned and company-owned; 2-3 with ownership-transfer history)
- Interests linking clients ↔ yachts ↔ berths with realistic pipeline-stage distribution
- A handful of active berth reservations + a few ended/cancelled ones
- Rich contact / address / membership / ownership-history data covering every test scenario
Seeder shares factory helpers with tests (`tests/helpers/factories.ts`).
## Testing strategy
### Coverage targets (CI-enforced)
| Tier | Target |
| ------------- | ------------------- |
| Service layer | ≥ 90% line coverage |
| Validators | 100% line coverage |
| API routes | ≥ 85% line coverage |
| Overall | ≥ 85% line coverage |
Hard rules: no skipped tests on `main`; no PR merge without green CI on all tiers.
### Tier 1 — Unit tests (Vitest)
- Every new service function: happy path, each validation failure, each precondition failure, tenant-scoping
- Merge-field resolver: every new token resolves correctly across each context shape
- Validators: every zod schema tested for pass + fail on each field
### Tier 2 — Integration tests (Vitest + Postgres via docker-compose test DB)
- Migration up/down correctness
- Partial unique indexes (`berth_reservations(berthId) WHERE status='active'`, `yacht_ownership_history(yachtId) WHERE endDate IS NULL`) reject duplicate inserts
- FK cascades: deleting a client cascades contacts/addresses; yacht-with-this-owner is BLOCKED from being lost
- Atomic `transferOwnership`: concurrent retries result in consistent state
- Polymorphic integrity checks: `yacht.currentOwnerType='client'` with a companyId is rejected by service-layer validation
- Company name case-insensitive uniqueness
- Every new API route: auth → permission → service → DB → response shape
### Tier 3 — E2E scenario tests (Playwright)
Full-lifecycle flows:
1. Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO
2. Same, in-app template path → verify PDF content contains expected yacht name
3. Create company → add two clients as members → create yacht owned by company → generate invoice billed to company
4. Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI
5. Reserve berth: create → verify visible → attempt duplicate reservation → blocked
6. Public interest form → admin sees new client+yacht+company+interest trio
7. (Spec 3 stub): merge flow tested end-to-end in Spec 3
Multi-cardinality flows (the core justification for this refactor):
8. One client with 3 yachts, 3 interests, 3 different berths — all representable
9. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail
Portal flows:
10. Portal user views "my yachts" — sees only owned/represented
11. Portal user submits interest — new yacht linked to their identity
### Tier 3.5 — Exhaustive Playwright click-through suite
Location: `tests/e2e/exhaustive/`. Separate CI job (15-20 min, runs in parallel with other tiers, blocks merge if failing).
Spec files: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`.
Per-page logic:
1. Navigate to page
2. Enumerate every interactive element (`button`, `a`, `[role="button"]`, `[data-testid]`, form inputs)
3. Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state
4. Coverage assertion: elements clicked ≥ total elements on page (minus declared destructive-action allowlist)
Helper: `tests/helpers/click-everything.ts` exports `clickEverythingOnPage(page, opts)`.
Destructive actions allowlist (tested separately with create-then-destroy isolation):
```
yachts.delete, yachts.archive, yachts.transferOwnership
companies.delete, companies.archive
companyMemberships.end
berthReservations.cancel, berthReservations.end
invoices.delete
```
Acceptance criteria for Spec 1 completion:
- Every new or changed page has 100% coverage in the exhaustive suite (minus allowlist)
- Every allowlist entry has its own narrow destructive test
- Zero console errors across the full suite
- Zero unexpected 4xx/5xx responses
### Tier 4 — EOI template regression
- **Documenso payload snapshot test**: mock Documenso API; assert POST body contains every expected field name with correct value sourced from new schema
- **In-app template rendering test**: render seeded template against each scenario's context; assert resolved HTML contains expected substrings; assert `pdfme` produces a non-empty PDF
- **Visual diff**: render in-app EOI to PDF, compare against committed golden-image PDFs per scenario; regressions surface as image diffs in PR
- **Error paths**: missing yacht, missing company with company-owned yacht reference, missing config (Documenso API key missing) — all produce explicit errors, not silent blanks
### Tier 5 — Security tests
- Cross-tenant isolation: yacht/company/reservation in port A invisible/unmodifiable from port B
- Permission enforcement: user without `yachts:write` cannot `POST /yachts`; `yachts:transfer` required for transfer endpoint
- Portal authorization: portal user cannot see yachts they don't own/represent
- Public interest endpoint: anonymous submitter cannot read existing records
### Test infrastructure
Fixture factories in `tests/helpers/factories.ts`:
```
makeYacht({ owner: client|company, ...overrides })
makeCompany({ overrides })
makeMembership({ client, company, role, ...overrides })
makeOwnershipHistoryRow({ yacht, owner, startDate, endDate })
makeReservation({ berth, client, yacht, status })
```
Scenario builders produce Tier 3 multi-cardinality setups in a single call.
Integration tests run against a fresh migrated DB; each test file wraps in a transaction that rolls back OR uses per-file schema isolation.
## Rollout plan
Green-field Postgres DB — no dual-write, no phased migration needed. Concern is only sequencing so the working tree never enters a broken half-migrated state.
### PR sequence (≈ 15 PRs, feature branch `refactor/data-model`)
| # | PR | Depends on |
| --- | --------------------------------------------------------------------------------------------------- | ------------ |
| 1 | Schema migration: add all new tables, leave old client columns in place | — |
| 2 | Service layer: new services (yachts, companies, memberships, reservations) | 1 |
| 3 | API routes for new services + new permissions | 2 |
| 4 | Seeder rewrite with multi-cardinality fixtures | 2 |
| 5 | UI: yacht list + detail + form + picker + ownership-history + transfer-dialog | 3 |
| 6 | UI: company list + detail + form + picker + memberships tab + add-membership dialog | 3 |
| 7 | UI: berth reservations tab + reserve dialog + ownership-transfer wiring | 3 |
| 8 | Client form refactor: strip yacht/company/proxy fields, add nav links to yachts/companies | 5, 6 |
| 9 | Interest form: require `yachtId` + public interest form creates trio | 5 |
| 10 | Invoice billing-entity support (client or company) | 6 |
| 11 | EOI shared payload builder + seed in-app Standard EOI template + dual-path dialog | 5, 6 |
| 12 | Merge-field catalog update + resolver extension for `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` | 11 |
| 13 | Drop old columns from `clients` (`yacht*`, `companyName`, proxy fields) | 8, 9, 10, 11 |
| 14 | Exhaustive Playwright click-through suite (Tier 3.5) | 13 |
| 15 | Documentation updates (CLAUDE.md, numbered spec files 01-15, API catalog) | 13 |
After PR 15, merge the feature branch into `main` as one final PR.
## Risks and mitigations
| Risk | Severity | Mitigation |
| -------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Spec 2 (importer) depends on final schema; mid-development schema churn → rework | High | Schema freeze after PR 1 lands; amendments require deliberate spec update |
| Polymorphic owner columns have no DB-level FK — service-layer bug could insert inconsistent owner | Medium | Service-layer validation + integration test for every create/update path; runtime assertion in `buildEoiContext` |
| EOI dual-template drift (two engines produce subtly different output) | Medium | Golden-image visual-diff tests in Tier 4, CI-gated |
| Documenso template at `templateId=8` expects specific field names — new payload builder must match | Medium | One-time audit: document every field the existing template expects; map each to a source in new schema; Spec 2's importer uses same mapping |
| Old `client-portal/` sub-repo coordination during Spec 2 cutover | Low | Confirm old client-portal is decommissioned at Spec 2 cutover (not running concurrently against shared data) |
| Seeder becomes dev-onboarding bottleneck | Low | Seeder uses same factory helpers as tests — code path shared + tested |
| Documentation rot in numbered spec files | Low | PR 15 updates them before the feature branch merges to `main` |
| Exhaustive-click-suite runtime (15-20 min per PR) | Low | Separate CI job, runs in parallel with other tiers |
| Handoff quality — "EOIs don't work" / "I can't see my yachts" | Addressed | Dual template paths + exhaustive click coverage + golden-image diff + template regression tests collectively mitigate |
## Open questions / deferred items
Explicitly out of scope for this spec:
- Yacht survey / class-cert document categorization (requires taxonomy work)
- Multi-level company hierarchy (holding → subsidiary) — additive later
- Invoice line items referencing specific yacht
- Berth reservation auto-renewal flow
- Per-yacht row-level permissions (e.g., "broker can only see yachts they represent")
- Portal branding per company
## Success criteria
Spec 1 is complete when:
1. All PRs in the sequence are merged to `main`
2. CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes
3. Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build
4. Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed)
5. Documentation (CLAUDE.md + numbered spec files) updated
6. Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema

View File

@@ -1,171 +0,0 @@
# Country / Phone / Timezone — i18n form polish
**Status:** Agenda — awaiting prioritization (likely Phase B or B.5)
**Date:** 2026-04-28
**Phase:** Cross-cutting; touches every form that captures contact data
## Why
Today every CRM form takes free-text strings for nationality, phone, and timezone. That's fine for a marina with one operator typing it in once, but it leaks operator inconsistencies into reports and breaks any later system that consumes these fields (Documenso prefill, public website inquiry, portal sync, exports). For a multi-port platform that's about to onboard non-Polish-speaking residential clients, the data quality matters.
Three coupled UX upgrades:
1. **Nationality → ISO-3166 country dropdown.** Searchable. Stores ISO alpha-2 code (`'GB'`), displays localized country name.
2. **Phone → country-code dropdown + format-as-you-type.** E.164 storage on the wire, formatted display per country.
3. **Timezone → autofilled from country with override dropdown.** Most countries are single-zone; the few that aren't (US, RU, AU, BR, CA, ID, KZ, MN, MX, CD) get a sub-select. Stores IANA TZ string (`'Europe/Warsaw'`).
## Scope
### In scope
- New shared primitives: `<CountryCombobox>`, `<PhoneInput>`, `<TimezoneCombobox>`
- ISO-3166 country list bundled (no API call); names from `Intl.DisplayNames` with locale fallback to English
- Country → primary IANA timezone map (~250 entries, JSON)
- Phone parsing/validation/formatting via `libphonenumber-js` (server + client)
- Wire into every form that captures contact data:
- `<ClientForm>` (name, nationality, phone)
- `<ResidentialClientDetail>` inline editor (nationality, phone, place_of_residence — country-aware)
- `<CompanyForm>` (incorporation_country)
- `<PortalActivateForm>` (phone)
- public inquiry form (form-template renderer, when phone field present)
- DB migration: store ISO codes (`countries`, `nationality_iso`), E.164 phone (`phone_e164`), IANA timezone (`timezone`)
- Backfill: best-effort parse existing free-text into the new columns; keep originals as `_legacy` for one release cycle
- Display: localized country name in tables/detail pages; phone formatted per country (e.g. `+44 20 7946 0958`); timezone shown as friendly `'London (UTC+1)'` when current
- Tests: unit (parser edge cases), integration (form submit → E.164 storage), smoke (typing + selecting flows)
### Out of scope (deferred)
- Multilingual UI surface (only the country _names_ localize via `Intl.DisplayNames`; rest of the UI stays English for now)
- Subdivision picker (states/provinces) — only top-level country
- Phone number geocoding / carrier lookup
- Address autocomplete (Google Places, etc.)
- Currency localization
- RTL layout
## Library choices
| Concern | Library | Why |
| --------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Phone input + flag dropdown | `omeralpi/shadcn-phone-input` | Built on shadcn-ui's `Input` primitive (zero styling friction with our component library), wraps `libphonenumber-js`, ships with country dropdown + format-as-you-type. Small bundle. |
| Phone parsing/validation | `libphonenumber-js` | Google's library, ~88 benchmark, used by every popular React phone input. Server-side validation in zod. |
| Country list | Bundled JSON of ISO-3166 alpha-2 codes + 3-letter codes + display names (English baseline) | No need for the heavier `country-state-city` databases — we don't need cities or states yet. |
| Country → timezone | Hand-curated `country-timezones.json` (250 entries, ~10kb) sourced from `country-tz` or moment-timezone's data | Static, no network call. For multi-zone countries, expose a sub-select. |
| Timezone formatting | `Intl.DateTimeFormat` (built-in) | Browser API; renders `'Europe/Warsaw (UTC+1)'`-style labels. |
| Timezone list | `Intl.supportedValuesOf('timeZone')` (built-in, ~600 entries) | Used as the override dropdown when a user wants a non-primary zone. |
Bundle impact: `libphonenumber-js` mobile build is ~80 KB gz; `shadcn-phone-input` is ~5 KB; country/timezone JSONs ~30 KB. All client-side, lazy-loaded on first form render via `next/dynamic`.
## Schema deltas
```sql
-- clients
ALTER TABLE clients ADD COLUMN nationality_iso text; -- 'GB'
ALTER TABLE clients ADD COLUMN timezone text; -- 'Europe/London'
-- existing 'nationality' free-text column stays for a release; new code reads ISO
-- client_contacts (or wherever phone lives)
ALTER TABLE client_contacts ADD COLUMN value_e164 text; -- '+442079460958'
ALTER TABLE client_contacts ADD COLUMN value_country text; -- 'GB' (where the number was parsed against)
-- existing 'value' stays as the human-displayable formatted form
-- residential_clients — same pattern
ALTER TABLE residential_clients ADD COLUMN nationality_iso text;
ALTER TABLE residential_clients ADD COLUMN timezone text;
ALTER TABLE residential_clients ADD COLUMN phone_e164 text;
ALTER TABLE residential_clients ADD COLUMN phone_country text;
-- companies
ALTER TABLE companies ADD COLUMN incorporation_country_iso text;
```
Indexes: `idx_clients_nationality_iso`, `idx_clients_timezone` (cheap; powers analytics filters later).
## Component primitives
```tsx
<CountryCombobox
value={iso} // 'GB' | undefined
onChange={(iso) => }
locale="en" // for name lookup; default to navigator.language
variant="default" | "compact" // compact = icon-only flag, default = name
/>
<PhoneInput
value={e164} // '+442079460958'
onChange={({ e164, country }) => }
defaultCountry={'GB'} // pre-selects the dropdown
required={false}
/>
<TimezoneCombobox
value={iana} // 'Europe/London'
onChange={(iana) => }
countryHint={'GB'} // when set, narrows the dropdown to matching zones first
/>
```
All three are shadcn-styled, keyboard-accessible, support form integration with react-hook-form + zod.
## Validators
```ts
// src/lib/validators/contact.ts
import { isValidPhoneNumber } from 'libphonenumber-js';
export const phoneE164Schema = z
.string()
.refine((v) => isValidPhoneNumber(v), 'Invalid phone number');
export const isoCountrySchema = z
.string()
.length(2)
.toUpperCase()
.refine((c) => ISO_COUNTRIES.has(c), 'Unknown country');
export const ianaTimezoneSchema = z
.string()
.refine((tz) => Intl.supportedValuesOf('timeZone').includes(tz), 'Unknown timezone');
```
## Backfill plan
A migration script (`scripts/backfill-iso-and-e164.ts`) that:
1. For each client/residential_client, attempt `libphonenumber-js` `parsePhoneNumber(rawPhone, { defaultCountry: 'PL' })` → if valid, write `phone_e164` + `phone_country`.
2. For each free-text `nationality`, fuzzy-match against the country name list (exact match first, then Levenshtein ≤2). Write `nationality_iso` if confident.
3. For each timezone, exact-match against IANA list. Otherwise leave null and let user fill it.
4. Log unparseable rows to `backfill-iso-report.csv` for manual review.
Run on staging first; require dry-run flag.
## Build sequence
| # | PR | Effort | Depends on |
| --- | ------------------------------------------------------------ | ------ | ---------- |
| 1 | Country list JSON + ISO sets + `<CountryCombobox>` primitive | 0.5d | — |
| 2 | `libphonenumber-js` integration + `<PhoneInput>` primitive | 1d | — |
| 3 | Country → timezone JSON + `<TimezoneCombobox>` primitive | 0.5d | 1 |
| 4 | Schema deltas + drizzle migrations + zod validators | 0.5d | — |
| 5 | Wire into ClientForm + ClientDetail inline editors | 1d | 1, 2, 3, 4 |
| 6 | Wire into ResidentialClientDetail | 0.5d | 5 |
| 7 | Wire into CompanyForm | 0.5d | 1 |
| 8 | Public inquiry form template renderer support | 0.5d | 2 |
| 9 | Backfill script + dry-run runbook | 1d | 4 |
| 10 | Smoke + integration tests | 1d | 59 |
Total: ~7 dev days. Self-contained; no external dependencies on Phase B (analytics/alerts).
## Risk register
| Risk | Mitigation |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Bundle bloat from libphonenumber data | Use the `mobile` metadata build, lazy-import via `next/dynamic` |
| Existing free-text data is too messy to backfill | Keep the legacy column for one release; expose a "needs review" badge in admin |
| Multi-zone country UX confusion | Sub-select only appears when country is multi-zone; otherwise zone is hidden behind "Override" |
| Public inquiry form breaks if phone is required and user can't find their country | Default to PL, search by country name and dial code |
## Open questions for the user
- Which port's locale should drive the _default_ country in `<PhoneInput>` (Poland for now, or detect from browser)?
- Should existing free-text `nationality` field be removed once backfilled, or kept indefinitely as a fallback?
- Is there an appetite for adding the same treatment to subdivision (state/region/voivodship) selectors, or strictly country-level for now?

View File

@@ -1,775 +0,0 @@
# Documents Hub, Reservation Agreements, and Visual Polish (Phase A)
**Status:** Draft — awaiting final review
**Date:** 2026-04-28
**Phase:** A of D (B = Insights & Alerts; C = Website integration; D = Pre-prod ops)
## Overview
Phase A delivers a unified Documents Hub that tracks every signature-based document (EOI, Reservation Agreements, NDAs, ad-hoc uploads), generalises the existing single-purpose EOI dialog into a multi-format create-document wizard, builds the missing CRM-side reservation detail page with an end-to-end agreement workflow, polishes the reminder framework so non-EOI docs auto-remind correctly, and applies a system-wide visual upgrade to the polished-SaaS aesthetic the project already has tokens for.
The project already ships a usable CRM with auth, multi-tenancy, full client/yacht/company/interest/berth/reservation data model, an EOI dual-path (Documenso template + in-app PDF), socket-driven real-time updates, and 130 smoke specs. What's missing for the next release: a single place to see what documents need signing and chase the people who haven't signed.
## Scope boundaries
### In scope (this spec)
- New `/[port]/documents` hub page replacing the existing list
- New `/[port]/documents/[id]` document detail page
- Generalised create-document wizard supporting four template formats (HTML, PDF AcroForm fillable, PDF overlay-positioned, Documenso-rendered) plus ad-hoc PDF upload
- New `/[port]/berth-reservations/[id]` reservation detail page with agreement-generation flow
- Reservation Agreement as a first-class document type with default template seeded
- Email composer extended with attachments and a System-vs-User From selector (admin-gated)
- Reminder framework: per-template cadence, per-doc override, per-doc disable, per-signer manual reminders
- Documenso version-aware abstraction layer covering field placement and document voiding across v1.13.1 and v2.x
- System-wide visual polish: shadow scale, gradient layer, animation tokens, primitive components (`<StatusPill>`, `<KPITile>`, `<EmptyState>`, polished `<PageHeader>`), applied across all list and detail pages
- Mobile-responsive sweep across every page touched
- Comprehensive test coverage: unit, integration, smoke, exhaustive click-through, real-API round-trips, visual baseline regeneration
### Explicitly out of scope (deferred to later phases)
- Analytics dashboard, alert framework, interests-by-berth view, expense duplicate detection (Phase B)
- Website-side integration: `/api/form/[token]/data` prefill endpoint, `/api/webhook/document-signed` callback receiver, public-endpoint shape compat (Phase C)
- NocoDB to Postgres data migration, email deliverability (DKIM/SPF/DMARC), Sentry error reporting, audit log retention, performance baseline at 5k clients / 50k interests, backup/restore automation, production deploy readiness (Phase D)
- Native in-CRM PDF field-placement editor (deferred until upload-path pain emerges; Phase A v1 ships with auto-placed footer signature fields and a "Customize fields in Documenso" link)
- Word `.docx` template upload (deferred; PDF prioritized because Word adds LibreOffice/CloudConvert toolchain dependency without saving the field-placement step)
- Per-interest "silence all reminders" toggle (was implicit in old `interests.reminderEnabled` gating which this spec drops; can be re-added as a bulk action if anyone misses it)
## Information architecture
### URL surface
```
/[port]/documents hub (replaces existing list)
/[port]/documents/[id] document detail (new)
/[port]/documents/new create-document wizard (new)
/[port]/berth-reservations/[id] reservation detail (new)
/[port]/admin/templates existing; extended for new template formats
/[port]/admin/email existing; one new toggle
```
### Schema deltas
```
documents — additions:
+ reservation_id text null references berth_reservations(id)
+ reminders_disabled boolean default false
+ reminder_cadence_override int null
document_templates — additions:
+ reminder_cadence_days int null (null = no auto-reminders)
+ template_format text default 'html' ('html'|'pdf_form'|'pdf_overlay'|'documenso_render')
+ source_file_id text null references files(id)
+ documenso_template_id text null
+ field_mapping jsonb default '{}' (pdf_form: { acroFieldName: mergeToken })
+ overlay_positions jsonb default '[]' (pdf_overlay: [{token, page, x, y, fontSize}])
document_templates.body_html — relax to nullable (only required when template_format='html')
document_watchers — new table:
document_id text not null references documents(id) on delete cascade
user_id text not null references users(id)
added_by text not null references users(id)
added_at timestamptz default now()
primary key (document_id, user_id)
documents indexes — additions:
+ idx_docs_reservation on (reservation_id)
+ idx_docs_status_port on (port_id, status) — powers tab counts cheaply
document_watchers indexes:
+ idx_doc_watchers_doc on (document_id)
+ idx_doc_watchers_user on (user_id)
documents.documentType enum — already includes 'reservation_agreement'; no migration needed
documents.status enum — already accepts 'expired'; no migration needed
documentSigners.status enum — pending|signed|declined; no migration needed
```
Backfill (one statement, safe to run in same migration):
```sql
UPDATE document_templates SET reminder_cadence_days = 1 WHERE template_type = 'eoi';
```
This preserves the existing 1-day-effective reminder cadence for existing EOI templates. Admins can edit per-template later.
After running migration on a dev/staging server, restart `next dev` to flush postgres.js prepared-statement cache (existing project convention).
### Polymorphic ownership pattern
Documents already use the multi-FK pattern (`interest_id`, `client_id`, `yacht_id`, `company_id` as separate nullable columns). Adding `reservation_id` matches this. No conversion to polymorphic discriminator columns despite yachts and invoices using that pattern; staying consistent with the existing documents shape avoids a destructive migration.
### Service-layer changes
- `documents.service.ts`:
- `createFromWizard(portId, data, meta)` — dispatches across template/upload paths
- `createFromUpload(portId, data, meta)` — new upload-driven path; calls Documenso `createDocument`, stores file in MinIO via `files` service, mirrors to `documents` + `documentSigners`, optionally calls `sendDocument` if `sendImmediately`
- `cancelDocument(documentId, portId, meta)` — user-initiated cancel; calls Documenso void, updates DB status, logs event
- `composeSignedDocEmail(documentId, portId)` — returns prefilled `{ to, cc, subject, body, attachments, defaultSenderType }` for the composer
- `getDocumentDetail(id, portId)` — single-roundtrip aggregator returning doc + signers + events + watchers + linked-entity summary
- `document-templates.ts`:
- `generateAndSign` extended for new `template_format` values
- `fillAcroForm(sourceFile, fieldMapping, mergeContext)` — pdf-lib AcroForm fill
- `drawOverlay(sourceFile, overlayPositions, mergeContext)` — pdf-lib text-draw at positions
- Documenso-render path uses existing `generateDocumentFromTemplate`
- `documenso-client.ts`:
- `placeFields(docId, fields, portId?)` — version-aware bulk field placement
- `placeDefaultSignatureFields(docId, recipientIds, portId?)` — auto-position one SIGNATURE per recipient at footer
- `voidDocument(docId, portId?)` — version-aware doc void/delete
- Coordinate normalization helpers (caller passes percent 0-100; converted to pixels for v1 using cached page dimensions)
- `document-reminders.ts`:
- `sendReminderIfAllowed(documentId, portId, options?)` — extended signature with optional `signerId` and `auto: boolean`
- `processReminderQueue(portId)` — query rewritten around `documents.reminder_cadence_override ?? template.reminder_cadence_days`; drops `interests.reminderEnabled` gating
- `notifications.service.ts`:
- `notifyDocumentEvent(docId, eventType)` — fans out to creator + entity-assignee + watchers; existing socket events keep firing
- New: `reservation-agreement-context.ts`:
- `buildReservationAgreementContext(reservationId, portId)` — joins reservation -> client + yacht + berth -> port; returns context shape for template merge
- `email-compose.service.ts`:
- Validator extended: `{ senderType: 'system'|'user', accountId? (when user), attachments[] }`
- System path: calls `lib/email/index.ts → sendEmail()` with `portId` + attachments; logs `documentEvents` row `signed_doc_emailed`; skips `email_messages`/`email_threads` writes
- User path: existing flow, with attachments resolution from `files` table
- Port-isolation: cross-port `fileId` returns 403
- `lib/email/index.ts`:
- `SendEmailOptions.attachments?: Array<{ fileId, filename? }>` — fetches files from MinIO, passes to nodemailer
## Documents hub page
Replaces existing `/[port]/documents` list.
### Layout
```
[ Header strip: title, KPI sub-line, "+ New document" button ]
[ Tabs: All | Awaiting them (count) | Awaiting me (count) | Completed | Expired ]
[ Search · Type · Status · Sent · Watcher filter chips · saved-view selector · overflow ]
[ Table:
checkbox | Document | Type pill | Subject pill | Status (X/Y signed + dot) | Sent
▾ expand row inline to show signers + watchers strip
]
[ Sticky bulk-action bar appears when ≥1 row checked:
"N selected" | Remind unsigned | Cancel | Export | pagination
]
```
### Tab queries
- All — every document in port
- Awaiting them — `status IN ('sent','partially_signed')` AND has pending signer != current user
- Awaiting me — at least one `documentSigners` row matching `signer_email = current user email` AND `status = 'pending'`
- Completed — `status IN ('completed','signed')`
- Expired — `status = 'expired'` OR (`status IN ('sent','partially_signed')` AND `expires_at < now()`)
Counts run cheap thanks to `idx_docs_status_port`.
### Filters and saved views
- Search: fuzzy match on title, subject name, signer email
- Type: multi-select doc types
- Status: multi-select status enum
- Sent: date-range chips (Today, 7d, 30d, custom)
- Watcher: filter by watching user
- "Signature-based only" chip defaults to ON; toggle off to see non-signed docs (welcome letters etc.) as well, rendered with a "Delivered" pill
- Saved-view integration: filter combos save to existing `saved_views` table
### Row anatomy
- Collapsed: name (links to detail), type pill (colored per type), subject pill (links to entity), status indicator (X/Y signed with progress dot), sent age
- Expanded: per-signer rows with email, status pill, sent timestamp, signed timestamp, `[Remind]` and overflow `[...]` (resend invite, copy signing link, skip — skip is UI-only flag, not implemented in v1)
- Watchers strip at bottom of expansion: chips + `+ Add watcher` autocomplete
- Hover: row gets soft brand-soft gradient bg
### Real-time
Subscribes to existing `documents.service.ts`-emitted socket events: `document:created`, `document:updated`, `document:deleted`, `document:sent`, `document:completed`, `document:expired`, `document:cancelled`, `document:rejected`, `document:signer:signed`, `document:signer:opened`. All already fire today.
### Empty states
- No docs yet: illustration + 1-line explanation + `[+ New document]` CTA
- Filtered empty: "No docs match these filters. Clear filters?"
### Mobile (< 768px)
- Tabs collapse into `<select>`
- Filters collapse behind `[Filters]` button into a sheet
- Rows stack as cards: title + status + age, expand to show signers
- "+ New document" floats as FAB bottom-right
## Document detail page
New `/[port]/documents/[id]` page. No detail page exists today.
### Layout
```
[ Breadcrumb: All documents ]
[ Header strip with gradient: title (editable inline), type pill, status pill, subtitle (subject link, creator, age) ]
[ Action bar — context-aware ]
[ Two-column body:
Left (2fr):
Signers panel (vertical list, replaces existing horizontal SigningProgress)
Linked entity card
Right (1fr):
Watchers panel (chips + add)
Activity timeline (from documentEvents)
Notes (auto-saving editable text)
Preview (PDF; tabbed Original/Signed when completed)
]
```
### Action bar by status
- `draft``[Send for signing]` `[Edit signers]` `[Delete]`
- `sent | partially_signed``[Send reminder to all]` `[Resend invite]` `[Cancel]`
- `completed``[Download signed PDF]` `[Email signed PDF to all signatories]`
- `cancelled | rejected | expired``[Duplicate]`
- Always `[...]` overflow: Duplicate, Move to other entity, View Documenso URL, Audit log
### Signers panel (vertical, replaces horizontal stepper)
Per-row:
- Numbered status circle (pending grey, signed green, declined red)
- Name, email, role
- Sent age, last-reminded age, signed timestamp
- `[Remind]` button — disabled with countdown if cooldown active (24h-or-cadence) for auto mode; bypassed in manual mode
- `[Copy signing link]` — copies `signingUrl` (hosted Documenso); overflow offers "Copy embed link" if `embeddedUrl` present (used by website embed at `/sign/[type]/[token]`)
- `[...]` overflow: Resend invite, View signing history, Replace email (draft only)
- Sequential mode: only current pending signer's `[Remind]` active; others greyed with tooltip
### Send-signed-PDF email flow
Action visible only when `status='completed' AND signedFileId IS NOT NULL`.
Click opens email composer drawer prefilled:
- From: dropdown defaulting to System (port-config noreply identity); Personal accounts available only when port admin enables `email.allowPersonalAccountSends`
- To: union of `documentSigners.signerEmail` for the doc
- Cc: empty; "Cc watchers" toggle adds users from `document_watchers`
- Subject: `"Signed {document type} — {document title}"`
- Body: from `signed_doc_completion` per-port template (new template type; default seeded for new ports)
- Attachments: signed PDF auto-attached from `documents.signedFileId` (chip with filename + size; removable)
Send dispatch:
- System path: `lib/email/index.ts → sendEmail()` with portId + attachments; writes `documentEvents` row; skips email_messages/threads writes (no IMAP sync expected)
- User path: `email-compose.service.ts` existing flow; writes email_messages + thread; subject to `allowPersonalAccountSends` gate (server-side enforces 403 on user senderType when toggle off)
### Backend additions
- `POST /api/v1/documents/[id]/cancel` — calls `cancelDocument` service; service calls Documenso void via new client function
- `POST /api/v1/documents/[id]/remind` — accepts optional `{ signerId }`; passes `auto: false` to service
- `GET /api/v1/documents/[id]/watchers` — list
- `POST /api/v1/documents/[id]/watchers` — add `{ userId }`
- `DELETE /api/v1/documents/[id]/watchers/[userId]` — remove
- `POST /api/v1/documents/[id]/compose-completion-email` — returns prefilled draft
## Create-document wizard
Replaces `<EoiGenerateDialog>`. Single drawer/dialog, three steps.
### Step 1 — Type and source
```
Render: ● Generate the PDF here (using template format below)
○ Use a Documenso-stored template (Documenso renders + signs)
Format (when "Generate the PDF here" selected):
● HTML (write inline)
○ PDF (AcroForm fillable upload)
○ PDF (overlay positioning)
Template: [ pick from port's templates of selected format ]
OR
Upload PDF: [ drop or pick file; preview renders inline ]
Document type: [ auto-derived from template, or picked from DOCUMENT_TYPES enum ]
```
Signing destination is always Documenso. The "Render in CRM" vs "Render in Documenso" axis is about PDF generation only.
### Step 2 — Recipients
```
Attached to: [ Interest #142 — Smith family Change ]
↑ pre-filled if launched from a detail page
Signers: (hidden for documenso-render path; signers embedded in template)
① name email role [✕]
② name email role [✕]
[+ Add signer] (autocomplete from clients/companies/users; or manual entry)
Drag to reorder; signing-order assigned by row position
Signing mode: ● Sequential ○ Parallel
Watchers (optional): [chips] [+ Add watcher] (CRM users)
Reminder cadence:
● Use template default (every 7 days)
○ Override: [_____] days
○ Disable for this document
[ For upload path only ]
☑ Auto-place signature fields at footer (default; refine later in Documenso)
```
### Step 3 — Review and send
```
Title: [ EOI — Smith family ____________ ] (editable; default rendered from merge tokens)
Notes (internal): [_____________]
Preview: [ rendered PDF inline · 4 pages · scrollable ]
Signing-order banner (multi-signer in-app/upload only): "Sequential — Carol must sign before Bob" [Switch to parallel]
[← Back] [Save as draft] [Send →]
```
Save as draft → status='draft'; `[Send for signing]` available later from detail page. Send → calls Documenso, status='sent', socket event fires.
### Documenso version-aware field placement
For upload path, `placeDefaultSignatureFields` auto-positions one SIGNATURE per recipient at last-page footer (staggered to avoid overlap). User can refine in Documenso via "Customize fields in Documenso" link on detail page.
`placeFields` and `placeDefaultSignatureFields` in `documenso-client.ts` hide v1/v2 differences:
- v1: `POST /api/v1/documents/{id}/fields` per field; pixel coordinates; requires page dimension lookup
- v2: `POST /api/v2/envelope/field/create-many` bulk; percentage 0-100 coordinates; rich `fieldMeta`
- Caller passes percentage; abstraction converts for v1 using cached page dimensions
### `createDocumentSchema` extension
```ts
export const createDocumentSchema = z.object({
source: z.enum(['template', 'upload']),
templateId: z.string().uuid().optional(),
uploadedFileId: z.string().uuid().optional(),
documentType: z.enum(DOCUMENT_TYPES),
title: z.string().min(1).max(200),
notes: z.string().optional(),
// Subject (exactly one required)
interestId: z.string().uuid().optional(),
reservationId: z.string().uuid().optional(),
clientId: z.string().uuid().optional(),
companyId: z.string().uuid().optional(),
yachtId: z.string().uuid().optional(),
// Signers (required when render=in-app or source=upload)
signers: z.array(z.object({
signerName: z.string().min(1),
signerEmail: z.string().email(),
signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
signingOrder: z.number().int().min(1),
})).optional(),
signingMode: z.enum(['sequential', 'parallel']).default('sequential'),
pathway: z.enum(['documenso-template', 'inapp', 'upload']).optional(),
watchers: z.array(z.string().uuid()).optional(),
reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
remindersDisabled: z.boolean().default(false),
autoPlaceFields: z.boolean().default(true),
sendImmediately: z.boolean().default(true),
}).refine(...one-subject-FK-required...);
```
## Template formats
### Authoring paths
| Format | Authoring | Merge fields | Best for |
| ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------ |
| HTML (existing) | Inline rich-text editor with merge tokens | Server-side substitution, rendered to PDF via pdfme | Welcome letters, acknowledgments, correspondence |
| PDF (AcroForm fillable) | Admin uploads fillable PDF; UI scans AcroForm field names; admin maps each to a merge token | pdf-lib fills form at gen time | EOI, Reservation Agreement, NDA |
| PDF (overlay positioning) | Admin uploads any PDF; UI specifies merge token positions per page+x+y+fontSize | pdf-lib draws text over PDF at positions | Quick wins where preparing AcroForm is overkill |
| Documenso template reference | Admin enters Documenso template ID + label | None in CRM; Documenso owns it | Documenso-rendered signing flows |
### Generator dispatch
```ts
switch (template.template_format) {
case 'html': generatePdf(template.body_html, mergeContext);
case 'pdf_form': fillAcroForm(template.source_file_id, template.field_mapping, mergeContext);
case 'pdf_overlay': drawOverlay(template.source_file_id, template.overlay_positions, mergeContext);
case 'documenso_render': documenso.generateDocumentFromTemplate(template.documenso_template_id, ...);
}
```
All four formats end at Documenso for signing — only PDF generation location differs. Non-signature templates (welcome letters etc.) skip the upload-to-Documenso step entirely; they render to PDF then get emailed.
### Admin template editor extension
Format picker added to `/admin/templates` editor:
- For PDF (AcroForm): file upload field, then two-column mapping UI (AcroForm field names ↔ merge tokens autocomplete from existing `MERGE_FIELDS` catalog)
- For PDF (overlay): file upload, then per-token form with page/x/y/fontSize inputs (visual placement editor deferred)
- For Documenso template: single text input + Test connection button calling `getDocumensoTemplate`
- For HTML: existing inline editor unchanged
### Word (.docx) deferred
Reasons: LibreOffice headless adds significant install/memory/security surface; CloudConvert adds paid dependency and third-party data exposure; `docxtemplater` merge syntax incompatible with existing `{{token}}` convention; field placement still needs PDF flow afterwards. If marinas push back, the feasible path is `.docx → server-side conversion → PDF → existing AcroForm/overlay flow`. Not worth the engineering until requested.
## Reservation agreements as a doc type
### What differs from EOI's pattern
| Aspect | EOI | Reservation Agreement |
| --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
| Subject FK | `interestId` | `reservationId` |
| Default template | Documenso EOI per port | Documenso reservation_agreement per port (seeded) |
| Default signers | client + sales/approver | client + port admin |
| Trigger | Manual on interest detail | Manual on reservation detail |
| Lifecycle integration | None | Active reservations without an agreement get flagged in dashboard alert |
| Final-PDF storage | `documents.signedFileId` only | `documents.signedFileId` AND mirrored to `berth_reservations.contractFileId` on completion |
### New CRM-side reservation detail page
`/[port]/berth-reservations/[id]` doesn't exist today (only the portal's `/portal/my-reservations`). Phase A builds it.
Layout:
```
[ Header: "Reservation #88 · M/Y Tate" status pill subtitle: berth, client, dates, tenure ]
[ Action bar: Activate | Generate agreement | Cancel | ... ]
[ Two columns:
Left: Reservation details card
Linked interest card
Activity timeline
Right: Agreement card (state-dependent: no agreement / in-flight / completed)
]
```
Agreement card states:
- No agreement yet: warning + `[Generate agreement →]`
- In-flight (sent/partially_signed): "X/Y signed", per-signer status, `[View document →]` `[Send reminder]` `[Cancel]`
- Completed: "Completed YYYY-MM-DD", `[Download signed PDF]` `[Email to all signatories]`, "Signed contract attached to reservation."
Generate-agreement button launches the wizard with prefills:
- `documentType='reservation_agreement'`
- `templateId=<port's default>`
- `reservationId=<current>`
- Default signers from linked client + configurable port-admin user
- Wizard step 1 pre-validated; user lands on step 2
### Backend additions
- Merge field catalog extended in `src/lib/templates/merge-fields.ts`:
- `{{reservation.startDate}}` `{{reservation.endDate}}` `{{reservation.tenureType}}` `{{reservation.termSummary}}` `{{reservation.signedDate}}`
- New service `reservation-agreement-context.ts.buildReservationAgreementContext(reservationId, portId)`
- New seeder for default `reservation_agreement` template on port creation (HTML format; admins can switch to AcroForm/overlay later); template stored at `assets/templates/reservation-agreement-default.html`
- Webhook handler extension: `handleDocumentCompleted` detects `documentType='reservation_agreement'` and sets `berth_reservations.contractFileId = doc.signedFileId` for the linked reservation
- Dashboard alert query: active reservations without a completed agreement (LEFT JOIN against documents filtered on type+status); rows surface as a warning card
### Trade-off
`berth_reservations.contractFileId` becomes a denormalized convenience pointer duplicated with `documents.signedFileId` for the linked reservation. Updating it on completion costs one extra UPDATE. Benefit: anyone querying reservations directly (portal "My Reservations") doesn't need to join through documents to know which file is the contract.
## Reminder framework polish
### Problems with today's logic
1. Eligibility gated by `interests.reminderEnabled` — reservation agreements, NDAs, ad-hoc upload docs (no interest link) never auto-remind
2. Hardcoded 24h cooldown — effective cadence is 1 day; can't slow down for low-urgency docs
3. Always reminds lowest-pending signer — parallel-signing docs can't nudge a specific signer
4. No per-doc disable
### New eligibility logic
```
function isReminderDue(doc, template, lastReminderAt) {
if (!['sent','partially_signed'].includes(doc.status)) return false;
if (doc.documenso_id == null) return false;
if (doc.reminders_disabled) return false;
const effectiveCadence = doc.reminder_cadence_override ?? template.reminder_cadence_days;
if (effectiveCadence === null) return false;
if (lastReminderAt == null) return true;
return (now - lastReminderAt) >= effectiveCadence * 24h;
}
```
`processReminderQueue` query rewritten:
```sql
SELECT d.* FROM documents d
LEFT JOIN document_templates t ON t.id = d.template_id
WHERE d.port_id = $1
AND d.status IN ('sent','partially_signed')
AND d.documenso_id IS NOT NULL
AND d.reminders_disabled = false
AND COALESCE(d.reminder_cadence_override, t.reminder_cadence_days) IS NOT NULL;
```
`interests.reminderEnabled` is dropped from the gating logic but the column stays for now (no migration). Future cleanup PR can drop the column.
### `sendReminderIfAllowed` extended signature
```ts
export async function sendReminderIfAllowed(
documentId: string,
portId: string,
options: {
auto?: boolean; // true = cron; false (default) = manual
signerId?: string; // optional — target a specific pending signer
} = {},
): Promise<{ sent: boolean; reason?: string; signerId?: string }>;
```
Behaviour matrix:
| Mode | 9-16 window | Cadence cooldown | Manual cooldown |
| ----------- | ----------- | ---------------- | ------------------------ |
| auto: true | enforced | enforced | n/a |
| auto: false | bypassed | bypassed | 30s client-side debounce |
Per-signer logic:
- If `signerId` provided in sequential-mode doc, signer must be the lowest-pending signer (otherwise reason='Signer is not next in sequence')
- In parallel-mode doc, any pending signer can be reminded independently
- Returns `{ sent, reason }` so caller can show toast on skip
### Admin and per-doc UI
Admin `/admin/templates` editor:
```
Auto-reminders for this template:
☑ Enabled Cadence: every [_____] days (1-365; default 7)
☐ Disabled (manual reminders only)
```
Doc detail page (Section 3) "Reminders" panel under signers, with edit drawer for per-doc override.
## Visual polish system
### Token additions
```
--radius-sm: 0.375rem (existing)
--radius-md: 0.5rem (NEW — default cards)
--radius-lg: 0.625rem (NEW — sheets, dialogs)
--radius-xl: 0.875rem (NEW — KPI tiles, hero strips)
--shadow-xs: 0 1px 2px 0 rgb(15 23 42 / 0.04)
--shadow-sm: 0 2px 4px -1px rgb(15 23 42 / 0.06)
--shadow-md: 0 4px 12px -2px rgb(15 23 42 / 0.08)
--shadow-lg: 0 12px 32px -8px rgb(15 23 42 / 0.12)
--shadow-glow: 0 0 0 4px rgb(58 123 200 / 0.12)
--gradient-brand: linear-gradient(135deg, #3a7bc8 0%, #2f6ab5 100%)
--gradient-brand-soft: linear-gradient(135deg, #d8e5f4 0%, #ffffff 100%)
--gradient-success: linear-gradient(135deg, #e8f5e9 0%, #ffffff 100%)
--gradient-warning: linear-gradient(135deg, #fef3c7 0%, #ffffff 100%)
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1)
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1)
--duration-fast: 150ms
--duration-base: 200ms
--duration-slow: 300ms
```
All exposed as Tailwind utilities.
### Existing token foundation (already in place; not changing)
- Full HSL shadcn token system (primary, secondary, muted, accent, destructive, border, input, ring, popover, card)
- Brand palette `brand` (50-700, default `#3a7bc8`)
- Navy palette `navy` (50-600, default `#1e2844` for sidebar)
- Maritime accents: `sage`, `mint`, `teal`, `purple` with light/default/dark variants
- Semantic `success` / `warning` with bg+border
- Recharts chart-1 through chart-6 token system
- Dark mode wired
- Sidebar tokens separate from main palette
### New primitive components
- `<StatusPill status="...">` — colored-by-state pill (pending grey, sent brand, partial teal, completed success, expired warning, rejected destructive, cancelled muted-darker, active success, archived muted)
- `<KPITile title value delta sparkline?>` — rounded-xl, shadow-sm, gradient-brand-soft border-top accent stripe; recharts mini sparkline using `--chart-1`
- `<EmptyState icon title body actions>` — large icon in brand-soft circle, title, body, action buttons
- `<PageHeader>` polished — gradient-brand-soft background, eyebrow optional, KPI sub-line, primary action right-aligned
### Component pattern updates
- List rows: hover gradient (subtle brand-soft 4% opacity), shadow-xs lift, animation `transition-all duration-base ease-smooth`; row-update from socket events animates 1s fade-in highlight
- Detail pages: two-column responsive grammar (header strip → 2fr main + 1fr side; cards stack vertical < 768px)
- Sidebar (already dark navy): active item gets 4px brand left-edge stripe instead of bg shift; section headers smaller-caps + brand-200 text
- Topbar: search inset shadow + brand focus ring; "+ New" trigger gets `bg-gradient-brand`; notification bell gets badge spring animation; user avatar gets shadow-sm + 2px white ring
- Forms: focus ring uses `--shadow-glow`; primary submit buttons get `bg-gradient-brand` with hover scale-1.01; inline validation gets destructive-bg pill with caret pointing up
### Loading skeleton system
- List pages: 8 skeleton rows matching column widths with subtle pulse
- Detail pages: header strip skeleton + 2-column section skeletons
- Dashboard: KPI tile skeletons + chart skeletons
- Replaces today's mix of "Loading..." text and spinners
### Mobile responsive (full sweep)
Breakpoints:
- < 640px (phone): single column, sticky bottom action bar, sheet overlays for filters
- 640-1024px (tablet): single column with wider gutters, side column under main
- ≥ 1024px (desktop): full two-column
Per-page rules:
- List tables → card stack < 768px
- Detail page header collapses subtitle to "Show more"
- Tabs collapse to `<select>` < 640px
- Sidebar slides over content < 1024px
- Primary "+ New" actions float as FAB bottom-right < 640px
## Test plan
### Unit (`tests/unit/`)
- `document-reminders-cadence.test.ts``isReminderDue` math; manual-vs-auto window/cooldown bypass
- `documenso-place-fields.test.ts` — v1/v2 dispatch (mocked HTTP); coord normalization; default field staggering for 1/2/3/5 recipients
- `email-attachments-resolver.test.ts` — fileId → MinIO buffer; cross-port 403; 10 MB cap warning
### Integration (`tests/integration/`)
- Extend `document-templates-generate-and-sign.test.ts` — new template formats (`pdf_form`, `pdf_overlay`, `documenso_render`); upload-path test
- New `document-watchers.test.ts` — add/remove endpoints; notification fan-out; port isolation
- New `document-cancel.test.ts` — user-initiated cancel; mocked Documenso void; status + event log; reject 409 if completed
- New `reservation-agreement-contract-mirror.test.ts``handleDocumentCompleted` mirrors `signedFileId` to `berth_reservations.contractFileId` only for `reservation_agreement` type
- New `reminder-cron-cadence.test.ts` — seed varied templates; simulated time advance; assert correct docs reminded
### E2E smoke (`tests/e2e/smoke/`)
- Extend `04-documents.spec.ts` — hub tabs, expand row, per-signer remind with cooldown, type/status filters, saved-view round-trip, bulk-remind with per-row toast reasons
- Extend `05-eoi-generate.spec.ts` — wizard invocation prefills (template, interest); existing flow regression
- New `27-document-create-wizard.spec.ts` — template path full flow; upload path full flow; watcher addition; reminder-override radios produce correct DB state
- New `28-reservation-agreements.spec.ts` — reservation detail → Generate agreement → wizard prefilled → Send → agreement section state transitions; post-completion contract attached + email button visible
- New `29-email-attachments.spec.ts` — system path send (documentEvents row, no email_messages); user path send when toggle on (email_messages with attachment_file_ids); cross-port 403
### E2E exhaustive (`tests/e2e/exhaustive/`) — click-everything sweep
- New `10-documents-hub.spec.ts` — crawl each tab, filter dropdowns, saved-view, expand row, signer-row buttons, bulk-action bar
- New `11-document-detail.spec.ts` — crawl in three states (draft/sent/completed); watcher add/remove; notes auto-save; preview download; "Email signed PDF" launch
- New `12-document-create-wizard.spec.ts` — crawl each wizard step under both template and upload paths; picker dropdowns, signer add/remove, drag-handle, reminder-cadence radios
- New `13-reservation-detail.spec.ts` — crawl in three states (pending no agreement / agreement-in-flight / agreement-completed); Activate/Cancel/Generate buttons; inline notes
- New `14-email-composer.spec.ts` — crawl composer drawer with attachments; From dropdown; attach button; recipient chips
- Extend exhaustive `05-eoi-generate.spec.ts` — parallel-mode + signing-order edge cases (greyed-out reminder buttons; out-of-order remind rejection)
### E2E real-API (`tests/e2e/realapi/`)
Each spec gates on env vars; clean skip if missing.
- Extend `documenso-real-api.spec.ts`:
- Generate from Documenso template (real send) and assert in real Documenso
- Generate from in-app PDF AcroForm fill, upload to real Documenso, assert
- Generate from upload path with auto-placed signature fields, assert fields visible in Documenso
- v1 and v2 explicit version-flag tests (via `DOCUMENSO_API_VERSION`)
- Manually sign in real Documenso (or simulate webhook) and assert local DB updates
- Cancel real in-flight doc, assert local + remote state
- Send reminder via real Documenso, assert HTTP + documentEvents row
- New `smtp-system-send.spec.ts` — system-path send → IMAP fetch → assert subject + attachment; verify port-config from-identity; cleanup via IMAP delete
- New `smtp-user-send.spec.ts` — user-path send (requires connected account, allowPersonalAccountSends=true) → IMAP fetch → email_messages row with attachment_file_ids
- New `minio-file-lifecycle.spec.ts` — upload, list, preview, download (byte-equal), delete; port isolation; mime-type validation
- New `documenso-webhook-ingress.spec.ts` — requires cloudflared tunnel; configure tunnel URL as Documenso webhook target; trigger doc completion; assert webhook fires + handler updates DB; verify timing-safe secret check rejects wrong secret with 401; verify event normalisation (uppercase enum + lowercase-dotted both accepted)
- New `email-attachments-roundtrip.spec.ts` — compose with fileId attachment; SMTP send; IMAP fetch; assert attachment bytes match; reject cross-port fileId with 403 before SMTP touched
### Visual baselines (`tests/e2e/visual/`)
`snapshots.spec.ts-snapshots/` regenerated as polish ships per page; one PR per surface group, baselines reviewed in PR diff. New baselines added: documents hub, doc detail, create-document wizard (each step), reservation detail, email composer with attachments.
### Test data fixtures
`global-setup.ts` extended with:
- Seed default `reservation_agreement` template (HTML format)
- Seed default `signed_doc_completion` template
- Seed one in-flight EOI doc with two pending signers (for hub-tab tests)
- Seed one `berth_reservation` with `status='active'` and no agreement (for lifecycle alert query)
### CI vs local runs
| Project | When |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `setup` + `smoke` (~14 min) | Every PR via CI |
| `exhaustive` (with new click-everything specs) | Every PR via CI; ~25 min budget |
| `visual` | Every PR; baselines reviewed in PR diffs |
| `realapi` | Locally before merging touch-points; pre-release; not on CI (avoids burning Documenso quota and SMTP costs) |
## Build sequence
| # | Title | Effort | Depends on |
| ----- | ------------------------------------------------- | ------ | -------------- |
| 1 | Data model + service skeletons | 1d | — |
| 2 | Documenso v1/v2 abstraction layer | 1d | — |
| 3 | Visual primitives + token additions | 1.5d | — |
| 4 | Documents hub page | 2d | 1, 3 |
| 5 | Document detail page | 2d | 1, 3 |
| 6 | Create-document wizard + new template formats | 2.5d | 1, 2, 3 |
| 7 | Reservation detail + agreement flow | 1.5d | 1, 6 |
| 8 | Email composer attachments + From selector | 1d | 1, 3 |
| 9 | Reminder framework polish | 1d | 1 |
| 10a-e | Visual polish sweep (5 PRs across surface groups) | 3-4d | 3 |
| 11 | Real-API integration tests | 1.5d | 2, 4-9 shipped |
### Critical path
```
1 → 2 → 6 → 7 (data model → Documenso → wizard → reservation)
1 → 3 → 4 → 5 → 9 (data model → primitives → hub → detail → reminders)
1 → 8 (composer)
3 → 10a-e (sweep)
all → 11 (realapi)
```
Wall-clock minimum ~9 days; realistic with overhead ~17 days; calendar ~3.5-5 weeks.
### Acceptance gates per PR
- `pnpm tsc --noEmit` and `pnpm lint` clean
- Vitest unit + integration green
- Playwright smoke green for surface touched
- Visual baselines regenerated and reviewed in PR diff
- For PRs touching external integrations (2, 6 upload, 7 contract mirror, 8 SMTP, 11): relevant `realapi` spec verified locally before merge
### Risk register
| Risk | Mitigation |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Documenso v2 endpoint shape drifts from docs | PR2 validates against real v2 instance during dev; realapi spec re-runs nightly post-ship |
| Visual polish scope creeps | One PR per surface group (10a-e), each independently shippable |
| Cron migration changes effective behaviour | Backfill sets EOI cadence to 1 day matching today's effective; run on staging first |
| Mobile responsive regressions | Visual baselines include phone-viewport snapshots; PR10e is the responsive sweep |
| EOI dialog → wizard migration breaks "Generate EOI" button | Wizard launched with prefills from interest detail; PR6 includes regression spec |
| AcroForm template format confuses non-technical admins | HTML default; inline help; default templates seeded |
| Phase A wall-clock past 5 weeks | Tier-2 sweep items + optional realapi specs deferrable to follow-up release |
## Glossary
- **Documenso** — open-source document signing service, self-hosted instance at `signatures.portnimara.dev`
- **EOI** — Expression of Interest, a pre-reservation signed document
- **Reservation Agreement** — contract signed when a berth reservation is committed
- **Hub** — the new `/[port]/documents` page
- **Watcher** — a CRM user added to a doc to receive notifications on signature events without being a signer themselves
- **Signing order** — sequential index across signers; sequential mode requires lower order to sign first; parallel mode lets all sign concurrently
- **Cadence** — interval in days between auto-reminders to unsigned signers
- **System send / User send** — email dispatch identity: System uses port-config noreply SMTP; User uses connected personal email account (gated by admin toggle)
- **Render location** — where the PDF is generated (CRM-local via HTML/AcroForm/overlay, or in Documenso). Signing is always Documenso; render location is independent.

View File

@@ -1,435 +0,0 @@
# Phase B — Insights, Alerts, and Operational Awareness
**Status:** Draft — awaiting review
**Date:** 2026-04-28
**Phase:** B of D (A = Documents hub + visual polish ✓ shipped; C = Website integration; D = Pre-prod ops)
## Overview
Phase A made the CRM look polished and finished the documents/signing surface. Phase B turns it into a tool that _tells operators what's happening_ — instead of forcing them to navigate every list to find pipeline drift, expiring documents, or stalled reservations. It also closes the seven highest-priority Nuxt→Next gaps the 2026-04-28 audit surfaced (analytics, berth-interests, EOI queue, OCR, alerts, audit log, expense dedup).
The product story changes from "system of record" to "system of attention." Operators land on the dashboard and immediately see what needs them today — not a flat list they have to filter.
## Scope boundaries
### In scope (this spec)
- **Analytics dashboard** — chart-driven KPI page replacing the current 4-tile placeholder; pipeline funnel, occupancy timeline, revenue breakdown, lead-source attribution, with date-range and per-port filters
- **Alert framework** — rule engine that evaluates conditions on a schedule and surfaces actionable cards (alerts) in the dashboard's right rail; dismissible per-user; deep-links into the offending entity
- **Interests-by-berth view** — `/[port]/berths/[id]/interests` panel showing every interest targeting a berth, sortable by stage/score/age
- **Expense duplicate detection** — heuristic match on (vendor + amount + date ± 3 days); surfaces in expense detail with "Merge" action; background scan on new expense
- **EOI queue** — saved-view filter on the existing documents hub for `documentType='eoi' AND status IN ('sent','partially_signed')`, surfaced as a hub tab and a dashboard alert link
- **OCR for expense receipts** — Claude Vision integration on the existing `/expenses/scan` route to extract vendor, amount, date, currency, line items from uploaded receipts; user confirms before save
- **Audit log read view** — admin-gated UI for the existing `audit_logs` table with filters (user, action, entity type, date range, entity id search) and per-port + global (super-admin) scopes
### Explicitly out of scope (deferred to later phases)
- Custom user-defined alert rules (Phase B v1 ships with a fixed catalog of ~10 rules; user-rule creation deferred to Phase D)
- Real-time alert push notifications (only socket-fired updates of the alert list; SMS/email push deferred)
- Alert grouping / digests (each alert is its own card)
- Predictive analytics, ML scoring (separate from existing AI feature flag)
- Cross-port roll-up dashboards for super-admins (per-port only in v1)
- Full audit-log retention / archival policy (Phase D)
- OCR for PDF receipts (only image formats: jpg/png/heic; PDF expense uploads bypass OCR and stay manual until Phase D)
- Excel/CSV import for bulk expense backfill
- Country / phone / timezone work (separate cross-cutting agenda at `2026-04-28-country-phone-timezone-design.md`)
## Information architecture
### URL surface
```
/[port]/dashboard replaces existing; analytics-driven
/[port]/insights deep-link analytics page (charts only, no alerts)
/[port]/alerts full alert list (admin filter, dismissed history)
/[port]/berths/[id]/interests new tab on berth detail
/[port]/expenses/scan extend existing route with Claude Vision OCR
/[port]/admin/audit admin-gated audit log viewer
/[port]/documents extended: 'EOI queue' tab pre-filters to EOI in flight
```
### Schema deltas
```sql
-- alerts: surfaces operational warnings the user should act on
CREATE TABLE alerts (
id text PRIMARY KEY DEFAULT generate_id('alrt'),
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
rule_id text NOT NULL, -- 'reservation.no_agreement', 'interest.stale', ...
severity text NOT NULL, -- 'info' | 'warning' | 'critical'
title text NOT NULL,
body text,
link text NOT NULL, -- relative path the card deep-links to
entity_type text, -- optional FK target ('interest', 'reservation', ...)
entity_id text,
fingerprint text NOT NULL, -- hash of (rule_id + entity_type + entity_id) — dedupe
fired_at timestamptz NOT NULL DEFAULT now(),
dismissed_at timestamptz,
dismissed_by text REFERENCES users(id),
acknowledged_at timestamptz, -- "I'm on it" without dismissing
acknowledged_by text REFERENCES users(id),
resolved_at timestamptz, -- auto-set when underlying condition clears
metadata jsonb DEFAULT '{}' -- per-rule extras (e.g. days_stale, amount_at_risk)
);
CREATE UNIQUE INDEX idx_alerts_fingerprint_open ON alerts (port_id, fingerprint) WHERE resolved_at IS NULL;
CREATE INDEX idx_alerts_port_fired ON alerts (port_id, fired_at DESC);
CREATE INDEX idx_alerts_port_severity_open ON alerts (port_id, severity) WHERE resolved_at IS NULL AND dismissed_at IS NULL;
-- expense duplicate detection (column-only, no new table)
ALTER TABLE expenses ADD COLUMN duplicate_of text REFERENCES expenses(id);
ALTER TABLE expenses ADD COLUMN dedup_scanned_at timestamptz;
CREATE INDEX idx_expenses_dedup ON expenses (port_id, vendor_name, amount, expense_date)
WHERE duplicate_of IS NULL;
-- analytics support: materialized refresh tracking (avoids recomputing on every dashboard hit)
CREATE TABLE analytics_snapshots (
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
metric_id text NOT NULL, -- 'pipeline_funnel.30d', 'occupancy_timeline.90d', ...
computed_at timestamptz NOT NULL DEFAULT now(),
data jsonb NOT NULL,
PRIMARY KEY (port_id, metric_id)
);
-- audit_logs already exists; add a tsvector column for fast search
ALTER TABLE audit_logs ADD COLUMN search_text tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(action, '') || ' ' ||
coalesce(entity_type, '') || ' ' ||
coalesce(entity_id::text, '') || ' ' ||
coalesce(actor_email, ''))
) STORED;
CREATE INDEX idx_audit_search ON audit_logs USING gin(search_text);
-- ocr extracted fields on receipt files (most fields already on expenses)
ALTER TABLE expenses ADD COLUMN ocr_status text DEFAULT 'pending'; -- 'pending'|'ok'|'failed'|'low_confidence'
ALTER TABLE expenses ADD COLUMN ocr_raw jsonb; -- the model's full response
ALTER TABLE expenses ADD COLUMN ocr_confidence numeric; -- 0..1
```
After running migration on dev/staging, restart `next dev` to flush postgres.js prepared-statement cache (project convention).
### Service-layer changes
**New services:**
- `alerts.service.ts` — CRUD + fanout: `evaluateRules(portId)`, `dismissAlert(id, userId)`, `acknowledgeAlert(id, userId)`, `resolveStaleAlerts(portId)`
- `alert-rules.ts` — fixed catalog of evaluator functions, each takes `(portId, db)` and returns `Array<{ rule_id, severity, fingerprint, ... }>`
- `analytics.service.ts``getPipelineFunnel(portId, range)`, `getOccupancyTimeline(portId, range)`, `getRevenueBreakdown(portId, range)`, `getLeadSourceAttribution(portId, range)`; reads `analytics_snapshots` first, recomputes if stale
- `analytics-snapshot-job.ts` — BullMQ recurring job that recomputes snapshots every 15 min per port
- `expense-dedup.service.ts``scanForDuplicates(expenseId)`, returns candidate matches with confidence; called from BullMQ on `expense:created`
- `expense-ocr.service.ts` — Claude Vision wrapper: takes file URL, returns parsed expense fields; uses prompt caching for the system prompt to keep cost down
- `audit-search.service.ts` — wraps drizzle query with tsvector match + filters
**Extended services:**
- `documents.service.ts` — adds `getEoiQueueRows(portId, opts)` that joins documents + signers + last-reminder for the EOI queue tab
- `expenses.service.ts``createExpense` triggers OCR + dedup BullMQ jobs after row insert
- `notifications.service.ts` — fires `alert:created` and `alert:resolved` socket events
### Alert rule catalog (v1)
| Rule ID | Severity | Trigger | Resolves when | Why it matters |
| ---------------------------- | -------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------- |
| `reservation.no_agreement` | warning | active reservation > 3d old without a `reservation_agreement` doc in any non-cancelled state | doc reaches `sent` | flagged in Phase A spec |
| `interest.stale` | info | `pipelineStage IN ('details_sent','in_communication','visited')` AND last activity > 14d | activity timestamp updates | dropped leads |
| `document.expiring_soon` | warning | `expires_at` within 7 days, `status IN ('sent','partially_signed')` | doc completed/cancelled or expires_at passes | nudge before contracts lapse |
| `document.signer_overdue` | warning | signer pending > 14d AND last reminder > 7d ago | signer signs/declines | classic chase target |
| `berth.under_offer_stalled` | info | berth `status='under_offer'` > 30d | status changes | reservation never closed |
| `expense.duplicate` | info | `expense.duplicate_of IS NOT NULL` | merged or marked-not-duplicate | bookkeeping cleanup |
| `expense.unscanned` | info | expense with file but `ocr_status='pending'` > 1h | `ocr_status='ok'` | OCR failed silently |
| `interest.high_value_silent` | critical | `leadCategory='hot_lead'` AND last activity > 7d | activity update | revenue at risk |
| `eoi.unsigned_long` | warning | EOI doc `status='sent'` > 21d | doc completed/cancelled | EOI funnel leak |
| `audit.suspicious_login` | critical | >3 failed logins from same IP in 1h | manual dismiss | security awareness |
Rules are pure functions; the engine takes their outputs, upserts on `(port_id, fingerprint)` to avoid spam, and auto-resolves alerts whose rule no longer fires.
## Per-feature design
### Analytics dashboard
Replaces the current 4-tile dashboard. Layout:
```
[ Gradient PageHeader: "Dashboard" · last-updated stamp · Date range picker (Today / 7d / 30d / 90d / custom) ]
[ KPI row (4 KPITiles, sparkline + delta vs prior period):
Total Clients Active Interests Pipeline Value Occupancy Rate
]
[ Pipeline funnel (recharts FunnelChart): | Alert rail (right column):
horizontal bars per stage with conversion % | Critical (red) cards
click bar → filtered interests list | Warning (amber) cards
| Info (blue) cards
| "Show dismissed" toggle
] |
[ Revenue breakdown (recharts BarChart, stacked by source) ] | (continues)
[ Occupancy timeline (recharts AreaChart, daily/weekly) ] |
[ Lead source attribution (recharts PieChart with legend) ]
```
Charts are server-rendered via the recharts already-in-bundle. Data comes from `analytics.service.ts` which reads `analytics_snapshots` (refreshed every 15 min by cron) — first hit warms the cache, subsequent hits are sub-100ms.
Date-range picker re-runs `analytics.service` queries with the selected range; cache key includes the range so 30d and 90d don't fight.
Export: each chart card has a `[...]` overflow menu with "Download as CSV" and "Download as PNG"; uses recharts' `getDataUrl()` for PNG.
### Alert rail
Right column on `/dashboard`, full page at `/alerts`. Each alert is a card:
```
[severity-color stripe-left]
[rule-icon] Title (entity name)
Body — body text describing the condition
Last fired N days ago · entity: link
[Acknowledge] [Dismiss] [Open →]
```
- Acknowledge: marks `acknowledged_at` but stays visible (someone's on it)
- Dismiss: hides from the rail; appears in `/alerts` "Dismissed" tab
- Auto-resolve: when the rule re-evaluates and the condition no longer fires, alert moves to "Resolved" history
Real-time: socket emits `alert:created` / `alert:resolved` from the cron worker; React Query invalidates the alert list.
### Interests-by-berth view
New tab on `/[port]/berths/[id]` called "Interests" — count badge in tab.
```
[ Berth header (existing) ]
[ Tabs: Overview | Reservations | Interests (N) | Notes | Files | Activity ]
[ Interests tab body:
[Filter: All stages | Active only | Lost] [Sort: Newest | Stage progress | Lead score]
Table: client name | stage pill | source | category | last activity | score badge
Click row → interest detail
]
```
Pure read; no mutations. The list filters interests where `interest.berthId = berth.id`. Already exists in DB; just needs the UI tab.
### Expense duplicate detection
When a new expense is created, BullMQ job `expense.dedup` runs:
```ts
async function scanForDuplicates(expenseId: string) {
const e = await db.query.expenses.findFirst({ where: eq(expenses.id, expenseId) });
const candidates = await db.query.expenses.findMany({
where: and(
eq(expenses.portId, e.portId),
eq(expenses.vendorName, e.vendorName),
eq(expenses.amount, e.amount),
between(expenses.expenseDate, addDays(e.expenseDate, -3), addDays(e.expenseDate, 3)),
ne(expenses.id, e.id),
),
});
if (candidates.length > 0) {
await db
.update(expenses)
.set({ duplicate_of: candidates[0].id, dedup_scanned_at: new Date() })
.where(eq(expenses.id, expenseId));
// fires `expense.duplicate` alert via rule engine on next sweep
}
}
```
Detail page: when `duplicate_of` is set, show a yellow banner: "Looks like a duplicate of {linked expense}. [Merge them] [Mark as not duplicate]". Merge: deletes the new expense and merges any line items into the original.
### EOI queue tab
Documents hub gets a new tab between "Awaiting them" and "Awaiting me":
```
Tabs: All | EOI queue (N) | Awaiting them | Awaiting me | Completed | Expired
```
`EOI queue` filters: `documentType='eoi' AND status IN ('sent','partially_signed')`. Same row chrome as the rest of the hub. Bulk-action bar adds an "EOI bulk reminder" preset that respects the rule engine's reminder cooldown.
### OCR for expense receipts
Existing `/expenses/scan` route — extend to call Claude Vision on upload:
```ts
// expense-ocr.service.ts (uses Anthropic SDK; already in deps)
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const SYSTEM_PROMPT = `You extract structured expense data from receipts...
Output JSON: { vendor, amount, currency, date (ISO), lineItems: [...], confidence (0-1) }
`; /* cached via ephemeral cache_control for cost savings */
export async function ocrReceipt(fileUrl: string) {
const file = await fetch(fileUrl);
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const message = await client.messages.create({
model: 'claude-haiku-4-5-20251001', // haiku for cost; sonnet if quality needed
max_tokens: 1024,
system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
messages: [
{
role: 'user',
content: [
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
{ type: 'text', text: 'Extract expense fields from this receipt.' },
],
},
],
});
return parseAndValidate(message.content[0].text);
}
```
UI: existing scan page now shows a 3-step flow:
1. Upload receipt photo
2. Wait for OCR (spinner; ~3s avg with Haiku)
3. Confirm extracted fields (pre-filled form, user can edit)
4. Save → existing expense create flow
Low-confidence (< 0.6) extractions show a yellow banner "Please verify all fields" and pre-select the file uploader.
### Audit log read view
Admin route `/[port]/admin/audit`:
```
[ PageHeader: "Audit Log" · "Last 30 days · 12,847 events" ]
[ Filter row:
Search [tsvector] Actor [combobox of users] Action [pills] Entity type [select]
Date range [picker] Severity [pills] [Reset]
]
[ Table:
Timestamp | Actor | Action | Entity | Diff button | IP | User-agent
Click row → expand to show before/after JSON diff
]
[ Pagination · Export CSV button (admin-gated) ]
```
Server-side: `audit-search.service.ts` builds a drizzle query with the tsvector match + filters; supports cursor pagination on `(created_at, id)`.
Super-admin sees a port toggle that switches between current port and "All ports" view.
## Test plan
### Unit (`tests/unit/`)
- `alert-rules-evaluators.test.ts` — each rule tested with seeded data; covers fire/no-fire cases and resolution conditions
- `expense-dedup-heuristic.test.ts` — vendor/amount/date matching with edge cases (case-insensitive, ±3d window, currency mismatch ignored)
- `analytics-pipeline-funnel.test.ts` — funnel math against fixture interests
- `analytics-occupancy-timeline.test.ts` — daily aggregation against fixture berth status changes
- `audit-search-filters.test.ts` — tsvector + filter composition
- `ocr-prompt-caching.test.ts` — assert cache_control presence on system prompt; mocked Claude response
### Integration (`tests/integration/`)
- `alerts-engine.test.ts` — full evaluation cycle: seed conditions, run engine, assert correct alerts upserted, run again to assert dedupe via fingerprint, mutate state, assert auto-resolve
- `analytics-snapshot-refresh.test.ts` — recurring job: snapshot row written, served from cache on next read, refreshed on next tick
- `expense-dedup-flow.test.ts` — create A, create matching B, assert B.duplicate_of=A; merge B → A absorbs line items, B archived
- `audit-search-tsvector.test.ts` — seed audit_logs, query by free-text, assert returned ids
- `eoi-queue-listing.test.ts` — extends documents-hub test; assert EOI tab returns correct subset
### E2E smoke (`tests/e2e/smoke/`)
- New `27-analytics-dashboard.spec.ts` — dashboard renders charts; date-range picker re-renders; KPI tiles show non-zero data after seed
- New `28-alerts.spec.ts` — alert appears after seeding stale-interest condition; click-to-deep-link; dismiss persists; resolve hides
- New `29-interests-by-berth.spec.ts` — tab visible on berth detail; lists interests; sort works
- New `30-expense-dedup.spec.ts` — create two matching expenses; banner appears; merge button works
- New `31-ocr-flow.spec.ts` — uploads fixture receipt image; extracted fields pre-filled; user can edit and save
- New `32-audit-log.spec.ts` — admin page loads; search by entity id returns expected row; date filter narrows
- Extend `04-documents.spec.ts` — EOI queue tab presence + count badge
### E2E exhaustive (`tests/e2e/exhaustive/`)
- `15-analytics-dashboard.spec.ts` — crawl every chart's hover tooltips, legend toggles, export menu
- `16-alerts.spec.ts` — crawl alert card actions, severity filters, dismissed history, real-time arrival via socket emit
- `17-audit-log.spec.ts` — crawl filter combos, expand row diffs, super-admin all-ports toggle
### E2E real-API (`tests/e2e/realapi/`)
- New `claude-vision-receipt-ocr.spec.ts` — gates on `ANTHROPIC_API_KEY`; uploads two real fixture receipts (one clean, one blurry); asserts Haiku response shape and confidence score; verifies `cache_control` headers in HTTP trace; cleanup deletes test expense
### Test data fixtures
`global-setup.ts` extends:
- Seed one stale interest in `details_sent` stage with `last_activity_at = now - 20d` (fires `interest.stale`)
- Seed one active reservation without an agreement (fires `reservation.no_agreement`)
- Seed two matching expenses (fires `expense.duplicate`)
- Seed 90 days of pipeline activity for analytics charts
- Add a `tests/e2e/fixtures/receipts/` dir with two .jpg receipts for OCR tests
## Build sequence
| # | Title | Effort | Depends on |
| --- | ------------------------------------------------------------ | ------ | ----------------- |
| 1 | Schema + alert/analytics service skeletons | 1d | — |
| 2 | Alert rules engine + recurring evaluator + socket | 1.5d | 1 |
| 3 | Analytics snapshot job + service layer | 1d | 1 |
| 4 | Analytics dashboard page (KPI tiles + 4 charts + date-range) | 2.5d | 1, 3, A's KPITile |
| 5 | Alert rail UI + `/alerts` page | 1.5d | 2 |
| 6 | EOI queue tab on documents hub | 0.5d | A's hub |
| 7 | Interests-by-berth tab on berth detail | 0.5d | — |
| 8 | Expense duplicate detection (job + UI banner + merge) | 1.5d | 1 |
| 9 | OCR for expense receipts (Claude Vision + 3-step UI) | 1.5d | — |
| 10 | Audit log read view (admin page + filters + tsvector search) | 1.5d | 1 |
| 11 | Real-API integration tests | 1d | 9 |
### Critical path
```
1 → 2 → 5 (data → alert engine → alert UI)
1 → 3 → 4 (data → analytics service → analytics page)
8 → 2 (alert rule) (dedup populates the data the alert reads)
9 (OCR) → 11 (realapi)
```
Wall-clock minimum ~10 days (one engineer, sequential critical path); realistic with overhead ~13 days; calendar 2.53 weeks.
### Acceptance gates per PR
- `pnpm tsc --noEmit` and `pnpm lint` clean
- Vitest unit + integration green (incl. new tests)
- Playwright smoke green for the surface touched
- Visual baselines regenerated and reviewed in PR diff
- For PRs touching external integrations (9 OCR, 11 realapi): relevant `realapi` spec verified locally before merge
### Risk register
| Risk | Mitigation |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Alert engine false positives spam users | Each rule has a "snooze" window in metadata; rules ship behind a feature flag `alerts.{rule_id}.enabled`; QA seeds production-shape data before flipping flags on |
| Analytics queries slow on large datasets | `analytics_snapshots` materialized cache; cron recomputes off the request path; queries use existing per-port indexes |
| Claude Vision OCR cost spirals | Default to Haiku 4.5 (~10× cheaper than Sonnet); ephemeral system-prompt cache hits ~80%; per-port quota with admin-visible meter |
| OCR low-quality on blurry receipts | Confidence threshold (< 0.6) flips to "verify mode" — user must touch every field before save; failure metric tracked in admin/monitoring |
| Audit log table large (millions of rows) | Already partitioned-friendly via the GIN tsvector index; pagination uses cursor on `(created_at, id)` not OFFSET |
| Alert socket fanout overwhelms client | Throttle the engine cron to once per 5min; client debounces React Query refetches |
| Interest stale rule fires for legitimately paused leads | Add a per-interest `paused_until` field as a follow-up if operators ask; v1 ships without |
## Glossary
- **Alert** — operator-facing actionable card, rule-fired, dismissible
- **Rule** — a pure-function evaluator that takes (port, db) and returns alert candidates
- **Fingerprint** — `hash(rule_id + entity_type + entity_id)` used to dedupe alerts across re-evaluations
- **Snapshot** — cached chart data row in `analytics_snapshots`, refreshed on cron
- **EOI queue** — saved-view filter on the documents hub, not a separate page
- **OCR** — Claude Vision extraction of structured expense fields from receipt images
- **Audit log** — read view of the existing `audit_logs` table; no schema change beyond a tsvector column
## Open questions for the user
- Which port should be the **default landing dashboard** when a super-admin logs in (currently first-port-by-name; analytics page works the same)?
- Should the alert rail be **always visible on all dashboard pages** or only on `/dashboard` (currently spec'd as the latter)?
- Do you want the **Audit log retention policy** (delete > N days old) wired in v1 or deferred to Phase D?
- Should **OCR be opt-in per port** (admin toggle) or always-on with a quota?

View File

@@ -1,376 +0,0 @@
# Google Workspace inbox-triage integration (exploratory)
**Status:** Exploratory — not approved for build
**Date:** 2026-04-29
**Tracks:** AI inbox-triage, Google Workspace email connection
## What this spec is for
The user has flagged inbox-triage as the most valuable AI surface left to
build, but conditioned email integration on it being via Google Workspace
specifically (not generic IMAP), with a per-port toggle so clients who
don't use GWS aren't billed for capability they can't reach.
This document captures what that build actually costs — especially on
the Google side, which is where most teams underestimate the work — so
we can decide whether to commit before writing any code. **Nothing in
this spec is approved for implementation.** The deliverable is a go /
no-go decision and, if go, a scope choice between three deployment
models that cost wildly different amounts of calendar time.
## What inbox-triage actually does for the user
Concretely, on the staff member's desktop:
1. **Linked-inbox panel on the client detail page.** When you open
`/[port]/clients/<id>` you see the last N email threads with that
client, pulled from the staff member's own Gmail. Each thread has
the latest message preview, an "open in Gmail" deep-link, and a
"draft reply" button (Phase 2+).
2. **Inbox triage queue.** A new top-level page `/[port]/inbox` that
lists unread/unanswered threads ranked by AI-assessed importance
(high-value client, contractual urgency, chase-overdue). Each row
has one-click actions: "log this as a note on the client",
"create a follow-up reminder", "draft reply".
3. **Email-driven alerts.** When a high-value client emails and no one
responds within X hours, the existing alerts engine fires a
`inbox.unanswered_high_value` rule (slots into the alert framework
from Phase B without schema change).
4. **Reply drafts (Phase 3).** AI generates a reply draft grounded in
the client's CRM record (open interests, pending reservations,
recent invoices). Staff edit and send through Gmail.
The value is selective: a port with three staff members fielding 50
client emails a day saves maybe an hour a day collectively if the
ranking is right. Below that volume the build doesn't pay back.
## What already exists in the codebase
The CRM is roughly halfway scaffolded for this:
| Surface | Status | Notes |
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `email_accounts` table | ✅ Exists | Has `provider: 'google' \| 'outlook' \| 'custom'` discriminator and `imap_*` / `smtp_*` cols. Built for IMAP, not OAuth. |
| `email_threads` / `email_messages` tables | ✅ Exists | Already linked to `clientId`. Schema is good as-is for Gmail. |
| `email-threads.service.ts` `syncInbox()` | ⚠ Stub-ish | IMAP-flow only. Won't reach Gmail without OAuth + Gmail API rewrite. |
| `email` BullMQ queue + `inbox-sync` job name | ✅ Exists | Worker dispatches on the job name; new sync impl drops in. |
| `google_calendar_tokens` table | ✅ Exists | OAuth token storage shape we can mirror for Gmail. |
| Per-port email override (port `email_settings`) | ✅ Exists | Used for outbound only today; Gmail integration is per-staff-user, not per-port. |
| `ai_usage_ledger` + per-port `aiEnabled` flag | ✅ Exists (Phase 3a/3b) | Triage AI calls book against the same ledger. |
| `withRateLimit('ai', ...)` wrapper | ✅ Exists (Phase 3c) | Caps triage AI traffic at 60/min/user out of the box. |
Net: schemas are mostly right. The OAuth flow, Gmail API client, push
notification receiver, and triage classifier are the new builds.
## Why Google Workspace specifically
The user's stated constraint: "I don't think we need email integration
unless we connect it to Google Workspace." Reasons that hold up:
- **No password storage.** OAuth tokens are revocable, scoped, and
rotate. IMAP requires app passwords, which Google has been actively
deprecating since 2024 — they'll be gone for the workspace plans
this product targets.
- **Push notifications, not polling.** Gmail's `users.watch` API plus
Google Pub/Sub means we get an HTTP callback within seconds of a new
message landing. IMAP requires polling on a 30-60 second cadence,
which costs more and lags worse.
- **Search and labels.** The Gmail API exposes label management and
full-text search natively; IMAP search is much weaker.
- **Threading.** Gmail's `threadId` is canonical. Reconstructing
threads over IMAP from `In-Reply-To` / `References` headers is
reliable in theory, painful in practice.
Microsoft 365 is the obvious peer integration but is out of scope here.
The Graph API model is similar enough that a future M365 path can reuse
most of the storage shape.
## Three deployment models — pick one before building
This is the most important decision in the spec. Each model has
different OAuth-verification consequences, which dominate everything
else.
### Model A — Marketplace-published OAuth app
A single OAuth client owned by Port Nimara, listed in the Google
Workspace Marketplace, that any GWS customer can install. Each staff
member clicks "Connect Gmail," consents to the scopes, and the CRM
stores their refresh token.
**Google-side work:**
1. Build the OAuth flow in CRM (~1 week).
2. Submit for OAuth verification. Gmail's `gmail.readonly` /
`gmail.modify` scopes are **restricted scopes** — they require:
- Domain-verified production URLs
- A homepage with a privacy policy that explicitly enumerates which
scopes are used and why
- A demo video (literally a screen recording) showing the consent
screen and what happens next
- **A third-party security assessment from a Google-approved
vendor** ($15k$75k, 612 weeks)
- A Cloud Application Security Assessment (CASA) report
3. Marketplace listing review (~2 weeks after CASA passes).
**Calendar time:** 46 months.
**Money:** $15k$75k for the security assessment alone.
**Recurring:** Re-verification every 12 months.
Right answer if Port Nimara wants to be the marina-CRM that ships GWS
out of the box for _any_ customer. Wrong answer if there are <5
customers who'd use it.
### Model B — Per-customer "Internal" OAuth app
Each customer's GWS admin creates an OAuth client _inside their own
workspace_ and gives Port Nimara the client ID + secret. Because the
app is "Internal," Google skips verification entirely — the consent
screen is unverified-but-permitted. Tokens never cross workspace
boundaries.
**Google-side work per customer:**
1. Customer's GWS admin enables the Gmail API in their Cloud project.
2. Creates an OAuth 2.0 client ID with type "Internal" + your CRM's
redirect URI.
3. Hands the client ID + secret to Port Nimara out-of-band.
4. Staff connect their Gmail through that client.
**Calendar time per customer:** ~1 hour of admin work.
**Money:** $0.
**Limit:** Doesn't span across GWS workspaces. A user with two GWS
accounts (e.g. the marina + a personal workspace) can only connect the
one matching the OAuth client.
This is the **clear winner for the current customer base**: small
number of customers, each with their own GWS workspace, and each
buying the integration as part of an onboarding conversation.
### Model C — Forward-to-CRM mailbox
The CRM exposes a per-port email alias (e.g.
`port-nimara-NN@inbox.portnimara.com`). Customers configure a Gmail
filter or mailing rule that BCCs that alias on relevant threads. The
CRM ingests via SMTP and runs the same triage pipeline.
**Google-side work:** None. Customer does it as a Gmail filter.
**Calendar time:** ~1 week of CRM-side build.
**Limit:** Receive-only — no reply drafts, no thread state changes,
no labels. The "draft reply" feature in Phase 3 above is impossible
under this model.
Model C is the right answer if the user wants to ship inbox-triage
_now_ and decide on bidirectional Gmail integration later. The schema
is designed so the model can be upgraded to A or B without data
migration.
### Recommendation
**Build Model B first.** It costs nothing on the Google side, takes
~3 weeks of CRM work, and matches the actual customer profile.
**Promote to Model A only after 3+ paying customers ask for it
unprompted.** Until then, the security-assessment cost can't justify
itself.
Model C as a fallback for customers who refuse to set up an Internal
OAuth app. Build it last, lazily — the schema accommodates it.
## End-to-end flow (Model B)
### 1. Per-port OAuth-app config
New admin page `/[port]/admin/google-workspace`:
- Field: "OAuth client ID" (their internal client ID)
- Field: "OAuth client secret" (encrypted at rest using `ENCRYPTION_KEY`)
- Field: "Authorized redirect URI" (read-only; we display the value
they need to paste into their Google Cloud Console)
- Toggle: "Enable Gmail integration for this port"
Stored in `system_settings` under key `gws.config`, port-scoped.
Resolution mirrors the existing OCR config service.
### 2. Per-staff connect flow
Staff member visits `/[port]/me/integrations`, clicks "Connect Gmail."
```
GET /api/v1/auth/gws/start
→ looks up port's gws.config
→ builds Google authorize URL with port's client_id + state token
→ 302 to Google
[ user consents ]
→ 302 back to /api/v1/auth/gws/callback?code=…&state=…
→ exchanges code for tokens via port's client_secret
→ stores in new `gws_user_tokens` table (encrypted)
→ schedules an `inbox-watch` job
```
### 3. Push notification subscription
After tokens are stored, the worker calls
`gmail.users.watch({ topicName: <Pub/Sub topic>, labelIds: ['INBOX'] })`.
Gmail then posts to a Pub/Sub topic on every inbox change. The CRM
exposes a Pub/Sub push subscription endpoint at
`/api/webhooks/gmail-push` which fetches the changed messages via the
delta `historyId` and writes them into `email_messages`.
Watch subscriptions expire every 7 days. A maintenance job
re-establishes them daily.
### 4. Triage pipeline
For each new inbound message:
1. Match against `clients` and `companies` by `from_address` against
`client_contacts` (email channel). Persist a thread→client link if
found.
2. If port has `aiEnabled` AND `gws.triageEnabled`, queue an `ai`
job that classifies the thread:
- `urgency`: low / medium / high
- `category`: invoice-question / availability / contract / other
- `requires_response`: boolean
3. AI call records into `ai_usage_ledger` with `feature='inbox_triage'`.
The existing per-port budget gates apply automatically.
4. Triage output written to a new `email_triage` table keyed on
`email_messages.id`.
### 5. UI surfaces
- `/[port]/inbox` — sorted by triage rank, port-wide view.
- Linked-inbox panel on `client-tabs.tsx` — adds a new "Email" tab
pulling from `email_threads` filtered to that client.
- Alert rule `inbox.unanswered_high_value` slots into Phase B's
alert engine; no schema change.
## Schema additions
Three new tables, all port-scoped where it matters:
```ts
// Per-staff Gmail tokens. Mirror of google_calendar_tokens.
gws_user_tokens {
id, userId (UNIQUE), portId, emailAddress,
accessTokenEnc, refreshTokenEnc, tokenExpiry,
scope, watchExpiresAt, watchHistoryId,
connectedAt, lastSyncAt, syncEnabled, createdAt, updatedAt
}
// Triage classifications keyed to messages.
email_triage {
messageId (PK, FK email_messages.id ON DELETE CASCADE),
urgency, category, requiresResponse,
modelVersion, tokensUsed, classifiedAt
}
// Pub/Sub idempotency log. Gmail re-delivers; we dedupe.
gws_push_log {
messageId (Pub/Sub message id, PK),
historyId, receivedAt
}
```
Plus extensions to `email_messages`:
- `googleMessageId` (text, indexed) — Gmail's own ID for thread ops.
- `googleThreadId` (text, indexed).
- `gmailLabels` (text[]) — for "is unread" checks without hitting Gmail.
The existing `emailAccounts.provider='google'` column repurposes
unchanged; the IMAP fields go nullable since OAuth-flow accounts won't
populate them.
## AI cost interaction
Triage AI is opt-in **twice**: the port admin must turn on
`aiEnabled` (Phase 3a flag, default off) **and** `gws.triageEnabled`
(this spec, default off). Either toggle off and the inbox sync still
runs but skips classification, so staff can manually scan threads
without burning tokens.
Per-message token cost on a current Haiku-class model is roughly
15002500 tokens including the system prompt. A port doing 200 inbound
emails a day at the upper bound is ~500k tokens/day. The default
hard-cap is 500k/month, so triage will trip it inside a day. Two
mitigations baked in:
- The system prompt is short (<500 tokens) and prompt-cached on the
Anthropic side, so most tokens are output.
- Triage runs only on threads not already classified — re-syncs from
the watch loop don't re-bill.
The admin UI shows triage as its own line in the per-feature breakdown
so customers can see how much their inbox is costing them and tune
caps accordingly.
## Phased build (assuming Model B)
| Phase | Scope | Effort | Ships when |
| ---------------------------- | ------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------- |
| **G1** Connect | OAuth flow + per-port config + per-user token storage. No sync yet. Staff can connect; nothing happens. | 1 week | Standalone |
| **G2** Read-only sync | Pub/Sub push receiver + delta sync into `email_messages`. Linked-inbox tab on client detail. No AI. | 1 week | After G1 |
| **G3** Triage classification | AI classifier, `email_triage` writes, `/inbox` page sorting. Per-port toggle. | 1 week | After G2; depends on Phase 3b budgets being live (they are) |
| **G4** Reply drafts | Gmail API send + draft creation. "Draft reply" button on the client detail Email tab. | 1 week | After G3 |
| **G5** Alerts | New `inbox.unanswered_high_value` rule. Hooks into Phase B alert engine. | 2 days | After G3 |
Total: ~5 weeks for a single engineer, assuming the user provides one
real GWS workspace to test against during G1.
## Open decisions for the user
These are the questions to resolve before scheduling the build, in
priority order:
1. **Deployment model — A, B, or C?** Default recommendation B.
2. **Single user or domain-wide delegation?** Per-staff connect (one
token per user) is simpler. Domain-wide delegation lets the port
admin connect once on behalf of every staff member but requires
the customer to grant a service account broader access. Default
recommendation: per-staff.
3. **Scope set.** Minimal viable scope is `gmail.readonly`. To send
replies (G4) we need `gmail.send`. To manage labels (e.g. mark
"triaged-by-CRM") we need `gmail.modify`. Each scope expansion
widens the consent screen scariness but doesn't add new
verification steps under Model B.
4. **Pub/Sub topic ownership.** Pub/Sub topics live in _some_ GCP
project. Under Model B the customer's project owns the topic —
they pay for Pub/Sub (cents/month) and grant our service account
subscriber access. Alternative: Port Nimara owns the topic and
the customer's Gmail publishes cross-project (allowed, slightly
more setup). Default: customer-owned topic, fewer moving parts.
5. **Triage model.** Haiku 4.5 is right for cost; Sonnet 4.6 is
right if the ranking quality on Haiku turns out to be poor.
Defer this until G3 has real-world tuning data.
## Things that are NOT in this spec
- **Microsoft 365 / Outlook integration.** Same shape, different API.
Once Model B is proven on GWS, Graph API takes another ~3 weeks.
- **Reply drafts grounded in CRM context.** That's G4 and depends on
the work in this spec, but the prompt engineering for "good replies
citing this client's open interests + reservations + invoices"
deserves its own design pass before building.
- **Cross-staff triage queue (i.e. "show me all unanswered emails
across the team").** That requires either domain-wide delegation
(decision #2 above) or per-staff opt-in to a shared view. Punt
until staff actually ask for it.
- **Sentiment / urgency tone analysis.** Tempting; almost always
wrong; skip in v1.
- **"Smart drafts" using the recipient's past replies as context.**
Every customer asks for this and almost no one uses it once
built. Skip.
## Cost summary at a glance
| Item | Model A | Model B | Model C |
| ------------------------------- | ------------------------------- | -------------------------------------- | ------------------------------------ |
| Build effort | 34 weeks | ~5 weeks (over G1G5) | ~1 week (receive-only) |
| Calendar time to first customer | 46 months | 1 hour of customer admin work | 1 hour of customer Gmail-filter work |
| Up-front cash | $15k$75k (CASA) | $0 | $0 |
| Recurring | Re-verification annually | None | None |
| Best for | 50+ customers, Marketplace play | 110 customers, white-glove onboarding | Customers who refuse OAuth setup |
The recommendation stands: build Model B for G1 + G2 + G3, ship that,
and let real customer demand decide whether G4/G5 and Model A
promotion are worth the calendar time.

View File

@@ -1,189 +0,0 @@
# Mobile Optimization Design
**Status**: Design approved 2026-04-29 — pending plan.
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
**Branch base**: stacks on `refactor/data-model`.
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
---
## 1. Background
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
## 2. Approach
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
### 2.1 Target iPhone viewport range
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
| Tier | Models | Viewport |
| ------------------------------------------ | ----------------------------------------------- | -------- |
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
## 3. Foundation PR
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
### 3.1 Infrastructure
- `viewport` export in `src/app/layout.tsx``width=device-width, initial-scale=1, viewport-fit=cover`.
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
### 3.2 Mobile shell
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
- **`<MobileTopbar>`**
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
- **`<MobileBottomTabs>`**
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
- **`<MoreSheet>`**
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
- **`<MobileLayoutProvider>`**
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
### 3.3 Primitives
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
### 3.4 Default style adjustments
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
### 3.5 Bundle impact
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
### 3.6 PWA assets
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
## 4. Per-page playbook
Once foundation lands, each page follows the same workflow:
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
2. Replace any `<Dialog>` with `<Sheet>`.
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
4. Replace the ad-hoc page header with `<PageHeader>`.
5. Replace ad-hoc action button rows with `<ActionRow>`.
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
7. User reviews live in the headed browser, points out tweaks, iterate.
Most pages take 515 minutes in this loop. Heavy pages (email inbox, documents hub) may take 3060 because the embedded widgets need their own mobile treatment beyond the primitives.
## 5. Migration sequence
After foundation PR:
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
2. **List pages** (~12 days) — convert via `<DataView>` + per-page `cardRender`:
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
4. **Detail pages** (~12 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
6. **Portal** — same patterns, smaller scope:
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
## 6. Testing
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
## 7. Open questions
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
## 8. Files to create
```
src/hooks/use-is-mobile.ts
src/components/layout/mobile/
mobile-layout.tsx
mobile-topbar.tsx
mobile-bottom-tabs.tsx
more-sheet.tsx
mobile-layout-provider.tsx
src/components/shared/
sheet.tsx (new — vaul wrapper)
data-view.tsx (new — table↔card)
page-header.tsx (new)
action-row.tsx (new)
detail-page-shell.tsx (new)
filter-chips.tsx (new)
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
public/icon-192.png (placeholder PWA asset)
public/icon-512.png (placeholder PWA asset)
public/icon-512-maskable.png (placeholder PWA asset)
public/apple-touch-icon.png (placeholder PWA asset)
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
```
## 9. Files to modify per page
Per the playbook in §4, each page typically needs:
- One swap of header markup → `<PageHeader>`.
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
- For detail pages: wrap in `<DetailPageShell>`.
- Replace `<Dialog>` imports with `<Sheet>`.
- No service, validator, query, or schema changes anywhere.

View File

@@ -1,564 +0,0 @@
# Client Deduplication and NocoDB Migration Design
**Status**: Design draft 2026-05-03 — pending approval.
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
---
## 1. Background
### 1.1 Why this exists
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
- **252 Interests rows** in NocoDB, against an estimated ~190200 unique humans (~2025% duplication rate).
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
- **No Clients table.** The conflated structure is structural, not accidental.
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
### 1.2 Real duplicate patterns observed in the live data
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
| Pattern | Example rows | Signature |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
### 1.3 Dirty data inventory
The migration normalizer must survive these real values from production:
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
### 1.4 Existing battle-tested algorithm
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
### 1.5 Why the website is no longer the source of new dirty data
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
---
## 2. Approach
Three artifacts, layered:
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
---
## 3. Normalization library
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
### 3.1 `normalizeName(raw: string)`
```ts
export function normalizeName(raw: string): {
display: string; // human-readable, kept for UI
normalized: string; // for matching
surnameToken?: string; // for surname-based blocking
};
```
- Trim leading/trailing whitespace
- Replace `\r`, `\n`, tabs with single space
- Collapse consecutive whitespace to single space
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
- `display` preserves user's intent (slash-with-company stays intact)
- `normalized` is `display.toLowerCase()` for comparison
- `surnameToken` is the last non-particle token for blocking
### 3.2 `normalizeEmail(raw: string)`
```ts
export function normalizeEmail(raw: string): string | null;
```
- Trim + lowercase
- Validate via `zod.email()` schema
- Returns `null` for empty / invalid (caller decides what to do)
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
```ts
export function normalizePhone(
raw: string,
defaultCountry: string,
): {
e164: string | null; // canonical, e.g. '+15742740548'
country: string | null; // ISO-3166-1 alpha-2
display: string | null; // user-facing pretty
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
} | null;
```
Pipeline:
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
4. If starts with `00` → replace with `+`
5. If starts with `+` → parse as E.164
6. Else if `defaultCountry` provided → parse against that country
7. Else return null (caller's problem)
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
### 3.4 `resolveCountry(text: string)`
```ts
export function resolveCountry(text: string): {
iso: string | null; // ISO-3166-1 alpha-2
confidence: 'exact' | 'fuzzy' | 'city' | null;
};
```
Reuses `src/lib/i18n/countries.ts`. Pipeline:
1. Lowercase + strip diacritics
2. Exact match against country names (any locale we ship)
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
---
## 4. Dedup algorithm
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
### 4.1 Public API
```ts
export interface MatchCandidate {
id: string;
fullName: string | null;
emails: string[]; // already normalized
phonesE164: string[]; // already normalized E.164
countryIso: string | null;
}
export interface MatchResult {
candidate: MatchCandidate;
score: number; // 0100
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
confidence: 'high' | 'medium' | 'low';
}
export function findClientMatches(
input: MatchCandidate,
pool: MatchCandidate[],
thresholds: DedupThresholds,
): MatchResult[];
```
### 4.2 Scoring rules (compound)
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
| Rule | Score | Notes |
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
| **Negative**: Same email but different country code on phone | 15 | Suggests spouse / coworker / shared inbox |
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | 20 | Two distinct people with the same name |
### 4.3 Confidence tiers (post-compound)
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
- **score 5089 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
### 4.4 Blocking strategy
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 05 candidates per query, regardless of N.
### 4.5 Performance budget
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
---
## 5. Configurable thresholds (admin settings)
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
| Key | Default | Effect |
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
---
## 6. Merge service contract
### 6.1 Data flow
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
- `interests.clientId`
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
- `clientAddresses.clientId` — same conflict handling
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
- `clientTags.clientId`
- `clientYachtMembership.clientId` (or whatever the table is called)
- `auditLogs.entityId` — annotate, don't move (audit truth)
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
4. **Soft-archive loser**`loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
5. **Write `clientMergeLog`**`{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
### 6.2 Schema additions (migration)
`clients` table gets a new column:
```ts
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
```
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
```sql
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
```
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
### 6.3 Undo
`unmergeClients(mergeLogId, ctx)`:
1. Within the undo window, look up the snapshot
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
3. Restore loser's contacts/addresses/notes/tags from snapshot
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
### 6.4 Concurrency
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
---
## 7. Runtime surfaces
### 7.1 Layer 1 — At-create suggestion
In `ClientForm` (and the public `register` form once that hits the new system):
- Debounced 300ms after email or phone field changes
- Calls `findClientMatches` against current port's clients
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
```
┌─────────────────────────────────────┐
│ This looks like an existing client │
│ ML Marcus Laurent │
│ marcus@… +33 6 12 34 56 78 │
│ 2 interests · last 9d ago │
│ [ Use this client ] [ Create new ] │
└─────────────────────────────────────┘
```
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
### 7.2 Layer 2 — Interest-level same-berth guard
Cheap one-liner in `createInterest` service:
- Check `(clientId, berthId)` against existing non-archived interests
- If hit, throw `BerthDuplicateError` with the existing interest details
- UI catches and prompts: "Update existing or create separate?"
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
### 7.3 Layer 3 — Background scoring + review queue
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
```ts
export const clientMergeCandidates = pgTable('client_merge_candidates', {
id: text('id').primaryKey()...,
portId: text('port_id').notNull()...,
clientAId: text('client_a_id').notNull()...,
clientBId: text('client_b_id').notNull()...,
score: integer('score').notNull(),
reasons: jsonb('reasons').notNull(),
status: text('status').notNull().default('pending'), // pending | dismissed | merged
createdAt: timestamp('created_at')...,
resolvedAt: timestamp('resolved_at'),
resolvedBy: text('resolved_by'),
})
```
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
---
## 8. NocoDB → new system field mapping
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
### 8.1 Top-level transform
```
NocoDB Interests row
─→ 01 client (deduped against existing pool)
─→ 01 client_address
─→ 02 client_contacts (email, phone)
─→ exactly 1 interest
─→ 01 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
─→ 01 document (when documensoID present)
```
### 8.2 Field map
| NocoDB field | Target | Transform |
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `Full Name` | `clients.fullName` | `normalizeName().display` |
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
| `Time LOI Sent` | `interests.dateContractSent` | parse |
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
### 8.3 Sales-stage mapping (8 → 9)
| NocoDB | New (PIPELINE_STAGES) |
| ------------------------------- | ------------------------------------------------------------------------ |
| General Qualified Interest | `open` |
| Specific Qualified Interest | `details_sent` |
| EOI and NDA Sent | `eoi_sent` |
| Signed EOI and NDA | `eoi_signed` |
| Made Reservation | `deposit_10pct` |
| Contract Negotiation | `contract_sent` |
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
### 8.4 Other tables
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
---
## 9. Migration script
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
```
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
Reads the apply log, undoes the writes (only valid within the undo window).
```
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
### 9.1 Dry-run report format
`.migration/<timestamp>/report.csv`:
```csv
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
```
Plus `.migration/<timestamp>/summary.md`:
```
# Migration Dry-Run — 2026-05-03 14:23 UTC
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
Auto-linked (high confidence, no human action needed):
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
- John Lynch: rows 716,725 → 1 client + 2 interests
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
- [12 more]
Flagged for manual review (medium confidence):
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
- [4 more]
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
- Row 239: "Sag Harbor Y" → AI (likely US)
- [6 more]
Phone parsing failed for 3 rows. All flagged, no contact created:
- Row 178: empty
- Row 641: placeholder "+447000000000"
- Row 175: empty
Run `--apply` to commit these changes.
```
### 9.2 Apply phase
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
### 9.3 Idempotency
The script tracks NocoDB row IDs in a `migration_source_links` table:
```ts
export const migrationSourceLinks = pgTable('migration_source_links', {
id: text('id').primaryKey()...,
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
sourceId: text('source_id').notNull(), // NocoDB row id as string
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
targetEntityId: text('target_entity_id').notNull(),
appliedAt: timestamp('applied_at')...,
appliedBy: text('applied_by'),
}, (table) => [
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
]);
```
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
---
## 10. Test plan
### 10.1 Library-level (vitest unit)
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
### 10.2 Service-level (vitest integration)
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
### 10.3 Migration script (vitest integration with NocoDB mock)
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
### 10.4 E2E (Playwright)
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
---
## 11. Rollback plan
Three layers of safety, ordered by reversibility:
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
---
## 12. Open items
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
- **Profile photo / face match** — out of scope.
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
---
## Implementation sequence
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~57 days.
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
Total: ~1012 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.

View File

@@ -1,375 +0,0 @@
# Documents Hub Split + Auto-Filed Client Folders
**Status:** Draft — awaiting final review
**Date:** 2026-05-10
**Builds on:** Wave 11.B `feat/documents-folders` (per-port nestable `document_folders` tree, soft-rescue delete, sibling-name uniqueness)
## Overview
Today the CRM has two parallel document surfaces that confuse reps:
1. `/[port]/documents` — Documenso signature workflows only (rows in `documents`). Hub tabs are signing-status (`in_progress` / `awaiting_them` / `awaiting_me` / `completed` / `expired`). Carries the new `document_folders` tree (Wave 11.B).
2. `/[port]/documents/files` — bare uploaded files only (rows in `files`). Has its **own** "folder" mechanism driven by `storagePath` prefix matching, completely disconnected from `document_folders`.
The signed PDF that Documenso produces lives in the `files` table (`documents.signed_file_id` points at it), but it has no folder home and no entity-driven grouping — reps can't find a client's signed contracts without going through the signing workflow row first.
This spec unifies both surfaces under a single hub with a stacked **Signing in progress / Files** layout, anchored by a per-port nestable folder tree that gains three system-managed roots (`Clients/`, `Companies/`, `Yachts/`). Each entity gets one auto-created subfolder on first need; signed PDFs from completed workflows auto-deposit into the owner's folder. The folder view is **owner-aggregated**: opening `Clients/Smith, John/` surfaces files attached to John, plus files of his linked companies and yachts, each rendered as a labelled subsection.
## Conceptual model
Three first-class concepts after this spec ships:
- **File** (`files` row) — a stored binary artifact (PDF/image/etc.) with one `folder_id` and entity FKs (`client_id` / `company_id` / `yacht_id`). The canonical "document" reps file and find. Produced by either direct upload or as the output of a completed signing workflow.
- **Signing workflow** (`documents` row) — the _process_ of getting a PDF signed via Documenso. Lifecycle `draft``sent``partially_signed``completed`. Surfaces in the hub's Signing section while in-flight. On completion, produces a signed-PDF file; the workflow row itself becomes audit history accessed via a "view signing details" link on the resulting file. Stops appearing in user-facing folder views.
- **Folder** (`document_folders` row) — per-port nestable tree (existing). Extended to hold both files and in-flight workflows. Gains three system-managed roots and per-entity auto-subfolders.
`documents.folder_id` stays meaningful for in-flight workflows (rep can file by deal/project). Becomes irrelevant on completion — the rendering layer hides completed workflows from folder views entirely.
`files.folder_id` is **new** (not in current schema) — added by this spec.
## Scope boundaries
### In scope
- New `files.folder_id` column + index, FK to `document_folders.id`
- `document_folders` schema additions: `system_managed`, `entity_type`, `entity_id`, `archived_at`
- Three system roots (`Clients/`, `Companies/`, `Yachts/`) auto-created on port init
- Lazy per-entity subfolder creation on first auto-deposit or first manual upload
- Auto-deposit logic in `handleDocumentCompleted` (set `files.folder_id` + entity FKs on signed PDF)
- Owner-resolution chain (Owner-wins: `client_id ?? company_id ?? yacht_id` on workflow, falling back to interest)
- Owner-aggregation projection in the files & documents listing endpoints
- Symmetric relationship walking (Client ↔ Company ↔ Yacht via memberships and ownership)
- Hub UI rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder 🔒 markers
- "View signing details" dialog on signed-PDF file rows
- System-folder protection: rename/move/delete blocked at API + UI
- Entity rename auto-syncs system folder name (transactional)
- Entity archive applies `(archived)` suffix; entity hard-delete demotes to user folder with `(deleted)` suffix
- Search box scope: current folder + descendants, results across both Signing and Files
- Hub root view (no folder selected): port-wide Signing + recent Files
- One-time backfill script: ensure system folders exist, set `files.folder_id` from entity FKs, copy entity FKs from completed workflows onto signed files
- Removal of `/[port]/documents/files` route (301 redirect to `/[port]/documents`)
- Removal of the legacy `storagePath`-prefix folder rendering
### Explicitly out of scope
- Permission/role changes beyond what `documents.view` and `documents.manage_folders` already gate
- Bulk file actions (multi-select move, multi-select download zip) — separate work
- Tagging or labels on files — separate work
- Trash / restore for hard-deleted files (current behavior preserved)
- Search across file _content_ (full-text PDF search) — current behavior preserved (search is title/filename only)
- Per-port admin override for aggregation symmetry (rejected as needless setting at E11)
- Per-user feature flag rollout — hard cutover (E rollout decision)
- Native PDF preview rebuild — existing `FilePreviewDialog` reused
## Folder tree structure & governance
### System-managed roots and subfolders
Three reserved root folders are auto-created when a port is initialised:
```
Clients/
Companies/
Yachts/
```
Per-entity subfolders are created **lazily on first need** — when a workflow completes for that entity, when a rep manually uploads a file scoped to that entity, or when a rep clicks "Open folder" on the entity's detail page. Empty entities don't appear in the tree.
Subfolder naming:
- Default name = entity display name (client `firstName lastName` / company `name` / yacht `name`).
- Numeric collision suffix: `Smith, John (2)`, `Smith, John (3)`, etc. Suffix appended to the _new_ (later-created) folder; existing folder names never change due to collision.
- Auto-rename on entity rename — runs in the same DB transaction as the entity update.
- Entity archive: `(archived)` suffix appended, folder shown muted in tree, auto-deposit blocked until restored.
- Entity hard-delete: `(deleted)` suffix appended, `system_managed` flipped to `false` (folder demoted to a regular user folder; rep can rename/move/delete normally).
### System-folder protection
When `system_managed = true`:
- Rename API rejects with `ConflictError("System folders can't be renamed")`.
- Move API rejects with `ConflictError("System folders can't be moved")`.
- Delete API rejects with `ConflictError("System folders can't be deleted")`.
- UI hides rename/move/delete actions in `FolderActionsMenu` for these rows.
- UI displays a 🔒 marker next to the folder name.
The three roots themselves (`Clients/` / `Companies/` / `Yachts/`) are also `system_managed = true` and protected identically.
### User folders
User-created folders sit alongside the three system roots and inside any other folder (subject to existing depth/cycle rules from Wave 11.B). Standard CRUD via `documents.manage_folders` permission. Examples reps will create: `Templates/`, `Compliance/`, `Marketing PDFs/`.
## Routing on workflow completion
`handleDocumentCompleted` (in `src/app/api/webhooks/documenso/route.ts`) currently:
1. Verifies the Documenso secret.
2. Downloads the fully signed PDF.
3. Creates a `files` row for the signed PDF.
4. Sets `documents.signed_file_id` to the new file id.
5. Updates `documents.status = 'completed'`.
This spec extends the handler with steps 3a, 3b, 3c — inserted between (3) and (4):
```
3a. resolveOwner(workflow):
candidates = [
workflow.client_id,
workflow.company_id,
workflow.yacht_id,
workflow.interest?.primary_client_id,
workflow.interest?.primary_company_id,
workflow.interest?.primary_yacht_id,
]
return first non-null candidate (with its entity_type) OR null
3b. if owner != null:
folder = ensureEntityFolder(port_id, owner.entity_type, owner.entity_id)
// INSERT … ON CONFLICT (port_id, entity_type, entity_id) DO NOTHING RETURNING id
// re-SELECT on conflict to get the existing folder's id
file.folder_id = folder.id
// copy entity FK to file row if not already set (so aggregation reads file FKs as source of truth)
file[`${owner.entity_type}_id`] ??= owner.entity_id
3c. if owner == null:
file.folder_id remains null
// file lives at root, surfaced in the root-view Files section
```
Owner resolution happens at **completion time**, not creation time — if the rep edited the workflow's owner mid-signing (rare), the signed PDF lands in the most recent owner's folder.
The workflow's own `folder_id` is not touched. After `status = 'completed'`, the rendering layer hides the workflow from folder views; only the resulting signed file is visible (with a "view signing details" link to the workflow + signers + events timeline).
## Owner-aggregation projection
The killer feature. When a rep opens an entity folder (`Clients/Smith, John/`), the listing query is **not** a simple `WHERE folder_id = …` — it's a projection that walks the relationship graph and groups results by owner-source.
### Aggregation graph
Aggregation is **symmetric** (E aggregation reach decision). Walking from any entity, surface files attached to:
- the entity itself (DIRECTLY ATTACHED)
- linked clients via `company_memberships`
- linked companies via `company_memberships` and via yacht ownership
- linked yachts via current ownership (`yachts.current_owner_type` + `current_owner_id`)
- - any second-degree links (e.g., `Clients/Smith` shows files of `Smith Marine LLC`'s yachts via the chain Smith → Smith Marine LLC → owned yachts)
Each result group is rendered with a labelled header: `DIRECTLY ATTACHED · 3`, `FROM COMPANY — SMITH MARINE LLC · 1`, `FROM YACHT — MV SERENITY · 2`, etc. Files lived where they were physically filed (e.g., `Yachts/MV Serenity/`); the aggregation only borrows them for display, with a `lives in <path>` caption per row.
### Source-of-truth: file FKs
Aggregation reads each file's own `client_id` / `company_id` / `yacht_id` (snapshotted at upload/creation time), **not** the linked entity's current relationships. This makes yacht ownership transfer a no-op for historical files: a file uploaded for John when he owned MV Serenity stays under John's view forever, even after the yacht is sold to Mary. Mary's view shows files uploaded after the transfer (which carry `client_id = Mary`). Both clients' folders coexist with their respective historical artifacts.
### Per-group pagination
Each owner-source group renders its top 20 rows by `created_at desc`. When a group has more, a `Show all (148)` link drills into a flat paginated list scoped to that source. Keeps page render bounded for large portfolios (200+ yacht leasing clients).
### Defense-in-depth port_id
Every join in the aggregation SQL filters `port_id = $port` — at the entity table, at the membership table, at the yacht table, at the file table. Project pattern (per CLAUDE.md "defense-in-depth port_id scope" / berth recommender precedent). Single-place port_id check at the entry point alone is rejected — it bit the recommender exactly once and we fixed it the same way.
## UI layout
### Layout A: stacked sections, owner-labelled groups inside each
Confirmed in mockup review.
```
┌─────────────────────────────────────────────────────────────────────┐
│ /port-nimara/documents → Clients / Smith, John 🔒 │
├──────────────┬──────────────────────────────────────────────────────┤
│ FOLDERS │ Clients Smith, John 🔒 [Upload] [+ Sign] │
│ │ │
│ 📁 Clients │ ⏳ SIGNING IN PROGRESS · 2 │
│ 📁 Smith…🔒│ FROM CLIENT │
│ 📁 … │ ▢ EOI · Berth A12 · sent 2d ago Awaiting them │
│ 📁 Companies│ FROM YACHT — MV SERENITY │
│ 📁 Yachts │ ▢ NDA · sent yesterday Awaiting them │
│ │ │
│ 📁 Templates│ 📎 FILES │
│ 📁 Complian.│ DIRECTLY ATTACHED · 3 │
│ │ ▢ Signed EOI · A11.pdf signed Apr 14 · view sig… │
│ + New folder│ ▢ Passport scan.pdf uploaded Mar 2 │
│ │ │
│ │ FROM COMPANY — SMITH MARINE LLC · 1 │
│ │ ▢ Articles of inc.pdf · lives in Companies/… │
│ │ │
│ │ FROM YACHT — MV SERENITY · 2 │
│ │ ▢ Signed NDA.pdf · lives in Yachts/… │
│ │ ▢ Survey report.pdf · lives in Yachts/… │
└──────────────┴──────────────────────────────────────────────────────┘
```
Layout primitives:
- **Left panel:** existing `FolderTree` extended for 🔒 markers and `system_managed`-aware action suppression (rename/move/delete hidden in `FolderActionsMenu`).
- **Main panel:** breadcrumb + actions row, then stacked Signing/Files sections. Each section has its in-section grouped headers.
- **Signing section:** hidden entirely when no in-flight workflows match the entity scope. When present, renders above Files.
- **Files section:** always present (may be empty with placeholder).
- **"View signing details" link:** appears on rows for signed-PDF files (those whose source can be traced via `documents.signed_file_id`). Click opens `<SigningDetailsDialog>` — modal showing signers, events, timeline, signed-at timestamps.
### Hub root view (no folder selected)
Default landing when rep clicks Documents in the sidebar:
- **Signing section:** all in-flight workflows port-wide (effectively today's `/[port]/documents` hub behavior, minus the signing-status sub-tabs which collapse).
- **Files section:** recently uploaded/modified files port-wide, paginated by `updated_at desc`.
The folder tree on the left is the primary navigation; root view is the "I just opened the hub, show me what's recent" landing.
### Old `/[port]/documents/files` route
Removed. Server-side 301 redirect to `/[port]/documents`. The `<Files…>` components and the legacy `storagePath`-prefix folder code are deleted.
### Hub-tab simplification
Today's signing-status tabs (`in_progress` / `eoi_queue` / `awaiting_them` / `awaiting_me` / `completed` / `expired`) collapse into one Signing section — the rep will filter by signer-status via in-section chips if needed, but the dominant navigation is folders, not signing-status. The `documentsHubTabs` enum + `tab` query param are removed; `hub-counts` API endpoint is reduced to "in-flight count" only (used for the Signing section's counter badge).
## Edge cases — decisions
| ID | Edge case | Decision |
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| E1 | Entity renamed | System folder name auto-syncs in the same transaction. |
| E2 | Two entities collide on folder name (e.g., both "Smith, John") | Append numeric suffix `(2)`, `(3)` to the **new** colliding folder. Existing folders never change. |
| E3 | Entity archived | Folder stays with `(archived)` suffix, muted style. Auto-deposit halts. |
| E4 | Entity hard-deleted | Folder gets `(deleted)` suffix, `system_managed` flips to `false` (rep can clean up). Files retain orphaned data. |
| E5 | Yacht ownership transferred | Files snapshot their entity FKs at upload time. Old client folder retains historical files; new client gets a new folder for files created after transfer. Both coexist. |
| E6 | Workflow's owner FK changes mid-signing | Resolve owner at completion time. Signed PDF lands in current owner's folder. |
| E7 | Rep moves a file out of a system folder | Allowed. `folder_id` changes; entity FK is unchanged so aggregation still surfaces it via FK. The "lives in …" caption updates. |
| E8 | Rep manually uploads into an entity folder | Auto-set the file's matching entity FK from the destination folder's `entity_type` + `entity_id`. Custom folders → no auto-mapping. |
| E9 | Workflow has no entity at all | Signed PDF lands at root with `folder_id = null`. Surfaces in root-view Files section only. |
| E10 | File/workflow attached to interest only, interest has no resolved owner | Same as E9 — root, null folder. Manual move or future backfill resolves later. |
| E11 | Aggregated view returns 1000+ files | Top 20 per owner-source group, `Show all (N)` drilldown into flat paginated list per source. |
| E12 | Hub root view (no folder selected) | Port-wide Signing + recent Files, both paginated. |
| E13 | Concurrent completions race for the same entity folder | `INSERT … ON CONFLICT DO NOTHING RETURNING id`, then re-`SELECT` if needed. Uses the new partial unique index `uniq_document_folders_entity`. |
| E14 | Cross-port aggregation leak | `port_id = $p` filter at every join in aggregation SQL. Defense-in-depth. |
| Lazy folder creation | When are system root + per-entity folders created? | Roots: on port init. Subfolders: lazy on first need (auto-deposit, manual upload, or "Open folder" button on entity page). |
| Aggregation reach | Symmetric or owner-down only? | Symmetric — walk relationships in both directions. `Clients/Smith/`, `Companies/Smith Marine LLC/`, `Yachts/MV Serenity/` all show the full graph from their vantage point. |
| Search scope | Where does the search box look? | Current folder + descendants. Empty/root selection → port-wide. Includes both Signing and Files results. |
| Rollout | Feature flag or hard cutover? | Hard cutover. Migration backfills data; new hub replaces old hub on merge. |
## Schema deltas
### `files` table
```sql
ALTER TABLE files
ADD COLUMN folder_id text REFERENCES document_folders(id) ON DELETE SET NULL;
CREATE INDEX idx_files_folder ON files(folder_id);
CREATE INDEX idx_files_port_folder ON files(port_id, folder_id);
```
### `document_folders` table
```sql
ALTER TABLE document_folders
ADD COLUMN system_managed boolean NOT NULL DEFAULT false,
ADD COLUMN entity_type text, -- null | 'root' | 'client' | 'company' | 'yacht'
ADD COLUMN entity_id text, -- null when entity_type is null or 'root'
ADD COLUMN archived_at timestamptz; -- mirrors entity archive state
-- Per-port uniqueness on (entity_type, entity_id) for entity subfolders.
-- Excludes 'root' folders (handled by name uniqueness already in place).
CREATE UNIQUE INDEX uniq_document_folders_entity
ON document_folders(port_id, entity_type, entity_id)
WHERE entity_id IS NOT NULL;
-- Enforce: system_managed=true requires either entity_type='root' OR (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL).
ALTER TABLE document_folders
ADD CONSTRAINT chk_system_folder_shape CHECK (
NOT system_managed OR
entity_type = 'root' OR
(entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL)
);
```
### Backfill migration (one-time data migration script)
Runs as part of the deploy. Idempotent — safe to re-run.
1. For every port: ensure `Clients/`, `Companies/`, `Yachts/` exist with `system_managed=true`, `entity_type='root'`.
2. For every `(client | company | yacht)` entity that has at least one file or completed workflow attached: ensure its subfolder exists.
3. For every file with a non-null `client_id` / `company_id` / `yacht_id`: set `folder_id` to the matching subfolder via owner-resolution (Owner-wins).
4. For every completed workflow with `signed_file_id`: ensure the signed file's entity FKs are populated by copying from the workflow row (handles legacy completions where the signed file row was created without entity FKs).
5. Files with no entity FKs → `folder_id` left null.
Script: `pnpm tsx scripts/backfill-document-folders.ts`. Wraps in `pg_advisory_xact_lock(<port_id_hash>)` per port to serialize concurrent runs.
## Implementation surface (preview, full breakdown in the plan)
### Service layer
- `src/lib/services/document-folders.service.ts`
- `ensureEntityFolder(portId, entityType, entityId)` — INSERT-ON-CONFLICT + re-SELECT
- `ensureSystemRoots(portId)` — idempotent root creation
- `syncEntityFolderName(portId, entityType, entityId, newName)` — called from entity update services
- `applyEntityArchivedSuffix(portId, entityType, entityId)` / `applyEntityRestoredSuffix(...)` — toggle `(archived)` suffix
- `demoteSystemFolderOnEntityDelete(portId, entityType, entityId)` — flip `system_managed=false`, append `(deleted)` suffix
- `src/lib/services/files.service.ts`
- `listFilesInFolder(portId, folderId, opts)` — direct listing (folder_id match)
- `listFilesAggregatedByEntity(portId, entityType, entityId, opts)` — owner-grouped projection
- `applyEntityFkFromFolder(portId, folderId, fileInsert)` — used by upload endpoints (E8)
- `src/lib/services/documents.service.ts`
- `listInflightWorkflowsAggregatedByEntity(...)` — same projection for in-flight workflows
- `src/lib/services/clients.service.ts` / `companies.service.ts` / `yachts.service.ts`
- Add hooks to call `syncEntityFolderName` on rename, `applyEntityArchivedSuffix` on archive/restore, `demoteSystemFolderOnEntityDelete` on hard delete
### API routes
- `src/app/api/v1/files/route.ts` — accept `folderId` (direct) or `entityType + entityId` (aggregated) query params
- `src/app/api/v1/documents/route.ts` — same; collapse `tab` enum to a `signingState` filter (in-flight only by default)
- `src/app/api/v1/documents/hub-counts/route.ts` — reduce to in-flight count
- `src/app/api/v1/documents/[id]/signing-details/route.ts`**new** — returns workflow + signers + events for the dialog
- `src/app/api/webhooks/documenso/route.ts` (`handleDocumentCompleted`) — extend with owner-resolve + ensure-folder + set-FK steps
### UI components
- `src/components/documents/documents-hub.tsx` — major rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder integration. Drop the signing-status tabs.
- `src/components/documents/folder-tree.tsx` — render 🔒 marker for `system_managed`; suppress rename/move/delete in `FolderActionsMenu` for system rows
- `src/components/documents/aggregated-section.tsx`**new** — renders a Signing or Files section grouped by owner-source with per-group pagination
- `src/components/documents/signing-details-dialog.tsx`**new** — modal for "view signing details"
- `src/app/(dashboard)/[portSlug]/documents/files/page.tsx`**deleted**, replaced by 301 redirect in `next.config.mjs`
- `src/components/files/folder-tree.tsx` and the legacy `storagePath`-prefix logic — **deleted**
### Stores / hooks
- `src/stores/file-browser-store.ts` — repurposed to drive the unified hub state (currentFolder, viewMode); the legacy storagePath-keyed currentFolder semantics are replaced with `document_folders.id` references
## Testing strategy
### Unit (vitest)
- `document-folders.service.test.ts`: extend with system-folder tests — `ensureEntityFolder` idempotency, `syncEntityFolderName` collision (numeric suffix), `applyEntityArchivedSuffix` round-trip, `demoteSystemFolderOnEntityDelete` flips `system_managed`.
- `files.service.aggregated.test.ts`: aggregation projection — symmetric walk, defense-in-depth port_id, per-group pagination, file-FK-as-source-of-truth (yacht transfer scenario).
- `documents-completion.handler.test.ts`: `handleDocumentCompleted` with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner).
### Integration (vitest + real Postgres)
- `documents-hub-system-folders.integration.test.ts`: API-level — listing aggregated, system folder protection (rename/move/delete return 4xx), entity rename round-trips, archive/delete lifecycle.
- `backfill-document-folders.integration.test.ts`: backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows.
### E2E (Playwright)
- `documents-hub-aggregated.smoke.spec.ts`: open client folder → see grouped Signing + Files → open signing-details dialog → close.
- `documents-hub-upload-into-entity-folder.smoke.spec.ts`: upload PDF into Clients/Smith/ → verify `client_id` auto-set → verify file appears in entity folder.
- `documents-hub-completion-auto-deposit.realapi.spec.ts`: round-trip Documenso completion → verify signed PDF lands in owner's entity folder. (Joins the existing realapi project.)
### Visual
- Regenerate baselines for `/[port]/documents` (root view) and `/[port]/documents` with a folder selected. Snapshot key: hub-root, hub-entity-folder.
## Risks and mitigations
| Risk | Mitigation |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Aggregation queries slow on large portfolios (5k+ files per client) | Per-group pagination caps render cost; supporting indexes on `files(port_id, client_id)`, `files(port_id, company_id)`, `files(port_id, yacht_id)` already exist; new `files(folder_id)` and `files(port_id, folder_id)` cover folder filtering |
| Backfill migration locks production for too long | Per-port advisory lock; backfill batched in chunks of 1000 file rows per transaction; safe to re-run if interrupted |
| System-folder protection bypass via direct DB write | Application-level enforcement; we accept that direct DB writes can bypass (no DB constraint enforces "you can't update system_managed=true rows"). Audit log entries on folder ops surface anomalies |
| Hard cutover means broken hub if backfill fails | Backfill is idempotent and runs _before_ code rollout; if backfill fails, the new hub still renders (just with sparse folders); rollback = revert the migration + redeploy old hub binary |
| Rep confused by "view signing details" link disappearing for non-Documenso signed files (e.g., manually uploaded "already signed" PDFs via /upload-signed) | The link shows only when `signed_file_id` traces to a `documents` row; manually-uploaded "signed" PDFs that bypass the workflow won't have the link, which is correct — there's nothing to show |
## Open questions deferred to plan
- Whether to add a "Signing status" filter chip strip inside the Signing section (the deferred replacement for `awaiting_them`/`awaiting_me` tabs). Default: defer; add if rep feedback asks for it.
- Whether `Signing section in entity folders` should also surface workflows whose `interest_id` resolves to the entity (not just direct entity FK match). Default: yes, via the same Owner-wins resolution chain — codify in the projection helper.

View File

@@ -1,491 +0,0 @@
# PDF Stack Overhaul — Design
**Date:** 2026-05-12
**Branch:** `feat/documents-folders`
**Status:** Design approved; pending user review of spec; implementation planned via writing-plans skill.
## Goal
Replace `pdfme` (3 deps, 8 hand-coded coordinate templates, 571-line TipTap-to-pdfme bridge) with `@react-pdf/renderer` (JSX components, real layout primitives). Add `unpdf` for berth-PDF tier-2 rasterization. Add port-level logo upload with quality safeguards. Migrate only the internal-only PDF surfaces; remove invoice and admin-TipTap PDF generation entirely (they violate the new "no client-facing CRM-generated PDFs" rule).
## Scope (locked)
### KEEP & migrate to `@react-pdf/renderer` (internal-only)
| Surface | Current location | Caller |
| ----------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------- |
| Activity report | `src/lib/pdf/templates/reports/activity-report.ts` | `src/lib/services/reports.service.ts` |
| Revenue report | `src/lib/pdf/templates/reports/revenue-report.ts` | same |
| Pipeline report | `src/lib/pdf/templates/reports/pipeline-report.ts` | same |
| Occupancy report | `src/lib/pdf/templates/reports/occupancy-report.ts` | same |
| Client summary export | `src/lib/pdf/templates/client-summary-template.ts` | `src/lib/services/record-export.ts` |
| Berth spec export | `src/lib/pdf/templates/berth-spec-template.ts` | same |
| Interest summary export | `src/lib/pdf/templates/interest-summary-template.ts` | same |
| Expense sheet | `src/lib/services/expense-pdf.service.ts` (currently uses pdfme indirectly via `expense-export.ts`) | same |
### REMOVE entirely
| Removal | Reason |
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/lib/pdf/templates/invoice-template.ts` + `generatePdf` call in `invoices.ts:604` + API route `/api/v1/invoices/[id]/generate-pdf` | Invoices are client-facing; no CRM-generated client-facing PDFs. Future invoice rendering will use the deferred AcroForm-fill admin-template feature. |
| `src/lib/pdf/tiptap-to-pdfme.ts` (571 lines) + API route `/api/v1/admin/templates/preview` + `generatePdf` block in `document-templates.ts:516` | TipTap document templates are Documenso seed bodies; CRM does not render them to PDF anymore. |
| `src/lib/pdf/templates/eoi-standard-inapp.ts` (337 lines, HTML seed) + seed-data references | Only used as the seed `bodyHtml` text on a `document_templates` row. The in-app EOI is rendered by `fill-eoi-form.ts` (pdf-lib), not from this HTML. Safe to drop. |
| `src/lib/pdf/generate.ts` (24 lines) | Pdfme wrapper; replaced by `src/lib/pdf/render.ts`. |
| Deps: `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` | Replaced by `@react-pdf/renderer`. |
### STAYS UNTOUCHED
- `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm fill on `assets/eoi-template.pdf`) — the in-app EOI pathway.
- `src/lib/services/berth-pdf-parser.ts` tier-1 (pdf-lib AcroForm read) and tier-3 (AI fallback). Tier-2 (Tesseract OCR) gets `unpdf` for PDF→image rasterization.
- `pdf-lib` dep (still needed by `fill-eoi-form.ts` and `berth-pdf-parser.ts`).
- All Documenso integration code.
## Architecture
Three orthogonal PDF paths post-migration, each with a single owner:
```
┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐
│ react-pdf (this phase) │ │ pdf-lib AcroForm fill │ │ Documenso (external) │
│ Internal only │ │ Standardized + signing │ │ Client-facing signed │
│ │ │ │ │ docs │
│ • Reports (×4) │ │ • In-app EOI │ │ │
│ • Expenses │ │ • Future admin-upload │ │ (handled outside our │
│ • Record exports (×3) │ │ invoice templates │ │ system) │
│ • Future internal lists │ │ (deferred) │ │ │
└────────────┬─────────────┘ └────────────┬─────────────┘ └────────────────────────┘
│ │
▼ ▼
src/lib/pdf/render.ts src/lib/pdf/fill-eoi-form.ts
(renderToBuffer + (unchanged this phase)
renderToStream)
src/lib/pdf/brand-kit/
├─ DocumentShell.tsx
├─ Header.tsx
├─ Footer.tsx
├─ DataTable.tsx
├─ KeyValueGrid.tsx
├─ Section.tsx
├─ Badge.tsx
├─ charts/{Bar,Line,Pie,Funnel}Chart.tsx
├─ tokens.ts
└─ logo.ts
```
### Module boundaries
- **`brand-kit/`** — pure presentation primitives. No DB access, no CRM domain knowledge. Each component has typed props and renders react-pdf elements.
- **`templates/`** — one `.tsx` per document type. Imports brand-kit primitives + receives typed data props. No DB access; data fetching stays in the calling service.
- **`render.ts`** — the only module that touches `@react-pdf/renderer`'s `renderToBuffer` / `renderToStream`. Services call `renderPdf(<MyTemplate {...data} />)` or `renderPdfStream(<MyTemplate {...data} />)`.
- **`logo.ts`** — `resolvePortLogo(portId)` reads `system_settings.port_logo_file_id` and returns `{ source, buffer, mimeType }`. Cached per request via React `cache()`.
- **Chart rendering** — pure SVG components emitting react-pdf's native `<Svg>` primitive. No JSDOM, no headless Chrome, no canvas. Server-rendered like any other PDF component.
- **Photo embedding** (expense PDFs) — `sharp` (existing dep) compresses each receipt to ~150KB JPEG before embed. Stream-renders pages so memory stays bounded with hundreds of entries.
### Header layout constraint
The brand-kit `<Header>` reserves a fixed logo slot:
```
maxWidth: 200 (≈ 56mm)
maxHeight: 60 (≈ 17mm)
objectFit: contain // letterbox, never stretch
align: left, vertically centered within the dark header band
fallback: when resolvePortLogo returns 'fallback', render <Text style={bold}>{port.name}</Text>
at the same slot. The port-name + doc-title combination keeps the header visually balanced.
```
This is enforced inside `<Header>`, not at upload time, so the upload pipeline can accept any 200-1200px logo and trust the layout to letterbox correctly.
### Brand kit tokens
```ts
// src/lib/pdf/brand-kit/tokens.ts
export const PDF_TOKENS = {
colors: {
text: '#111111',
textMuted: '#666666',
border: '#e5e7eb',
headerBand: '#0f172a', // dark slate — matches CRM sidebar
headerText: '#ffffff',
accentBlue: '#1d4ed8',
zebra: '#f9fafb',
success: '#16a34a',
warning: '#d97706',
danger: '#dc2626',
},
fonts: {
sans: 'Helvetica',
sansBold: 'Helvetica-Bold',
mono: 'Courier',
},
sizes: {
docTitle: 18,
sectionH: 13,
body: 10,
small: 8,
caption: 7,
},
spacing: {
pagePadding: 36,
sectionGap: 18,
rowGap: 6,
},
} as const;
```
Single source of truth. Future design pass = edit this file, every PDF updates.
## Logo handling
### Layer 1 — Server-side sharp normalization (required)
```
upload → magic-byte check via sharp metadata (PNG | JPEG | WEBP | SVG | HEIC | HEIF | AVIF)
→ reject animated GIF / multi-frame PNG / multi-page TIFF
→ size cap 5MB raw
→ if SVG:
sanitize first via svgo (strip <script>, on*=, <foreignObject>, external href)
reject if sanitization removed dangerous nodes
rasterize to PNG via sharp(buf, { density: 300 }) // 300 DPI from vector
→ standard pipeline:
sharp(buf)
.extract({ left: cropX, top: cropY, width: cropW, height: cropH }) ← from client crop
.trim({ threshold: 10 })
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
.toColorspace('srgb')
.removeAlpha()-if-jpeg-source-and-near-white
.png({ compressionLevel: 9, palette: true }) ← palette where possible for smaller files
.toBuffer()
→ reject if final > 1MB
→ reject if min dimension after trim < 200px
→ store via getStorageBackend().put()
→ set system_settings.port_logo_file_id = files.id (atomic upsert)
→ soft-archive previous logo's files row (archivedAt = now)
→ write audit_logs entry: action=branding.logo.uploaded, by=user.id
→ collect warnings: [trimmed, resized, noAlpha, jpegSource, svgRasterized, heicConverted]
```
**Why rasterize SVGs to PNG at upload time:** react-pdf's `<Svg>` primitive supports a subset of SVG (Path, Rect, Circle, Line, Text, gradients, clip-paths) but not filters, animations, embedded fonts, or all the quirks of a designer-exported SVG. Sharp rasterizes via librsvg at 300 DPI on upload, eliminating runtime surprises. Single PNG to embed at render time. The vector source is captured-in-time; if the admin later needs higher resolution, they re-upload.
**Why HEIC/AVIF support:** iPhone photo exports default to HEIC; common admin pain point. Sharp handles both natively via libheif; converts to PNG in the pipeline. Less common but worth supporting.
### Layer 2 — Live upload UI
Admin opens **Port Settings → Branding → Logo**. The dialog shows:
1. **Rules above the dropzone:**
- Use PNG or SVG with a transparent background
- Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square)
- Max 5MB; we'll auto-trim and optimize
- Avoid JPEGs unless the background is solid white
2. **`react-image-crop` cropper** with aspect-ratio toggle (Wide 3:1 / Square 1:1 / Freeform).
3. **Live HTML preview** rendering the actual brand-kit `<Header>` React component beside the cropper, with the user's logo. Two preview swatches: dark header band (where the logo actually appears) and a colored background (to spot the "white box" problem with non-transparent JPEGs).
4. **Post-upload warnings** displayed in the preview:
- "JPEG with no alpha channel — white background will show on dark headers"
- "Logo trimmed to remove whitespace borders"
- "Resized from 4000×4000 to 1200×1200"
5. **"Test with sample PDF" button** — hits a sample-PDF endpoint that renders a minimal report header and streams it back. Browser opens in a new tab.
### Layer 3 — `react-image-crop` integration
Client renders the original image inside `react-image-crop` with a constrained aspect ratio. On save:
1. Client sends `multipart/form-data` with `file` + `{ cropX, cropY, cropW, cropH }` JSON sidecar.
2. Server runs the sharp pipeline above with the crop applied as the first step.
This keeps sharp as the single source of truth (no canvas-tainted-CORS issues client-side; the actual crop happens server-side using the user-provided coordinates).
### Storage path
Logos use the existing pluggable storage backend (`src/lib/storage/`). Object key shape:
```
ports/{portId}/branding/logo-{uuid}.png
```
The same backend currently serves brochures, berth PDFs, gdpr exports, etc. — `s3` for prod, `filesystem` for single-node dev. Logos inherit whatever's configured; no special routing. Trivial-image-inline-in-DB would save one S3 round-trip per PDF render but break consistency with every other file artifact; not worth it.
### Permission gating
The upload endpoint is wrapped with `withAuth(withPermission('port_settings', 'manage', …))` (same gate currently used for brochures admin, send-from accounts, etc.). Audit trail goes to `audit_logs` (`action: branding.logo.uploaded`, `entityType: port`, `entityId: portId`). Soft-archive of the prior logo file row is logged as `branding.logo.archived`.
### Resolution at render time
```ts
// src/lib/pdf/brand-kit/logo.ts
export const resolvePortLogo = cache(
async (
portId: string,
): Promise<{
source: 'logo' | 'fallback';
buffer: Buffer | null;
mimeType: 'image/png' | 'image/svg+xml' | null;
}> => {
const setting = await getSystemSetting(portId, 'port_logo_file_id');
if (!setting) return { source: 'fallback', buffer: null, mimeType: null };
const file = await db.query.files.findFirst({ where: eq(files.id, setting) });
if (!file || file.archivedAt) return { source: 'fallback', buffer: null, mimeType: null };
const backend = await getStorageBackend();
const buffer = await backend.get(file.storageKey);
return { source: 'logo', buffer, mimeType: file.mimeType as 'image/png' | 'image/svg+xml' };
},
);
```
Brand-kit `<DocumentShell>` internally calls this and passes the buffer down through context. Every template that wraps in `<DocumentShell port={port}>...</DocumentShell>` gets the logo automatically. No per-template wiring. When no logo is set, the header renders the port name as bold text instead.
## Per-template designs
### Reports — shared shell
```
┌──────────────────────────────────────────────────────────────────┐
│ [LOGO] PORT NAME REPORT TITLE │
│ generated 2026-05-12 18:44 Date-range badge │
├──────────────────────────────────────────────────────────────────┤
│ Summary cards (3-4 KPI stat boxes) │
│ ┌──────┬──────┬──────┐ │
│ │
│ ◌ CHART (full-width SVG) │
│ │
│ Detail Table (zebra rows, columns vary per report) │
├──────────────────────────────────────────────────────────────────┤
│ Port Name · Confidential · Page 1 of 3 · Generated … │
└──────────────────────────────────────────────────────────────────┘
```
| Report | Summary stat cards | Chart | Detail table columns |
| --------- | ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |
| Activity | total events, top action, top user, busiest day | Stacked bar — events per day by action | date · action · entity type · entity · user |
| Revenue | total revenue, paid, outstanding, avg invoice | Line — revenue per month + small pie paid/outstanding | invoice # · client · issued · due · amount · status |
| Pipeline | total interests, win rate, avg cycle days, top stage | Funnel — count per stage | interest · client · stage · lead category · days in stage |
| Occupancy | total berths, occupied %, available %, under-offer % | Time-series — occupancy % over period + small pie current status | berth # · status · current interest · last change |
### Expense PDF
```
┌──────────────────────────────────────────────────────────────────┐
│ [LOGO] PORT NAME — Expense Sheet │
│ Period: 2026-04-01 → 2026-04-30 · 247 entries │
├──────────────────────────────────────────────────────────────────┤
│ Summary cards: total · by category · by status │
├──────────────────────────────────────────────────────────────────┤
│ Expense entries (one row per entry, multi-page) │
│ ┌──┬──────────┬──────────┬────────┬─────────┬─────────┐ │
│ │# │ Date │ Category │ Vendor │ Amount │ Receipt │ │
│ │ │ Notes: <inline notes line, optional> │ │
│ │ │ [receipt photo, max 200×200, ~150KB JPEG] │ │
│ └──┴──────────┴──────────┴────────┴─────────┴─────────┘ │
│ Page break inserted between entries when remaining vertical │
│ space < 200px (no orphan partial rows) │
├──────────────────────────────────────────────────────────────────┤
│ Page 1 of 47 · Total: $48,232 · 247 entries │
└──────────────────────────────────────────────────────────────────┘
```
Critical: **stream-render via `renderToStream`** because 247 entries × ~150KB photos = 37MB peak memory if all loaded at once. Stream renders one page at a time, freeing buffers as it goes. Each photo passes through `sharp.resize(800, 800, { fit: 'inside' }).jpeg({ quality: 70 })` once and is cached for the lifetime of the request.
### Record exports
- **Client Summary** — brand shell + key/value grid for client info + table for yachts + table for interests + activity timeline at bottom.
- **Berth Spec** — brand shell + two-column key/value grid (info / dimensions / pricing / tenure) + infrastructure table + waiting-list table + maintenance-log table.
- **Interest Summary** — brand shell + stage badge in header + key/value grids for client/yacht/berth + notes block + activity timeline.
## Data flow
### Caller migration pattern
Before:
```ts
import { generatePdf } from '@/lib/pdf/generate';
import {
activityReportTemplate,
buildActivityInputs,
} from '@/lib/pdf/templates/reports/activity-report';
const inputs = buildActivityInputs(data, port.name);
const pdfBytes = await generatePdf(activityReportTemplate, inputs);
```
After:
```ts
import { renderPdf } from '@/lib/pdf/render';
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
const pdfBytes = await renderPdf(<ActivityReportPdf port={port} data={data} />);
```
### Render module
```ts
// src/lib/pdf/render.ts
import { renderToBuffer, renderToStream } from '@react-pdf/renderer';
import type { ReactElement } from 'react';
import { logger } from '@/lib/logger';
export async function renderPdf(element: ReactElement): Promise<Buffer> {
try {
return await renderToBuffer(element);
} catch (err) {
logger.error({ err }, 'PDF render failed');
throw new Error('Failed to render PDF');
}
}
export async function renderPdfStream(element: ReactElement): Promise<NodeJS.ReadableStream> {
return renderToStream(element);
}
```
### Chart rendering (sketch)
```tsx
// src/lib/pdf/brand-kit/charts/BarChart.tsx
import { Svg, Line, Rect, Text as SvgText } from '@react-pdf/renderer';
import { PDF_TOKENS } from '../tokens';
export function BarChart({
data,
width = 480,
height = 200,
color = PDF_TOKENS.colors.accentBlue,
}) {
const max = Math.max(...data.map((d) => d.value));
const barW = (width - 60) / data.length;
return (
<Svg width={width} height={height}>
<Line
x1={40}
y1={20}
x2={40}
y2={height - 30}
strokeWidth={1}
stroke={PDF_TOKENS.colors.border}
/>
<Line
x1={40}
y1={height - 30}
x2={width - 10}
y2={height - 30}
strokeWidth={1}
stroke={PDF_TOKENS.colors.border}
/>
{data.map((d, i) => {
const h = (d.value / max) * (height - 60);
return (
<Rect
key={i}
x={50 + i * barW}
y={height - 30 - h}
width={barW - 4}
height={h}
fill={color}
/>
);
})}
{data.map((d, i) => (
<SvgText
key={i}
x={50 + i * barW + (barW - 4) / 2}
y={height - 14}
textAnchor="middle"
fontSize={7}
>
{d.label}
</SvgText>
))}
</Svg>
);
}
```
Same pattern for LineChart / PieChart / FunnelChart. ~60-100 lines each.
## Error handling
| Failure mode | Detection | Surface |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Logo file missing at render time | `resolvePortLogo` returns `source: 'fallback'` | Header renders port-name text only; structured log warning. |
| Logo file corrupt | `sharp` throws on load | 500 via `errorResponse(InternalError)`; structured log; admin sees "Logo file is unreadable, please re-upload." |
| Chart data empty | Component prop validation in template | Render "No data for selected period" placeholder; no crash. |
| Receipt photo missing (expense PDF) | Storage backend `get` throws | Skip photo for that entry; render "Receipt unavailable" placeholder text; continue; collect into `warnings[]` and log. |
| Receipt photo unprocessable by sharp | `sharp` throws on resize | Same as above. |
| Stream-render aborted mid-page | `renderToStream` rejects | Caller drains stream into try/catch; surface `errorResponse(error)`; partial bytes not stored. |
| OOM on huge expense PDF | Heap monitor | Stream-render keeps peak bounded; cap entries at 1000 per PDF; prompt admin to split into multiple periods. |
| Sharp pipeline rejects upload | Specific error code | 422 `ValidationError` with the rejection reason ("file > 5MB", "dimension < 200px", "unsupported format: GIF animated"). |
| SVG with embedded JS or external href | `svgo` strips scripts; post-sanitize node-count check | Reject with `ValidationError('SVG contained disallowed nodes')`. |
| Concurrent logo uploads (admin clicks save twice / two browser tabs) | Last-writer-wins via atomic `system_settings` upsert | Both `files` rows persist; only newer is pointed at. Soft-archive doesn't race because it operates on the OLD setting's file_id captured before the upsert. |
| Mid-render logo upload | `resolvePortLogo` reads at render-start | In-flight PDF uses whichever logo was current when the request entered. Next request gets the new one. No mid-PDF logo swap. |
| Logo dimensions wildly off the header aspect ratio | Brand-kit `<Header>` constrains logo to `maxWidth: 200, maxHeight: 60` with `objectFit: contain` | Logo letterboxes inside its slot; never distorts. |
| Cropper coords out of bounds | Server-side validation against image metadata before sharp extract | 422 `ValidationError('Crop coordinates out of image bounds')`. |
| File mime header lies (claims PNG, bytes are HTML) | Sharp's `metadata()` reads actual magic bytes, ignores declared mime | Sharp throws → 422 `ValidationError('File contents do not match a supported image format')`. |
| Storage backend `put` fails (network glitch) | Catch around `backend.put` | Roll back: do not insert files row, do not change system_settings; return 503 with retry hint. |
| `port_logo_file_id` setting points at archived/deleted file | `resolvePortLogo` checks `archivedAt` | Treat as missing; fall back to text header; structured log warning so ops notices. |
## Testing
### Unit (vitest)
- `brand-kit/charts/*.test.tsx` — snapshot SVG output for known inputs.
- `brand-kit/logo.test.ts``resolvePortLogo` with fixtures for: configured / missing / archived / corrupt.
- `pdf/render.test.ts` — round-trip a tiny `<Page>` and verify the output starts with `%PDF-`.
- `services/logo-upload.test.ts` — sharp pipeline for: PNG-with-alpha (passes) / JPEG (warning) / undersized (rejects) / oversized (resizes) / SVG (passthrough) / animated GIF (rejects) / SVG with script tag (rejects).
### Integration (vitest)
- Each template renders to bytes without throwing, given representative fixtures from seed data.
- `reports.service.test.ts` — generate each of the 4 reports for a seeded port; assert PDF magic byte + non-zero length.
- `record-export.test.ts` — generate client / berth / interest summaries for seeded entities.
- `expense-export.test.ts` — generate expense PDF for 250 seeded entries; assert pages > 5; assert peak heap delta < 200MB (proxy for stream-render working).
### Playwright (smoke)
- New spec: `branding-logo-upload.spec.ts` — upload PNG, see preview, save, generate sample PDF, assert PDF downloads.
- New spec: `reports-pdf-export.spec.ts` — for each of the 4 reports, click export, assert PDF downloads.
- Existing specs: anywhere clicking "export PDF" was tied to pdfme, update assertion.
### Visual regression (existing visual project)
- 4 new baselines (one per report) using seed port's logo.
- 3 new baselines (client / berth / interest summary).
- 1 new baseline (expense PDF, first 2 pages).
- Snapshots stored as PNG (rendered from PDF via first-page extraction).
## Migration sequence
| # | Commit | Files touched | Verifies |
| --- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| 1 | Foundation: install deps + brand kit | +`@react-pdf/renderer`, +`unpdf`, +`react-image-crop`, +`svgo`; new `src/lib/pdf/brand-kit/*`, `src/lib/pdf/render.ts` | brand kit unit tests pass; nothing wired yet |
| 2 | Logo upload feature | new `src/lib/services/logo.service.ts`, `src/app/api/v1/admin/branding/logo/*`, admin UI in port settings, `system_settings.port_logo_file_id` key | upload + preview + sample-PDF test work in dev |
| 3 | Migrate activity report | port `activity-report.ts``activity-report.tsx`; rewire `reports.service.ts` caller; visual baseline | report exports work; visual diff approved |
| 4 | Migrate revenue report | same shape | same |
| 5 | Migrate pipeline report | same shape | same |
| 6 | Migrate occupancy report | same shape | same |
| 7 | Migrate client summary | port `client-summary-template.ts``.tsx`; rewire `record-export.ts` | same |
| 8 | Migrate berth spec | same | same |
| 9 | Migrate interest summary | same | same |
| 10 | Migrate expense PDF | port `expense-pdf.service.ts` to react-pdf streaming; sharp photo compression | 250-entry seed test passes |
| 11 | Remove invoice PDF generation | delete `invoice-template.ts`, the `generatePdf` call in `invoices.ts`, the API route `/api/v1/invoices/[id]/generate-pdf`; remove UI link | invoice list still works minus PDF button |
| 12 | Remove TipTap-→-pdfme bridge | delete `tiptap-to-pdfme.ts`, the preview route, the `generatePdf` block in `document-templates.ts:516`, the `getStandardEoiTemplateHtml` seed reference | admin template editor still saves; preview removed |
| 13 | Add unpdf to berth parser tier-2 | wire `unpdf` into `berth-pdf-parser.ts` for PDF→image rasterization; keep tesseract.js | berth PDF upload still parses |
| 14 | Cleanup: drop pdfme deps | remove `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` from package.json; delete `generate.ts`, `eoi-standard-inapp.ts`; clean up unused validators | `pnpm install` clean; no remaining imports |
Total: 14 commits. Most are small (5-15 file diffs). Commits 2, 10, and 12 are the heaviest. Vitest + tsc stay green throughout; each commit only flips behavior after its tests pass.
## Deferred (added to BACKLOG)
- Admin-uploaded PDF templates with AcroForm-fill (the invoice template-fill pattern). Needs: new `pdf_templates` table + field-mapping editor + admin upload UI + generalized `fillAcroForm()` utility. Likely ~1 week solo.
- Port brand color tokens (admin sets brand color → flows into PDF accent color). ~2h.
- Per-template logo override (different logo for invoices vs reports). YAGNI unless asked.
- Optical receipt-photo rotation/deskew (auto-rotate phone-upload receipts to readable orientation). ~half day.
- Replace tesseract.js with cloud OCR (AWS Textract / Google Vision) for berth parsing tier-2. Out of scope.
## Open questions
None blocking. Implementation can begin after user spec review.

View File

@@ -1,124 +0,0 @@
# Website ↔ CRM cutover runbook
This document captures the agreed plan (per the 2026-05-09 audit, Q6) for
moving the marketing website off the legacy NocoDB Berths table and onto
the CRM as the source of truth. Decision: **double-write transition
window** — both feeds stay live for ~30 days, then NocoDB is decommissioned.
The CRM side is fully wired today. Most outstanding work lives in the
**website repo**.
---
## Endpoints involved
### Public berth feed (replaces NocoDB Berths read path)
- `GET /api/public/berths` — list (NocoDB-verbatim shape; see
`src/lib/services/public-berths.ts`)
- `GET /api/public/berths/[mooringNumber]` — single
- Cache: `s-maxage=300, stale-while-revalidate=60` (5 min)
- Status mapping: `Sold` > `Under Offer` > `Available`
### Public inquiry intake (replaces NocoDB inquiry write path)
- `POST /api/public/website-inquiries` — accepts inquiry form submissions
from the marketing site
- Auth: shared secret in `X-Intake-Secret` header, compared via timing-safe
equality against `WEBSITE_INTAKE_SECRET`. Refuses every request when the
env var is unset (correct posture for dev / staging until the website is
also configured).
### Health endpoint (monitoring contract)
- `GET /api/public/health` — anonymous: `{status, timestamp}` (always 200,
for uptime monitors). Authenticated with `X-Intake-Secret`: full
`{status, env, appUrl, timestamp, checks: {db, redis}}` payload, returns
503 when any dependency is down. The website calls the authenticated
variant on startup so it refuses to boot when its `CRM_PUBLIC_URL`
points at the wrong env.
---
## Pre-cutover checklist (CRM side — done)
- [x] `/api/public/berths` serves Map Data (117 rows backfilled
2026-05-09).
- [x] PublicBerth payload exposes verbatim NocoDB fields, plus
booleans / metric variants / timestamps (commit `72ab718`). Price
intentionally omitted (decision Q4).
- [x] `/api/public/website-inquiries` POST handler exists, gated on
`WEBSITE_INTAKE_SECRET`.
- [x] `WEBSITE_INTAKE_SECRET` documented in `.env.example`.
## Pre-cutover checklist (website repo — owed)
- [ ] Generate a strong shared secret (`openssl rand -hex 32`) and set
`CRM_INTAKE_SECRET` (website) **and** `WEBSITE_INTAKE_SECRET` (CRM)
to the same value in production.
- [ ] Wire the website's berth-map fetch to `${CRM_PUBLIC_URL}/api/public/berths`.
Keep the existing NocoDB fetch in parallel for the transition window.
- [ ] Wire the website's inquiry submit handler to `POST` to
`${CRM_PUBLIC_URL}/api/public/website-inquiries` with the
`X-Intake-Secret` header. Keep the existing NocoDB write in parallel.
- [ ] Add a startup probe to `${CRM_PUBLIC_URL}/api/public/health`
(authenticated) so the website fails fast on misconfigured env.
## Double-write window (target: 30 days)
During the window:
1. Marketing site reads from BOTH feeds for any change-detection or
reconciliation jobs (or just CRM if reads can flip atomically).
2. Marketing site writes inquiries to BOTH NocoDB and CRM. The CRM
surface is treated as authoritative for triage; NocoDB stays as a
passive backup so the rollback path is one DNS / env flip away.
3. Berth status edits made in CRM are NOT synced back to NocoDB.
NocoDB will progressively go stale — accepted because the website is
already preferring the CRM read. NocoDB stays usable as a snapshot of
pre-cutover state.
4. Daily sanity check: `curl -s ${CRM_PUBLIC_URL}/api/public/berths | jq '.pageInfo'`
— confirms the public feed still serves and the row count matches
expectations (117 berths in port-nimara).
## Cutover steps (target: ~Day 30)
1. Stop the NocoDB-side writes from the website (drop the dual write).
2. Stop the NocoDB-side reads from the website (CRM-only).
3. Mark the NocoDB Berths table read-only via NocoDB ACL.
4. Wait 7 days; if no one notices anything missing, drop the NocoDB
Berths table and revoke the NocoDB MCP token from `~/.claude.json`.
## Rollback path
The double-write design means rollback within the 30-day window is a
single env / DNS flip:
- Website: change `CRM_PUBLIC_URL` to the old NocoDB-fronted URL OR
toggle a feature flag back to NocoDB.
- CRM: no change required — the public endpoints stay live for any
consumer that didn't roll back.
After NocoDB is decommissioned, rollback requires restoring the table
from backup. That's the trade-off for the cleaner final state.
---
## Open follow-ups
- **Berth `archived_at`** — when retiring a berth, the public feed will
still serve it. Add a soft-delete column + filter on
`/api/public/berths` before any berth is permanently removed. (Not
blocking the cutover; flagged in the audit.)
- **CRM-edit drift vs re-imports** — `scripts/import-berths-from-nocodb.ts`
skips rows where `updated_at > last_imported_at`. After cutover the
website MUST stop writing to NocoDB; if any straggler write hits
NocoDB and someone re-runs the import script, those edits would
silently win over CRM data. Mitigation: the script is opt-in, and the
`updated_at` guard means a full re-import only overwrites when the
rep explicitly passes `--force`. Decommission the script once cutover
is irreversible.
- **5-minute cache** — `s-maxage=300` on `/api/public/berths` means a
CRM-side status flip won't show on the website for up to 5 minutes.
Acceptable for marketing; bump if marketing wants near-real-time
updates.

View File

@@ -1,160 +0,0 @@
# Website → CRM wiring refactor
The `website/` subrepo (Nuxt) currently writes inquiry submissions to NocoDB.
The new CRM exposes its own public ingestion endpoints, so the website needs
to be re-pointed at the CRM and the website's local server-side helpers can
eventually be retired.
This document describes **what needs to change in the website repo**. Nothing
here applies to the CRM repo — that side is already done.
## Endpoints the CRM now exposes
Both are unauthenticated, IP-rate-limited (5/hour), and require an explicit
port id (query param `?portId=…` or header `X-Port-Id`).
| Form intent | New CRM endpoint | Old NocoDB target |
| -------------------- | ---------------------------------------- | ------------------------ |
| Berth interest | `POST /api/public/interests` | `Interests` (NocoDB) |
| Residential interest | `POST /api/public/residential-inquiries` | `Interests (Residences)` |
Notification emails (client confirmation + sales-team alert) are sent by the
CRM itself when these endpoints succeed, so the website's
`sendRegistrationEmails` helper (`server/utils/email.ts`) is no longer
required for these flows.
## Required changes in the website repo
### 1. New env vars
Add to `.env` and the deploy environment:
```
PN_CRM_BASE_URL=https://crm.portnimara.com
PN_CRM_PORT_ID=<uuid of the Port Nimara port row in CRM>
```
`PN_CRM_BASE_URL` defaults to the prod CRM. In dev it can point to the local
tunnel (`shoulder-contain-…trycloudflare.com`) so submissions hit a dev DB.
### 2. Refactor `server/api/register.ts`
Today the file owns both the berth and residence branches and writes to
NocoDB directly. After the refactor, both branches just relay to the CRM:
```ts
const baseUrl = process.env.PN_CRM_BASE_URL;
const portId = process.env.PN_CRM_PORT_ID;
if (category === 'Residences') {
await $fetch(`${baseUrl}/api/public/residential-inquiries?portId=${portId}`, {
method: 'POST',
body: {
firstName: body.first_name,
lastName: body.last_name,
email: body.email,
phone: body.phone,
placeOfResidence: body.address,
preferredContactMethod: body.method_of_contact, // 'email' | 'phone'
notes: body.notes,
// preferences: collect via new optional textarea (see section 4)
},
});
return { success: true };
}
// Berth branch
await $fetch(`${baseUrl}/api/public/interests?portId=${portId}`, {
method: 'POST',
body: {
// map to the CRM's publicInterestSchema (see src/lib/validators/interests.ts)
firstName: body.first_name,
lastName: body.last_name,
email: body.email,
phone: body.phone,
address: body.address,
berthSize: body.berth_size,
berthMinLength: body.berth_min_length,
berthMinWidth: body.berth_min_width,
berthMinDraught: body.berth_min_draught,
yachtName: body.berth_yacht_name,
preferredMethodOfContact: body.method_of_contact,
specificBerthMooring: body.berth, // optional, links interest to a specific berth
},
});
return { success: true };
```
The reCAPTCHA verification stays in the website handler — the CRM trusts the
website to gate its public endpoints.
### 3. Retire dead code
After step 2, the following can be deleted from the website:
- `server/utils/websiteInterests.ts`
- `server/utils/residentialInterests.ts`
- `server/utils/nocodb.ts`
- The NocoDB-specific call sites in `server/utils/email.ts` (the CRM
sends its own confirmation/alert emails)
- NocoDB env vars (`NOCODB_*`)
The Nuxt `/api/berths` route stays as-is — it reads from the
`directus_items.berths` collection for the public site, not the CRM.
### 4. Form additions on `pages/register.vue`
The current residence branch only collects contact info. The CRM accepts an
optional `preferences` field (free-text) and `notes` field. Add a
"Preferences" textarea inside the residences block of
`components/pn/specific/website/register/form.vue`:
```vue
<transition name="fade-down">
<div v-show="interest === 'residences'">
<vee-field
as="textarea"
class="form-input py-3 px-0 md:text-lg border-0 border-t border-davysgrey ..."
placeholder="Tell us what you're looking for (unit type, budget, timeline)"
name="residence_preferences"
:disabled="loading"
/>
</div>
</transition>
```
Append `preferences: body.residence_preferences` in the POST body in
`server/api/register.ts`.
### 5. Stand up a residential-only `residences.vue` form (optional)
Today the residences interest is captured on `register.vue` via a radio. If
the marketing team wants a dedicated CTA on `residences.vue`, add a small
inline form using the same submit handler from step 2. No new endpoint —
this is purely a UX addition.
## Deployment order
1. **CRM first**: deploy this repo, ensure `/api/public/interests` and
`/api/public/residential-inquiries` are reachable from the website host.
2. **Verify in CRM**: configure `Inquiry Contact Email` and (for residential)
`Residential Notification Recipients` per port in
admin → settings.
3. **Smoke test from a dev tunnel** (curl the public endpoints with a JSON
payload). Confirm rows land in `clients`/`residential_clients` and
notification emails are received.
4. **Then deploy website changes** (sections 13 above). The form
submissions immediately start landing in the new CRM.
5. **Cut-over note**: once the website is pointed at the CRM, leave the
NocoDB tables read-only as a historical archive. Don't delete them until
prod data has been imported into the new CRM (see "Prod data import
strategy" task #59 in the task list).
## Open questions
- **Port routing for multi-port deploys**: today the website only knows about
Port Nimara. If/when the website serves multiple ports, the `portId`
resolution needs to happen per-domain or per-route, not a single env var.
- **Brand/email domain**: confirm whether residential confirmations should
send from the same `noreply@letsbe.solutions` address as marina, or a
dedicated residential mailbox. The CRM uses `SMTP_FROM`, which is global.

View File

@@ -33,6 +33,72 @@ const eslintConfig = [
'react-hooks/refs': 'error',
'react-hooks/set-state-in-effect': 'error',
'react-hooks/incompatible-library': 'off',
// Icon-only buttons must carry a label that screen readers can
// surface — either an explicit `aria-label`, an `aria-labelledby`,
// a `title`, or a visible-but-sr-only text child. Catches the
// pattern where a `<button><Trash2 /></button>` ships with no
// accessible name. Default Next config enables this at `error`;
// we keep it loud so new code doesn't regress.
'jsx-a11y/control-has-associated-label': [
'warn',
{
labelAttributes: ['label'],
controlComponents: ['Button'],
ignoreElements: ['audio', 'canvas', 'embed', 'input', 'textarea', 'tr', 'video'],
ignoreRoles: [
'grid',
'listbox',
'menu',
'menubar',
'radiogroup',
'row',
'tablist',
'toolbar',
'tree',
'treegrid',
],
depth: 5,
},
],
},
},
{
// User-facing copy in src/components and src/app should never use
// em-dashes (—) in JSX text. The user reads em-dashes as a
// tell-tale "AI-generated" marker; we prefer periods, commas, or
// simple hyphens. Code comments / audit-log strings / templates
// outside these directories are exempt.
//
// Same rule block also nudges new code toward CSS logical properties
// (ms-/me-/ps-/pe-/text-start/text-end/border-s/border-e) instead of
// physical Tailwind utilities. RTL isn't a roadmap requirement today,
// but every new ml-/mr-/pl-/pr-/text-left/text-right we accept now
// is a class we'd have to migrate later. Existing 1,000+ sites stay
// untouched (warn-only). Inline `// eslint-disable-next-line` when
// the directional intent is truly physical (e.g. a chevron icon).
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
rules: {
// Both selectors share `warn` severity because the RTL nudge is
// grandfathered (1,000+ existing sites use ml-/mr-/etc). The
// em-dash sweep cleared every existing instance (2026-05-21), so
// `warn` still effectively gates new code — it just doesn't break
// CI on grandfathered RTL utilities. Inline
// `// eslint-disable-next-line no-restricted-syntax` when the
// directional intent is truly physical.
'no-restricted-syntax': [
'warn',
{
selector: "JSXText[value=/\\u2014/]",
message:
'No em-dash in user-facing JSX text. Use period, comma, or hyphen instead.',
},
{
selector:
"JSXAttribute[name.name='className'] > Literal[value=/(?:^|[\\s:])(?:ml-|mr-|pl-|pr-|text-left|text-right|border-l\\b|border-r\\b|rounded-l-|rounded-r-)/]",
message:
'Prefer CSS logical properties (ms-/me-/ps-/pe-/text-start/text-end/border-s/border-e/rounded-s-/rounded-e-) over physical directional Tailwind utilities. Existing code is grandfathered; new code should default to logical so a future RTL pass is bounded.',
},
],
},
},
{

View File

@@ -1,9 +0,0 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"back": "Back"
}
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/dev/types/routes.d.ts';
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,13 +1,7 @@
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';
import createNextIntlPlugin from 'next-intl/plugin';
import { withSentryConfig } from '@sentry/nextjs';
// next-intl plugin — points at our request-config entrypoint. Even
// though we ship only English today, the plugin is wired so future
// locale additions are a config-only change, not a code rewrite.
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const isProd = process.env.NODE_ENV === 'production';
// Wrap the config with the bundle analyzer. Run `ANALYZE=true pnpm build`
@@ -84,11 +78,13 @@ const nextConfig: NextConfig = {
// visible in every screenshot from the iPhone testing pass.
devIndicators: false,
// LAN access from a real iPhone hits the dev server via the Mac's
// local IP (e.g. 192.168.x.x), not localhost. Next 15 surfaces a
// warning for cross-origin /_next/* fetches unless we allow-list the
// origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so
// any LAN device works without a config edit per network.
...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }),
// local IP (e.g. 192.168.x.x), not localhost. Next surfaces a warning
// and blocks cross-origin /_next/* fetches (incl. HMR) unless we
// allow-list the origins explicitly. When HMR is blocked the page
// never fully hydrates and form click handlers fall back to native
// submits — the symptom that bit us with a hard-coded IP. Wildcards
// cover any LAN device without a per-network config edit.
...(isProd ? {} : { allowedDevOrigins: ['192.168.*.*', '10.*.*.*', '172.16.*.*', '172.20.*.*'] }),
// Native/CJS-leaning server-only packages — list here so Next doesn't
// bundle them into the route trace (slower cold start + risk that
// native bindings fail at runtime). Build-auditor C3+M3: socket.io
@@ -118,6 +114,10 @@ const nextConfig: NextConfig = {
remotePatterns: [{ protocol: 'https', hostname: '*.portnimara.com' }],
},
typedRoutes: true,
// ECharts ships ES modules that older Next/webpack versions can't parse
// without a transpile-pass. Listing here is the official recommendation
// from echarts-for-react when used inside Next.
transpilePackages: ['echarts', 'zrender', 'echarts-for-react'],
outputFileTracingIncludes: {
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
// runtime in the standalone build. Reading via fs.readFile from
@@ -165,4 +165,4 @@ const withSentry = process.env.NEXT_PUBLIC_SENTRY_DSN
})
: (cfg: NextConfig) => cfg;
export default withSentry(withBundleAnalyzer(withNextIntl(nextConfig)));
export default withSentry(withBundleAnalyzer(nextConfig));

View File

@@ -37,6 +37,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@formkit/auto-animate": "^0.9.0",
"@hookform/resolvers": "^5.2.2",
"@next/bundle-analyzer": "^16.2.6",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
@@ -67,6 +68,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@types/pdfkit": "^0.17.6",
"@umami/node": "^0.4.0",
"@use-gesture/react": "^10.3.1",
"archiver": "^7.0.1",
"better-auth": "^1.6.11",
@@ -75,10 +77,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"country-flag-icons": "^1.6.17",
"cron-parser": "^5.5.0",
"date-fns": "^4.1.0",
"docx-preview": "^0.3.7",
"drizzle-orm": "^0.45.2",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"embla-carousel-react": "^8.6.0",
"exceljs": "^4.4.0",
"imapflow": "^1.3.3",
"ioredis": "^5.10.1",
"iso-3166-2": "^1.0.0",
@@ -90,7 +97,6 @@
"minio": "^8.0.7",
"motion": "^12.38.0",
"next": "16.2.6",
"next-intl": "^4.11.2",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.7",
"openai": "^6.37.0",
@@ -120,6 +126,7 @@
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"ssh2-sftp-client": "^12.1.1",
"svgo": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tesseract.js": "^7.0.0",
@@ -136,11 +143,11 @@
"@axe-core/playwright": "^4.11.3",
"@faker-js/faker": "^10.4.0",
"@hookform/devtools": "^4.4.0",
"@next/bundle-analyzer": "^16.2.6",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.3.0",
"@total-typescript/ts-reset": "^0.6.1",
"@types/archiver": "^7.0.0",
"@types/geojson": "^7946.0.16",
"@types/iso-3166-2": "^1.0.4",
"@types/mailparser": "^3.4.6",
"@types/node": "^20.19.0",
@@ -148,6 +155,8 @@
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6",
"dotenv": "^17.4.2",

1038
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/Overhead_1_blur.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -73,10 +73,6 @@ const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
pattern: /\/custom-fields\/\[entityId\]\//,
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
},
{
pattern: /\/berth-reservations\/\[id\]\/route\.ts$/,
reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).',
},
];
interface Finding {

View File

@@ -0,0 +1,158 @@
/**
* Backfill `document_signers` rows for EOI documents that were generated
* before the per-recipient signer-row insert landed (pre-2026-05-15).
*
* Symptom on the affected docs: the EOI tab's "Signing progress" panel
* reads "No signers loaded" forever because the webhook handler updates
* existing rows (by token / email) and never inserts new ones.
*
* This script walks every documents row that has a documensoId, status
* in ('sent', 'partially_signed', 'completed'), and zero signer rows.
* For each, it pulls the envelope from Documenso and recreates the
* signer rows from the recipients array. Idempotent — safe to re-run.
*
* Usage:
* pnpm tsx scripts/backfill-eoi-signers.ts # dry-run, lists candidates
* pnpm tsx scripts/backfill-eoi-signers.ts --apply # actually inserts
*/
import 'dotenv/config';
import { and, inArray, isNotNull, sql } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { documents, documentSigners } from '@/lib/db/schema/documents';
import { getDocument as getDocumensoDoc } from '@/lib/services/documenso-client';
import { logger } from '@/lib/logger';
interface BackfillStats {
scanned: number;
withZeroSigners: number;
inserted: number;
failed: number;
skipped: number;
}
async function main() {
const apply = process.argv.includes('--apply');
// 1. Find candidate documents: in-flight or completed EOIs with a
// documensoId and no signer rows.
const candidates = await db
.select({
id: documents.id,
portId: documents.portId,
documensoId: documents.documensoId,
status: documents.status,
documentType: documents.documentType,
title: documents.title,
signerCount: sql<number>`(
SELECT COUNT(*)::int FROM ${documentSigners}
WHERE ${documentSigners.documentId} = ${documents.id}
)`,
})
.from(documents)
.where(
and(
inArray(documents.status, ['sent', 'partially_signed', 'completed']),
isNotNull(documents.documensoId),
),
);
const stats: BackfillStats = {
scanned: candidates.length,
withZeroSigners: 0,
inserted: 0,
failed: 0,
skipped: 0,
};
const needsBackfill = candidates.filter((c) => c.signerCount === 0);
stats.withZeroSigners = needsBackfill.length;
console.log(
`Scanned ${stats.scanned} document${stats.scanned === 1 ? '' : 's'}; ${stats.withZeroSigners} need backfill.`,
);
if (!apply) {
console.log('\nDRY RUN (pass --apply to insert):');
for (const doc of needsBackfill) {
console.log(` - ${doc.id} (${doc.title}) — port=${doc.portId}, status=${doc.status}`);
}
await closeDb();
return;
}
// 2. For each candidate, fetch the envelope from Documenso and insert
// the signer rows. Failures are logged + counted; processing
// continues so one broken doc doesn't halt the run.
for (const doc of needsBackfill) {
if (!doc.documensoId) {
stats.skipped++;
continue;
}
try {
const envelope = await getDocumensoDoc(doc.documensoId, doc.portId);
if (envelope.recipients.length === 0) {
logger.warn({ documentId: doc.id }, 'Backfill: envelope has no recipients — skipping');
stats.skipped++;
continue;
}
// Use the same role-mapping logic as the create-time flow:
// - signingOrder=1 + role SIGNER → 'client' (positional)
// - SIGNER otherwise → 'signer'
// - APPROVER → 'approver'
// - CC / VIEWER → pass-through
const rows = envelope.recipients.map((r) => {
const cleanName = (r.name || r.email)
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.trim();
const upRole = r.role.toUpperCase();
const role =
upRole === 'SIGNER' && r.signingOrder === 1
? 'client'
: upRole === 'APPROVER'
? 'approver'
: upRole === 'CC'
? 'cc'
: upRole === 'VIEWER'
? 'viewer'
: 'signer';
return {
documentId: doc.id,
signerName: cleanName || r.email,
signerEmail: r.email,
signerRole: role,
signingOrder: r.signingOrder,
status: (r.status === 'SIGNED' ? 'signed' : 'pending') as 'signed' | 'pending',
signingUrl: r.signingUrl ?? null,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
// No invitedAt — the backfill can't reconstruct the original
// dispatch timestamp. Reps see the card as "Not yet invited"
// for any pending signer; clicking Send invitation re-stamps.
invitedAt: null,
};
});
await db.insert(documentSigners).values(rows);
stats.inserted += rows.length;
console.log(`${doc.id} (${doc.title}) — inserted ${rows.length} signer row(s)`);
} catch (err) {
stats.failed++;
logger.error(
{ err: err instanceof Error ? err.message : err, documentId: doc.id },
'Backfill failed for document',
);
console.log(`${doc.id}${err instanceof Error ? err.message : 'unknown error'}`);
}
}
console.log(`\nDone. inserted=${stats.inserted} failed=${stats.failed} skipped=${stats.skipped}`);
await closeDb();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env tsx
/**
* Phase 2 nested-subfolders backfill.
*
* Re-files every existing `files` row that has `entity_type='interest'`
* (or a non-null `interest_id`) under a nested
* `Clients/<Name>/<Interest folder>/` subfolder. Idempotent — already-
* filed rows are skipped.
*
* Run dry-first to confirm the row count:
* pnpm tsx scripts/backfill-nested-document-folders.ts
*
* Apply for real:
* pnpm tsx scripts/backfill-nested-document-folders.ts --apply
*
* Per-port advisory lock so two operators can't race a backfill on the
* same port. Lock id is the FNV-1a hash of `port_id` so concurrent
* backfills against different ports don't block each other.
*/
import { sql } from 'drizzle-orm';
import { db } from '../src/lib/db';
import { ensureEntityFolder } from '../src/lib/services/document-folders.service';
const APPLY = process.argv.includes('--apply');
function fnv1a(input: string): number {
// Simple deterministic 32-bit hash — used as the advisory-lock id so
// the lock is stable across runs. PostgreSQL accepts a bigint here.
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
async function main() {
console.log(`[backfill-nested-folders] dry-run=${!APPLY}`);
// 1. Gather every (port_id, interest_id) pair whose files need to be
// nested. We only need to ensure the folder exists — the
// `files.interest_id` column is populated separately by Phase 1.
const rows = await db.execute<{ port_id: string; interest_id: string; row_count: number }>(
sql`
SELECT f.port_id, f.interest_id, COUNT(*)::int AS row_count
FROM files f
WHERE f.interest_id IS NOT NULL
AND f.archived_at IS NULL
GROUP BY f.port_id, f.interest_id
ORDER BY f.port_id, f.interest_id
`,
);
// postgres-js returns the raw result iterable; the `.rows` property is
// pgnative-only — iterate the result directly.
const list = Array.isArray(rows) ? rows : ((rows as { rows?: typeof rows }).rows ?? rows);
console.log(`[backfill-nested-folders] ${list.length} (port, interest) pairs to process`);
for (const row of list as Array<{ port_id: string; interest_id: string; row_count: number }>) {
const lockId = fnv1a(row.port_id);
if (APPLY) {
await db.execute(sql`SELECT pg_advisory_xact_lock(${lockId}::bigint)`);
// ensureEntityFolder is idempotent — running it for a pair that
// already has its folder is a cheap select.
await ensureEntityFolder(row.port_id, 'interest', row.interest_id, 'system');
}
console.log(
` ${APPLY ? '✓' : '·'} port=${row.port_id.slice(0, 8)} interest=${row.interest_id.slice(
0,
8,
)} files=${row.row_count}`,
);
}
console.log(`[backfill-nested-folders] done.`);
process.exit(0);
}
main().catch((err) => {
console.error('[backfill-nested-folders] failed', err);
process.exit(1);
});

View File

@@ -0,0 +1,48 @@
/**
* Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json)
* to a local file. Same code path as the admin "Download full backup" button
* (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for
* rehearsing the restore runbook (docs/backup-restore-runbook.md).
*
* pnpm tsx scripts/create-full-backup.ts [outfile.tar]
*
* Defaults the output name to ./pn-crm-backup-<timestamp>.tar in the CWD.
*/
import 'dotenv/config';
import { copyFile } from 'node:fs/promises';
import path from 'node:path';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
import { logger } from '@/lib/logger';
async function main(): Promise<void> {
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
try {
const dest = path.resolve(process.argv[2] ?? filename);
await copyFile(tarPath, dest);
logger.info(
{
dest,
storageBackend: manifest.storageBackend,
dbDumpBytes: manifest.database.sizeBytes,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
},
'Full backup written',
);
if (manifest.skipped.length) {
logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage');
}
} finally {
await cleanup();
}
}
main()
.then(() => process.stdout.write('', () => process.exit(0)))
.catch((err) => {
logger.error({ err }, 'Full backup failed');
process.stderr.write('', () => process.exit(1));
});

31
scripts/decrypt-backup.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Decrypt an encrypted backup bundle (`*.tar.enc`) produced when a destination
* has bundle encryption enabled. Restore step — see
* docs/backup-restore-runbook.md.
*
* BACKUP_PASSPHRASE='…' pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>
*
* The passphrase is read from $BACKUP_PASSPHRASE (not argv, to keep it out of
* shell history / the process list).
*/
import { decryptFileToFile } from '@/lib/services/backup-destinations/bundle-encryption';
async function main(): Promise<void> {
const [input, output] = process.argv.slice(2);
const passphrase = process.env.BACKUP_PASSPHRASE;
if (!input || !output) {
throw new Error(
'Usage: BACKUP_PASSPHRASE=… pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>',
);
}
if (!passphrase) throw new Error('Set BACKUP_PASSPHRASE in the environment');
await decryptFileToFile(input, output, passphrase);
process.stdout.write(`Decrypted → ${output}\n`, () => process.exit(0));
}
main().catch((err) => {
process.stderr.write(
`Decrypt failed: ${err instanceof Error ? err.message : String(err)}\n`,
() => process.exit(1),
);
});

View File

@@ -0,0 +1,28 @@
import 'dotenv/config';
import { and, eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { user, account } from '@/lib/db/schema/users';
async function main() {
const email = process.argv[2] ?? 'admin@portnimara.test';
const pw = process.argv[3] ?? 'SuperAdmin12345!';
const [u] = await db.select().from(user).where(eq(user.email, email)).limit(1);
if (!u) throw new Error(`user not found: ${email}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ctx = await (auth as any).$context;
const hash = await ctx.password.hash(pw);
const res = await db
.update(account)
.set({ password: hash })
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
.returning({ id: account.id });
console.log(`updated ${res.length} credential row(s) for ${email}`);
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,138 @@
/**
* One-time migration: encrypt any plaintext credential rows in
* `system_settings` that should now be AES-256-GCM encrypted per the
* settings registry. Safe to re-run (idempotent — only touches plaintext
* rows, skips rows that are already encrypted envelopes).
*
* Currently handles:
* - `documenso_api_key_override` → in-place encrypt
* - `storage_s3_access_key` (legacy) → encrypt + move to
* `storage_s3_access_key_encrypted`
* - `documenso_webhook_secret` (if string) → in-place encrypt
*
* Run: `pnpm tsx scripts/encrypt-plaintext-credentials.ts`
*/
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema';
import { encrypt } from '@/lib/utils/encryption';
const KEYS_TO_ENCRYPT_IN_PLACE = ['documenso_api_key_override', 'documenso_webhook_secret'];
function isEncryptedEnvelope(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { iv?: unknown }).iv === 'string' &&
typeof (value as { tag?: unknown }).tag === 'string' &&
typeof (value as { data?: unknown }).data === 'string'
);
}
async function encryptInPlace(key: string): Promise<{ touched: number; skipped: number }> {
const rows = await db
.select({ key: systemSettings.key, portId: systemSettings.portId, value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key));
let touched = 0;
let skipped = 0;
for (const row of rows) {
if (isEncryptedEnvelope(row.value)) {
skipped++;
continue;
}
if (typeof row.value !== 'string' || row.value === '') {
skipped++;
continue;
}
const envelope = JSON.parse(encrypt(row.value)) as {
iv: string;
tag: string;
data: string;
};
if (row.portId) {
await db
.update(systemSettings)
.set({ value: envelope, updatedAt: new Date() })
.where(and(eq(systemSettings.key, key), eq(systemSettings.portId, row.portId)));
} else {
await db
.update(systemSettings)
.set({ value: envelope, updatedAt: new Date() })
.where(and(eq(systemSettings.key, key), isNull(systemSettings.portId)));
}
touched++;
}
return { touched, skipped };
}
async function moveS3AccessKeyToEncrypted(): Promise<{
moved: number;
alreadyMigrated: number;
}> {
// Move global rows only — s3 storage settings are global by design.
const legacyRows = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId)));
if (legacyRows.length === 0) {
return { moved: 0, alreadyMigrated: 0 };
}
// Check if the encrypted form already exists.
const existingEncrypted = await db
.select({ key: systemSettings.key })
.from(systemSettings)
.where(
and(eq(systemSettings.key, 'storage_s3_access_key_encrypted'), isNull(systemSettings.portId)),
);
if (existingEncrypted.length > 0) {
// Encrypted form wins; leave the legacy row in place so reads still
// tolerate it (the storage layer reads both and prefers encrypted).
return { moved: 0, alreadyMigrated: legacyRows.length };
}
const plaintext = legacyRows[0]!.value;
if (typeof plaintext !== 'string' || plaintext === '') {
return { moved: 0, alreadyMigrated: 0 };
}
const envelope = JSON.parse(encrypt(plaintext)) as { iv: string; tag: string; data: string };
await db.insert(systemSettings).values({
key: 'storage_s3_access_key_encrypted',
portId: null,
value: envelope,
});
// Drop the legacy plaintext row so it doesn't show up in admin
// settings dumps anymore. The storage layer's backward-compat path
// continues to handle older rows on other deployments.
await db
.delete(systemSettings)
.where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId)));
return { moved: 1, alreadyMigrated: 0 };
}
async function main(): Promise<void> {
console.log('Encrypting plaintext credentials...');
for (const key of KEYS_TO_ENCRYPT_IN_PLACE) {
const { touched, skipped } = await encryptInPlace(key);
console.log(` ${key}: ${touched} encrypted, ${skipped} skipped`);
}
const s3 = await moveS3AccessKeyToEncrypted();
console.log(
` storage_s3_access_key → _encrypted: ${s3.moved} moved, ${s3.alreadyMigrated} already migrated`,
);
console.log('Done.');
process.exit(0);
}
main().catch((err: unknown) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

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

View File

@@ -30,6 +30,7 @@ import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { SUPER_ADMIN_USER_ID } from '@/lib/db/seed-bootstrap';
import { applyPlan } from '@/lib/dedup/migration-apply';
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
import { transformSnapshot } from '@/lib/dedup/migration-transform';
@@ -154,7 +155,7 @@ async function main(): Promise<void> {
const snapshot = await fetchSnapshot(config);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths, ${snapshot.expenses?.length ?? 0} expenses.`,
);
console.log('[migrate] Running transform + dedup pipeline…');
@@ -184,6 +185,7 @@ async function main(): Promise<void> {
console.log(
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
);
console.log(` ${s.outputExpenses} expenses`);
console.log(
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
);
@@ -208,7 +210,7 @@ async function main(): Promise<void> {
console.log('[migrate] Inserting…');
const applyStart = Date.now();
const result = await applyPlan(plan, { port, applyId });
const result = await applyPlan(plan, { port, applyId, appliedBy: SUPER_ADMIN_USER_ID });
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
console.log('');
@@ -231,6 +233,9 @@ async function main(): Promise<void> {
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
);
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
console.log(
` Expenses: ${result.expensesInserted} inserted, ${result.expensesSkipped} already linked`,
);
if (result.warnings.length > 0) {
console.log('');
@@ -242,6 +247,27 @@ async function main(): Promise<void> {
console.log(`${result.warnings.length - 20} more`);
}
}
// ── Multi-berth links (folded in for the one-shot seed) ──────────────────
// The dedup plan only carries each deal's single `Berth Number`; the legacy
// `_nc_m2m_Berths_Interests` junction (multi-berth deals) is reconnected
// here from the local `nocodb_legacy` snapshot. Best-effort: if the dump
// isn't restored, log + continue (the standalone script can run it later).
try {
const { connectBerthLinks } = await import('./migration/connect-berth-links');
const bl = await connectBerthLinks({ portSlug: port.slug });
console.log(
` Berths: ${bl.inserted} multi-berth links inserted (${bl.madePrimary} new primary), ${bl.skipped} already linked`,
);
if (bl.unresolved.length > 0) {
console.log(`${bl.unresolved.length} moorings with no CRM berth`);
}
} catch (err) {
console.log(
` Berths: ⚠ multi-berth link step skipped (${(err as Error).message}). ` +
`Run scripts/migration/connect-berth-links.ts once the nocodb_legacy dump is restored.`,
);
}
console.log('');
}

View File

@@ -0,0 +1,503 @@
/**
* Phase 2 of the legacy migration: pull signed EOI PDFs + berth spec PDFs from
* the LEGACY MinIO (`client-portal` bucket) and deposit them into the CRM's own
* storage, linking them to the already-migrated deals + berths.
*
* Two storage worlds, kept strictly separate:
* - LEGACY read : a dedicated `minio` Client using LEGACY_MINIO_* env.
* - CRM write : `getStorageBackend()` (the CRM's own configured storage).
* ⚠ We NEVER route legacy creds through getStorageBackend — that would
* write INTO prod. LEGACY_MINIO_* is distinct from the CRM's MINIO_*.
*
* Idempotent + re-runnable: an EOI is skipped once its `documents.signedFileId`
* is set; a berth is skipped once it has a `currentPdfVersionId`.
*
* Run AFTER `migrate-from-nocodb.ts --apply`:
* LEGACY_MINIO_ACCESS_KEY=… LEGACY_MINIO_SECRET_KEY=… \
* pnpm tsx scripts/migration/backfill-documents.ts --port-slug port-nimara [--dry-run]
*/
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import { Client as MinioClient } from 'minio';
import postgres from 'postgres';
import { and, eq, isNull } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { berths } from '@/lib/db/schema/berths';
import { documents, files } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import { getStorageBackend } from '@/lib/storage';
import { buildStoragePath } from '@/lib/minio';
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
import { uploadBerthPdf } from '@/lib/services/berth-pdf.service';
import { normalizeName } from '@/lib/dedup/normalize';
import { SUPER_ADMIN_USER_ID } from '@/lib/db/seed-bootstrap';
const DRY = process.argv.includes('--dry-run');
const slugArg = (() => {
const i = process.argv.indexOf('--port-slug');
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
})();
const LEGACY_BUCKET = process.env.LEGACY_MINIO_BUCKET ?? 'client-portal';
// NocoDB's own attachment store — where pre-Documenso "LOI process" EOIs live.
const DATABASE_BUCKET = process.env.LEGACY_MINIO_DATABASE_BUCKET ?? 'database';
const legacy = new MinioClient({
endPoint: process.env.LEGACY_MINIO_ENDPOINT ?? 's3.portnimara.com',
port: 443,
useSSL: true,
accessKey: process.env.LEGACY_MINIO_ACCESS_KEY ?? '',
secretKey: process.env.LEGACY_MINIO_SECRET_KEY ?? '',
});
// Read-only connection to the LOCAL restored NocoDB dump (`nocodb_legacy`) —
// used to read the `EOI_Document` attachment metadata. Never prod.
const CRM_DB_URL = process.env.DATABASE_URL ?? '';
const LEGACY_DB_URL = process.env.LEGACY_DB_URL ?? CRM_DB_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
/** Levenshtein edit distance — conservative fuzzy name matching for legacy
* spelling/format drift (Koshbin↔Khoshbin, Costanzo↔Constanzo). */
function lev(a: string, b: string): number {
const m = a.length;
const n = b.length;
if (!m) return n;
if (!n) return m;
let prev = Array.from({ length: n + 1 }, (_, i) => i);
for (let i = 1; i <= m; i++) {
const cur = [i];
for (let j = 1; j <= n; j++) {
cur[j] = Math.min(
prev[j]! + 1,
cur[j - 1]! + 1,
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
prev = cur;
}
return prev[n]!;
}
function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (c: Buffer) => chunks.push(c));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
interface LegacyObject {
name: string;
size: number;
}
function listLegacy(prefix: string): Promise<LegacyObject[]> {
return new Promise((resolve, reject) => {
const out: LegacyObject[] = [];
const stream = legacy.listObjectsV2(LEGACY_BUCKET, prefix, true);
stream.on('data', (o) => {
if (o.name && !o.name.endsWith('/')) out.push({ name: o.name, size: o.size ?? 0 });
});
stream.on('end', () => resolve(out));
stream.on('error', reject);
});
}
async function resolvePort(slug: string): Promise<{ id: string; slug: string }> {
const [p] = await db
.select({ id: ports.id, slug: ports.slug })
.from(ports)
.where(eq(ports.slug, slug))
.limit(1);
if (!p) throw new Error(`No port with slug "${slug}"`);
return p;
}
// ─── Berth PDFs ──────────────────────────────────────────────────────────────
// client-portal/Berth-PDFs/<ts>-Berth_Spec_Sheet_<Mooring>.pdf → berth by mooring.
async function backfillBerthPdfs(port: { id: string; slug: string }) {
const objs = (await listLegacy('Berth-PDFs/')).filter((o) => /\.pdf$/i.test(o.name));
const berthRows = await db
.select({ id: berths.id, mooring: berths.mooringNumber, cur: berths.currentPdfVersionId })
.from(berths)
.where(eq(berths.portId, port.id));
const byMooring = new Map(berthRows.map((b) => [b.mooring, b]));
let attached = 0;
let skipped = 0;
let unmatched = 0;
for (const o of objs) {
const m = o.name.match(/Berth_Spec_Sheet_([A-Za-z]+\d+)\.pdf$/i);
if (!m) {
unmatched++;
continue;
}
const mooring = `${m[1]!.replace(/[a-z]+/g, (s) => s.toUpperCase())}`
.toUpperCase()
.replace(/([A-Z]+)0*(\d+)/, '$1$2');
const berth = byMooring.get(mooring);
if (!berth) {
console.log(` [berth] no berth for mooring "${mooring}" (${o.name})`);
unmatched++;
continue;
}
if (berth.cur) {
skipped++;
continue;
}
if (DRY) {
attached++;
continue;
}
const buf = await streamToBuffer(await legacy.getObject(LEGACY_BUCKET, o.name));
await uploadBerthPdf({
berthId: berth.id,
portId: port.id,
buffer: buf,
fileName: o.name.split('/').pop() ?? `${mooring}.pdf`,
uploadedBy: SUPER_ADMIN_USER_ID,
});
attached++;
}
return { total: objs.length, attached, skipped, unmatched };
}
// ─── Signed EOIs ─────────────────────────────────────────────────────────────
// client-portal/EOIs/<Client Name>/<file>.pdf → match by normalized client name.
async function backfillEois(port: { id: string; slug: string }) {
// Signed EOIs live under EOIs/<Name>/ and (some) under Client Documents/<Name>/.
const objs = [...(await listLegacy('EOIs/')), ...(await listLegacy('Client Documents/'))].filter(
(o) => /\.pdf$/i.test(o.name) && /eoi|sign/i.test(o.name),
);
// Index the best signed PDF per normalized folder (client) name.
const byName = new Map<string, { key: string; size: number }>();
for (const o of objs) {
const parts = o.name.split('/'); // <prefix> / <Name> / <file>.pdf
if (parts.length < 3) continue;
const folder = (parts[1] ?? '').replace(/_/g, ' '); // "Matt_Ciaccio" → "Matt Ciaccio"
const norm = normalizeName(folder).display;
if (!norm) continue;
const isSigned = /sign/i.test(o.name);
const prev = byName.get(norm);
// Prefer a "signed" file; among those, the largest (the full signed PDF).
if (!prev || (isSigned && o.size > prev.size)) byName.set(norm, { key: o.name, size: o.size });
}
// Migrated EOI documents missing a signed file.
const docRows = await db
.select({ id: documents.id, interestId: documents.interestId, clientId: documents.clientId })
.from(documents)
.where(
and(
eq(documents.portId, port.id),
eq(documents.documentType, 'eoi'),
isNull(documents.signedFileId),
),
);
const backend = await getStorageBackend();
let attached = 0;
let unmatched = 0;
const unresolved: string[] = [];
for (const doc of docRows) {
const clientId = doc.clientId;
if (!clientId) {
unmatched++;
continue;
}
const [c] = await db
.select({ name: clients.fullName })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (!c) {
unmatched++;
continue;
}
const target = normalizeName(c.name).display;
let match = byName.get(target);
if (!match && target.length >= 6) {
// Conservative fuzzy fallback: best edit-distance ≤ 2 on the full name.
let bestDist = 3;
for (const [name, v] of byName) {
const d = lev(name, target);
if (d < bestDist) {
bestDist = d;
match = v;
}
}
}
if (!match) {
unresolved.push(c.name);
unmatched++;
continue;
}
if (DRY) {
attached++;
continue;
}
// Pull legacy bytes → write to CRM storage → files row → link signedFileId.
const buf = await streamToBuffer(await legacy.getObject(LEGACY_BUCKET, match.key));
const key = buildStoragePath(port.slug, 'eoi-signed', doc.id, randomUUID(), 'pdf');
const putRes = await backend.put(key, buf, {
contentType: 'application/pdf',
sizeBytes: buf.length,
});
// File into the client's entity folder (mirrors handleDocumentCompleted's
// owner-folder filing). files.interestId still scopes the row to the deal;
// interest "Deal" folders aren't system-managed (chk_system_folder_shape).
const folder = await ensureEntityFolder(port.id, 'client', clientId, SUPER_ADMIN_USER_ID);
const fileName = match.key.split('/').pop() ?? 'eoi-signed.pdf';
await db.transaction(async (tx) => {
const [f] = await tx
.insert(files)
.values({
portId: port.id,
filename: fileName,
originalName: fileName,
storagePath: putRes.key,
mimeType: 'application/pdf',
sizeBytes: String(putRes.sizeBytes),
category: 'eoi',
folderId: folder.id,
clientId,
interestId: doc.interestId,
uploadedBy: 'system',
})
.returning({ id: files.id });
if (!f) throw new Error('files insert returned no row');
await tx
.update(documents)
.set({ signedFileId: f.id, status: 'completed', isManualUpload: true })
.where(eq(documents.id, doc.id));
});
attached++;
}
return {
totalBlobs: objs.length,
indexedClients: byName.size,
candidates: docRows.length,
attached,
unmatched,
unresolved,
};
}
// ─── Old-LOI EOIs (NocoDB `database` bucket attachments) ─────────────────────
// The ~10 pre-Documenso "LOI process" deals have no documensoID and no curated
// client-portal/EOIs copy; their signed PDF lives only as a NocoDB attachment
// in the `database` bucket. The main pipeline keys EOI-doc creation off
// documensoID, so it never created a document row for them. Here we CREATE the
// document + file + folder and link the recovered PDF. Idempotent via a
// `nocodb_eoi_document` ledger entry per legacy interest.
function legacyKeyFromUrl(url: string): string | null {
// https://<host>/database/nc/uploads/... → nc/uploads/...
const marker = `/${DATABASE_BUCKET}/`;
const i = url.indexOf(marker);
if (i < 0) return null;
return decodeURIComponent(url.slice(i + marker.length));
}
async function backfillOldLoiEois(
port: { id: string; slug: string },
legacyDb: ReturnType<typeof postgres>,
) {
const rows = (await legacyDb`
select id, "EOI_Document"::text as doc
from plplouets5zw1um."Interests"
where "EOI_Document" is not null and "EOI_Document"::text not in ('', '[]', 'null')
`) as unknown as Array<{ id: number; doc: string }>;
const backend = await getStorageBackend();
let created = 0;
let skipped = 0;
let unmatched = 0;
const unresolved: string[] = [];
for (const r of rows) {
let url: string | null = null;
let title: string | null = null;
try {
const parsed = JSON.parse(r.doc) as unknown;
const first = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
if (first && typeof first === 'object') {
const rec = first as Record<string, unknown>;
if (typeof rec.url === 'string') url = rec.url;
if (typeof rec.title === 'string') title = rec.title;
}
} catch {
// ignore malformed attachment JSON
}
const key = url ? legacyKeyFromUrl(url) : null;
if (!key) {
unmatched++;
continue;
}
// legacy interest id → migrated interest
const [link] = await db
.select({ interestId: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
eq(migrationSourceLinks.sourceId, String(r.id)),
eq(migrationSourceLinks.targetEntityType, 'interest'),
),
)
.limit(1);
if (!link) {
unresolved.push(`legacy#${r.id} (not a migrated interest)`);
unmatched++;
continue;
}
const interestId = link.interestId;
// Idempotency: skip if this attachment was already recovered.
const [already] = await db
.select({ id: migrationSourceLinks.id })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_eoi_document'),
eq(migrationSourceLinks.sourceId, String(r.id)),
eq(migrationSourceLinks.targetEntityType, 'document'),
),
)
.limit(1);
if (already) {
skipped++;
continue;
}
const [intRow] = await db
.select({ clientId: interests.clientId, yachtId: interests.yachtId })
.from(interests)
.where(eq(interests.id, interestId))
.limit(1);
if (!intRow?.clientId) {
unmatched++;
continue;
}
const clientId = intRow.clientId;
if (DRY) {
created++;
continue;
}
const buf = await streamToBuffer(await legacy.getObject(DATABASE_BUCKET, key));
const docId = randomUUID();
const storageKey = buildStoragePath(port.slug, 'eoi-signed', docId, randomUUID(), 'pdf');
const putRes = await backend.put(storageKey, buf, {
contentType: 'application/pdf',
sizeBytes: buf.length,
});
const folder = await ensureEntityFolder(port.id, 'client', clientId, SUPER_ADMIN_USER_ID);
const fileName = title || key.split('/').pop() || 'eoi-signed.pdf';
await db.transaction(async (tx) => {
const [f] = await tx
.insert(files)
.values({
portId: port.id,
filename: fileName,
originalName: fileName,
storagePath: putRes.key,
mimeType: 'application/pdf',
sizeBytes: String(putRes.sizeBytes),
category: 'eoi',
folderId: folder.id,
clientId,
interestId,
uploadedBy: 'system',
})
.returning({ id: files.id });
if (!f) throw new Error('files insert returned no row');
await tx.insert(documents).values({
id: docId,
portId: port.id,
interestId,
clientId,
yachtId: intRow.yachtId ?? null,
documentType: 'eoi',
title: `External EOI (legacy) - ${fileName}`,
status: 'completed',
isManualUpload: true,
signedFileId: f.id,
createdBy: SUPER_ADMIN_USER_ID,
});
await tx
.update(interests)
.set({ eoiDocStatus: 'signed', updatedAt: new Date() })
.where(eq(interests.id, interestId));
await tx.insert(migrationSourceLinks).values({
sourceSystem: 'nocodb_eoi_document',
sourceId: String(r.id),
targetEntityType: 'document',
targetEntityId: docId,
appliedId: `oldloi-${docId}`,
appliedBy: SUPER_ADMIN_USER_ID,
});
});
created++;
}
return { total: rows.length, created, skipped, unmatched, unresolved };
}
async function main() {
if (!process.env.LEGACY_MINIO_ACCESS_KEY || !process.env.LEGACY_MINIO_SECRET_KEY) {
console.error(
'Set LEGACY_MINIO_ACCESS_KEY + LEGACY_MINIO_SECRET_KEY (legacy MinIO read creds).',
);
process.exit(1);
}
const port = await resolvePort(slugArg);
console.log(
`[backfill] port=${port.slug} legacy-bucket=${LEGACY_BUCKET} ${DRY ? '(DRY RUN)' : ''}`,
);
console.log('[backfill] Berth PDFs…');
const berthRes = await backfillBerthPdfs(port);
console.log(
` berth PDFs: ${berthRes.total} blobs → ${berthRes.attached} attached, ${berthRes.skipped} already had one, ${berthRes.unmatched} unmatched`,
);
console.log('[backfill] Signed EOIs…');
const eoiRes = await backfillEois(port);
console.log(
` EOIs: ${eoiRes.totalBlobs} blobs (${eoiRes.indexedClients} client folders) · ${eoiRes.candidates} migrated EOI docs needing a file → ${eoiRes.attached} attached, ${eoiRes.unmatched} unmatched`,
);
if (eoiRes.unresolved.length > 0) {
console.log(` ⚠ EOI docs with no name-matched legacy PDF (${eoiRes.unresolved.length}):`);
for (const n of eoiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
}
console.log('[backfill] Old-LOI EOIs (NocoDB `database` bucket)…');
const legacyDb = postgres(LEGACY_DB_URL, { max: 2 });
try {
const loiRes = await backfillOldLoiEois(port, legacyDb);
console.log(
` old-LOI EOIs: ${loiRes.total} attachments → ${loiRes.created} created, ${loiRes.skipped} already done, ${loiRes.unmatched} unmatched`,
);
if (loiRes.unresolved.length > 0) {
for (const n of loiRes.unresolved.slice(0, 25)) console.log(` - ${n}`);
}
} finally {
await legacyDb.end().catch(() => {});
}
await closeDb();
process.exit(0);
}
main().catch(async (err) => {
console.error('[backfill] failed:', err);
await closeDb().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,175 @@
/**
* Fix-up: connect the multi-berth links the main dedup pipeline misses.
*
* The dedup pipeline migrates only each interest's single `Berth Number` text
* field; the legacy `_nc_m2m_Berths_Interests` junction (multi-berth deals) is
* not carried over by it. This reads that junction from the `nocodb_legacy`
* snapshot, resolves each legacy interest → its migrated interest (via the
* ledger) and each mooring → the migrated berth, and inserts the missing
* `interest_berths` rows.
*
* Idempotent: `ON CONFLICT (interest_id, berth_id) DO NOTHING`. Primary safety:
* only makes a berth primary when the interest has no primary yet (≤1 primary
* per interest is a partial unique index).
*
* Exposed as `connectBerthLinks(...)` so `migrate-from-nocodb.ts --apply` can
* fold it into the one-shot seed; also runnable standalone:
*
* pnpm tsx scripts/migration/connect-berth-links.ts [--port-slug port-nimara] [--dry-run]
*/
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import postgres from 'postgres';
const canonMoo = (raw: string): string => {
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim());
return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim();
};
export interface ConnectBerthLinksResult {
inserted: number;
madePrimary: number;
skipped: number;
unresolved: string[];
}
/**
* Self-contained: opens its own CRM + legacy connections (read-only on the
* legacy snapshot), does the work, closes them, returns stats. Safe to call
* from the runner or standalone.
*/
export async function connectBerthLinks(opts: {
portSlug?: string;
dryRun?: boolean;
}): Promise<ConnectBerthLinksResult> {
const slug = opts.portSlug ?? 'port-nimara';
const dry = opts.dryRun ?? false;
const CRM_URL = process.env.DATABASE_URL!;
const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
const crm = postgres(CRM_URL, { max: 4 });
const legacy = postgres(LEGACY_URL, { max: 4 });
try {
const [port] = await crm`select id from ports where slug=${slug} limit 1`;
if (!port) throw new Error(`no port ${slug}`);
const portId = port.id as string;
// legacy junction: interestId → set(moorings)
const mooById = new Map<number, string>();
for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`)
mooById.set(b.id as number, canonMoo(b.m as string));
const legacyMoo = new Map<number, Set<string>>();
for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) {
const set = legacyMoo.get(j.i as number) ?? new Set<string>();
const m = mooById.get(j.b as number);
if (m) set.add(m);
legacyMoo.set(j.i as number, set);
}
// EOI-signed flag per legacy interest (for is_in_eoi_bundle)
const signed = new Set<number>();
for (const r of await legacy`select id, "EOI_Status" e, "LOI_NDA_Document" l from plplouets5zw1um."Interests"`) {
const e = ((r.e as string) ?? '').trim();
const l = ((r.l as string) ?? '').trim();
if (
e === 'Signed' ||
['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l)
)
signed.add(r.id as number);
}
// ledger: legacy interest id → new interest id
const links =
await crm`select source_id, target_entity_id from migration_source_links where source_system='nocodb_interests' and target_entity_type='interest'`;
const newInterestBySrc = new Map(
links.map((l) => [Number(l.source_id), l.target_entity_id as string]),
);
// CRM berth id by mooring (this port)
const berthByMoo = new Map(
(await crm`select id, mooring_number m from berths where port_id=${portId}`).map((b) => [
b.m as string,
b.id as string,
]),
);
let inserted = 0;
let madePrimary = 0;
let skipped = 0;
const unresolved: string[] = [];
for (const [legacyId, moorings] of legacyMoo) {
const interestId = newInterestBySrc.get(legacyId);
if (!interestId) continue; // not a migrated interest (backup/copy tables)
const primaryCheck =
await crm`select exists(select 1 from interest_berths where interest_id=${interestId} and is_primary) as has`;
let hasPrimary = (primaryCheck[0]?.has as boolean | undefined) ?? false;
for (const moo of moorings) {
const berthId = berthByMoo.get(moo);
if (!berthId) {
unresolved.push(`${legacyId}:${moo}`);
continue;
}
const makePrimary = !hasPrimary;
if (dry) {
inserted++;
if (makePrimary) {
madePrimary++;
hasPrimary = true;
}
continue;
}
const res = await crm`
insert into interest_berths (id, interest_id, berth_id, is_primary, is_specific_interest, is_in_eoi_bundle)
values (${randomUUID()}, ${interestId}, ${berthId}, ${makePrimary}, true, ${signed.has(legacyId)})
on conflict (interest_id, berth_id) do nothing
returning id`;
if (res.length > 0) {
inserted++;
if (makePrimary) {
madePrimary++;
hasPrimary = true;
}
} else {
skipped++;
}
}
}
return { inserted, madePrimary, skipped, unresolved };
} finally {
await crm.end().catch(() => {});
await legacy.end().catch(() => {});
}
}
// ─── Standalone CLI ──────────────────────────────────────────────────────────
function isMain(): boolean {
const arg = process.argv[1] ?? '';
return arg.includes('connect-berth-links');
}
if (isMain()) {
const slugArg = (() => {
const i = process.argv.indexOf('--port-slug');
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
})();
const dry = process.argv.includes('--dry-run');
connectBerthLinks({ portSlug: slugArg, dryRun: dry })
.then((r) => {
console.log(
`connect-berth-links ${dry ? '(DRY)' : ''}: inserted ${r.inserted} links (${r.madePrimary} new primary), ${r.skipped} already linked`,
);
if (r.unresolved.length)
console.log(
`${r.unresolved.length} moorings with no CRM berth: ${r.unresolved.slice(0, 20).join(', ')}`,
);
process.exit(0);
})
.catch((e) => {
console.error('connect-berth-links failed:', e);
process.exit(1);
});
}

View File

@@ -0,0 +1,102 @@
/**
* Read-only MinIO inventory for the legacy → new-CRM migration (Phase 2 sizing).
*
* Lists every bucket the creds can see, then for the document buckets
* (`client-portal`, `signatures`) groups objects by top-level prefix with
* counts + sizes + samples — so we can see exactly where the EOIs, berth
* PDFs, receipts and business-card images live before backfilling them.
*
* Secret-free: reads creds from env. Run with:
* MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... \
* pnpm tsx scripts/migration/probe-minio.ts
*
* Strictly read-only (listBuckets + listObjectsV2). No writes.
*/
import { Client } from 'minio';
const endPoint = process.env.MINIO_ENDPOINT || 's3.portnimara.com';
const accessKey = process.env.MINIO_ACCESS_KEY;
const secretKey = process.env.MINIO_SECRET_KEY;
if (!accessKey || !secretKey) {
console.error('Set MINIO_ACCESS_KEY and MINIO_SECRET_KEY');
process.exit(1);
}
const client = new Client({ endPoint, port: 443, useSSL: true, accessKey, secretKey });
interface PrefixStat {
count: number;
bytes: number;
samples: string[];
}
async function inventory(bucket: string) {
const byPrefix = new Map<string, PrefixStat>();
let total = 0;
let totalBytes = 0;
await new Promise<void>((resolve, reject) => {
const stream = client.listObjectsV2(bucket, '', true);
stream.on('data', (o) => {
if (!o.name) return;
total++;
totalBytes += o.size || 0;
const top = o.name.includes('/') ? o.name.split('/')[0] + '/' : '(root)';
const e = byPrefix.get(top) || { count: 0, bytes: 0, samples: [] };
e.count++;
e.bytes += o.size || 0;
if (e.samples.length < 4) e.samples.push(`${o.name} (${o.size}b)`);
byPrefix.set(top, e);
});
stream.on('end', () => resolve());
stream.on('error', reject);
});
return { bucket, total, totalBytes, byPrefix };
}
const mb = (b: number) => (b / 1e6).toFixed(1);
async function main() {
console.log(`MinIO @ ${endPoint}\n`);
let buckets: string[] = [];
try {
const list = await client.listBuckets();
buckets = list.map((b) => b.name);
console.log('=== all buckets visible to these creds ===');
for (const b of list) console.log(` ${b.name}`);
} catch (err) {
console.log(`listBuckets failed: ${(err as Error).message}`);
}
const targets = (process.env.MINIO_BUCKETS || 'client-portal,signatures')
.split(',')
.map((s) => s.trim());
for (const bucket of targets) {
if (buckets.length && !buckets.includes(bucket)) {
console.log(`\n=== bucket: ${bucket} — NOT VISIBLE to these creds ===`);
continue;
}
try {
const inv = await inventory(bucket);
console.log(
`\n=== bucket: ${inv.bucket}${inv.total} objects, ${mb(inv.totalBytes)} MB ===`,
);
const rows = [...inv.byPrefix.entries()].sort((a, z) => z[1].count - a[1].count);
for (const [prefix, e] of rows) {
console.log(
` ${prefix.padEnd(30)} ${String(e.count).padStart(5)} obj ${mb(e.bytes).padStart(8)} MB`,
);
for (const s of e.samples) console.log(` e.g. ${s}`);
}
} catch (err) {
console.log(`\n=== bucket: ${bucket} — ERROR: ${(err as Error).message} ===`);
}
}
}
main().catch((err) => {
console.error('probe-minio failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,277 @@
/**
* Exhaustive migration reconciliation (read-only): cross-checks EVERY migrated
* record against its legacy NocoDB source row (via the migration ledger) and
* verifies every relationship is connected. Independently re-derives the
* expected mapped values (stage, eoiStatus, berth, …) so it validates the
* migration logic, not just echoes it.
*
* Connects to BOTH local DBs:
* - CRM : DATABASE_URL (the migrated data)
* - legacy : LEGACY_DB_URL (the nocodb_legacy snapshot); defaults to the
* CRM url with the db name swapped to `nocodb_legacy`.
*
* pnpm tsx scripts/migration/reconcile-migration.ts [--port-slug port-nimara]
*/
import 'dotenv/config';
import postgres from 'postgres';
const slugArg = (() => {
const i = process.argv.indexOf('--port-slug');
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
})();
const CRM_URL = process.env.DATABASE_URL!;
const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
const crm = postgres(CRM_URL, { max: 4 });
const legacy = postgres(LEGACY_URL, { max: 4 });
// ── transforms, re-implemented independently (cross-validation) ──────────────
const STAGE_MAP: Record<string, string> = {
'General Qualified Interest': 'qualified',
'Specific Qualified Interest': 'nurturing',
'EOI and NDA Sent': 'eoi',
'Signed EOI and NDA': 'eoi',
'Made Reservation': 'reservation',
'Contract Negotiation': 'contract',
'Contract Negotiations Finalized': 'contract',
'Contract Signed': 'contract',
};
const expectStage = (level: string | undefined, deposit: string | undefined): string => {
let s = STAGE_MAP[(level ?? '').trim()] ?? 'enquiry';
if ((deposit ?? '').trim() === 'Received' && s !== 'contract') s = 'deposit_paid';
return s;
};
const expectEoi = (
eoiStatus: string | undefined,
loi: string | undefined,
documensoId: string | undefined,
): string | null => {
const e = (eoiStatus ?? '').trim();
const l = (loi ?? '').trim();
if (e === 'Signed' || ['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l))
return 'signed';
if (e === 'Waiting for Signatures' || (documensoId ?? '').trim()) return 'waiting_for_signatures';
return null;
};
const canonMoo = (raw: string): string => {
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim());
return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim();
};
const normEmail = (e: string) => (e ?? '').trim().toLowerCase();
const issues: string[] = [];
const add = (cat: string, msg: string) => issues.push(`[${cat}] ${msg}`);
async function main() {
const [port] = await crm`select id, slug from ports where slug=${slugArg} limit 1`;
if (!port) throw new Error(`no port ${slugArg}`);
const portId = port.id as string;
// ── load legacy source (by id) ───────────────────────────────────────────
const legacyInterests = new Map<number, Record<string, unknown>>();
for (const r of await legacy`select * from plplouets5zw1um."Interests"`)
legacyInterests.set(r.id as number, r);
const legacyExpenses = new Map<number, Record<string, unknown>>();
for (const r of await legacy`select * from p3hq2fxdevqcaq8."Expenses"`)
legacyExpenses.set(r.id as number, r);
const legacyRes = new Map<number, Record<string, unknown>>();
for (const r of await legacy`select * from plplouets5zw1um."Interests (Residences)"`)
legacyRes.set(r.id as number, r);
// legacy berth links per interest (Interests_id -> [mooring])
const berthMooById = new Map<number, string>();
for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`)
berthMooById.set(b.id as number, b.m as string);
const legacyBerthsByInterest = new Map<number, string[]>();
for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) {
const arr = legacyBerthsByInterest.get(j.i as number) ?? [];
const moo = berthMooById.get(j.b as number);
if (moo) arr.push(canonMoo(moo));
legacyBerthsByInterest.set(j.i as number, arr);
}
// ── ledger ────────────────────────────────────────────────────────────────
const ledger =
await crm`select source_system, source_id, target_entity_type, target_entity_id from migration_source_links`;
const interestLinks = ledger.filter((l) => l.target_entity_type === 'interest'); // sourceId(legacy interest) -> new interest
const expenseLinks = ledger.filter((l) => l.target_entity_type === 'expense');
const resLinks = ledger.filter((l) => l.target_entity_type === 'residential_client');
const clientLinks = ledger.filter((l) => l.target_entity_type === 'client');
// ── 1. COVERAGE — every legacy row migrated; nothing dropped ──────────────
const migratedInterestSrc = new Set(interestLinks.map((l) => Number(l.source_id)));
const droppedInterests = [...legacyInterests.keys()].filter((id) => !migratedInterestSrc.has(id));
const migratedExpSrc = new Set(expenseLinks.map((l) => Number(l.source_id)));
const droppedExp = [...legacyExpenses.keys()].filter((id) => !migratedExpSrc.has(id));
const migratedResSrc = new Set(resLinks.map((l) => Number(l.source_id)));
const droppedRes = [...legacyRes.keys()].filter((id) => !migratedResSrc.has(id));
for (const id of droppedInterests)
add(
'COVERAGE',
`legacy interest #${id} NOT migrated (${(legacyInterests.get(id) as { Full_Name?: string }).Full_Name ?? '?'})`,
);
for (const id of droppedExp) add('COVERAGE', `legacy expense #${id} NOT migrated`);
for (const id of droppedRes) add('COVERAGE', `legacy residential #${id} NOT migrated`);
// ── 2. INTEREST field fidelity (every migrated deal vs legacy) ────────────
const newInterests = await crm`
select i.id, i.pipeline_stage, i.lead_category, i.source, i.eoi_status, i.documenso_id, i.client_id, i.yacht_id
from interests i where i.port_id=${portId}`;
const newInterestById = new Map(newInterests.map((i) => [i.id as string, i]));
// berths per new interest
const ibRows = await crm`
select ib.interest_id, b.mooring_number from interest_berths ib join berths b on b.id=ib.berth_id where b.port_id=${portId}`;
const newBerthsByInterest = new Map<string, string[]>();
for (const r of ibRows) {
const a = newBerthsByInterest.get(r.interest_id as string) ?? [];
a.push(r.mooring_number as string);
newBerthsByInterest.set(r.interest_id as string, a);
}
let stageMiss = 0,
eoiMiss = 0,
docMiss = 0,
berthMiss = 0;
for (const l of interestLinks) {
const legacyRow = legacyInterests.get(Number(l.source_id));
const ni = newInterestById.get(l.target_entity_id as string);
if (!legacyRow || !ni) {
add(
'INTEGRITY',
`interest link sourceId=${l.source_id}${l.target_entity_id}: ${!legacyRow ? 'legacy row missing' : 'new interest missing'}`,
);
continue;
}
const lr = legacyRow as Record<string, string>;
const exp = expectStage(lr.Sales_Process_Level, lr.Deposit_10__Status);
if (ni.pipeline_stage !== exp) {
stageMiss++;
add(
'STAGE',
`interest src#${l.source_id} (${lr.Full_Name}): legacy "${lr.Sales_Process_Level}" → expected ${exp}, got ${ni.pipeline_stage}`,
);
}
const expEoi = expectEoi(lr.EOI_Status, lr.LOI_NDA_Document, lr.documensoID);
if ((ni.eoi_status ?? null) !== expEoi) {
eoiMiss++;
add(
'EOI',
`interest src#${l.source_id} (${lr.Full_Name}): expected eoiStatus ${expEoi}, got ${ni.eoi_status}`,
);
}
if ((ni.documenso_id ?? null) !== ((lr.documensoID ?? '').trim() || null)) {
docMiss++;
add(
'DOCID',
`interest src#${l.source_id} (${lr.Full_Name}): documensoId legacy="${lr.documensoID}" vs new="${ni.documenso_id}"`,
);
}
// berth: every legacy-linked mooring should be present on the new interest
const legacyMoo = new Set([...(legacyBerthsByInterest.get(Number(l.source_id)) ?? [])]);
if (lr.Berth_Number && /^[A-Za-z]+-?0*\d+$/.test(lr.Berth_Number.trim()))
legacyMoo.add(canonMoo(lr.Berth_Number));
const newMoo = new Set(newBerthsByInterest.get(ni.id as string) ?? []);
const missingBerths = [...legacyMoo].filter((m) => !newMoo.has(m));
if (missingBerths.length > 0) {
berthMiss++;
add(
'BERTH',
`interest src#${l.source_id} (${lr.Full_Name}): legacy berths [${[...legacyMoo].join(',')}] but new has [${[...newMoo].join(',') || '-'}] (missing ${missingBerths.join(',')})`,
);
}
}
// ── 3. CLIENT contact fidelity (migrated email is from a legacy source row)
const clientContacts = await crm`
select c.id, c.full_name, string_agg(cc.value, '|') filter (where cc.channel='email') emails
from clients c left join client_contacts cc on cc.client_id=c.id
where c.port_id=${portId} group by c.id, c.full_name`;
const emailsByClient = new Map(
clientContacts.map((c) => [
c.id as string,
(c.emails as string | null)?.split('|').map(normEmail) ?? [],
]),
);
// group ledger client links: client -> its legacy source emails
const legacyEmailsByClient = new Map<string, Set<string>>();
for (const l of clientLinks) {
const lr = legacyInterests.get(Number(l.source_id)) as Record<string, string> | undefined;
const e = normEmail(lr?.Email_Address ?? '');
if (!e) continue;
const set = legacyEmailsByClient.get(l.target_entity_id as string) ?? new Set();
set.add(e);
legacyEmailsByClient.set(l.target_entity_id as string, set);
}
let emailMiss = 0;
for (const [cid, legacyEmails] of legacyEmailsByClient) {
const newEmails = new Set(emailsByClient.get(cid) ?? []);
const missing = [...legacyEmails].filter((e) => !newEmails.has(e));
if (missing.length > 0) {
emailMiss++;
const nm = clientContacts.find((c) => c.id === cid)?.full_name;
add(
'EMAIL',
`client ${nm}: legacy email(s) [${[...legacyEmails].join(',')}] not all on client (have [${[...newEmails].join(',') || '-'}])`,
);
}
}
// ── 4. RELATIONSHIP integrity (orphans / dangling FKs) ────────────────────
const orphanInterests =
await crm`select count(*) n from interests i where i.port_id=${portId} and not exists (select 1 from clients c where c.id=i.client_id)`;
const orphanIB =
await crm`select count(*) n from interest_berths ib where not exists (select 1 from interests i where i.id=ib.interest_id) or not exists (select 1 from berths b where b.id=ib.berth_id)`;
const orphanDocs =
await crm`select count(*) n from documents d where d.port_id=${portId} and d.interest_id is not null and not exists (select 1 from interests i where i.id=d.interest_id)`;
const orphanYachts =
await crm`select count(*) n from yachts y where y.port_id=${portId} and y.current_owner_type='client' and not exists (select 1 from clients c where c.id=y.current_owner_id)`;
const danglingSignedFile =
await crm`select count(*) n from documents d where d.signed_file_id is not null and not exists (select 1 from files f where f.id=d.signed_file_id)`;
if (Number(orphanInterests[0]!.n) > 0)
add('INTEGRITY', `${orphanInterests[0]!.n} interests with no client`);
if (Number(orphanIB[0]!.n) > 0)
add('INTEGRITY', `${orphanIB[0]!.n} interest_berths with dangling FK`);
if (Number(orphanDocs[0]!.n) > 0)
add('INTEGRITY', `${orphanDocs[0]!.n} documents with dangling interest`);
if (Number(orphanYachts[0]!.n) > 0)
add('INTEGRITY', `${orphanYachts[0]!.n} yachts with missing owner`);
if (Number(danglingSignedFile[0]!.n) > 0)
add('INTEGRITY', `${danglingSignedFile[0]!.n} documents with dangling signed_file_id`);
// ── report ────────────────────────────────────────────────────────────────
console.log('═══════════ MIGRATION RECONCILIATION ═══════════\n');
console.log(
`Coverage: legacy interests ${legacyInterests.size} → migrated ${migratedInterestSrc.size} (dropped ${droppedInterests.length})`,
);
console.log(
` legacy expenses ${legacyExpenses.size} → migrated ${migratedExpSrc.size} (dropped ${droppedExp.length})`,
);
console.log(
` legacy residential ${legacyRes.size} → migrated ${migratedResSrc.size} (dropped ${droppedRes.length})`,
);
console.log(
`Fidelity: stage mismatches ${stageMiss} · eoiStatus ${eoiMiss} · documensoId ${docMiss} · berth-link ${berthMiss} · client-email ${emailMiss}`,
);
console.log(
`Integrity: orphan interests ${orphanInterests[0]!.n} · interest_berths ${orphanIB[0]!.n} · docs ${orphanDocs[0]!.n} · yachts ${orphanYachts[0]!.n} · signed-file ${danglingSignedFile[0]!.n}`,
);
console.log(`\nTotal discrepancies: ${issues.length}`);
const byCat = issues.reduce<Record<string, number>>((a, s) => {
const c = s.slice(1, s.indexOf(']'));
a[c] = (a[c] || 0) + 1;
return a;
}, {});
console.log('By category:', JSON.stringify(byCat));
console.log('\n── discrepancy detail (first 60) ──');
for (const i of issues.slice(0, 60)) console.log(' ' + i);
if (issues.length > 60) console.log(` … +${issues.length - 60} more`);
await crm.end();
await legacy.end();
process.exit(0);
}
main().catch(async (e) => {
console.error('reconcile failed:', e);
await crm.end().catch(() => {});
await legacy.end().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,210 @@
/**
* Migration verification / audit (read-only against the local dev DB + storage).
*
* 1. EOI PDF ↔ person: opens each attached signed-EOI PDF, extracts its text,
* and confirms the linked client's name actually appears inside — catching
* any wrong attachment from the name/fuzzy matcher. Flags any PDF where a
* *different* client's name appears instead.
* 2. Berth PDF ↔ mooring: confirms each berth's spec-sheet PDF mentions its
* mooring number.
* 3. Per-person completeness: clients missing contact info, deals missing a
* stage, clients with no deal, + a sample full dump to eyeball.
*
* pnpm tsx scripts/migration/verify-migration.ts [--port-slug port-nimara]
*/
import 'dotenv/config';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { extractText, getDocumentProxy } from 'unpdf';
import { and, eq, isNotNull, sql } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { documents, files } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { berths, berthPdfVersions } from '@/lib/db/schema/berths';
const STORAGE_ROOT = process.env.STORAGE_ROOT || 'storage';
const slugArg = (() => {
const i = process.argv.indexOf('--port-slug');
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
})();
const norm = (s: string) =>
s
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z ]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
async function pdfText(storagePath: string): Promise<string> {
const buf = await readFile(path.join(STORAGE_ROOT, storagePath));
const pdf = await getDocumentProxy(new Uint8Array(buf));
const res = await extractText(pdf, { mergePages: true });
const t = Array.isArray(res.text) ? res.text.join(' ') : res.text;
return norm(t);
}
async function main() {
const [port] = await db
.select({ id: ports.id, slug: ports.slug })
.from(ports)
.where(eq(ports.slug, slugArg))
.limit(1);
if (!port) throw new Error(`no port ${slugArg}`);
const allNames = (
await db
.select({ id: clients.id, name: clients.fullName })
.from(clients)
.where(eq(clients.portId, port.id))
).map((c) => ({
id: c.id,
tokens: norm(c.name)
.split(' ')
.filter((t) => t.length >= 4),
name: c.name,
}));
// ── 1. EOI PDF ↔ person ──────────────────────────────────────────────────
const eoiRows = await db
.select({
docId: documents.id,
clientId: documents.clientId,
fullName: clients.fullName,
storagePath: files.storagePath,
})
.from(documents)
.innerJoin(files, eq(files.id, documents.signedFileId))
.innerJoin(clients, eq(clients.id, documents.clientId))
.where(
and(
eq(documents.portId, port.id),
eq(documents.documentType, 'eoi'),
isNotNull(documents.signedFileId),
),
);
console.log(`\n═══ 1. EOI PDF ↔ person (${eoiRows.length} attached signed EOIs) ═══`);
let ok = 0,
weak = 0,
bad = 0,
err = 0;
for (const r of eoiRows) {
try {
const text = await pdfText(r.storagePath);
const tokens = norm(r.fullName)
.split(' ')
.filter((t) => t.length >= 3);
const first = tokens[0];
const last = tokens[tokens.length - 1];
const hasFirst = !!first && text.includes(first);
const hasLast = !!last && text.includes(last);
if (hasFirst && hasLast) {
ok++;
} else if (hasFirst || hasLast) {
weak++;
console.log(
` ⚠ WEAK "${r.fullName}" — only ${hasLast ? 'surname' : 'first name'} found in its PDF`,
);
} else {
bad++;
const other = allNames.find(
(c) => c.id !== r.clientId && c.tokens.some((t) => text.includes(t)),
);
console.log(
` ✗ BAD "${r.fullName}" — name NOT in its PDF${other ? ` — but "${other.name}" DOES appear (likely mis-attached!)` : ''}`,
);
}
} catch (e) {
err++;
console.log(` ! ERR "${r.fullName}": ${(e as Error).message}`);
}
}
console.log(` → strong ${ok} · weak ${weak} · NO-match ${bad} · read-error ${err}`);
// ── 2. Berth PDF ↔ mooring ───────────────────────────────────────────────
const berthRows = await db
.select({ mooring: berths.mooringNumber, storageKey: berthPdfVersions.storageKey })
.from(berths)
.innerJoin(berthPdfVersions, eq(berthPdfVersions.id, berths.currentPdfVersionId))
.where(eq(berths.portId, port.id));
console.log(`\n═══ 2. Berth PDF ↔ mooring (${berthRows.length} berths with a PDF) ═══`);
let bOk = 0,
bBad = 0,
bErr = 0;
for (const r of berthRows) {
try {
const text = await pdfText(r.storageKey);
// mooring like "A1"/"D32" — match letter+space?+number loosely
const moo = r.mooring.toLowerCase();
const m = moo.match(/^([a-z]+)(\d+)$/);
const found =
text.includes(moo) ||
(m && text.includes(`${m[1]} ${m[2]}`)) ||
(m && new RegExp(`${m[1]}\\s*${m[2]}\\b`).test(text));
if (found) bOk++;
else {
bBad++;
console.log(` ✗ "${r.mooring}" mooring not found in its spec sheet`);
}
} catch (e) {
bErr++;
console.log(` ! ERR ${r.mooring}: ${(e as Error).message}`);
}
}
console.log(` → mooring-in-PDF ${bOk} · not-found ${bBad} · read-error ${bErr}`);
// ── 3. Per-person completeness ───────────────────────────────────────────
console.log(`\n═══ 3. Per-person data completeness (migrated clients) ═══`);
const noContact = await db.execute(sql`
select c.full_name from clients c
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
where not exists (select 1 from client_contacts cc where cc.client_id=c.id)`);
console.log(` clients with NO contact (email/phone): ${noContact.length}`);
for (const r of noContact.slice(0, 15))
console.log(` - ${(r as { full_name: string }).full_name}`);
const noDeal = await db.execute(sql`
select c.full_name from clients c
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
where not exists (select 1 from interests i where i.client_id=c.id)`);
console.log(` migrated clients with NO deal: ${noDeal.length}`);
const noStage = await db.execute(sql`
select count(*) n from interests i
join migration_source_links l on l.target_entity_id=i.id and l.target_entity_type='interest'
where i.pipeline_stage is null`);
console.log(` migrated deals with NULL stage: ${(noStage[0] as { n: number }).n}`);
// sample full dump to eyeball
console.log(`\n -- sample of 6 migrated clients (eyeball) --`);
const sample = await db.execute(sql`
select c.full_name,
(select string_agg(cc.channel||':'||cc.value, ', ') from client_contacts cc where cc.client_id=c.id) contacts,
(select count(*) from interests i where i.client_id=c.id) deals,
(select string_agg(distinct i.pipeline_stage, ',') from interests i where i.client_id=c.id) stages
from clients c
join migration_source_links l on l.target_entity_id=c.id and l.target_entity_type='client'
order by deals desc nulls last limit 6`);
for (const r of sample as unknown as Array<{
full_name: string;
contacts: string;
deals: number;
stages: string;
}>) {
console.log(
` ${r.full_name} · ${r.deals} deal(s) [${r.stages}] · ${r.contacts ?? '(no contacts)'}`,
);
}
await closeDb();
process.exit(0);
}
main().catch(async (e) => {
console.error('verify failed:', e);
await closeDb().catch(() => {});
process.exit(1);
});

64
scripts/tunnel-url.sh Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Print the current Cloudflare quick-tunnel URL, or a clear status line
# if the launchd job isn't running.
#
# Usage:
# ./scripts/tunnel-url.sh # print URL or status
# ./scripts/tunnel-url.sh --copy # print URL and copy to clipboard
#
# Paired with the launchd plist at:
# ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist
#
# Quick ops:
# launchctl load ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # start
# launchctl unload ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # stop
# launchctl kickstart -k gui/$(id -u)/solutions.letsbe.pn-crm-tunnel # restart (NEW URL)
set -euo pipefail
LOG_FILE="$HOME/Library/Logs/pn-crm-tunnel.err.log"
LABEL="solutions.letsbe.pn-crm-tunnel"
if ! launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then
echo "Tunnel is not loaded. Start with:"
echo " launchctl load ~/Library/LaunchAgents/$LABEL.plist"
exit 1
fi
if [[ ! -f "$LOG_FILE" ]]; then
echo "Tunnel job is loaded but hasn't produced a log yet. Try again in a few seconds."
exit 1
fi
# cloudflared prints the public URL once on startup, like:
# https://<words>.trycloudflare.com
# Take the most recent occurrence so a restart-then-rerun picks the
# current one rather than a stale earlier line.
URL=$(grep -Eo 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" | tail -1 || true)
if [[ -z "$URL" ]]; then
echo "Tunnel is running but no URL has appeared in the log yet."
echo "Tail it: tail -f $LOG_FILE"
exit 1
fi
echo "$URL"
echo "$URL/api/webhooks/documenso ← paste this into Documenso webhook settings"
if [[ "${1:-}" == "--copy" ]]; then
printf "%s/api/webhooks/documenso" "$URL" | pbcopy
echo "(webhook URL copied to clipboard)"
fi
# Auto-PATCH Documenso's webhook URL when the env flag is set. Gated so
# production ports can never have their webhook rotated by a stale dev
# script. The TS script reads DOCUMENSO_API_URL + DOCUMENSO_API_KEY +
# DOCUMENSO_API_VERSION from .env and updates every webhook whose URL
# already points at our path OR at any *.trycloudflare.com host.
if [[ "${DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK:-}" == "1" ]]; then
echo ""
echo "DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 — updating Documenso webhook(s)…"
cd "$(dirname "$0")/.." || exit 1
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 \
pnpm tsx scripts/update-documenso-webhook.ts "$URL"
fi

View File

@@ -0,0 +1,194 @@
/**
* Documenso webhook URL auto-updater. Called by `./scripts/tunnel-url.sh`
* when the env flag `DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1` is set so a
* freshly-restarted cloudflared quick-tunnel (which gets a NEW hostname
* on every restart) doesn't leave Documenso pointing at a dead URL.
*
* Gated by env flag so production ports — which may have a stable
* webhook URL — can never have their config rotated by a stale dev
* script. Reads Documenso credentials from env (DOCUMENSO_API_URL +
* DOCUMENSO_API_KEY + optional DOCUMENSO_API_VERSION).
*
* Usage (manual invocation):
* DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com
*
* Behaviour:
* - Lists every webhook currently configured on the Documenso
* instance.
* - Identifies webhooks whose `webhookUrl` looks like a
* trycloudflare.com domain OR matches our `/api/webhooks/documenso`
* path suffix. These are the ones to rotate.
* - PATCHes each matching webhook to point at the new tunnel URL.
* - Leaves all other webhooks alone (in case the instance also
* services another tenant or a stable production URL).
*
* Tries Documenso v2 first, falls back to v1 if the v2 endpoint
* returns 404. Both versions support GET /webhook(s) + PATCH on the
* webhook resource — the shape differs slightly between them but the
* fields we touch (`id`, `webhookUrl`) are stable across versions.
*/
import 'dotenv/config';
const ENABLE_FLAG = process.env.DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK;
const TUNNEL_BASE = process.argv[2];
if (ENABLE_FLAG !== '1') {
console.log(
'DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK is not set to 1 — skipping Documenso webhook update.',
);
process.exit(0);
}
if (!TUNNEL_BASE) {
console.error('Usage: pnpm tsx scripts/update-documenso-webhook.ts <tunnel-base-url>');
console.error(
'Example: pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com',
);
process.exit(1);
}
const API_URL = process.env.DOCUMENSO_API_URL;
const API_KEY = process.env.DOCUMENSO_API_KEY;
const API_VERSION = (process.env.DOCUMENSO_API_VERSION ?? 'v2').toLowerCase();
if (!API_URL || !API_KEY) {
console.error('DOCUMENSO_API_URL and DOCUMENSO_API_KEY must be set in env to update webhooks.');
process.exit(1);
}
// Trim trailing slash so we can compose paths cleanly.
const BASE = API_URL.replace(/\/+$/, '');
const NEW_WEBHOOK_URL = `${TUNNEL_BASE.replace(/\/+$/, '')}/api/webhooks/documenso`;
async function documensoRequest(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: API_KEY!,
...(init?.headers ?? {}),
},
});
}
interface DocumensoWebhook {
id: string | number;
webhookUrl: string;
}
/**
* Pluck the array of webhooks out of whatever shape the Documenso
* version returned. v1 historically returned an array directly; v2
* tends to wrap in `{ data: [...] }` or similar. Be tolerant.
*/
function extractWebhooks(raw: unknown): DocumensoWebhook[] {
if (Array.isArray(raw)) return raw as DocumensoWebhook[];
if (raw && typeof raw === 'object') {
const r = raw as Record<string, unknown>;
if (Array.isArray(r.data)) return r.data as DocumensoWebhook[];
if (Array.isArray(r.webhooks)) return r.webhooks as DocumensoWebhook[];
}
return [];
}
async function listWebhooks(): Promise<{ webhooks: DocumensoWebhook[]; version: 'v1' | 'v2' }> {
if (API_VERSION === 'v2' || API_VERSION === 'v2.0' || API_VERSION === 'v2.x') {
const res = await documensoRequest('/api/v2/webhook');
if (res.ok) {
const body = (await res.json()) as unknown;
return { webhooks: extractWebhooks(body), version: 'v2' };
}
if (res.status !== 404) {
console.error(`v2 webhook list returned ${res.status}: ${await res.text()}`);
}
// Fall through to v1.
}
const res = await documensoRequest('/api/v1/webhooks');
if (!res.ok) {
console.error(`v1 webhook list returned ${res.status}: ${await res.text()}`);
process.exit(1);
}
const body = (await res.json()) as unknown;
return { webhooks: extractWebhooks(body), version: 'v1' };
}
async function patchWebhook(
version: 'v1' | 'v2',
webhook: DocumensoWebhook,
newUrl: string,
): Promise<boolean> {
const path =
version === 'v2'
? '/api/v2/webhook'
: `/api/v1/webhooks/${encodeURIComponent(String(webhook.id))}`;
const body = version === 'v2' ? { id: webhook.id, webhookUrl: newUrl } : { webhookUrl: newUrl };
const res = await documensoRequest(path, {
method: 'PATCH',
body: JSON.stringify(body),
});
if (!res.ok) {
console.error(`PATCH ${path} (id=${webhook.id}) returned ${res.status}: ${await res.text()}`);
return false;
}
return true;
}
/**
* Decide whether a given existing webhook is "ours" (i.e. matches the
* pattern we want to rotate). Two signals:
* 1. Path tail matches `/api/webhooks/documenso` — the CRM-side
* handler we own.
* 2. Host matches `*.trycloudflare.com` — almost certainly a stale
* quick-tunnel URL. Rotating these is always safe.
*/
function isRotatableWebhook(w: DocumensoWebhook): boolean {
if (!w.webhookUrl) return false;
if (w.webhookUrl.endsWith('/api/webhooks/documenso')) return true;
try {
const host = new URL(w.webhookUrl).hostname;
if (host.endsWith('.trycloudflare.com')) return true;
} catch {
/* malformed — leave alone */
}
return false;
}
async function main(): Promise<void> {
console.log(`Listing webhooks via Documenso ${API_VERSION.toUpperCase()} (base: ${BASE})…`);
const { webhooks, version } = await listWebhooks();
console.log(`Found ${webhooks.length} webhook(s).`);
const rotatable = webhooks.filter(isRotatableWebhook);
if (rotatable.length === 0) {
console.log(
`No rotatable webhooks found (looking for paths ending /api/webhooks/documenso or *.trycloudflare.com hosts).`,
);
console.log(`If your dev webhook is configured differently, point it at: ${NEW_WEBHOOK_URL}`);
return;
}
console.log(`Updating ${rotatable.length} webhook(s) to ${NEW_WEBHOOK_URL}`);
let ok = 0;
let fail = 0;
for (const w of rotatable) {
if (w.webhookUrl === NEW_WEBHOOK_URL) {
console.log(` ${w.id}: already at the target URL, skipping.`);
continue;
}
const succeeded = await patchWebhook(version, w, NEW_WEBHOOK_URL);
if (succeeded) {
ok++;
console.log(` ${w.id}: ${w.webhookUrl} -> ${NEW_WEBHOOK_URL}`);
} else {
fail++;
}
}
console.log(`Done. ${ok} updated, ${fail} failed.`);
if (fail > 0) process.exit(1);
}
main().catch((err) => {
console.error('Documenso webhook update failed:', err);
process.exit(1);
});

View File

@@ -1,12 +1,16 @@
import type { Metadata } from 'next';
import { AuthBrandingProvider } from '@/components/shared/auth-branding-provider';
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
export const metadata: Metadata = {
title: {
default: 'Sign In',
template: '%s | Port Nimara CRM',
template: '%s',
},
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const branding = await resolveAuthShellBranding();
return <AuthBrandingProvider branding={branding}>{children}</AuthBrandingProvider>;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -12,11 +12,14 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
// `identifier` accepts either an email address or a username (330 lowercase
// letters / digits / dot / underscore / hyphen). The server endpoint
// /api/auth/sign-in-by-identifier resolves the username server-side and
// forwards to better-auth in one round-trip the canonical email is never
// forwards to better-auth in one round-trip - the canonical email is never
// returned to the browser, which closes the username-enumeration vector.
const loginSchema = z.object({
identifier: z.string().min(1, 'Email or username is required'),
@@ -25,8 +28,27 @@ const loginSchema = z.object({
type LoginFormData = z.infer<typeof loginSchema>;
/**
* H-02: Validate a redirect target before pushing the user to it. The
* middleware appends `?redirect=<path>` when a session check fails on a
* protected route; an unsanitized router.push of that value would let a
* crafted URL bounce the user to an external host or protocol-relative
* `//evil.com` after a successful sign-in. Only same-origin, single-leading-
* slash paths pass.
*/
function safeRedirectTarget(raw: string | null): string {
if (!raw) return '/dashboard';
// Allow only paths starting with a single `/` (rules out `//evil.com`
// protocol-relative URLs and `https://…` absolute ones).
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}
export default function LoginPage() {
const router = useRouter();
const branding = useAuthBranding();
const appName = branding?.appName?.trim() || 'CRM';
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
@@ -41,7 +63,7 @@ export default function LoginPage() {
if (payload.data?.needsBootstrap) router.replace('/setup');
})
.catch(() => {
/* silent login UX must still work even if status check fails */
/* silent - login UX must still work even if status check fails */
});
return () => {
cancelled = true;
@@ -55,6 +77,7 @@ export default function LoginPage() {
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
async function onSubmit(data: LoginFormData) {
setIsLoading(true);
@@ -76,7 +99,8 @@ export default function LoginPage() {
return;
}
router.push('/dashboard');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(safeRedirectTarget(searchParams.get('redirect')) as any);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
@@ -87,11 +111,15 @@ export default function LoginPage() {
return (
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
<h1 className="text-xl font-semibold text-gray-900">{appName}</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
<FormErrorSummary
errors={errors}
labels={{ identifier: 'Email or username', password: 'Password' }}
/>
<div className="space-y-1.5">
<Label htmlFor="identifier">Email or username</Label>
<Input
@@ -112,7 +140,10 @@ export default function LoginPage() {
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
<Link
href="/reset-password"
className="text-xs text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Forgot password?
</Link>
</div>

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -10,6 +11,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
import { cn } from '@/lib/utils';
const resetSchema = z.object({
@@ -19,6 +22,8 @@ const resetSchema = z.object({
type ResetFormData = z.infer<typeof resetSchema>;
export default function ResetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -29,17 +34,41 @@ export default function ResetPasswordPage() {
} = useForm<ResetFormData>({
resolver: zodResolver(resetSchema),
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
// If the user landed here from a stale email link that points to
// `/reset-password?token=…` instead of `/set-password?token=…`, hand
// them off to the set-password form (the one that actually knows how
// to consume the token). New emails should point straight at
// `/set-password`, but old links live in inboxes for a long time.
useEffect(() => {
const token = searchParams.get('token');
if (token) {
router.replace(`/set-password?token=${encodeURIComponent(token)}`);
}
}, [router, searchParams]);
async function onSubmit(data: ResetFormData) {
setIsLoading(true);
try {
// Always show the same success message regardless of whether the email exists.
await fetch('/api/auth/reset-password', {
// Better-auth's request-link endpoint is `/api/auth/request-password-reset`.
// `/api/auth/reset-password` is the *consume-token* endpoint and silently
// rejects an email-only payload, which is why the old code appeared to
// "succeed" without ever sending mail.
const response = await fetch('/api/auth/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.email }),
body: JSON.stringify({ email: data.email, redirectTo: '/set-password' }),
});
// Treat 400 "user not found" as success so we don't leak whether the
// account exists - the success copy says "if an account exists…".
// Anything else (5xx, network) surfaces as a real error.
if (!response.ok && response.status !== 400) {
toast.error('Something went wrong. Please try again.');
return;
}
setSubmitted(true);
} catch {
toast.error('Something went wrong. Please try again.');
@@ -62,12 +91,16 @@ export default function ResetPasswordPage() {
If an account exists for that email address, we have sent a password reset link. Please
check your inbox and spam folder.
</p>
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
<Link
href="/login"
className="inline-block text-sm text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Back to sign in
</Link>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
<FormErrorSummary errors={errors} labels={{ email: 'Email' }} />
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
@@ -92,7 +125,10 @@ export default function ResetPasswordPage() {
<p className="text-center text-sm text-gray-500">
Remember your password?{' '}
<Link href="/login" className="text-[#007bff] hover:underline">
<Link
href="/login"
className="text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Sign in
</Link>
</p>

View File

@@ -1,8 +1,8 @@
'use client';
import { Suspense, useState } from 'react';
import { Suspense, useState, useSyncExternalStore } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
const MIN_LENGTH = 9;
@@ -27,10 +29,35 @@ const passwordSchema = z
type SetPasswordFormData = z.infer<typeof passwordSchema>;
/**
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
* carry `?token=…` and stay functional until every outstanding invite
* expires - drop the `?token=` fallback after that grace period.
*/
function readTokenFromUrl(): string {
if (typeof window === 'undefined') return '';
const hash = window.location.hash.replace(/^#/, '');
if (hash) {
const params = new URLSearchParams(hash);
const fromFragment = params.get('token');
if (fromFragment) return fromFragment;
}
const search = new URLSearchParams(window.location.search);
return search.get('token') ?? '';
}
const subscribeNoop = () => () => undefined;
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
// useSyncExternalStore so the fragment-only token is read post-hydration
// (server snapshot returns null; client returns the actual value).
const token = useSyncExternalStore<string | null>(
subscribeNoop,
() => readTokenFromUrl(),
() => null,
);
const [isLoading, setIsLoading] = useState(false);
const {
@@ -40,6 +67,7 @@ function SetPasswordInner() {
} = useForm<SetPasswordFormData>({
resolver: zodResolver(passwordSchema),
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
async function onSubmit(data: SetPasswordFormData) {
if (!token) {
@@ -73,6 +101,19 @@ function SetPasswordInner() {
}
}
// Pre-hydration: token is null. Show a loading placeholder so the user
// doesn't see a flash of "Link is missing" while the fragment is being
// read on the client.
if (token === null) {
return (
<BrandedAuthShell>
<div role="status" aria-live="polite" className="text-center text-sm text-gray-500">
Loading
</div>
</BrandedAuthShell>
);
}
if (!token) {
return (
<BrandedAuthShell>
@@ -82,7 +123,10 @@ function SetPasswordInner() {
Please use the link from the email we sent you. If the link is broken, ask your
administrator for a new one.
</p>
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
<Link
href="/login"
className="inline-block text-sm text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Back to sign in
</Link>
</div>
@@ -97,7 +141,11 @@ function SetPasswordInner() {
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4" noValidate>
<FormErrorSummary
errors={errors}
labels={{ password: 'Password', confirmPassword: 'Confirm password' }}
/>
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
@@ -105,10 +153,13 @@ function SetPasswordInner() {
type="password"
autoComplete="new-password"
disabled={isLoading}
aria-describedby="password-hint"
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
{...register('password')}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
<p id="password-hint" className="text-xs text-gray-500">
At least {MIN_LENGTH} characters.
</p>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>

View File

@@ -11,6 +11,9 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
@@ -30,12 +33,14 @@ interface StatusResp {
/**
* First-run setup. On a fresh DB the very first visitor can claim the
* super-admin account here. Once anyone claims it, future visits to
* /setup redirect back to /login the precondition is verified both
* /setup redirect back to /login - the precondition is verified both
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
* internal recheck) and client-side here.
*/
export default function SetupPage() {
const router = useRouter();
const branding = useAuthBranding();
const appName = branding?.appName?.trim() || 'this CRM';
const [checking, setChecking] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -47,6 +52,7 @@ export default function SetupPage() {
} = useForm<SetupFormData>({
resolver: zodResolver(setupSchema),
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
useEffect(() => {
let cancelled = false;
@@ -55,13 +61,13 @@ export default function SetupPage() {
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
if (cancelled) return;
if (!res.data.needsBootstrap) {
// Already initialized bounce to login. Replace, not push,
// Already initialized - bounce to login. Replace, not push,
// so back-button doesn't trap the user here.
router.replace('/login');
return;
}
} catch {
// Status endpoint failed let the user try anyway; the POST
// Status endpoint failed - let the user try anyway; the POST
// does its own check and will surface a 409 if the window closed.
} finally {
if (!cancelled) setChecking(false);
@@ -88,7 +94,7 @@ export default function SetupPage() {
password: data.password,
},
});
toast.success('Administrator account created sign in to continue.');
toast.success('Administrator account created - sign in to continue.');
router.replace('/login');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create administrator account');
@@ -109,14 +115,23 @@ export default function SetupPage() {
<BrandedAuthShell>
<div className="space-y-6">
<div className="text-center space-y-1">
<h1 className="text-xl font-semibold">Welcome to Port Nimara CRM</h1>
<h1 className="text-xl font-semibold">Welcome to {appName}</h1>
<p className="text-sm text-muted-foreground">
No administrator account exists yet. Create one to get started you&rsquo;ll be the
No administrator account exists yet. Create one to get started - you&rsquo;ll be the
super-administrator for this installation.
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
<FormErrorSummary
errors={errors}
labels={{
name: 'Name',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm password',
}}
/>
<div className="space-y-1.5">
<Label htmlFor="setup-name">Your name</Label>
<Input
@@ -177,7 +192,7 @@ export default function SetupPage() {
</Button>
</form>
<p className="text-center text-[11px] text-muted-foreground">
<p className="text-center text-xs text-muted-foreground">
This screen is only available until the first administrator is created. After that,
subsequent users are added through Admin &rarr; Users.
</p>

View File

@@ -1,100 +1,29 @@
import Link from 'next/link';
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
import { Bot, FileScan, Lightbulb } from 'lucide-react';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
const MASTER_FIELDS: SettingFieldDef[] = [
{
key: 'ai_enabled',
label: 'AI features enabled',
description:
'Master switch. When OFF, every AI surface (receipt OCR fallback, berth-PDF AI parse, future embedding-driven recommendations) is bypassed. Provider keys stay configured but unused.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_monthly_token_cap',
label: 'Monthly token cap (this port)',
description:
'Soft cap on total AI tokens consumed per calendar month across every feature. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
type: 'number',
defaultValue: 0,
},
];
const PROVIDER_FIELDS: SettingFieldDef[] = [
{
key: 'openai_api_key',
label: 'OpenAI API key',
description:
'Used by Receipt OCR fallback and (future) berth-PDF AI parse. Stored AES-encrypted at rest; the field shows blank after save.',
type: 'password',
placeholder: 'sk-…',
defaultValue: '',
},
{
key: 'openai_default_model',
label: 'Default OpenAI model',
description: 'Used when a feature does not specify an explicit model.',
type: 'select',
defaultValue: 'gpt-4o-mini',
options: [
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
],
},
];
interface FeatureLink {
href: string;
icon: typeof Bot;
title: string;
description: string;
}
const FEATURE_LINKS: FeatureLink[] = [
{
href: '../berth-pdf-parser',
icon: FileText,
title: 'Berth PDF parser',
description:
'Three-tier AcroForm → OCR → AI pipeline. The AI pass costs tokens; reps invoke it manually when OCR confidence is low.',
},
{
href: '../recommender',
icon: Brain,
title: 'Berth recommender',
description:
'Rule-based today; future versions will optionally use embeddings for soft preference matching. AI use is gated by the master switch above.',
},
];
export default function AiAdminPage() {
return (
<div className="space-y-6">
<PageHeader
title="AI configuration"
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds remain in their dedicated pages, linked below."
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds are embedded below."
eyebrow="ADMIN"
/>
<SettingsFormCard
<RegistryDrivenForm
title="Master controls"
description="Hard kill switch + budget guardrails covering every AI surface in this port."
fields={MASTER_FIELDS}
sections={['ai.master']}
/>
<SettingsFormCard
<RegistryDrivenForm
title="Provider credentials"
description="Shared API keys used by AI-enabled features. Per-feature pages can override the model on a feature-by-feature basis."
fields={PROVIDER_FIELDS}
description="Shared API keys used by AI-enabled features. AES-encrypted at rest. Per-feature pages can override the model on a feature-by-feature basis."
sections={['ai.providers']}
/>
<Card>
@@ -112,32 +41,44 @@ export default function AiAdminPage() {
</CardContent>
</Card>
{/*
Berth-PDF parser AI fallback - currently configured via the
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
when one is added, it lands here so admins don't have to hunt.
*/}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Bot className="h-4 w-4" /> Per-feature settings
<FileScan className="h-4 w-4" /> Berth PDF parser
</CardTitle>
<CardDescription>
Feature-specific tuning lives on each feature&apos;s admin page. They all read the
master switch + provider credentials configured above.
3-tier extraction (AcroForm on-device OCR AI fallback on low confidence) for
per-berth PDFs and brochures. Provider + confidence threshold are env-controlled today
(BERTH_PDF_PARSER_PROVIDER, BERTH_PDF_PARSER_CONFIDENCE_FLOOR); a per-port override UI
lands in a follow-up. The master switch above gates the AI tier across every port.
</CardDescription>
</CardHeader>
</Card>
{/*
Future AI surfaces. Each gets a section here once it ships:
- Recommender embeddings (currently rule-based, not LLM-based)
- Contact-log action extraction (deferred - needs user demand)
- Inquiry-form auto-classification (deferred)
Listing them inert here closes the "where do I configure AI?"
loop - admins land on /admin/ai and see the full landscape.
*/}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2 text-muted-foreground">
<Lightbulb className="h-4 w-4" /> Planned AI surfaces
</CardTitle>
<CardDescription>
Recommender embeddings, contact-log action extraction, and inquiry-form auto-
classification are queued. They will surface as additional sections on this page when
shipped, with no scattered admin entries to hunt down.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{FEATURE_LINKS.map((f) => (
<Link
key={f.href}
href={f.href as never}
className="rounded-md border bg-card p-3 hover:border-primary transition-colors block"
>
<div className="flex items-center gap-2 text-sm font-medium">
<f.icon className="h-4 w-4 text-muted-foreground" />
{f.title}
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</div>
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
</Link>
))}
</CardContent>
</Card>
</div>
);

View File

@@ -1,4 +1,5 @@
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() {
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
<PageHeader
title="Backup & Restore"
eyebrow="ADMIN"
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
description="Download a full backup, configure where automated backups are pushed, and browse history. Restore steps live in docs/backup-restore-runbook.md."
/>
<BackupAdminPanel />
<BackupDestinationsCard />
</div>
);
}

View File

@@ -0,0 +1,95 @@
import Link from 'next/link';
import type { Route } from 'next';
import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
/**
* Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed
* pre-2026-05-22 but were only reachable via deep links from inside the
* Berths list. Surfacing them on a dedicated admin landing tile so the
* tools are discoverable without prior knowledge of the URL - part of
* the admin IA regroup (B3 #10 Phase 2).
*/
export default async function BerthsAdminIndex({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
const tools = [
{
href: `/${portSlug}/admin/berths/bulk-add` as Route,
label: 'Bulk add berths',
description:
'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.',
icon: Anchor,
},
{
href: `/${portSlug}/admin/berths/reconcile` as Route,
label: 'Reconciliation queue',
description:
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch,
},
{
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
label: 'Price reconciliation',
description:
'Parse the purchase price from each berths current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
icon: BadgeDollarSign,
},
] as const;
return (
<div className="space-y-6">
<PageHeader
title="Berths admin"
eyebrow="ADMIN"
description="Tools for bulk berth creation and post-import reconciliation. Single-berth edits stay on the Berths list - these surfaces are for batch operations."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{tools.map((t) => {
const Icon = t.icon;
return (
<Link key={t.href} href={t.href} className="block group">
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
aria-hidden
/>
<CardTitle className="text-base">{t.label}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>{t.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
})}
</div>
<Card className="border-amber-200 bg-amber-50/50">
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<AlertCircle className="h-5 w-5 mt-0.5 text-amber-600" aria-hidden />
<CardTitle className="text-sm">Not what you&apos;re looking for?</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-xs">
For single-berth edits, browse to the{' '}
<Link
href={`/${portSlug}/berths` as Route}
className="font-medium text-primary hover:underline"
>
Berths list
</Link>{' '}
and click any row. Per-berth PDF uploads + brochure assignment also live there.
</CardDescription>
</CardContent>
</Card>
</div>
);
}

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

@@ -4,6 +4,7 @@ import {
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { PdfLogoUploader } from '@/components/admin/branding/pdf-logo-uploader';
import { EmailPreviewCard } from '@/components/admin/branding/email-preview-card';
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
@@ -45,6 +46,18 @@ const FIELDS: SettingFieldDef[] = [
imageAspect: 1,
defaultValue: '',
},
{
key: 'branding_email_background_url',
label: 'Email background image',
description:
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
type: 'image-upload',
// 16:9 - landscape. Without an explicit aspect, the cropper falls
// back to 1:1 and renders a circular mask (intended for avatars),
// which is the wrong UX for a viewport-cover background.
imageAspect: 16 / 9,
defaultValue: '',
},
{
key: 'branding_primary_color',
label: 'Primary color',
@@ -88,6 +101,7 @@ export default function BrandingSettingsPage() {
description="HTML fragments rendered around every transactional email."
fields={FIELDS.slice(3)}
/>
<EmailPreviewCard />
<PdfLogoUploader />
</div>
);

View File

@@ -6,7 +6,7 @@ import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
*
* Lists brochures, lets per-port admins upload new versions via direct-to-
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
* body-size limit see §11.1), and toggle the default flag.
* body-size limit - see §11.1), and toggle the default flag.
*/
export default function BrochuresAdminPage() {
return (

View File

@@ -1,229 +1,69 @@
import { CheckCircle2, Info } from 'lucide-react';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { WarningCallout } from '@/components/ui/warning-callout';
const API_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_api_url_override',
label: 'API URL override',
description:
'Optional. Falls back to DOCUMENSO_API_URL env when blank. Bare host only — never include /api/v1; the client appends versioned paths based on the API version below.',
type: 'string',
placeholder: 'https://documenso.example.com',
defaultValue: '',
},
{
key: 'documenso_api_key_override',
label: 'API key override',
description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.',
type: 'password',
defaultValue: '',
},
{
key: 'documenso_api_version_override',
label: 'API version',
description:
'Which Documenso REST API this port targets. v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model and richer per-field metadata. Test the connection after switching. See the v2 benefits card above for what changes when you flip this — and note that template-based EOI generation still uses the v1 formValues shape regardless of this setting (v2 template/use migration is on the roadmap).',
type: 'select',
options: [
{ value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' },
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope, recommended for new ports)' },
],
defaultValue: 'v1',
},
];
const SIGNER_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_developer_name',
label: 'Developer signer — name',
description:
'The party who signs after the client (typically the marina developer or owner). Used as the static "developer" recipient in templated documents (EOI). Was hardcoded as "David Mizrahi" in the legacy single-tenant system.',
type: 'string',
placeholder: 'David Mizrahi',
defaultValue: '',
},
{
key: 'documenso_developer_email',
label: 'Developer signer — email',
description: 'Email used to send the developer signing request via Documenso.',
type: 'string',
placeholder: 'dm@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_developer_label',
label: 'Developer signer — display label',
description:
'How the developer slot is referenced in email subjects + signer-progress UI copy. Defaults to "Developer" when blank.',
type: 'string',
placeholder: 'Developer',
defaultValue: '',
},
{
key: 'documenso_developer_user_id',
label: 'Developer signer — linked CRM user (optional)',
description:
"Project Director RBAC binding. When set, the webhook handler fires an in-CRM notification for this user when it's their turn to sign — alongside the branded email. Leave blank if the developer slot doesn't map to a CRM user (e.g. external developer). Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
{
key: 'documenso_approver_name',
label: 'Approver — name',
description:
'The final approver who signs after the developer (typically a sales/legal lead). Was hardcoded as "Abbie May" in the legacy system.',
type: 'string',
placeholder: 'Abbie May',
defaultValue: '',
},
{
key: 'documenso_approver_email',
label: 'Approver — email',
description: 'Email used to route the final approval signing request.',
type: 'string',
placeholder: 'sales@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_approver_label',
label: 'Approver — display label',
description:
'How the approver slot is referenced in email subjects + signer-progress UI copy. Defaults to "Approver" when blank.',
type: 'string',
placeholder: 'Approver',
defaultValue: '',
},
{
key: 'documenso_approver_user_id',
label: 'Approver — linked CRM user (optional)',
description:
"Same as developer's linked user — when set, fires an in-CRM notification when it's the approver's turn. Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
const EOI_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_eoi_template_id',
label: 'EOI Documenso template ID',
description: 'Numeric template ID used by the Documenso EOI pathway.',
type: 'string',
placeholder: '12345',
defaultValue: '',
},
{
key: 'eoi_default_pathway',
label: 'Default EOI pathway',
description:
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
type: 'select',
options: [
{ value: 'documenso-template', label: 'Documenso template' },
{ value: 'inapp', label: 'In-app (pdf-lib)' },
],
defaultValue: 'documenso-template',
},
{
key: 'eoi_send_mode',
label: 'Initial signing-invitation email behaviour',
description:
'Auto = the system sends our branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Auto is the lower-friction option for high-volume teams; manual lets reps review before sending. Applies to all document types, not just EOI.',
type: 'select',
options: [
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
{ value: 'auto', label: 'Auto (send branded email on generate)' },
],
defaultValue: 'manual',
},
];
const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_contract_template_id',
label: 'Contract Documenso template ID (optional)',
description:
'Numeric template ID for sales contract generation. Leave blank to use the per-interest upload-and-place-fields flow instead (the typical path for contracts, since they are usually drafted custom per client).',
type: 'string',
placeholder: '',
defaultValue: '',
},
{
key: 'documenso_reservation_template_id',
label: 'Reservation agreement Documenso template ID (optional)',
description:
'Numeric template ID for reservation agreements. Same logic — leave blank to upload per interest.',
type: 'string',
placeholder: '',
defaultValue: '',
},
];
const EMBED_FIELDS: SettingFieldDef[] = [
{
key: 'embedded_signing_host',
label: 'Embedded signing host',
description:
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
type: 'string',
placeholder: 'https://portnimara.com',
defaultValue: '',
},
];
const V2_FEATURE_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_signing_order',
label: 'Signing order',
description:
'PARALLEL = recipients can sign in any order (faster, current default). SEQUENTIAL = Documenso refuses to email recipient N+1 until recipient N has signed, enforcing client → developer → approver order on EOIs. Only applies when API version above is v2 — v1 instances ignore this and always behave as PARALLEL.',
type: 'select',
options: [
{ value: '', label: 'PARALLEL (default)' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — enforce signing order (v2 only)' },
],
defaultValue: '',
},
{
key: 'documenso_redirect_url',
label: 'Post-signing redirect URL',
description:
"URL Documenso redirects the signer to after they complete signing. Typically the marketing site's success page so signers land on a branded thank-you rather than Documenso's own page. Leave blank to use Documenso's default. v1 and v2 both honour this. Example: https://portnimara.com/sign/success",
type: 'string',
placeholder: 'https://portnimara.com/sign/success',
defaultValue: '',
},
];
// All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
// source badge on each field. The settings themselves live in
// `src/lib/settings/registry.ts` under sections `documenso.api` /
// `.signers` / `.templates` / `.behavior`.
export default function DocumensoSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Documenso & EOI"
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
title="Signing service (Documenso)"
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
/>
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
<p>
The CRM&apos;s signing features are built for Documenso 2.x (v2). Set the API version
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
</p>
<ul className="ms-4 mt-1 list-disc space-y-1">
<li>
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
</li>
<li>
<strong>Upload-and-send contracts / reservations</strong> fall back to v1&apos;s
per-field placement: page size is assumed to be A4, and rich field metadata (required
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
</li>
<li>
<strong>One-call send with per-recipient signing links</strong>,{' '}
<strong>sequential signing enforcement</strong>, and the{' '}
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
are unavailable or ignored on v1.
</li>
</ul>
<p className="mt-1">
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
the test-connection button to confirm.
</p>
</WarningCallout>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-4 w-4" aria-hidden="true" />
v1 vs v2 what changes when you flip the API version
v1 vs v2 - what changes when you flip the API version
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">
The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for
backwards compatibility. v2 is recommended for new ports and unlocks the features below.
Switching versions does <strong>not</strong> require any code changes version-aware
Switching versions does <strong>not</strong> require any code changes - version-aware
client methods pick the right endpoint per port. Switch, save, then run the
test-connection button to confirm the chosen instance is actually on the matching
Documenso version.
@@ -252,7 +92,7 @@ export default function DocumensoSettingsPage() {
/>
<span>
<strong>Percent-based field coordinates.</strong> No page-dimension lookup needed
coordinates are portable across page sizes. v1 requires us to assume A4 for
- coordinates are portable across page sizes. v1 requires us to assume A4 for
auto-placed fields.
</span>
</li>
@@ -263,7 +103,7 @@ export default function DocumensoSettingsPage() {
/>
<span>
<strong>Richer field metadata.</strong> TEXT labels &amp; required flags, NUMBER
min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults all ignored
min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults - all ignored
by v1, surfaced by v2 in the signing UI.
</span>
</li>
@@ -275,7 +115,7 @@ export default function DocumensoSettingsPage() {
<span>
<strong>v2-flavoured webhook events.</strong> <code>RECIPIENT_VIEWED</code>,{' '}
<code>RECIPIENT_SIGNED</code>, <code>DOCUMENT_RECIPIENT_COMPLETED</code>,{' '}
<code>DOCUMENT_DECLINED</code>, <code>DOCUMENT_REMINDER_SENT</code> all routed
<code>DOCUMENT_DECLINED</code>, <code>DOCUMENT_REMINDER_SENT</code> - all routed
through the same dedup + audit pipeline as v1 events.
</span>
</li>
@@ -288,9 +128,9 @@ export default function DocumensoSettingsPage() {
<strong>Envelope CRUD endpoints.</strong> <code>GET</code>, <code>DELETE</code>,
<code>POST /envelope/create</code> (multipart),{' '}
<code>POST /envelope/distribute</code>, <code>POST /envelope/redistribute</code>,{' '}
<code>GET /envelope/{'{id}'}/download</code> all routed through{' '}
<code>GET /envelope/{'{id}'}/download</code> - all routed through{' '}
<code>/api/v2/envelope/...</code> when v2 is selected. The template-generate path
is intentionally still v1 (relies on Documenso 2.x&apos;s backward-compat window
is intentionally still v1 (relies on Documenso 2.x&apos;s backward-compat window -
see the deferred-roadmap below).
</span>
</li>
@@ -301,7 +141,7 @@ export default function DocumensoSettingsPage() {
/>
<span>
<strong>One-call send.</strong> v2&apos;s <code>/envelope/distribute</code>{' '}
returns per-recipient <code>signingUrl</code> in the same response v1 requires a
returns per-recipient <code>signingUrl</code> in the same response - v1 requires a
separate GET to fetch them. Faster send flow on the rep side.
</span>
</li>
@@ -327,7 +167,7 @@ export default function DocumensoSettingsPage() {
behaviour&quot; card; Documenso redirects the signer to that URL after they
complete signing. Use to land clients on the marketing site&apos;s success page or
back in the portal instead of Documenso&apos;s default thank-you page. (v1 honours
this too listed here because the admin setting was added with the v2 work.)
this too - listed here because the admin setting was added with the v2 work.)
</span>
</li>
</ul>
@@ -342,7 +182,7 @@ export default function DocumensoSettingsPage() {
<strong>
Single-shot <code>/template/use</code>
</strong>{' '}
with v2 <code>prefillFields</code> by ID current EOI flow uses{' '}
with v2 <code>prefillFields</code> by ID - current EOI flow uses{' '}
<code>/api/v1/templates/{'{id}'}/generate-document</code> with{' '}
<code>formValues</code> keyed by name. v2 instances accept both during their
backward-compat window; full migration requires per-template field-ID capture in
@@ -352,59 +192,52 @@ export default function DocumensoSettingsPage() {
<strong>
Update envelope metadata after creation (<code>/envelope/update</code>)
</strong>{' '}
change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
- change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
re-generating.
</li>
<li>
<strong>Non-SIGNER recipient roles (CC / VIEWER)</strong> APPROVER role is already
<strong>Non-SIGNER recipient roles (CC / VIEWER)</strong> - APPROVER role is already
used by the EOI template; CC + VIEWER not yet exposed in the recipient builder.
Useful for sales managers who want a copy without a signature slot.
</li>
</ul>
<p className="mt-2 text-xs text-muted-foreground">
Sequential signing and post-signing redirect URL <strong>are now wired</strong> see
Sequential signing and post-signing redirect URL <strong>are now wired</strong> - see
the new &quot;v2 signing behaviour&quot; card below to configure them.
</p>
</div>
</CardContent>
</Card>
<SettingsFormCard
<RegistryDrivenForm
title="Documenso API"
description="Per-port API credentials. Leave blank to use the global env defaults."
fields={API_FIELDS}
description="Per-port API credentials. AES-encrypted at rest. Leave blank to inherit from the env fallback (badged below each field)."
sections={['documenso.api']}
extra={<DocumensoTestButton />}
/>
<SettingsFormCard
title="v2 signing behaviour"
<RegistryDrivenForm
sections={['documenso.behavior']}
title="Signing behaviour"
description="Cross-cutting settings that apply to EOIs + uploaded contracts/reservations. Sequential signing is v2-only (v1 instances ignore it). Redirect URL is honoured by both v1 and v2 instances."
fields={V2_FEATURE_FIELDS}
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['documenso.signers']}
title="Signers (developer + approver)"
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."
fields={SIGNER_FIELDS}
description="Identity bound to the developer (signing order 2) and approver (signing order 3) slots in your Documenso templates. Leave name + email blank to fall through to whatever you set on the Documenso template itself; set them here to override the template's stored values at send time. Recipient IDs are populated automatically by 'Sync from Documenso' below. Linking a CRM user is optional - when set, the platform fires an in-CRM notification for that user when it's their turn to sign."
/>
<SettingsFormCard
title="EOI generation"
description="Default pathway, template, and email behaviour when an interest's EOI is generated."
fields={EOI_FIELDS}
<RegistryDrivenForm
sections={['documenso.templates']}
title="Templates & signing pathway"
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
extra={<TemplateSyncButton />}
/>
<SettingsFormCard
title="Contract & reservation templates (optional)"
description="Most ports leave these blank because contracts/reservations are drafted per interest and uploaded for signing. Set a template ID only if you have a standardised contract/reservation Documenso template."
fields={CONTRACT_RESERVATION_FIELDS}
/>
<EmbeddedSigningCard />
<SettingsFormCard
title="Embedded signing"
description="Where the public-facing branded signing pages live. The CRM rewrites Documenso signing URLs to point here when sending invitation and reminder emails."
fields={EMBED_FIELDS}
/>
<WebhookHealthCard />
</div>
);
}

View File

@@ -1,67 +1,11 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { Info } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
const FIELDS: SettingFieldDef[] = [
{
key: 'email_from_name',
label: 'From name',
description: 'Display name shown in the From: header on outgoing email.',
type: 'string',
placeholder: 'Port Nimara',
defaultValue: '',
},
{
key: 'email_from_address',
label: 'From address',
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
type: 'string',
placeholder: 'noreply@example.com',
defaultValue: '',
},
{
key: 'email_reply_to',
label: 'Reply-to address',
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
type: 'string',
placeholder: 'sales@example.com',
defaultValue: '',
},
{
key: 'smtp_host_override',
label: 'SMTP host override',
description: 'Optional. Falls back to SMTP_HOST env when blank.',
type: 'string',
placeholder: 'mail.example.com',
defaultValue: '',
},
{
key: 'smtp_port_override',
label: 'SMTP port override',
description: 'Optional. Falls back to SMTP_PORT env when blank.',
type: 'number',
placeholder: '587',
defaultValue: null,
},
{
key: 'smtp_user_override',
label: 'SMTP username override',
description: 'Optional. Falls back to SMTP_USER env when blank.',
type: 'string',
defaultValue: '',
},
{
key: 'smtp_pass_override',
label: 'SMTP password override',
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password',
defaultValue: '',
},
];
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
import { TestTemplateCard } from '@/components/admin/email/test-template-card';
export default function EmailSettingsPage() {
return (
@@ -70,16 +14,46 @@ export default function EmailSettingsPage() {
title="Email Settings"
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
/>
<SettingsFormCard
title="From address"
description="Identity headers used by system-generated emails."
fields={FIELDS.slice(0, 3)}
{/* Explainer for the "two accounts" model - addresses the recurring
UAT question "why are there separate SMTP credentials for sales
and noreply?". Keeps the answer in front of the admin before
they reach the per-card form below. */}
<div className="rounded-md border border-border bg-muted/40 px-4 py-3 text-sm">
<div className="flex items-start gap-2">
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="space-y-1 text-muted-foreground">
<p>
<strong className="text-foreground">Why two accounts?</strong> Transactional emails
(signing invites, notifications, password resets) ship from your noreply mailbox over
the SMTP credentials below. Rep-authored sales emails (one-off messages, proposal
sends) ship from the sales mailbox with separate credentials so replies land in a
human-monitored inbox.
</p>
<p>
The noreply credentials are also used by the supplemental-info workflow + portal
activation, i.e. anywhere the platform sends on its own initiative. The sales
credentials are only used when a rep clicks Send in the compose UI.
</p>
</div>
</div>
</div>
{/* Registry-driven so each field shows the "Using env fallback /
port / global / default" badge inline - admins can tell at a
glance which fields are coming from .env vs. UI overrides. */}
<RegistryDrivenForm
sections={['email.from']}
title="From address (noreply)"
description="Identity headers used by system-generated emails. Set the From + Reply-To here; the matching SMTP credentials live in the next card."
/>
<SettingsFormCard
title="SMTP transport overrides"
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
fields={FIELDS.slice(3)}
<RegistryDrivenForm
sections={['email.smtp']}
title="SMTP transport overrides (noreply)"
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
/>
<SmtpTestSendCard />
<TestTemplateCard />
<SalesEmailConfigCard />
<EmailRoutingCard />
</div>

View File

@@ -4,17 +4,18 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
import { Copy, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import type { Route } from 'next';
import { Badge } from '@/components/ui/badge';
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
import { Button } from '@/components/ui/button';
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import type { ErrorEvent } from '@/lib/db/schema/system';
import type { LikelyCulprit } from '@/lib/error-classifier';
@@ -36,6 +37,17 @@ export default function ErrorEventDetailPage() {
const portSlug = params?.portSlug ?? '';
const requestId = params?.requestId ?? '';
// Smart-back target: send the user back to the error list, not the
// generic Administration page that URL-derivation would land on.
useBreadcrumbHint(
portSlug
? {
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
current: `Error ${requestId.slice(0, 8)}`,
}
: null,
);
const query = useQuery<DetailResponse>({
queryKey: ['admin', 'error-events', requestId],
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
@@ -71,15 +83,6 @@ export default function ErrorEventDetailPage() {
return (
<div className="space-y-4">
<div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error list
</Link>
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}</h1>
<Badge
@@ -163,11 +166,11 @@ export default function ErrorEventDetailPage() {
<KV label="Method" value={event.method} />
<KV label="Path" value={event.path} mono />
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : ''} />
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '-'} />
<KV label="Port" value={event.portId ?? '(none)'} mono />
<KV label="User" value={event.userId ?? '(none)'} mono />
<KV label="IP" value={event.ipAddress ?? ''} mono />
<KV label="User agent" value={event.userAgent ?? ''} />
<KV label="IP" value={event.ipAddress ?? '-'} mono />
<KV label="User agent" value={event.userAgent ?? '-'} />
</CardContent>
</Card>
@@ -176,11 +179,11 @@ export default function ErrorEventDetailPage() {
<CardTitle className="text-sm font-medium">Error</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<KV label="Name" value={event.errorName ?? ''} mono />
<KV label="Name" value={event.errorName ?? '-'} mono />
<div>
<p className="text-xs text-muted-foreground">Message</p>
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
{event.errorMessage ?? ''}
{event.errorMessage ?? '-'}
</p>
</div>
{event.errorStack && (
@@ -240,7 +243,7 @@ function KV({ label, value, mono }: { label: string; value: string | null; mono?
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? ''}</p>
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '-'}</p>
</div>
);
}

View File

@@ -1,16 +1,13 @@
'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
import type { Route } from 'next';
import { BookOpen, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { ERROR_CODES } from '@/lib/error-codes';
/**
@@ -20,13 +17,24 @@ import { ERROR_CODES } from '@/lib/error-codes';
* plain-language meaning + status code without leaving the app.
*
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
* automatically adding an entry to the registry adds a row here.
* automatically - adding an entry to the registry adds a row here.
*/
export default function ErrorCodeReferencePage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [search, setSearch] = useState('');
// Smart-back target: send the user back to the error inspector, not
// the generic Administration page URL-derivation would land on.
useBreadcrumbHint(
portSlug
? {
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
current: 'Error code reference',
}
: null,
);
const entries = useMemo(() => {
const all = Object.entries(ERROR_CODES) as Array<
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
@@ -39,7 +47,7 @@ export default function ErrorCodeReferencePage() {
}, [search]);
// Group by domain prefix (the part before the first underscore) so
// the table reads naturally Expenses, Berths, Storage, etc.
// the table reads naturally - Expenses, Berths, Storage, etc.
const grouped = useMemo(() => {
const groups = new Map<string, typeof entries>();
for (const entry of entries) {
@@ -53,15 +61,6 @@ export default function ErrorCodeReferencePage() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error inspector
</Link>
</Button>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
@@ -69,7 +68,7 @@ export default function ErrorCodeReferencePage() {
</h1>
<p className="text-muted-foreground text-sm mt-1">
Every error code the platform can return, with its HTTP status and the plain-language
message a user sees. Codes are stable identifiers once shipped, they never get
message a user sees. Codes are stable identifiers - once shipped, they never get
renamed.
</p>
</div>

View File

@@ -62,7 +62,7 @@ export default function DataImportPage() {
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
<li>Per-port import history with rollback.</li>
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li>
<li>Templates for clients, yachts, companies, berths, tenancies, expenses.</li>
</ul>
<p className="text-xs text-muted-foreground pt-2">
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial

View File

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

View File

@@ -1,14 +1,15 @@
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
import { PageHeader } from '@/components/shared/page-header';
import { redirect } from 'next/navigation';
export default function InvitationsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Invitations"
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
/>
<InvitationsManager />
</div>
);
/**
* 2026-05-21: /admin/invitations was merged into /admin/users (Users +
* Invitations tabs on a single page). This stub keeps old bookmarks +
* external links working by redirecting to the canonical destination.
*/
export default async function InvitationsRedirectPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
redirect(`/${portSlug}/admin/users`);
}

View File

@@ -1,14 +1,23 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { ShieldX } from 'lucide-react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
import { Button } from '@/components/ui/button';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may
* access any page under /[portSlug]/admin.
*
* H-15: previously this layout silently redirected non-admins to
* `/dashboard`, which left them staring at the dashboard with no
* explanation of why their bookmark / shared admin link "didn't work".
* Render an explicit 403 page instead so the URL stays on the failed
* route and the user can see why their request was denied.
*/
export default async function AdminLayout({
children,
@@ -29,7 +38,23 @@ export default async function AdminLayout({
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<ShieldX className="h-7 w-7 text-destructive" aria-hidden />
</div>
<div className="space-y-1">
<h1 className="text-xl font-semibold">Access denied</h1>
<p className="max-w-md text-sm text-muted-foreground">
This area is for super-administrators only. If you believe you should have access, ask
an administrator to grant the super-admin role on your account.
</p>
</div>
<Button asChild>
<Link href={`/${portSlug}/dashboard`}>Back to dashboard</Link>
</Button>
</div>
);
}
return <>{children}</>;

View File

@@ -1,5 +1,19 @@
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
import { redirect } from 'next/navigation';
export default function OcrSettingsPage() {
return <OcrSettingsForm />;
/**
* Legacy route. OCR settings now live on the consolidated AI panel at
* `/admin/ai` (the same `<OcrSettingsForm>` is mounted there alongside
* the master AI switch + provider credentials). Kept as a redirect-only
* page so any bookmarks / docs / deep links land on the right surface.
*
* Slated for full removal once the 2026-05-22 admin IA migration has
* had a quarter to bed in.
*/
export default async function OcrLegacyRedirectPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
redirect(`/${portSlug}/admin/ai`);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type Mode = 'auto' | 'suggest' | 'off';
const TRIGGERS: Array<{
key: string;
label: string;
description: string;
defaultMode: Mode;
}> = [
{
key: 'eoi_sent',
label: 'EOI sent',
description: 'Rep generates an EOI for signing - moves the deal to "EOI" stage.',
defaultMode: 'auto',
},
{
key: 'eoi_signed',
label: 'EOI signed (all parties)',
description:
'All signatories complete the EOI - moves the deal to "Reservation" stage. Conventional CRM behaviour.',
defaultMode: 'auto',
},
{
key: 'reservation_signed',
label: 'Reservation agreement signed',
description:
'Reservation paperwork signed by all parties - keeps the deal at "Reservation" with sub-status signed.',
defaultMode: 'auto',
},
{
key: 'deposit_received',
label: 'Deposit received in full',
description:
'Deposit total reaches the expected amount - moves the deal to "Deposit Paid" stage.',
defaultMode: 'auto',
},
{
key: 'contract_signed',
label: 'Sales contract signed',
description: 'Final contract signed by all parties - moves the deal to "Contract" stage.',
defaultMode: 'auto',
},
];
const PRESETS = {
aggressive: 'auto',
conservative: 'suggest',
} as const;
type PresetName = keyof typeof PRESETS;
export default function PipelineRulesPage() {
const queryClient = useQueryClient();
const [rules, setRules] = useState<Record<string, Mode>>(() =>
Object.fromEntries(TRIGGERS.map((t) => [t.key, t.defaultMode])),
);
const { data, isLoading } = useQuery<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>({
queryKey: ['admin', 'settings', 'pipeline.auto_advance'],
queryFn: () =>
apiFetch<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>('/api/v1/admin/settings/resolved?sections=pipeline.auto_advance'),
});
// Hydrate the local form once the server-side state arrives. We treat
// missing keys as the registered default - the page's persisted JSON
// doesn't have to enumerate every trigger, just the overrides.
useEffect(() => {
const persisted = data?.data?.values?.stage_advance_rules?.value;
if (!persisted || typeof persisted !== 'object') return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setRules((prev) => {
const next = { ...prev };
for (const t of TRIGGERS) {
const v = persisted[t.key];
if (v === 'auto' || v === 'suggest' || v === 'off') next[t.key] = v;
}
return next;
});
}, [data]);
const saveMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/admin/settings/stage_advance_rules', {
method: 'PUT',
body: { value: rules },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
toast.success('Pipeline rules saved.');
},
onError: (err) => toastError(err),
});
const applyPreset = (preset: PresetName) => {
const target = PRESETS[preset];
setRules(Object.fromEntries(TRIGGERS.map((t) => [t.key, target])));
};
const setMode = (key: string, mode: Mode) => {
setRules((prev) => ({ ...prev, [key]: mode }));
};
const allMatch = (mode: Mode) => TRIGGERS.every((t) => rules[t.key] === mode);
const currentPreset: PresetName | 'custom' = allMatch('auto')
? 'aggressive'
: allMatch('suggest')
? 'conservative'
: 'custom';
return (
<div className="space-y-6">
<PageHeader
title="Pipeline auto-advance rules"
description="Control which lifecycle events (signing, payments) automatically advance the deal stage on the kanban. Choose a preset or fine-tune per trigger."
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Preset</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3">
<PresetButton
name="aggressive"
label="Aggressive (default)"
description="Every trigger auto-advances the stage. Matches conventional CRM behaviour and saves rep clicks."
active={currentPreset === 'aggressive'}
onClick={() => applyPreset('aggressive')}
/>
<PresetButton
name="conservative"
label="Conservative"
description="Every trigger sends a notification suggesting the move. Reps click Approve to advance."
active={currentPreset === 'conservative'}
onClick={() => applyPreset('conservative')}
/>
<div
className={`rounded-lg border p-3 ${
currentPreset === 'custom'
? 'border-primary bg-primary/5'
: 'border-muted bg-muted/20'
}`}
>
<p className="text-sm font-semibold">Custom</p>
<p className="text-xs text-muted-foreground">
Mix and match - the per-trigger toggles below override the preset.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Per-trigger settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Loading
</div>
) : (
TRIGGERS.map((t) => (
<div
key={t.key}
className="flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex-1">
<p className="text-sm font-medium">{t.label}</p>
<p className="text-xs text-muted-foreground">{t.description}</p>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`mode-${t.key}`} className="sr-only">
Mode
</Label>
<Select
value={rules[t.key] ?? t.defaultMode}
onValueChange={(v) => setMode(t.key, v as Mode)}
>
<SelectTrigger id={`mode-${t.key}`} className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-advance</SelectItem>
<SelectItem value="suggest">Suggest only</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
</div>
))
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="gap-1.5 [&_svg]:size-3.5"
>
{saveMutation.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Save />}
Save rules
</Button>
</div>
</div>
);
}
function PresetButton({
name,
label,
description,
active,
onClick,
}: {
name: PresetName;
label: string;
description: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-lg border p-3 text-left transition-colors ${
active
? 'border-primary bg-primary/5 ring-2 ring-primary/40'
: 'border-muted hover:border-foreground/30 hover:bg-muted/30'
}`}
aria-pressed={active}
>
<p className="text-sm font-semibold">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
<p className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
{name === 'aggressive' ? 'auto for all triggers' : 'suggest for all triggers'}
</p>
</button>
);
}

View File

@@ -0,0 +1,51 @@
import Link from 'next/link';
import { Activity } from 'lucide-react';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function PulseAdminPage() {
return (
<div className="space-y-6">
<PageHeader
title="Deal Pulse"
description="Tune the chip that scores every interest's health. Toggle the chip off entirely, disable individual signals you don't want surfaced, or rename the tier labels per your sales vocabulary."
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="h-4 w-4" aria-hidden="true" />
How the pulse chip works
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="text-muted-foreground">
Every interest row carries a small coloured chip in the detail header. It scores the
deal from 0100 using rule-based signals (no AI). Click the chip on any interest to see
the per-signal breakdown - every +N or -N traces back to a dated event on the deal.
</p>
<p className="text-muted-foreground">
Positive signals (recent EOI sent, deposit received, contract signed) push the score up.
Risk signals (declined documents, cancelled reservations, berth resold elsewhere) push
it down. Stale-contact and stage-stuck signals weigh both directions automatically.
</p>
<p className="text-muted-foreground">
See the full guide at{' '}
<Link href="/docs/deal-pulse" className="underline">
/docs/deal-pulse
</Link>
.
</p>
</CardContent>
</Card>
<RegistryDrivenForm
title="Pulse chip behaviour"
description="Master toggle, per-signal toggles, and per-port label overrides. Defaults: chip visible, all signals on, built-in tier names ('Hot' / 'Warm' / 'Cold')."
sections={['pulse']}
/>
</div>
);
}

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