Commit Graph

325 Commits

Author SHA1 Message Date
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
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
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
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
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
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
202e0b1bc5 refactor(layout): single-tree responsive shell (#26)
Pre-fix the dashboard layout mounted BOTH the desktop and mobile shells
to the DOM on every page, hidden via CSS data-shell rules. Two Tabs
providers had data-state="active" concurrently, every fetch fired twice,
every component piece of state lived in two trees, a11y landmarks
duplicated, and half the click attempts hit the wrong layer.

New <AppShell> client wrapper mounts exactly ONE tree based on the
server-classified User-Agent (no hydration mismatch, no first-paint
flash on real mobile devices) plus a runtime matchMedia subscription
that swaps shells when the viewport crosses 1024px (e.g. desktop
browser resized).

Knock-on changes:
  - Dashboard layout fetches once and hands the data to AppShell;
    AppShell picks Desktop (Sidebar + Topbar + main) or MobileLayout
  - Stripped the now-orphan data-shell CSS rules from globals.css —
    nothing emits the attribute any more
  - MobileLayout drops its data-shell="mobile" attribute (was the lever
    the dead CSS rules pulled)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:59:30 +02:00
7d33e73eef feat(berths): manual status catch-up wizard + reconciliation queue (#67)
Wires the long-dormant berths.status_override_mode column into a closed
loop so reps can reconcile berths flipped to under_offer/sold without a
backing interest.

Phase 1 — Status source tracking:
  - updateBerthStatus() stamps 'manual' on every user-facing write
  - berth-rules-engine.ts stamps 'automated' on auto-rule writes
  - new clearBerthOverride() helper nulls the field and stamps the
    reason "Reconciled via interest <id>" — only the wizard calls it

Phase 2 — Visual indicator:
  - Amber "Manual" chip on berth-list rows where statusOverrideMode='manual'
    AND no active linked interest (the candidates for catch-up)

Phase 3 — Reconciliation queue:
  - new service listManualReconcileBerths() with cross-port-safe
    NOT-EXISTS against activeInterestsWhere
  - GET /api/v1/berths/reconcile-queue
  - new page /[portSlug]/admin/berths/reconcile listing the queue,
    each row linking to the catch-up wizard

Phase 4 — Catch-up wizard:
  - POST /api/v1/berths/[id]/reconcile orchestrates create-client
    (optional quick-create), create-interest with primary berth link,
    and clearBerthOverride — composed via existing service helpers
  - <CatchUpWizard> dialog: existing-client or quick-create, optional
    yacht link, stage picker scoped to the current berth status, with
    contract auto-setting outcome=won

Phase 5 — Entry points:
  - sidebar Admin > "Reconcile berths" link
  - berth-list row action menu shows "Catch up…" on flagged rows

Doc upload + payment recording (spec phases 4.4 / 4.5) are deferred —
once the interest exists, the rep uses the standard interest detail
page surfaces for those follow-ups. The wizard's MVP responsibility is
to take a manual berth to "interest exists, override cleared" in one
round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:55:22 +02:00
e7e498dedd fix(T3): copy + entry points + recommender alias
Batch of small fixes from the post-audit plan:

F11 — "Mark as won" dialog copy
  Was: "This will move the interest to Completed and stamp the outcome."
  Completed was retired in the 7-stage refactor; copy now reads
  "marks Won; stage stays where it is" with a parallel Lost variant.

F13 — Bulk-add berths wizard had no UI entry point
  Page existed at /[portSlug]/admin/berths/bulk-add but nothing linked
  to it. Added a "Bulk add" button on the Berths list toolbar, gated
  on `berths.import`. Also fixed the API route's permission key
  (was `berths.create`, a phantom — switched to `berths.import` to
  match seed-permissions).

F14 — Audit Log nav entry
  Sidebar Admin section now lists "Audit Log" → /admin/audit, gated
  by the adminRequired group rule.

F18 — Recommender `limit` param ignored
  POST /interests/[id]/recommend-berths now accepts `limit` as an
  alias for `topN`. Audit sent `{limit:3}` and silently got 8 rows
  back; both names now resolve.

Tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:59:38 +02:00
025648c40b fix(P1): soft-archive berths instead of hard-delete — F5
Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which
permanently dropped the row, cascade-vanished `interest_berths` links,
broke historical audit references, and could 404 the public feed mid-
customer-inquiry. The `berths.archived_at` column existed in the schema
but was never written.

Changes:
  - `archiveBerth(id, portId, { reason }, meta)` is the new canonical
    soft-archive. Requires a reason (min 5 chars). Blocks when an
    active interest still depends on the berth (forces the rep to
    resolve the deal first). Audit-logs the old status + reason.
  - `restoreBerth(...)` reverses it.
  - DELETE route now accepts `{ reason }` and routes to archiveBerth.
  - New POST /api/v1/berths/[id]/restore.
  - `getBerthOptions` + dashboard occupancy / status-distribution
    queries gain `isNull(berths.archivedAt)` so archived moorings
    don't show up in pickers or skew metrics.
  - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth
    so import sites we haven't migrated still work — labeled @deprecated.

Verified live:
  - DELETE w/o reason       → 400 (validation)
  - DELETE w/ "x"           → 400 "Reason must be ≥ 5 characters"
  - DELETE w/ proper reason → 204, row archived, reason persisted
  - DELETE twice            → 409 "Berth is already archived"
  - POST /restore           → 204, archived_at cleared

Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts,
alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules-
engine.ts. The current set covers the visible surfaces; the rest are
secondary aggregators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
2a2673e328 refactor(terminology): "deal" → "interest" sweep + route rename
Step 7 per PRE-DEPLOY-PLAN § 1.7. The canonical noun for an in-flight
sales record is "interest" everywhere in the codebase — entity name,
schema, kanban label, URL, etc. Customer-visible "deal" remnants are
either a holdover from pre-refactor copy or hand-written admin
descriptions that drifted.

Sweeps applied:

- /admin/qualification-criteria description: "before a deal moves out
  of the Enquiry stage" → "before an interest moves out…"
- /admin/documenso descriptions (×3): "per-deal upload-and-place…" →
  "per-interest upload-and-place…"; "upload per deal" → "upload per
  interest"; "drafted per deal" → "drafted per interest".
- bulk-archive-wizard.tsx placeholder: "late-stage deal" → "late-stage
  interest".
- smart-archive-dialog.tsx title: "Late-stage deal" → "Late-stage
  interest".
- /api/v1/berths/[id]/deal-documents → /api/v1/berths/[id]/interest-documents
  (route directory renamed; the single in-tree caller in
  berth-deal-documents-tab.tsx updated to match; React Query key also
  switched to "berth-interest-documents" for cache hygiene).

The `BerthDealDocumentsTab` component name + `berth-deal-documents-tab.tsx`
file path are intentionally left as-is — pure aliases, internal to the
codebase, churn cost > readability win. Rename when next touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:50:56 +02:00
66869c9a90 feat(dashboard): berth-heat widget + investor-default surfacing
Step 6 minimal-but-functional per PRE-DEPLOY-PLAN § 1.6.

Berth Heat — new widget showing top 15 berths by active interest
count via the interest_berths junction (non-primary links included so
multi-berth deals warm every berth in their bundle). Investor-friendly
demand-pressure view; the ranked-table shape exports cleanly to PDF/
CSV. Future heatmap viz reads the same shape via /api/v1/dashboard/
berth-heat.

Defaults flipped for investor-friendliness:
- kpi_pipeline_value → defaultVisible (currency-aware headline number).
- source_conversion → defaultVisible (conversion funnel by source;
  reads the inquiry → client linkage from Step 3).
- berth_heat → defaultVisible.

Pipeline-velocity-over-time + true heatmap viz deferred. pipeline_funnel
covers snapshot stage breakdowns; over-time velocity warrants its own
design pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:47:49 +02:00
709ef350ff feat(bulk-berths): 2-step wizard for new-port setup
Step 5 per PRE-DEPLOY-PLAN § 1.4.13.

Service: bulkAddBerths(portId, inputs, meta) — input-level dedup
catches in-batch duplicates, then a single SELECT against existing
port rows rejects with ConflictError on first collision. All inserts
in one round-trip; audit log + realtime alert.

Validator: bulkAddBerthsSchema with min(1) max(500) per call.

Route: POST /api/v1/berths/bulk-add gated on berths.create.

Wizard UI (/[portSlug]/admin/berths/bulk-add):
  Step 1 — dock letter A-E, range start+end mooring numbers, tenure
    default. Generates N empty rows.
  Step 2 — editable table with per-row dimensions / pontoon / pricing.
    "Apply to all" inputs in the header row copy a value down every
    row at once (covers the "every row is 40ft × 15ft at €125k" case
    in two clicks). Per-row remove button.

Drag-fill deferred. Server-side mooring uniqueness check is canonical;
client-side dedup is a pre-flight courtesy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:45:06 +02:00
4182652d49 feat(externally-signed): mark contract/reservation as signed without file
Step 4 second slice. Adds the "Mark as signed without file" action to
contract + reservation tabs per PRE-DEPLOY-PLAN § 1.5.14.

Service: `markExternallySigned(interestId, portId, docType, reason)`
flips the relevant doc-status column ('contract_doc_status' /
'reservation_doc_status' / 'eoi_doc_status') to 'signed', writes an
audit log entry with `metadata.type='externally_signed'` capturing
the optional reason, and fires the appropriate berth-rule trigger
(eoi_signed / contract_signed) so downstream automation (berth
status flips, notifications) treats it identically to a Documenso-
signed completion.

Route: POST /api/v1/interests/[id]/mark-externally-signed gated on
interests.edit. Validates docType against the canonical 3-value enum.

UI: <MarkExternallySignedDialog> AlertDialog with optional reason
textarea + per-docType copy. Wired into EmptyContractState and
EmptyReservationState empty-state buttons. The action sits alongside
"Upload draft for signing" and "Upload paper-signed copy" as a third
option for reps whose canonical paper lives elsewhere.

EOI not yet wired into a UI surface — the eoi flow already has a
full upload pipeline. Service supports it for completeness.

Followup: quick brochure/PDF download buttons + per-user reminder
digest schedule still pending in Step 4 backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:42:21 +02:00
e933e32dbd feat(schema): berths.archived_at + clients.source_inquiry_id + email_bounces
Step 3 schema additions per PRE-DEPLOY-PLAN § 1.4.

berths.archived_at (+ archived_by, archive_reason) — soft-delete column
so retired moorings can be hidden from the public feed and admin lists
without losing historical interest joins. Partial index `idx_berths_active`
on (port_id) WHERE archived_at IS NULL keeps the active-only list path
fast. Already wired:
- /api/public/berths and /api/public/berths/[mooringNumber] now filter
  out archived rows.
- berths.service.listBerths defaults to active-only with an
  ?includeArchived=true escape hatch for the archive bin.

clients.source_inquiry_id — text column with ON DELETE SET NULL FK to
website_submissions(id). Preserves the linkage from a website inquiry
to the client that came out of the "Convert to client" triage flow
(P-4.5). Drives the conversion-funnel-by-source chart (Step 6). The
Drizzle column ships without `.references()` to avoid the cross-file
circular import; the FK lives in the migration SQL.

email_bounces table — bounce-monitoring storage. The DSN poller worker
(forthcoming, depends on this table existing) writes one row per parsed
bounce; consumers join via (original_send_type, original_send_id).
Three secondary indexes cover the expected access patterns (port +
recent bounces; lookup by bounced address; lookup by original send).

Schema additions plus the migration SQL are ready for `pnpm db:push`
(or the migration runner once its journal is backfilled — separate
concern, journal currently stops at 0042 despite migrations through
0065 existing on disk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:33:20 +02:00
d556bb88f7 feat(email-routing): per-category send-from routing infra + admin matrix
Per PRE-DEPLOY-PLAN § 1.3.7. Lays the foundation for admin-configurable
routing of every outbound email category to either the noreply or
sales sender account.

Pieces shipped:
- `src/lib/services/email-routing.ts` — EmailCategory enum (17
  categories covering every shipped surface), DEFAULT_CATEGORY_ROUTING
  map (auth/notifications/EOI-invite → noreply; brochure/PDF/sales
  send-outs → sales), `resolveSenderForCategory()` + a graceful
  fallback to noreply when the resolved sender is sales but creds
  aren't configured.
- `GET / PATCH /api/v1/admin/email/routing` endpoints — gated on
  `admin.manage_settings`. Returns the routing + sales-availability
  flag + canonical category list.
- `EmailRoutingCard` — matrix UI dropped into /admin/email below the
  sales-email-config card. Per-category dropdown auto-disables the
  `sales` option when the port has no sales SMTP creds; explains the
  state in an amber callout. Save-on-change with toast + "Reset to
  defaults" button.

Setting persisted as `system_settings.email_routing` (JSONB blob).
Followup: opportunistic migration of existing dispatchers (sendEmail,
createSalesTransporter callers) to use `resolveSenderForCategory()` —
the defaults preserve current behavior so this is non-blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:24:38 +02:00
81d4e64f69 refactor(interests): drop pipelineStage='completed' sentinel convention
`outcome` is the canonical terminal-state signal. Pre-2026-05-14
`setInterestOutcome` also forced `pipelineStage='completed'` (a value
outside the 7-stage canon) which:

- broke `safeStage()` (silently coerced to 'enquiry' downstream)
- prevented analytics from answering "what stage was the deal at when
  it closed?" because every closed deal looked identical
- forced belt-and-suspenders filters everywhere ('outcome=won' AND
  'pipeline_stage=completed') that became redundant after migration 0062

Changes:

- `setInterestOutcome` no longer touches pipelineStage. Deal stays at
  whatever stage it was on when the outcome was recorded; outcome is
  the terminal signal. Audit log + websocket emit now carry
  `stageAtOutcome` instead of the stale `oldStage`.

- `clearInterestOutcome` smarter reopen-stage logic: if current stage
  is the legacy 'completed' sentinel (pre-existing rows from before
  this commit), default to 'qualified'. Otherwise preserve the stage
  the deal was at, so reopening drops the rep back where they were.
  Explicit data.reopenStage still wins.

- `/api/v1/admin/dashboard-stats` route reworked: per-stage breakdown
  now filters `outcome IS NULL` (only active rows count per stage);
  `closedTotal` derives from a new `outcome IS NOT NULL` count query;
  `completed30d` switches from `pipelineStage='completed' AND updatedAt`
  to `outcome IS NOT NULL AND outcomeAt` (avoids long-closed deals
  leaking into the window on unrelated edits).

- `berth-interests-tab.tsx` "active" filter switches from
  `pipelineStage !== 'completed'` to `!outcome && !archivedAt` — the
  legacy check stopped matching post-refactor.

- Socket event type `interest:outcomeSet` renames `oldStage` →
  `stageAtOutcome` with a doc-comment explaining the semantics shift.

PIPELINE_STAGES canon is now the only valid pipeline_stage value range
for newly-set outcomes. Legacy rows still carry 'completed' until they
naturally churn through reopen + re-close, at which point they enter
the new convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:04:13 +02:00
233129f91a feat(qualification-criteria): dnd reordering with whole-list PATCH
The chevron up/down buttons rewrote a single row's display_order,
which didn't actually swap positions since the neighbouring rows kept
their original orders. Replaced with a proper drag-handle (dnd-kit
sortable, matching the waiting-list-manager pattern) backed by a new
POST /admin/qualification-criteria/reorder endpoint that rewrites
display_order = index for every row in a transaction. The service
rejects partial / extraneous id lists so a stale UI can't silently
drop a criterion. Optimistic local-cache update keeps the row in
position during the round-trip; rollback on error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:49:17 +02:00
905852b8a5 feat(permissions): carve out dedicated payments resource
Payments (deposit / balance / refund records on an interest) used to
share `invoices.record_payment`, which forces a port that doesn't
issue invoices at all to still navigate the invoicing permission
group to grant its sales reps payment-recording rights. Splitting
the resource lets admins gate the two surfaces independently.

The new resource has three actions:
  - view   — gates the UI affordance (API reads still go through
             `interests.view`)
  - record — POST / PATCH a payment
  - delete — DELETE a payment record

Seed maps updated for all six system roles; existing role rows +
per-user permission overrides are backfilled by migration 0064 so
upgrades don't silently lose access. Two call sites (POST /interests/
[id]/payments, PATCH /payments/[id]) → payments.record; one
(DELETE /payments/[id]) → payments.delete. The PermissionGates on the
payments-section UI swap to the new keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:46:01 +02:00
6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
1a65e02885 feat(bootstrap): first-run super-admin setup flow
Fresh-DB detection on the login screen — if no super-admin row
exists, /api/v1/bootstrap/status reports needsBootstrap and login
redirects to /setup, which mints the first super-admin via
/api/v1/bootstrap/super-admin. Endpoint refuses once any user
already exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:37:19 +02:00
0fe3e984d1 feat(supplemental-info): pre-EOI public form flow
Lets a sales rep send a client a one-shot link to fill out the
information we need before drafting the EOI (intent, dimensions,
signatory, timeline). Token-keyed: single-use, soft-expiring, scoped
to one interest + client. Public POST endpoint accepts the form
submission; CRM endpoint mints tokens for rep-initiated requests;
portal page renders the form for the recipient.

Schema: supplemental_form_tokens table (migration 0061) with port_id
+ interest_id + client_id refs, unique token, consumed_at marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:36:56 +02:00
05b57abf05 refactor(settings): consolidate user profile into single settings page
Drop the standalone /settings/profile route + user-profile component;
folding the same fields into user-settings means one place to update
and one menu item. UserMenu loses the Profile dropdown entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:35:07 +02:00
12e22d9be3 fix(ui+auth): origin-forwarding for sign-in + disable dark mode + center dialog
Three related cleanups while QA-testing on iPad:

1. Origin-forwarding bug on /api/auth/sign-in-by-identifier
   - The custom identifier-sign-in route forwarded to better-auth's
     /sign-in/email handler but did NOT preserve the inbound Origin +
     Referer headers. Better-auth's CSRF check then 403'd every login
     with MISSING_OR_NULL_ORIGIN — and the UI showed a generic
     "Invalid credentials" toast even when the password was right.
   - Fix: pass through req.headers.get('origin') and
     req.headers.get('referer') when constructing forwardReq.
   - Affects: every login attempt from any device (this isn't dev-
     only); discovered testing from 192.168.1.17 → app on the same
     LAN IP. Production users hit the same path.

2. Dark mode disabled
   - Drop the Sun/Moon toggle from user-menu, the documentElement
     class flip, darkMode from ui-store, darkMode from the user-
     preferences validator. Hardcode sonner theme="light" (was
     reading next-themes which isn't actually wired anywhere else).
   - The 10 stray `dark:` Tailwind utilities are left alone — they're
     inactive without the `dark` class on <html> so they don't ship
     anything that renders, just dead CSS.

3. Center dialog animation
   - Dialog content was sliding in from the top-right corner (slide-
     in-from-left-1/2 + slide-in-from-top-[48%]) which felt jarring.
     Drop the slide directions, keep just zoom-in-95 + the base
     fade-in/out so dialogs appear in place with a subtle scale-up.

4. Login placeholder
   - Removed the "you@example.com  or  yourname" placeholder so the
     field reads as a clean empty input below the "Email or username"
     label.

No tests added (the 1340 vitest suite passes); changes are surface-
level UI tweaks + the origin-header fix where a unit-test of the
custom route would mostly be testing better-auth's behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:20:06 +02:00
adebd5f91d feat(documenso-phase-6): activity badges + per-document invitation message
Two of the six Phase 6 polish items shipped in one commit because they
share the data + plumbing path (per-doc message uses the signing-
progress UI's existing layout).

1) Signing-progress activity badges
   - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all
     populated by Phase 1+2 webhook handlers) per signer in the
     existing progress widget. Each badge renders as
     "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago"
     via Intl.RelativeTimeFormat.
   - Resend button: was silent on success/failure; now uses
     useMutation + toast so the rep sees whether the reminder fired
     or fell into a cadence cooldown. Honours the existing
     sendReminderIfAllowed return shape (`{sent, reason}`).
   - Title-tooltips on each badge show the exact ISO timestamp.

2) Per-document custom invitation message
   - New `documents.invitation_message` column (migration 0060;
     applied via psql per the dev-flow note in CLAUDE.md).
   - Textarea in UploadForSigningDialog step 2 (recipient configurator),
     1000-char cap, placeholder text shows the expected tone.
   - custom-document-upload.service accepts `invitationMessage`,
     trims + stores on the documents row.
   - sendCascadingInviteForNextSigner now reads
     doc.invitationMessage and passes as customMessage so every
     cascaded recipient (developer / approver / witness) sees the
     same note — not just the first signer.
   - send-invitation route (manual resend path) reads the same
     column → customMessage so manual reminders match.
   - The email template's existing customMessage rendering does
     the XSS escape; no other plumbing needed.

Phase 6 items still deferred (each ~2-3h, mostly independent):
- Auto-send delay (`eoi_send_delay_minutes` setting + scheduled
  BullMQ job — needs a scheduler hook).
- Document expiration (`documents.expires_at` + Documenso
  `expiresAt` passthrough — needs Documenso v2 endpoint shape
  verification).
- Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs
  an admin page with Replay button).

