Commit Graph

255 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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