Tests: 1340 → 1350 ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
b1dfec09a0 feat(documenso-phase-7): Project Director RBAC binding
Admin UI binding for the developer + approver user-id fields that
Phase 1 schema'd but left unwired. Surfaces four new fields in the
Documenso settings card so admins can:

  - Set per-port display labels for the developer/approver slots
    (documenso_developer_label / approver_label) — drives email
    subjects + signer-progress UI copy. Defaults to "Developer" /
    "Approver" when blank.
  - Link each slot to a CRM user (documenso_developer_user_id /
    approver_user_id) — UUID from /admin/users.

Webhook side-effect:
- handleRecipientSigned's cascade now fires an in-CRM notification
  for the next pending signer when their signerRole matches a
  configured developer_user_id / approver_user_id. The branded
  email is the primary channel; the notification is a defense-in-
  depth nudge for users who live in the CRM all day.
- New notification type `document_signing_your_turn` with dedupeKey
  `document:<id>:your-turn:<signerId>` so duplicate webhook
  deliveries don't re-notify.
- Falls back silently when the binding isn't set or the signer
  isn't a developer/approver — preserves the existing flow.

Out of scope (build plan flags as out-of-scope for v1):
- Auto-fill name/email when a user is selected: needs a typeahead
  field type the SettingsFormCard doesn't have yet. Admin reads the
  user's UUID from /admin/users and pastes; minor friction for a
  one-time per-port config.
- Webhook handler reading the linked user's email and matching
  against the inbound recipient: today the developer/approver email
  settings already drive the matching; the user-id is purely a
  notification target.

Tests: 1340/1340 ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:08:52 +02:00
7bf587de90 feat(documenso-phase-4): recipient configurator + field placement UI
Phase 4 lands the visual half of the Documenso build — the upload-
for-signing dialog the Contract + Reservation tabs hand off to. Four
files of new code; the existing tab placeholders point at it.

Files added:
- lib/services/document-field-detector.ts — Phase 4c auto-detect
  scanner. Uses pdfjs-dist to extract per-page text + positions, then
  matches anchor patterns (Signature, Date, Initials, Email, Name,
  underscore-runs) and produces percent-coordinate DetectedField
  rows. Recipient label inference walks ±100pt of each match for
  Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the
  PDF is image-only; UI falls back to manual placement without an
  error. 6 unit tests pin the matching + coordinate math.

- app/api/v1/documents/auto-detect-fields/route.ts — multipart POST
  endpoint that delegates to detectFields(). Permission-gated by
  documents.send_for_signing.

- app/api/v1/documents/signing-defaults/route.ts — GET endpoint that
  surfaces just the per-port developer + approver display name/email
  + sendMode flag. No secrets exposed; lets the dialog prefill the
  recipient configurator without an admin-scoped settings read.

- components/documents/upload-for-signing-dialog.tsx — the Phase 4
  UI. Three-step state machine inside a single Dialog:
    1. select-file:  drop/click PDF picker + title input
    2. configure-recipients: client + developer + approver prefilled,
       rep can add/remove/reorder + change role (SIGNER/APPROVER/CC)
    3. place-fields: react-pdf renders the source PDF; auto-detect
       runs in the background on file load and seeds the overlay;
       rep places, drags, resizes, deletes, reassigns fields via the
       palette + side panel. Native DOM drag (no dnd-kit dependency
       added — the coordinate math stays obvious).
  Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3
  service); success toast reflects port sendMode (auto fires the
  invite immediately, manual leaves it for the rep).

Files modified:
- components/interests/interest-contract-tab.tsx + reservation-tab.tsx:
  swap the ComingSoonDialog placeholder for the real
  UploadForSigningDialog with the matching documentType prop. The
  placeholder ComingSoonDialog helper is deleted from both.

- scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp
  staged-only tsconfig so side-effect CSS imports (e.g.
  react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing
  declare-module shim. Without this fix the staged compile reports
  TS2882 even though the full tsc --noEmit pass passes.

Design choices noted in code comments:
- Native drag over dnd-kit: the field overlay's percent-based
  coordinate math is short enough that adding a drag library adds
  complexity without saving lines.
- Auto-detect on file-load (not on demand): runs immediately so the
  rep doesn't have to click a second button — empty result drops
  back to manual placement silently.
- Per-recipient color swatches indexed by signingOrder.
- Recipient seed via useMemo + user-event handler instead of
  useEffect → setRecipients (Wave 3 set-state-in-effect avoidance).

Server-side, Phase 3 plumbing handles the rest: tenant guard, magic-
byte verify, Documenso round-trip with per-port v1/v2 routing,
recipient signingToken capture for Phase 2 webhook cascade, auto-
send when port.sendMode === 'auto'.

Tests: 1334 → 1340  (6 new for the detector); tsc clean.

Deferred polish (Phase 6):
- Per-field metadata side panel for DROPDOWN/RADIO option lists
- Pinch-zoom + zoom-out controls on the field-placement canvas
- Recipient drag-reorder via dnd-kit
- Required toggle per field

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
33d0426911 feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.

Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
  full PDF → Documenso → local-state-update flow:
    1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
       same posture as berth-pdf + brochures).
    2. Stores the source PDF via getStorageBackend(), works on s3 +
       filesystem backends. Auto-files into the client's entity folder
       when resolvable.
    3. Inserts the documents row (status=draft → sent), with the file
       FK + interest link + clientId snapshot.
    4. Documenso round-trip via createDocument → sendDocument →
       placeFields. Per-port apiVersion drives v1 vs v2 (existing
       client handles both — v1: /api/v1/documents; v2: envelope/create
       multipart). meta.signingOrder + redirectUrl flow through.
    5. Captures recipient signingUrl + token into document_signers so
       the Phase 2 cascade picks them up.
    6. Auto-send first invitation when port.eoi_send_mode === 'auto';
       stamps invitedAt to suppress duplicate cascades.
    7. Advances pipeline stage to contract_sent.

- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
  POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
  size (≤50MB), all 11 Documenso field types. Permission-gated by
  documents.send_for_signing + interests.edit (matches the
  external-eoi precedent — the auto-advance side-effect is
  interest-mutating).

Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.

Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.

The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.

Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.

Tests: 1326 → 1334 ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
3dc4c6ff14 feat(documenso-phase-2): webhook handler enhancement — cascade + completion fan-out
Closes the silence after the first signing invitation. Three real
improvements on top of the existing webhook plumbing, all aligned with
the Documenso v1.32 + v2 webhook payload shape (verified against the
official OpenAPI spec + Context7 docs):

1. Cascading "your turn" emails — when DOCUMENT_SIGNED / DOCUMENT_
   RECIPIENT_COMPLETED / RECIPIENT_SIGNED fires for a recipient,
   handleRecipientSigned now resolves the next pending signer in
   signing order and sends them the branded sendSigningInvitation()
   email with the embedded-host-wrapped URL. Stamps invitedAt so a
   duplicate webhook retry doesn't re-send.

2. On-completion PDF distribution — handleDocumentCompleted now re-
   reads the just-committed signedFileId, resolves all signers, and
   fires sendSigningCompleted() to every recipient with the signed
   PDF attached. resolveAttachments in lib/email already pulls bytes
   through getStorageBackend() so this works under both the s3/minio
   and filesystem backends without changes. Failures fall through to
   logger.error rather than throwing — the document is already marked
   completed and the admin can re-trigger manually.

3. Token-based recipient matching — Documenso v1 + v2 webhook recipients
   carry a `token` field (per the OpenAPI spec); same token appears in
   the document-create response. Captured at send time into the existing
   document_signers.signing_token column (already in schema from Phase 1)
   and used by handleRecipientSigned + handleDocumentOpened before
   falling back to email match. Robust against the case where one email
   serves multiple roles on a contract — which is the documented gap in
   the legacy nocodb-based handler.

Supporting changes:
- New helper module lib/services/documenso-signers.ts with
  extractSigningToken() (URL-tail fallback), DOC_TYPE_LABEL map, and
  nextPendingSigner() picker. 11 unit tests cover the token-regex,
  the helper picks the lowest pending signing-order, and rejects
  declined/signed correctly.
- documenso-client normalizeDocument now reads `token` from both
  `recipients[]` and the legacy capital-R `Recipient[]` array Documenso
  v1.32 sometimes ships in webhooks.
- documents.service signer-update at send time prefers the explicit
  token field, falling back to extractSigningToken(signingUrl) for any
  v2 deployment whose distribute response omits it.

Out of scope for Phase 2 (per the build plan):
- Custom-doc upload-to-Documenso path (Phase 3)
- Recipient + field-placement UI (Phase 4)
- DNS-rebinding hardening + circuit-breaker (deferred-refactor list)
- Auto-reminder cron — manual "Send reminder" button + auto-reminder
  toggle remain manual until Phase 6 polish

Tests: 1315/1315 vitest  + 11 new tests for documenso-signers ;
tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:47:33 +02:00
ebdd8408bf fix(audit-wave-11): dossier sweep — error-ux + webhook + storage + search + maintainability
Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the
tractable Critical/High items from each:

error-ux-auditor (5 items)
- C2: 17 toast.error(err.message) sites swept to toastError(err, …) so
  every user-visible failure carries a copy-paste Reference ID
- C3: apiFetch synthesizes a client-side correlation id when a 5xx
  comes back with a non-JSON body (reverse-proxy HTML pages); message
  becomes "The server is unreachable. Please try again." with code
  UPSTREAM_UNREACHABLE
- C4: checkRateLimit fails OPEN when Redis is unavailable so an outage
  no longer 500s login + portal sign-in; logged at warn so monitoring
  catches it
- H2: StorageTimeoutError (name='TimeoutError') replaces the plain
  Error throw in s3.ts withTimeout — error-classifier hints fire now
- H5: errorResponse() adopted across /api/storage/[token],
  /api/public/website-inquiries, and the Documenso webhook body (drops
  the "Invalid secret" reconnaissance string)

outbound-webhook-auditor (5 items)
- C1: signature is now HMAC(secret, `${ts}.${body}`) with the
  timestamp surfaced as X-Webhook-Timestamp so receivers can reject
  replays outside a freshness window
- C3: dead-letter with reason missing_signing_secret when secret is
  null (defence-in-depth against DB tampering / future migration
  mistakes)
- H2: webhooks queue bumped to maxAttempts=8 with 30 s base
  exponential backoff so a 30 s receiver blip during a deploy no
  longer dead-letters every in-flight event; per-queue
  backoffDelayMs added to QUEUE_CONFIGS
- M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192
- M2: dispatch-time https:// assertion before fetch, so a bad DB edit
  can't slip plaintext through

storage-pathing-auditor (2 items)
- H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…`
  with portSlug threaded into backend.presignUpload — engages the
  filesystem-proxy port-binding `p` token verifier
- H2: presignDownloadUrl auto-derives portSlug from the key's first
  segment when callers don't pass it, so all 8 download sites engage
  the `p`-token guard without per-site plumbing

search-auditor (1 item)
- H3: removed dead void wantEmail; void wantPhone; pair plus the
  unused looksLikeEmail helper — the bucket-reorder it was scaffolded
  for was never wired

maintainability-auditor (1 item)
- M2: swept seven abandoned `void <symbol>` markers and their dead
  imports across clients/bulk, interests/bulk, admin/email-templates,
  admin/website-submissions, alert-rules, and notes.service

Deferred to future work (substantial refactors, schema migrations, or
multi-file UI work):
- error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage,
  ErrorBanner component, /api/ready route, worker DLQ admin surface)
- maintainability C1-C4 (documents/search/notes service splits,
  interest-tabs split — multi-hour refactors)
- currency C1-H5 (mixed-currency dashboard aggregation, FX history
  table, rounding policy) — wait for second non-USD port
- outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU
  with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy)
- storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type
  binding)

Tests: 1315/1315 vitest  ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:27:32 +02:00
93399ea27e fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch
**mobile-pwa-auditor H4 — mobile shell uses min-h-screen**

`min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE
viewport height (URL bar collapsed). On first paint the page renders
~75–100px taller than visible, and reps see a blank strip past the
bottom tab bar until the URL bar collapses on first scroll. Swap
`min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner
layout already does this correctly.

**multi-port-auditor C1 — port-switcher race / cross-port bleed**

`apiFetch` previously preferred Zustand for the X-Port-Id header and
only consulted the URL slug as a fallback. Zustand lags by one render
behind `PortProvider`'s reconcile effect; clicking from /port-A to
/port-B fired the first round of queries with X-Port-Id = port-A
while the page chrome rendered port-B → silent cross-port data bleed
in the UI.

Make the URL slug authoritative: read it first via
`window.location.pathname` + `resolvePortIdFromSlug`, fall back to
Zustand only on global routes (/dashboard) without a port slug.

**multi-port-auditor C3 — defaultPortId silently stripped**

`withAuth` reads `preferences.defaultPortId` as the X-Port-Id
fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS
allow-list silently dropped the key on every write. The fallback was
therefore dead — super-admins always landed alphabetically-first.

Add `defaultPortId: z.string().uuid().optional()` to the strict
schema and include it in ALLOWED_PREF_KEYS so super-admins can
persist their last-picked port.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
7370b2cd7d fix(audit-wave-11): file-lifecycle hardening — avatar leak + files FK
**file-lifecycle-auditor C1 — avatar replace leaks rows + blobs**

`POST /api/v1/me/avatar` overwrote `userProfiles.avatarFileId` without
reading or deleting the previous file id. Every "Replace photo" leaked
one `files` row + one S3 blob, untethered (no client/yacht/company
FK) and invisible to every existing UI sweep.

Now captures the prior id BEFORE the UPDATE, then best-effort
`deleteFile()` on the old row (handles ref-check + blob delete + audit)
after the new id is committed. Failure is logged at warn — a stale
blob shouldn't block the user from setting a new avatar.

**file-lifecycle-auditor M1 — files.client_id missing ON DELETE**

`files.client_id` was the only entity FK on the polymorphic `files`
table that defaulted to `NO ACTION` (yacht_id + company_id were
`SET NULL` per migration 0042). Any future bulk-client-delete that
bypassed `hardDeleteClient`'s explicit FK-nullify pre-step would
FK-violate. Migration `0059_files_client_id_onDelete_setnull.sql`
brings it to parity; the explicit nullify in client-hard-delete is
kept as defense in depth.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:06:27 +02:00
72237a0191 fix(audit-wave-11): authz hardening — caller-superset on role assign
authz-auditor C-1 second half: while the permission-overrides PUT route
already enforces caller-superset (prior wave), the `updateUser`
role-reassignment path didn't. A port admin holding only
\`admin.manage_users\` could PATCH a peer's roleId to a sales-director-
equivalent and have the colleague execute permissions the granter
didn't hold.

\`updateUser\` now takes optional `callerPermissions` + `callerIsSuperAdmin`
parameters and, when both are supplied (every interactive admin route),
walks the new role's effective permission tree and refuses any \`true\`
leaf the caller doesn't already hold. Super admins bypass by definition.

Wired \`ctx.permissions\` + \`ctx.isSuperAdmin\` through the single caller
(`/api/v1/admin/users/[id]` PATCH). Legacy callers that omit the args
(none currently) would silently skip the check; if any future system
job calls \`updateUser\` it should pass `callerPermissions=ctx.permissions`
explicitly.

Other authz items confirmed resolved by earlier work or by-design:
- C-1 (permission-overrides PUT): caller-superset already shipped in
  an earlier wave; verified by reading the route.
- H-1 (alerts GET ungated): already gated on \`admin.view_audit_log\`
  per the auditor's tier-4 recommendation.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:54:29 +02:00
b2c8ed2ff1 fix(audit-wave-11): auth-flow hardening (auth-flow-auditor)
Address the two CRITICAL items from auth-flow-auditor plus the
high-impact M10 open-redirect.

**C1 — Password reset doesn't revoke existing sessions**

CRM side: Better Auth has a built-in
`emailAndPassword.revokeSessionsOnPasswordReset` flag — flip it on.
Verified by reading password.mjs in node_modules/better-auth: this
calls `internalAdapter.deleteSessions(userId)` after the password
update commits. One-line fix, closes the canonical session-bumping
gap on the CRM forgot-password flow.

Portal side: the portal uses JWT sessions (not DB-side rows) so
there's no `deleteSessions` to call. Add a per-user
`password_changed_at` watermark column on `portal_users` and have
`verifyPortalToken` reject any token whose `iat` predates the
watermark. Updated on `resetPassword`, `changePortalPassword`, and
`activateAccount` so every password mutation revokes outstanding
cookies. Token shape gains a required `portalUserId` claim so the
verify step can do the watermark lookup without an email-based join;
legacy tokens (pre-Wave-11) lack it and are rejected → forces one
re-login per portal user post-deploy (24h max delay since portal
tokens already self-expire at 24h).

Migration `0058_portal_password_revocation.sql` stamps existing
rows to `now()` so no current session is invalidated by the schema
change itself.

**M10 — Portal login `?next=` open redirect**

`portal/login/page.tsx` did `router.replace(next as never)` against
unvalidated `searchParams.get('next')`. An attacker could send a
victim to `/portal/login?next=https://evil.example` and the post-sign-in
redirect would navigate cross-site. Add `safeNextPath()` that requires
`/portal/...` prefix and rejects protocol-relative URLs; everything
else falls back to `/portal/dashboard`.

**Other auth-flow items confirmed resolved by earlier waves:**
- H6 resolve-identifier enumeration: endpoint deleted in Wave 1
  (replaced with sign-in-by-identifier which keeps the synthetic
  email behind a server-side proxy)

Tests updated: portal-auth integration test mocks `db` so the new
DB-watermark lookup in `verifyPortalToken` stays unit-pure.

Tests 1315/1315 after `psql ALTER TABLE` to apply migration locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:52:17 +02:00
ecf49be18c fix(audit-wave-10): concurrency hardening (concurrency-auditor)
Close the CRITICAL + HIGH-tractable race conditions the
concurrency-auditor flagged. The wide-impact items (BullMQ jobId
plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too
many call sites for a single contained wave and stay deferred.

**C-1 — handleDocumentCompleted concurrent-retry orphan-blob**
Wave 1 fixed the compensating-delete on single-process failure but the
idempotency gate at line 1110 reads `doc.status` outside any row lock.
Two webhook deliveries arriving in parallel both pass the gate, both
storage.put + db.insert(files), and the losing files row orphans its
blob since documents.signed_file_id only points at one. Now the
transaction at line 1176 SELECTs the document `FOR UPDATE` and
re-checks the gate; if a concurrent worker already completed, throws a
sentinel `DocumentAlreadyCompletedError` which the outer catch
recognizes and runs the compensating storage.delete at info level
(not error). Net effect: at-most-once signed-PDF persistence even
under Documenso 5xx-then-retry storms.

**H-1 — moveFolder cycle check race**
Two concurrent folder moves (A → B and B → A) in READ COMMITTED can
each pass the cycle check against pre-state and both commit, leaving
A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of
the move transaction so the walk-and-write is atomic per port.
Lock auto-releases on tx end; no impact on cross-port folder ops.

**H-3 — upsertInterestBerth 23505 → generic 500**
Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary`
and the loser surfaced as a generic 500. Catch the 23505 + constraint
name and remap to ConflictError so the UI gets a "Another rep changed
the primary berth at the same time. Refresh and try again." toast.

**M-2 — username uniqueness 23505 → generic 500**
Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the
UPDATE then fails at the partial unique index. Catch 23505 +
`idx_user_profiles_username_unique` and remap to ConflictError.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:34:23 +02:00
f183f58b0c fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson
Address the CRITICAL + high-leverage HIGH items from the types-auditor:

**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.

**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.

**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.

**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each

document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
689a114aba fix(audit-wave-9): copy/terminology sweep (copy-auditor)
Address the highest-impact items from the copy-auditor's CRITICAL +
HIGH + MEDIUM bands:

**C2 portal raw-status leak**
- Drop the staff-only `leadCategory` chip from the portal interests
  page entirely. Privacy + optics: clients should never see "hot lead"
  in their own portal. `eoiStatus` was already wrapped in
  `portalSigningLabel`; only the categorical chip remained.

**C3 signing-status label drift**
- Add `src/lib/labels/document-status.ts` as the single source of
  truth for the {draft, sent, partially_signed, completed, expired,
  cancelled} lifecycle: labels (CRM + portal variants), StatusPill
  variant, and the "active / in-flight" set.
- Wire it into interest-eoi-tab, interest-contract-tab,
  interest-reservation-tab — they previously redefined identical
  STATUS_LABELS / ACTIVE_STATUSES blocks per-file.

**H1 + M3 verbiage codemod**
- `Save Changes` → `Save changes` (sentence case, matches the
  surrounding admin/CRM pattern).
- `Saving...` (ASCII three dots) → `Saving…` (Unicode ellipsis).
  Matches the project's UTF-8-elsewhere convention and reads
  correctly via screen-readers.

**M1 envelope jargon → signing request**
- smart-archive-dialog: "Leave envelope pending" → "Leave signing
  request pending"; "Void the signing envelope" → "Cancel the signing
  request"; section header updated to match.
- document-detail: "voids the signing envelope" → "cancels the signing
  request".
- bulk-archive-wizard: "leave invoices/signing envelopes alone" →
  "leave invoices/signing requests alone".
- Documenso admin page intentionally keeps `envelope` (dev/integration
  vocabulary).

**M5 Hot Lead casing**
- Normalize `Hot Lead` / `General Interest` / `Specific Qualified` to
  sentence case in `constants.ts` LABEL_OVERRIDES and all per-file
  lead-category maps so the CRM trend (sentence case) is consistent.

**C1 surface-level rename**
- "Linked prospect (optional)" → "Linked interest (optional)" on the
  berth status-change dialog.
- "Deal Documents" tab → "Interest Documents" (URL/route kept as
  `/deal-documents` to avoid breaking deep links; rename deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:12:40 +02:00
c1fcc9d5c4 fix(audit-wave-9): route-level loading skeletons across dashboard
Add a default [portSlug]/loading.tsx that covers all 72 nested routes
that previously rendered nothing during the cold-load gap. Uses the
existing PageSkeleton (page-header + table-skeleton) so the empty-header
flash on direct-URL visits / tab navigations is gone.

Add tailored loading.tsx for the four other tab-strip detail surfaces so
their initial paint mirrors the real page structure (header strip,
pipeline stepper for interests, tab strip, two-column overview):

- yachts/[yachtId]/loading.tsx
- companies/[companyId]/loading.tsx
- interests/[interestId]/loading.tsx
- berths/[berthId]/loading.tsx

(clients/[clientId]/loading.tsx already existed.)

Closes ui/ux M3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:02:10 +02:00
4233aa3ac3 fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:07 +02:00
6ca94ee3f1 fix(compiler): migrate 6 list pages to useQuery (set-state-in-effect)
Replaces the useState + useEffect + apiFetch pattern with TanStack
Query in six admin list pages — same pattern, mechanical refactor:

- admin/tags/tag-list
- admin/ports/port-list
- admin/roles/role-list
- admin/users/user-list
- admin/document-templates/template-list
- admin/webhooks/page
- dashboard/timezone-drift-banner (also: detected-tz reads via
  useSyncExternalStore so render stays pure)

Side benefits: list refetches now share a query cache across tabs
(via @tanstack/query-broadcast-client-experimental that was wired
up earlier this branch), so when admin A edits a role in one tab,
admin B's tab sees the updated row without a manual reload.

set-state-in-effect warnings: 51 → 45.

Verified: tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:34:24 +02:00
3aa1275ed7 feat(deps): next-intl scaffold (English-only, future locale-add ready)
Minimal next-intl wire-up so future i18n additions are a config
change, not a code rewrite. No URL routing changes — there's no
`/<locale>/` prefix because there's no second locale today.

- `src/i18n/request.ts` — request-scoped locale + messages loader,
  hard-coded to 'en'
- `messages/en.json` — common namespace with a few sample keys
- `next.config.ts` — withNextIntlPlugin wraps the config
- `src/app/layout.tsx` — wraps body with NextIntlClientProvider so
  client components can `useTranslations('common')` now

When a real locale target appears (Polish for marina users, Italian
for broker portal, etc.):
1. Add `messages/<locale>.json`
2. Move route folders under `app/[locale]/` to enable URL routing
3. Add a `routing.ts` with the locale list + default

Verified: tsc clean, vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:47:18 +02:00
0ab96d74a8 feat(deps): Tailwind 3 → 4 + swap tailwindcss-animate for tw-animate-css
Ran the official @tailwindcss/upgrade tool:
- tailwind.config.ts → @theme directive in globals.css
- @tailwind base/components/utilities → @import 'tailwindcss'
- postcss.config switched from tailwindcss + autoprefixer to
  @tailwindcss/postcss (autoprefixer baked in)
- focus-visible:outline-none → focus-visible:outline-hidden (the v3
  utility was a footgun — outline still showed in forced-colors mode)

Reverted the migration tool's over-zealous variant="outline" →
variant="outline-solid" rename on CVA prop values; that rename was
meant for the Tailwind `outline:` utility, not our Button/Badge
component variants.

Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css
(v4-native @import). Same utility surface (animate-spin, animate-in,
etc.), one fewer JS plugin in the bundle.

Fixed the upgrade tool's malformed dark variant
(@custom-variant dark (&:is(class *)) — `class` was being parsed as
a tag) to canonical &:where(.dark, .dark *).

Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings),
vitest 1315/1315, next build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:14:38 +02:00
d8f1c0c34e feat(email): port remaining 7 templates to react-email
Phase 2 (single commit) — applies the portal-auth.tsx pattern to every
hand-strung transactional email template. JSX components rendered via
@react-email/components' render() replace inline-style string templates
+ hand-rolled escapeHtml().

Ported (.ts → .tsx, public function signatures become async):
  crm-invite.tsx                — admin/super-admin CRM invite
  admin-email-change.tsx        — sign-in email changed notification
  inquiry-client-confirmation.tsx — public berth inquiry receipt
  inquiry-sales-notification.tsx  — internal sales alert for inquiries
  residential-inquiry.tsx       — pair: client confirmation + sales alert
  notification-digest.tsx       — daily/hourly unread-notification digest
  document-signing.tsx          — triplet: invitation + completed + reminder

Each template now defines its body as a typed React component, drops
escapeHtml() entirely (react-email auto-escapes string interpolation
in JSX text + attributes), and passes the rendered HTML to the existing
renderShell() for shell wrapping. The shell + branding flow is unchanged.

Caller migration (all sync → async):
  src/app/api/public/residential-inquiries/route.ts
  src/lib/queue/workers/email.ts
  src/lib/services/notification-digest.service.ts
  src/lib/services/users.service.ts
  src/lib/services/document-signing-emails.service.ts
  src/lib/services/crm-invite.service.ts

All call sites already lived inside async functions; only the await was
needed. No public API shape changes other than return type (now Promise).

The pattern now applies uniformly across all 8 email templates (portal-
auth.tsx + the 7 in this commit). Email template directory is fully
react-email-based.

1298/1298 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:19:52 +02:00
411d0764e8 feat(document-templates): delete TipTap-to-pdfme bridge
Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme
serializer and every code path that depended on it. TipTap document
templates remain as Documenso-template seed bodies; the CRM no longer
renders them to PDF in-app.

Deleted:
  src/lib/pdf/tiptap-to-pdfme.ts                                (571 LOC)
  src/lib/pdf/templates/eoi-standard-inapp.ts                   (337 LOC)
  src/app/api/v1/admin/templates/preview/route.ts
  src/app/api/v1/document-templates/[id]/generate/route.ts
  src/app/api/v1/document-templates/[id]/generate-and-send/route.ts
  src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC)
  src/lib/services/document-templates.ts:generateAndSend       (~40 LOC)
  src/lib/validators/document-templates.ts:generateAndSendSchema
  src/lib/validators/document-templates.ts:previewAdminTemplateSchema
  tests/unit/tiptap-serializer.test.ts (old bridge tests)

Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC):
  - validateTipTapDocument()  — still used to reject unsupported nodes
    on save in the admin template editor
  - TEMPLATE_VARIABLES        — drives the merge-token picker in the
    admin template form + preview UI

generateAndSign() now throws a clear ValidationError when a non-EOI
template tries the in-app pathway. Use a Documenso template, or wait
for the deferred AcroForm-fill admin-upload feature.

seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub
bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never
actually rendered (in-app EOI is pdf-lib AcroForm fill on the source
PDF — generateEoiPdfFromTemplate, unchanged).

After this commit, pdfme has zero callers left. Commit 14 drops the
deps and the generate.ts shim.

1298/1298 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:11:23 +02:00
ed2424cc68 feat(invoices): remove client-facing PDF generation
Phase 1 / commit 11 of 14 — invoices are client-facing documents, and
per the new "no CRM-generated client-facing PDFs" rule (see the design
spec), the in-app pdfme rendering is removed entirely.

Future invoice rendering will use the deferred AcroForm-fill admin-
template feature: admin uploads a PDF template with named form fields,
CRM fills them with invoice data via pdf-lib. Same pattern as the
in-app EOI pathway. Tracked in BACKLOG.md.

Deleted:
  - src/lib/services/invoices.ts:generateInvoicePdf (60 LOC)
  - src/lib/pdf/templates/invoice-template.ts (entire pdfme template)
  - src/app/api/v1/invoices/[id]/generate-pdf/route.ts
  - src/components/invoices/invoice-pdf-preview.tsx (regenerate UI)
  - "PDF Preview" tab on invoice detail page
  - 5 now-unused imports in invoices.ts (files, ports, buildStoragePath,
    getStorageBackend, env)

sendInvoice() retained: still queues the send-invoice email job, still
flips status to "sent", still emits the socket event. The PDF-attach
step is gone — downstream consumers either render externally or wait
for the AcroForm-fill feature. The `pdfFileId` column on invoices stays
so existing rows don't break, just never gets written by this code path.

1319/1319 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:04:49 +02:00
6517e014a6 feat(branding): port logo upload pipeline for internal PDFs
Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.

Server pipeline (src/lib/services/logo.service.ts):
  - magic-byte format check via sharp metadata
  - rejects animated/multi-frame inputs
  - SVGs sanitized via svgo preset-default + post-pass regex check
    (rejects <script>, on*=, javascript:, external href, <foreignObject>),
    then rasterized to PNG at 300 DPI
  - HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
  - optional crop coords applied server-side (bounds-checked first)
  - auto-trim near-white borders
  - resize so longest edge <= 1200px, sRGB, palette-PNG
  - rejects undersized output (< 200px any side) or > 1MB
  - atomic system_settings upsert; soft-archives prior file row + storage object

API:
  GET    /api/v1/admin/branding/logo            current logo metadata
  POST   /api/v1/admin/branding/logo            multipart upload + crop
  DELETE /api/v1/admin/branding/logo            clear; future PDFs fall back
                                                 to port-name text header
  GET    /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
                                                 with the current logo so
                                                 admins can spot-check
                                                 letterboxing in real shell

UI:
  src/components/admin/branding/pdf-logo-uploader.tsx
    - react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
    - file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
    - dark-band preview swatch shows how the logo lands in the header
    - post-upload warnings panel surfaces every server-side normalization
      (resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
    - "Test with sample PDF" button streams a real PDF for spot-check
    - "Remove" tears down the file + storage object + setting
  Wired into the existing /admin/branding settings page beneath the
  Identity and Email-branding cards.

Audit:
  Two new AuditAction enum values added: branding.logo.uploaded and
  branding.logo.archived. Captured per upload + per archived prior logo.

Tests:
  tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
  undersized rejection, empty/oversized rejection, non-image rejection,
  out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
  with embedded script rejection, SVG with external href rejection,
  JPEG-with-no-alpha warning collection.

1308/1308 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:51:49 +02:00