108 Commits

Author SHA1 Message Date
6aaccb6d33 docs(uat): annotate plan with per-group SHIPPED commits
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m56s
Build & Push Docker Images / build-and-push (push) Has been skipped
Stamps the 2026-05-21 plan with the SHA of every group's landed
commit. Groups A through T are worked end-to-end across this
session; Group U (EOI bundle UX rework) is the only remaining
parked item with reasoning in its commit.

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

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

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

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

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

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

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

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

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

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

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

Verified: tsc clean, vitest 1454/1454.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three patterns handled:

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

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

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

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

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

Removed now-unused Input imports from four files.

Verified: tsc clean, vitest 1448/1448.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

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

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

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

tsc clean. 1419/1419 vitest pass.

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

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

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

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

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

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

tsc clean. 1419/1419 vitest pass.

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

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

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

tsc clean. 1419/1419 vitest pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: 1374/1374 passing. tsc clean.

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

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

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

Tests: 1374/1374 passing. tsc clean.

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

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

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

Tests: 1374/1374 passing. tsc clean.

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

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

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

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

Tests: 1374/1374 passing. tsc clean.

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

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

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

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

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

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

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

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

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

Tests: 1374/1374. tsc + lint clean.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:22:39 +02:00
498 changed files with 74827 additions and 4315 deletions

58
.env.dev.template Normal file
View File

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

View File

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

58
.env.prod.template Normal file
View File

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

260
CLAUDE.md
View File

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

View File

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

View File

@@ -0,0 +1,335 @@
# Comprehensive Audit Findings — 2026-05-15
Discovery pass across all 19 areas of `docs/AUDIT-CATALOG.md`. Code-side via 9 parallel sub-agents + browser sweep via Playwright MCP. Per-agent raw output cached under `docs/audit-findings-tmp/`.
## Scoreboard
| Severity | Count |
| ----------- | ------ |
| 🔴 CRITICAL | 3 |
| 🟠 HIGH | 15 |
| 🟡 MEDIUM | 48 |
| 🟢 LOW | 8 |
| **Total** | **74** |
The 3 critical and the most actionable HIGH issues should head the next fix wave.
---
## 🔴 CRITICAL
### C-01 (B-01) — INNER JOIN on hard-deleted berth silently drops interest→berth links
- **Files:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
- **What:** Three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. Hard-deleting a berth makes the join silently drop the row.
- **Impact:** Interest detail shows `berthId: null` / `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty mooring field. `archiveInterest` calls `getPrimaryBerth` before evaluating the berth rule — null result causes the rule to be **skipped entirely**.
- **Fix:** Switch all three to `LEFT JOIN berths`. Callers already handle null. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).
### C-02 (R-021) — `/setup` missing from `PUBLIC_PATHS` — bootstrap unreachable on fresh DB
- **File:** `src/proxy.ts:51-73`
- **What:** `PUBLIC_PATHS` includes `/api/v1/bootstrap/` but NOT `/setup`. Unauthenticated user → `/setup` → middleware redirects to `/login?redirect=/setup`. Login useEffect fetches bootstrap status, calls `router.replace('/setup')` → middleware again → infinite redirect loop.
- **Impact:** Fresh deployment (no super admin) is functionally deadlocked. The first operator cannot reach setup without already having a session — impossible on a fresh DB.
- **Fix:** Add `'/setup'` to `PUBLIC_PATHS`. `POST /api/v1/bootstrap/super-admin` already self-protects with `hasAnySuperAdmin()`.
- **Browser-verified:** Navigating to `/setup` unauthenticated redirects to `/login` (no `?redirect=` even). The bootstrap-status check at `src/app/(auth)/login/page.tsx:41` confirms: `if (payload.data?.needsBootstrap) router.replace('/setup');` — feeds the loop on fresh DB.
### C-03 (NEW, browser-discovered) — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards
- **Files:** `src/app/api/v1/interests/[id]/route.ts:20-32` (calls `updateInterest`); `src/lib/services/interests.service.ts:701` (`updateInterest`); `src/lib/validators/interests.ts:68,90` (`pipelineStage` flows through `updateInterestSchema` to the service)
- **What:** The `/stage` endpoint (`src/app/api/v1/interests/[id]/stage/route.ts`) calls `changeInterestStage` which enforces `STAGE_NOOP` early-return, `canTransitionStage()` table guard, override-requires-permission, and override-requires-≥5-char-reason. The generic PATCH endpoint calls `updateInterest` which writes the full payload (incl. `pipelineStage`) directly to the DB with **none** of those guards.
- **Browser proof:**
- PATCH `/api/v1/interests/<deposit-paid-id>` with `{ pipelineStage: 'enquiry' }`**200 OK**, interest demoted to enquiry. (Same call via `/stage` correctly returned 400 with "Cannot move from Deposit Paid directly to New Enquiry. Use the override option ...".)
- PATCH `/api/v1/interests/<eoi-id>` with `{ pipelineStage: 'eoi' }` (same-stage) → **200 with full 1249-byte body** instead of 204. F27 fix only works through `/stage`.
- Backwards write via generic PATCH leaves `eoiDocStatus: 'sent'` while `pipelineStage = 'enquiry'` — corrupted state.
- Audit row written as generic `action: 'update'` with diff, not `action: 'stage_change'` with proper metadata. Webhook event `interest:updated` not `interest:stageChanged`.
- **Impact:** Any caller (rep tool, integration, mistake in frontend) hitting the generic PATCH can drive an interest to any stage with no override permission, no reason, no audit-as-stage-change. Same-stage spam fires no-op writes that bump `updated_at` and emit redundant socket+webhook events. The corrupted-state surface (stage rolled back but doc-status still says signed) breaks downstream rules-engine evaluations that branch on stage.
- **Fix:** In `updateInterestSchema`, omit `pipelineStage` (force callers to use `/stage`); OR in `updateInterest`, when `pipelineStage` is in the payload, delegate to `changeInterestStage` with the full guard chain. Either prevents the bypass surface from existing.
---
## 🟠 HIGH
### H-01 (SC-02) — Multiple FKs `ON DELETE NO ACTION` while Drizzle declares them nullable
- **Files:** `src/lib/db/schema/interests.ts:29,32` (portId/clientId); `src/lib/db/schema/documents.ts:72,85,86,176` (clientId/fileId/signedFileId/signerId); `src/lib/db/schema/reservations.ts:18,24,25,27,28,33` (all 6 berthReservations FKs); `src/lib/db/schema/operations.ts:25` (reminders.clientId); `src/lib/db/schema/financial.ts:120` (invoices.pdfFileId)
- **What:** `.references(...)` without `{ onDelete }` emits `ON DELETE NO ACTION`. Hard-deleting a parent (client, berth, yacht, file) blocks at FK level.
- **Fix:** Add `{ onDelete: 'set null' }` for nullable FKs that should tolerate parent deletion; explicit `{ onDelete: 'restrict' }` for those that intentionally block (`interests.clientId` design intent is archive-first).
### H-02 (R-017/018) — CRM post-login redirect ignores `?redirect=` param
- **File:** `src/app/(auth)/login/page.tsx:79`
- **What:** Middleware redirects unauthenticated → `/login?redirect=<path>`. Login page never reads `useSearchParams()`; always `router.push('/dashboard')`.
- **Impact:** Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard.
- **Fix:** Read `searchParams.get('redirect')`, validate same-origin (`startsWith('/')`, not `'//'`), use as push target.
### H-03 (R-023) — CRM invite token in query string leaks to access logs
- **File:** `src/lib/services/crm-invite.service.ts:71,233`
- **What:** `${env.APP_URL}/set-password?token=${raw}` — raw 32-byte token in query param. Portal flow was migrated to `#token=` fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
- **Impact:** Every nginx/Caddy access log line for `GET /set-password?token=<raw>` persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
- **Fix:** Change `createCrmInvite` + `resendCrmInvite` to emit `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`. Update `set-password/page.tsx` to use the fragment-reading pattern from `PasswordSetForm` (`readTokenFromUrl()`) with `?token=` back-compat for outstanding tokens.
### H-04 (R-029) — `sign-in-by-identifier` 429 missing `Retry-After`
- **File:** `src/app/api/auth/sign-in-by-identifier/route.ts:47-51`
- **What:** Builds 429 response with `headers: rateLimitHeaders(rl)` which only emits `X-RateLimit-Limit/Remaining/Reset`. `enforcePublicRateLimit` adds `Retry-After`; this route uses `checkRateLimit` directly and skips it.
- **Impact:** RFC 6585 §4 violation. Automated clients can't back off correctly.
- **Fix:** Add `'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString()`.
### H-05 (AU-01a) — `toggleAccount` writes no audit row
- **File:** `src/lib/services/email-accounts.service.ts:86-116`
- **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent.
- **Impact:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
- **Fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`.
### H-06 (AU-02) — Encrypted credential ciphertext stored in audit log without masking
- **Files:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299`
- **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta)`. `upsertSetting` records `newValue: { value: '<ciphertext>' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`.
- **Impact:** Audit log readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store.
- **Fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean`) and record `newValue: { value: '[redacted]' }`.
### H-07 (AU-10) — Cascade-archived interests produce no individual audit rows
- **File:** `src/lib/services/clients.service.ts:578-618`
- **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing.
- **Impact:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
- **Fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget).
### H-08 (EM-XX) — Sales transporter missing SMTP timeouts
- **File:** `src/lib/services/sales-email-config.service.ts:331-337`
- **What:** `createSalesTransporter` builds nodemailer transport with no timeout options. Compare `createTransporter` in `src/lib/email/index.ts:26-37` which uses `SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }`.
- **Impact:** Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. One stuck TCP connection → 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
- **Fix:** Apply `SMTP_TIMEOUTS` constant to `nodemailer.createTransport` in `createSalesTransporter`.
### H-09 (B-16) — AppShell remounts children on breakpoint crossing, destroying form state
- **File:** `src/components/layout/app-shell.tsx:58-70`
- **What:** When `isMobile` flips on resize, the shell switches between `<MobileLayout>{children}</MobileLayout>` and the desktop `<div>...{children}...</div>`. React unmounts and remounts `children`, destroying any in-progress `useState` form drafts including `InlineEditableField`.
- **Impact:** User editing a client name on desktop who resizes past mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
- **Fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on shell wrappers with `children` stable via Portal.
### H-10 (U-059) — Unicode glyphs as status icons in portal documents page
- **File:** `src/app/(portal)/portal/documents/page.tsx:85-89`
- **What:** Signer status rendered as raw Unicode (`'✓'` signed, `'✗'` declined, `'○'` pending) inside colour-coded `<span>` with no `aria-label`.
- **Impact:** Screen readers read literal Unicode names. Project memory: decorative unicode glyphs explicitly flagged. `inline-stage-picker.tsx:443` comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
- **Fix:** Replace with `<CheckCircle2>` / `<XCircle>` / `<Circle>` Lucide icons + `aria-label`.
### H-11 (U-066) — Vaul Drawer used for mobile search overlay (violates Sheet doctrine)
- **File:** `src/components/search/mobile-search-overlay.tsx:6`
- **What:** `import { Drawer as VaulDrawer } from 'vaul'`. Search overlay is full-screen, not a bottom sheet. CLAUDE.md: Vaul reserved for mobile-bottom-sheet only (currently `MoreSheet` only).
- **Fix:** Convert to `<Sheet side="bottom">` or `<Dialog>` fullscreen. Custom visualViewport handling (lines 50-89) becomes redundant with Radix dialog backing.
### H-12 (U-076) — Native `alert()` for bulk-action failure feedback in 3 lists
- **Files:** `src/components/interests/interest-list.tsx:146`, `src/components/companies/company-list.tsx:73`, `src/components/yachts/yacht-list.tsx:66`
- **What:** Partial-failure feedback via `alert(...)`. `client-list.tsx:145` uses `toast.warning(...)` correctly.
- **Impact:** Native alert blocks main thread, can't be styled, fires in tests without suppression.
- **Fix:** Replace with `toast.warning(...)` matching `client-list.tsx`.
### H-13 (U-079) — Icon-only buttons missing `aria-label` (5 sites)
- **Files:** `src/components/notifications/notification-bell.tsx:65`, `src/components/files/file-grid.tsx:121`, `src/components/admin/forms/form-template-list.tsx:102`, `src/components/email/email-accounts-list.tsx:159`, `src/components/companies/company-members-tab.tsx:228`
- **Pattern reference:** `src/components/shared/folder-actions-menu.tsx:96` correctly uses `<span className="sr-only">More folder actions</span>`.
- **Fix:** Add `aria-label` to each, following the folder-actions-menu sr-only pattern.
### H-14 (NEW, browser-discovered) — `DELETE /api/v1/interests/[id]/outcome` with empty body crashes 500
- **File:** `src/app/api/v1/interests/[id]/outcome/route.ts:27-30`; `src/lib/api/route-helpers.ts` (parseBody)
- **What:** The DELETE handler calls `parseBody(req, clearOutcomeSchema)`. `clearOutcomeSchema` says `reopenStage` is optional. But DELETE with no body causes parseBody to throw an unhandled error → 500 internal-server-error JSON. Sending `{ reopenStage: 'qualified' }` returns 200.
- **Browser proof:** Two consecutive `DELETE /api/v1/interests/<wonId>/outcome` calls (no body) returned 500 with `requestId: bc807db5-...` / `d21b5b3e-...`. Same call with body `{}` would presumably also work (not tested) — the issue is empty-vs-omitted body.
- **Impact:** F26 reopen flow — when the user clicks "Reopen" without overriding the auto-detected previous stage, the request crashes. Frontend may always send a body, but the API contract claims optional and the wire-level test fails.
- **Fix:** In `parseBody`, treat empty request body as `{}` for DELETE/POST routes whose schemas have all-optional fields; OR in the route handler, parse the body conditionally on `req.headers.get('content-length') !== '0'`.
### H-15 (NEW, browser-discovered) — Sales-agent visiting an admin page silently bounces to dashboard (no 403 / feedback)
- **Files:** Middleware in `src/proxy.ts` and/or per-route admin layout
- **What:** Sales-agent navigating to `http://localhost:3000/port-amador/admin/audit` lands at `http://localhost:3000/port-amador/dashboard`. URL silently changes; no toast, no 403 page, no "Access denied" feedback. The API itself correctly returns 403 ("Insufficient permissions" or "No access to this port") — the UI just hides the failure.
- **Impact:** A rep clicking a deep link to an admin page (in an email, bookmark, or shared link) is silently redirected without explanation. They can't tell whether the link was wrong, whether their permission lapsed, or whether the page just doesn't exist. (The earlier A18 verification said "/admin/audit correctly 403s" at the API level, which is true — but the UI layer hides it.)
- **Fix:** Render a `/403` page or surface a toast on access denial in the admin route layout. Keep the URL on the failed route so users can verify what they tried to reach.
---
## 🟡 MEDIUM (45 findings — by area)
### Multi-tenancy (5)
| ID | Title | File:line | Fix sketch |
| ------ | ------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- |
| M-MT01 | `updateDefinition` UPDATE missing portId in WHERE | `src/lib/services/custom-fields.service.ts:136-145` | Add `and(eq(...id), eq(...portId, portId))` to UPDATE WHERE |
| M-MT02 | Notes UPDATE/DELETE missing entityId scope | `src/lib/services/notes.service.ts:846-850, 869-873, 897-901` | Add `eq(...notes.<parent>Id, entityId)` to WHERE |
| M-MT03 | Contact UPDATE/DELETE missing clientId scope | `src/lib/services/clients.service.ts:737-741, 764` | Add `eq(clientContacts.clientId, clientId)` to WHERE |
| M-MT04 | `listForYachtAggregated` ownerClientId lookup no portId | `src/lib/services/notes.service.ts:276-283` | Add `eq(clients.portId, portId)` |
| M-MT05 | Webhook reads expose row before JS portId check | `src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174` | Move portId into `findFirst` WHERE |
### Schema (5)
| ID | Title | File:line | Fix sketch |
| ------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| M-SC01 | Migrations 0000-0036 not idempotent (no IF NOT EXISTS / DO blocks) | `src/lib/db/migrations/0000_narrow_longshot.sql`, `0036_polymorphic_check_constraints.sql` | Standardize IF NOT EXISTS / DO block pattern for new migrations; document 0000-0036 not re-runnable |
| M-SC02 | `companies` missing soft-delete partial index | `src/lib/db/schema/companies.ts:39-45` | `CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;` |
| M-SC03 | FTS GIN index missing for `interests` and `berths` | `src/lib/db/migrations/0057_search_fts_indexes.sql` | Add `CREATE INDEX CONCURRENTLY ... USING gin (...)` for both |
| M-SC04 | `audit_logs.searchText` schema/DB mismatch (Drizzle plain, DB GENERATED ALWAYS) | `src/lib/db/schema/system.ts:53-54` | Annotate as non-updateable / generated marker |
| M-SC05 | `documents.clientId` Drizzle nullable but DB `ON DELETE NO ACTION` | `src/lib/db/schema/documents.ts:72`, migration `0000_narrow_longshot.sql:814` | Migration mirroring 0059's fix for `files.client_id`: drop + re-add with `ON DELETE SET NULL` |
### Routes / Middleware (2)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------- |
| M-R01 | `/portal/` blanket allowlist removes middleware backstop | `src/proxy.ts:65` | Allowlist only unauthenticated portal routes individually; add middleware portal-cookie check |
| M-R02 | No explicit OPTIONS handlers, no CORS headers (defer until cross-origin consumer exists) | All `route.ts` under `src/app/api/` | Add explicit `Access-Control-Allow-Origin: <marketing-domain>` to public routes when needed |
### Audit log (4)
| ID | Title | File:line | Fix sketch |
| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| M-AU01 | FTS `search_text` covers only 4 fields; placeholder text misleads | migration `0014_black_banshee.sql:47-55` + `audit-log-list.tsx:360` | Change placeholder OR add `metadata` to GENERATED expression |
| M-AU02 | Admin audit log shows field names but no old→new diff | `audit-log-list.tsx:290-305` + `audit-log-card.tsx:84-91` | Add row-expand using `buildDiffLine` from activity-feed.tsx |
| M-AU03 | No audit log CSV export endpoint | (absent) | `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs` |
| M-AU04 | Outcome change uses `action: 'update'` not distinct verb | `interests.service.ts:1047-1058` | Add `'outcome_change'` to `AuditAction`; use in setInterestOutcome/clearInterestOutcome; add to dropdown + severity map |
### Documents/files (1)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ |
| M-D01 | Real-time invalidation event-name mismatch (`'file:created'` vs `'file:uploaded'`) | `src/components/documents/documents-hub.tsx:141` | Change to `'file:uploaded': [['files']]` matching other components |
### Security (1)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| M-S01 | S3 access key ID stored plaintext in `system_settings` (secret encrypted, key not) | `src/lib/storage/index.ts:136`, `src/components/admin/storage-admin-panel.tsx:80` | Apply same `encrypt()` / `*IsSet` pattern as secret key; migration to re-key existing rows |
### Email + Integrations (8)
| ID | Title | File:line | Fix sketch |
| ------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| M-EM01 | Portal activation/reset emails not threaded with portId — falls back to global SMTP | `src/lib/services/portal-auth.service.ts:163-164` | Pass `portId` as 6th arg to both `sendEmail` calls |
| M-EM02 | No CC/BCC in main `sendEmail` | `src/lib/email/index.ts:54-68` | Add optional `cc`/`bcc` to `SendEmailOptions` |
| M-EM03 | Bounce-to-interest linking not implemented | `src/lib/services/sales-email-config.service.ts:13` | Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs (Phase 7 §14.9 deferred) |
| M-EM04 | Notification digest uses `'crm_invite' as any` for subject resolution | `src/lib/services/notification-digest.service.ts:161-169` | Add `'notification_digest'` to `TEMPLATE_KEYS`; update digest service |
| M-IN01 | Presigned URL TTL fixed at 900s for portal downloads | `src/lib/storage/index.ts:240-254`; `src/lib/services/portal.service.ts:350` | Pass `expirySeconds: 4 * 3600` for portal links, or sign on-demand from API |
| M-IN02 | OpenAI receipt-scanner module-level instantiation, no credential health check | `src/lib/services/receipt-scanner.ts:4` | Guard `OPENAI_API_KEY` upfront; add health-check endpoint |
| M-IN03 | Receipt OCR ignores per-port config; hardcoded `gpt-4o` | `src/lib/services/receipt-scanner.ts:19` | Accept `portId`, call `getResolvedOcrConfig(portId)`, branch on provider |
| M-IN04 | Stale "pdfme" references in comments/seed | `src/lib/db/seed-data.ts:807`, `src/lib/services/document-templates.ts:573` | Update comments to reference pdf-lib AcroForm fill |
| M-IN05 | Umami `testConnection` throws instead of typed `{ ok: false }` | `src/lib/services/umami.service.ts:80-101, 292` | Return `{ ok: false, error }` to match `checkDocumensoHealth` |
### Performance + Behavioral (1)
| ID | Title | File:line | Fix sketch |
| ----- | --------------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
| M-P01 | Leading-wildcard `ILIKE '%term%'` in `buildListQuery` defeats indexes | `src/lib/db/query-builder.ts` | Migrate to `pg_trgm` GIN indexes on searched columns, or move to FTS via existing `search_text` GIN |
### Legacy enum drift (2)
| ID | Title | File:line | Fix sketch |
| ----- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| M-L01 | Tenure type enum diverges between berths and reservations | `src/lib/db/schema/berths.ts:65` vs `src/lib/db/schema/reservations.ts:32` | Pick canonical enum union; update both schemas + comments |
| M-L02 | Reports stage rollup raw `pipelineStage` without `canonicalizeStage` | `src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192` | Wrap row.stage with `canonicalizeStage()` before keying maps (defensive) |
### UX/forms (12)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| M-U01 | Audit log uses inline div instead of `<EmptyState>` | `src/components/admin/audit/audit-log-list.tsx:524` | Replace with `<EmptyState title="..." />` |
| M-U02 | Two duplicate `EmptyState` components with incompatible APIs | `src/components/ui/empty-state.tsx` vs `src/components/shared/empty-state.tsx` | Migrate 3 `ui/` callers to `shared/`, delete `ui/empty-state` |
| M-U03 | Required-field marker inconsistent | `client-form.tsx:273`, `interest-form.tsx:281` | Single pattern: `<Label>Field <span aria-hidden>*</span></Label>` + `aria-required="true"` |
| M-U04 | Help-text discoverability inconsistent | `src/components/shared/filter-bar.tsx`, `client-form.tsx` | Document a rule (always-visible for constraints; tooltips only for icons) |
| M-U05 | Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm | `client-form.tsx`, `yacht-form.tsx` | Add `isDirty` guard + discard AlertDialog matching InterestForm |
| M-U06 | FileUploadZone size limit not surfaced as client-side check | `src/components/files/file-upload-zone.tsx:170` | Wire client-side size check before upload |
| M-U07 | No jump-to-page input in pagination | `src/components/shared/data-table.tsx:420` | Add small `<input type="number">` between Previous/Next |
| M-U08 | No column resize/reorder on DataTable | `src/components/shared/data-table.tsx` | Opt-in `enableColumnResizing` per table via TanStack v8 |
| M-U09 | Invoice delete uses custom overlay, not AlertDialog | `src/app/(dashboard)/[portSlug]/invoices/page.tsx:167` | Replace with `<ConfirmationDialog>` |
| M-U10 | Success toast missing on ClientForm + InterestForm create/edit | `client-form.tsx:215`, `interest-form.tsx:235` | `toast.success(isEdit ? 'Client updated' : 'Client created')` |
| M-U11 | Logo preview `<img alt="">` should describe state | `src/components/admin/shared/settings-form-card.tsx:420` | `alt="Port logo preview"` or dynamic from field label |
| M-U12 | Heading hierarchy inconsistent within tab components | `email-accounts-list.tsx:114`, `interest-contract-tab.tsx:130/251/291/364` | Audit each tab; standardize h2/h3 nesting |
| M-U13 | DialogContent missing aria-describedby on minimal dialogs | `compose-dialog.tsx:95` + ~40 others | Add `<DialogDescription className="sr-only">` or `aria-describedby={undefined}` |
| M-U14 | Mobile topbar title blank on list pages | `client-list.tsx`, `yacht-list.tsx`, `interest-list.tsx`, `berth-list.tsx` | `useMobileChrome({ title, showBackButton: false })` per list |
| M-U15 | Invoices missing from mobile navigation | `src/components/layout/mobile/more-sheet.tsx:54` | Add `{ label: 'Invoices', icon: FileText, segment: 'invoices' }` to Operations group |
---
## 🟢 LOW (8)
| ID | Title | File:line |
| ------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------- |
| L-AU01 | Tier map sparse; new actions default to 'info' (`password_change`, `portal_activate`, `revoke_invite`) | `src/lib/audit.ts:220-222` |
| L-AU02 | Action filter dropdown missing 12 verbs | `audit-log-list.tsx:393-415` |
| L-AU03 | Entity-type filter dropdown missing 7 entries | `audit-log-list.tsx:88-102` |
| L-AU04 | Dead code — `listAuditLogs` (ILIKE) | `src/lib/services/audit.service.ts` |
| L-D01 | `HubRootView` has 2 sections, not 3 (CLAUDE.md spec inaccuracy) | `src/components/documents/hub-root-view.tsx:50-100` |
| L-D02 | `interest.yachtId` branch in chain doc spec is unreachable (interests.clientId NOT NULL) | `src/lib/services/documents.service.ts:1225-1251` |
| L-P01 | List endpoint `limit` cap = 1000 (audit log uses 200 + cursor as the better pattern) | `src/lib/api/list-query.ts` |
| L-L01 | Reports stage-revenue rollup raw `pipelineStage` (defensive concern, no active bug) | `src/lib/services/report-generators.ts:71-192` |
---
## ✅ Areas verified clean
- Documents/files structurally solid across 22 checks (one event-name mismatch + 2 doc divergences only)
- Security XSS / SQLi / path traversal / SSRF / encryption-at-rest all clean (one S3 access key plaintext)
- Multi-tenancy entry-point port isolation correct everywhere; gaps are TOCTOU-style only
- Documenso v1+v2 routing complete and version-aware; magic-byte verification on both upload paths
- Public berths API + public health endpoint + cookie flags + CSP + CSRF all correctly configured
- Audit log core write path covers all sampled mutations; `maskSensitiveFields` covers expected PII fragments
- Better-auth session fixation, token expiry, audit-log tamper-resistance all clean
- Legacy 9-stage enum refactor — rank tables now include both legacy + modern keys (commit 9821106 closed the gap); all rendering surfaces route through `stageLabelFor` or `LEGACY_STAGE_REMAP`
- BullMQ retry/backoff configured; Redis noeviction enforced in compose; worker process bootstraps all 10 queues
- pdf-lib AcroForm fill, EOI merge tokens, `formatBerthRange` (single/contig/non-contig/cross-pontoon)
- Inline editing pattern present on all 6 detail page types; NotesList polymorphic across all 6 entity types
---
---
## Browser sweep findings (Playwright MCP) — 2026-05-15
Live exploratory testing of the dev instance (port-amador + port-nimara seeded) using Playwright MCP. All findings below were either (a) confirmation of static findings, or (b) new bugs only visible at runtime.
### New criticals + highs from browser sweep
- **🔴 C-03** — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards (see C-03 above for full detail). The single most impactful new finding from the sweep.
- **🟠 H-14** — `DELETE /outcome` with empty body returns 500 (see H-14 above).
- **🟠 H-15** — Sales-agent → `/admin/*` silently bounces to `/dashboard`, no 403 page or toast (see H-15 above).
### New medium from browser sweep
- **M-NEW-1** — `/api/v1/me` and `/api/v1/me/ports` return 400 "Port context required" for non-super-admin callers without the `X-Port-Id` header. Super-admin works without the header. **Impact:** chicken-and-egg for the bootstrap flow that needs to know which ports a user has access to in order to choose one. Frontend likely passes the header from cookie state, but the contract is asymmetric per role. **Fix:** treat absent `X-Port-Id` on `/me/ports` as "list all ports the user has access to, regardless of context".
- **M-NEW-2** — Activity feed entity-type label rendered without separator: "Test Person 1interest", "Audit_loglist", "Settingrecom" — entity name + type concatenated. **File:** `src/components/dashboard/activity-feed.tsx` (the line that renders the entity label + type tag). **Fix:** add a separator (space, dot, or pipe) between name and type.
### Verifications confirmed clean in browser
| Check | Result |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C-02 `/setup` deadlock | ✅ confirmed: navigation redirects to `/login` (no `?redirect=` param even); `bootstrap/status` returns `needsBootstrap: false` on populated DB; loop fires when fresh |
| H-02 `?redirect=` ignored | ✅ confirmed: signed in with `?redirect=%2Fport-amador%2Fclients%2Fsome-fake-id` → landed at `/port-amador/dashboard` |
| H-04 `Retry-After` missing | ✅ confirmed: 429 fired on 2nd bad sign-in attempt, headers `x-ratelimit-limit/remaining/reset` present, NO `Retry-After` |
| R-004 cross-port URL | ✅ clean: `/port-amador/clients/<port-nimara-uuid>` shows friendly "Client not found... different port" page |
| MT-02 cross-port PATCH | ✅ clean: `PATCH /api/v1/interests/<port-nimara-id>` with `X-Port-Id: port-amador` → 404 "We couldn't find that interest" |
| Viewer permissions | ✅ clean: read 200, write same-port 403 "Insufficient permissions", write cross-port 403 "No access to this port" |
| F27 same-stage no-op | ✅ clean via `/stage` endpoint (returns 204); ❌ broken via generic PATCH (200 + body) — see C-03 |
| Forbidden transition | ✅ clean via `/stage` (400 with override-required-reason copy); ❌ bypassed via generic PATCH (see C-03) |
| Override no-reason | ✅ clean via `/stage` (400 "Override requires a reason (min 5 chars)") |
| Override short-reason | ✅ clean via `/stage` (same 400) |
| AU-11 permission_denied filter | ✅ activity feed shows no raw `permission_denied` rows |
| A2 legacy enum in feed | ✅ no raw `deposit_10pct` / `eoi_sent` / `contract_signed` in activity feed text |
| R-008 mooring URL canonicalization | ✅ `A1`=200, `a1`=400, `A%201`=400, `A-1`=400 |
| B-10 webhook empty/malformed body | ✅ both return 200 `{ok:false}` (graceful) |
| Tag CRUD (AD-014) | ✅ 201 create + 204 delete |
| Settings update (AD-008) | ✅ 200 with persisted body |
| Interest detail render | ✅ EOI badge, milestone "EOI sent May 14, 2026", no raw legacy values, no errors |
| Interest reopen with reopenStage | ✅ 200 ok |
| Public berths shape | ✅ 117 berths, statuses split Sold=11 / Under Offer=49 / Available=57 |
### Out of scope for this sweep (not exercised)
- Live Documenso integration (requires real-API project — `pnpm exec playwright test --project=realapi`)
- IMAP bounce probe round-trip (requires SMTP+IMAP credentials)
- C-01 berth-INNER-JOIN bug — would require hard-deleting a berth in the live DB (destructive); static analysis already conclusive
- Browser-side cross-browser testing (BR-\* — Safari, Firefox, Edge)
- Drag-and-drop kanban interactions
- Visual regression baselines (`--project=visual` snapshots)

View File

@@ -0,0 +1,266 @@
# Audit Fix Wave — 2026-05-18
Progress report against `docs/AUDIT-FINDINGS-2026-05-15.md` (74 findings)
and the still-open Wave-11 items in `docs/AUDIT-FOLLOWUPS.md`. Each
finding was re-verified against the current code before being touched —
the previous session's 70 uncommitted files mostly added new behaviour
and rarely overlapped with the audit issues, so almost everything was
still applicable.
`pnpm exec vitest run` → 1374/1374 pass. `pnpm exec tsc --noEmit` clean.
---
## 🔴 CRITICAL — 3 / 3 done
- **C-01** interest-berths INNER JOIN on hard-deleted berths — three
helpers switched to LEFT JOIN; `listBerthsForInterest` return type
loosened so an orphaned junction row still renders. Berth hard-delete
is already redirected to soft-archive, so the audit's "service-layer
guard preventing hard-delete" requirement is implicitly satisfied via
`archiveBerth`'s active-interest check.
- **C-02** `/setup` missing from `PUBLIC_PATHS` — added.
- **C-03** generic `PATCH /api/v1/interests/[id]` bypassing stage guards
`updateInterestSchema` now omits `pipelineStage`, forcing every
caller through the `/stage` endpoint with the override-permission +
override-reason guard chain.
## 🟠 HIGH — 14 / 15 fixed, 1 not-applicable
- **H-01** FK `ON DELETE` actions made explicit across interests /
documents / reservations / reminders / invoices schemas; migration
`0070_h01_fk_on_delete.sql` drops + re-adds each constraint under
the same name (idempotent against re-run).
- **H-02** login page reads `?redirect=` param with same-origin guard
(`startsWith('/')` and `!startsWith('//')`).
- **H-03** CRM-invite token moved to URL fragment (`#token=…`); the
set-password page reads from fragment via `useSyncExternalStore` with
`?token=` back-compat for outstanding links.
- **H-04** `Retry-After` header added to the sign-in-by-identifier 429
response (RFC 6585 §4).
- **H-05** `toggleAccount` now writes an audit row (action 'update',
entityType 'email_account', oldValue/newValue around isActive).
- **H-06** `upsertSetting` masks any value whose key ends with
`_encrypted` to `[redacted]` before writing to `audit_logs.new_value`
— keeps the ciphertext out of the historical audit trail.
- **H-07** `archiveClient`'s cascade fires per-interest audit rows
(action 'archive', metadata.cascadeSource = 'client_archive') so the
audit FTS surfaces a search for a specific archived interest.
- **H-08** `createSalesTransporter` now applies the shared
`SMTP_TIMEOUTS` constant — sales send-outs can no longer stall the
BullMQ pool on a hung relay.
- **H-09** AppShell refactored so `<main>{children}</main>` lives at an
invariant tree path across mobile/desktop chrome — React preserves
in-progress form drafts when the viewport flips across the breakpoint.
- **H-10** portal documents page replaces Unicode glyph status icons
with Lucide CheckCircle2/XCircle/Circle + aria-labels.
- **H-12** three list components (interests/companies/yachts) swap
`alert(…)` for `toast.warning(…)` matching client-list.
- **H-13** 5 icon-only buttons gain `aria-label` (notification bell,
file-grid actions menu, form-template edit/delete, email-account
remove, member-actions menu).
- **H-14** `parseBody` now treats empty request bodies as `{}` so
routes whose schemas have all-optional fields don't crash on an empty
DELETE / PATCH payload.
- **H-15** admin layout renders an explicit 403 panel ("Access denied —
this area is for super-administrators only") instead of a silent
redirect to `/dashboard`, with a "Back to dashboard" CTA. URL stays
on the failed route.
**Not applicable:**
- **H-11** mobile-search-overlay Vaul → Sheet conversion. The audit's
premise ("full-screen, not a bottom sheet") is inaccurate — the
overlay has `top: 12px` (visible backdrop strip), drag handle,
swipe-to-dismiss, and explicit visualViewport sizing for iOS keyboard
behaviour. CLAUDE.md's "Sheet vs Drawer doctrine" explicitly allows
Vaul for "mobile-only bottom-sheet UX" which is this case.
## 🟡 MEDIUM — 28 / 48 fixed, 5 deferred, the rest covered by larger work
### Done
- **M-MT01-05** multi-tenancy defense-in-depth: `port_id` / parent-id
filters added to UPDATE/DELETE WHEREs across custom-fields, notes
(all 6 entity types × update + delete), client-contacts, yacht
ownerClient lookup, and webhooks reads.
- **M-AU01** audit log placeholder copy fixed.
- **M-AU02** already done in previous session (Details column + Sheet).
- **M-AU04** outcome change now uses distinct audit verbs
`outcome_set` / `outcome_cleared`; AuditAction type extended.
- **M-D01** documents-hub realtime event-name typo (`file:created`
`file:uploaded`) fixed.
- **M-EM01** portal-auth activation + reset emails now pass `portId`
to `sendEmail` so per-port SMTP is used.
- **M-EM02** `sendEmail` accepts `cc` / `bcc` params; redirect mode
drops both (consistent with the dev safety net).
- **M-EM04** `notification_digest` added to `TEMPLATE_KEYS` +
`TEMPLATE_CATALOG`; the digest service drops the `'crm_invite' as any`
cast.
- **M-IN01** portal presigned download URLs now use a 4-hour TTL so
client links from yesterday's emails still work.
- **M-IN02** OpenAI client lazy-instantiated; missing key surfaces a
clear error instead of crashing at module load.
- **M-IN04** stale pdfme comments in seed-data + document-templates
updated to pdf-lib AcroForm.
- **M-IN05** `umami.testConnection` returns `{ ok: true|false, … }`
tagged union instead of throwing.
- **M-L02** `report-generators.ts` canonicalises stage values via
`canonicalizeStage()` across pipeline / revenue / forecast rollups
so legacy 9-stage rows fold into the modern 7-stage buckets.
- **M-NEW-2** activity feed entity-name/type concatenation — explicit
middle-dot separator so "Test Person 1" + "interest" no longer renders
as one word.
- **M-R01** portal allowlist narrowed from blanket `/portal/` to the
three unauthenticated entry-points + portal_session backstop in the
middleware redirects to `/portal/login` when the cookie is missing.
- **M-SC02** companies gets `idx_companies_archived` partial index
matching the clients/yachts/interests pattern.
- **M-SC04** `auditLogs.searchText` documented as GENERATED ALWAYS /
DB-managed.
- **M-SC05** documents.clientId `ON DELETE SET NULL` covered by the
H-01 migration.
- **M-U01** audit-log empty state uses `<EmptyState>`.
- **M-U09** invoice delete dialog migrated from hand-rolled overlay to
`<AlertDialog>` (focus trap, ESC-to-close, a11y semantics).
- **M-U10** ClientForm + InterestForm fire `toast.success(...)` on
create/edit.
- **M-U11** logo preview `<img>` carries a descriptive alt.
- **M-U14** mobile topbar title surfaced on clients / interests /
yachts / berths list pages via `useMobileChrome`.
- **M-U15** Invoices added to the mobile More-sheet Operations group.
- **M-L01** `reservations.tenureType` comment unified with
`berths.tenureType` (canonical union).
- **M-S01** `storage_s3_access_key_encrypted` admin field added; the
encrypt-plaintext-credentials script handles the data migration.
### Deferred (need user input or scope-larger-than-an-audit-fix)
- **M-AU03** — audit log CSV export endpoint. New feature surface.
- **M-EM03** — bounce-to-interest IMAP linking (Phase 7 §14.9).
- **M-IN03** — receipt-scanner per-port OCR config (every call site
needs `portId` threading).
- **M-NEW-1** — `/me/ports` asymmetric port-context header semantics.
- **M-P01** — leading-wildcard ILIKE → pg_trgm GIN migration.
- **M-SC03** — FTS GIN on interests + berths (search.service.ts
doesn't use to_tsvector for these — feature work).
### Lower-priority M-U items left untouched (cosmetic / process)
`M-U02` (dedup EmptyState components), `M-U03` (required-field marker
standardisation), `M-U04` (help-text discoverability rule), `M-U05`
(unsaved-changes warning on ClientForm/YachtForm), `M-U06`
(FileUploadZone client-side size check), `M-U07` (pagination
jump-to-page), `M-U08` (column resize/reorder), `M-U12` (heading
hierarchy across tab components), `M-U13` (DialogContent aria-describedby
across ~40 sites). All polish-grade — drop into a focused UX session.
## 🟢 LOW — 6 / 8 fixed, 2 deferred / not-applicable
- **L-AU01** severity defaults extended (password_change → warning,
portal_password_reset → warning, etc).
- **L-AU02** action-filter dropdown gains 13 missing verbs
(password*change, portal*\_, gdpr\__, rule*evaluated, outcome*_,
branding.\_).
- **L-AU03** entity-type dropdown gains 7 missing entries (yacht,
company, reservation, email_account, portal_session, portal_user,
file).
- **L-AU04** dead `listAuditLogs` (ILIKE) stubbed out — callers all
use the FTS-backed `searchAuditLogs` now.
- **L-D02** CLAUDE.md "Owner-wins chain" tightened — `interest.yachtId`
tail branch removed from the spec (structurally unreachable since
`interests.clientId` is NOT NULL).
- **L-P01** list endpoint limit cap — DEFER per audit (cursor pagination
is on the routes where it matters; the 1000-row cap is fine at
current data sizes).
- **L-D01** HubRootView spec inaccuracy — verified accurate; the
CLAUDE.md "three render modes" line refers to render _modes_, not
sections within HubRootView. Audit finding is a misread.
- **L-L01** reports defensive concern — covered by M-L02's
canonicalize sweep.
---
## Bonus: document-detail polish (#67 partial)
Three of the six deliverables in MANUAL-TESTING-BACKLOG §4.10b shipped
in this wave:
- **State-aware action button per signer** — `invitedAt === null`
primary "Send invitation" CTA (paper-plane); else "Send reminder"
(bell). Hits the existing `/send-invitation` and `/remind` routes.
- **Watcher Add UI** — replaces the user-id stub display with the
display name from `/api/v1/admin/users/picker`, plus a "+ Add"
select that lets admins pick any user in the port that isn't already
watching. Existing delete affordance untouched.
- **`cleanSignerName` cleanup** — shared from `SigningProgress` and
applied to the doc-detail card so EMAIL_REDIRECT_TO `(was: …)` /
`(placeholder)` suffixes stop leaking through.
The remaining three deliverables (full SigningProgress visual parity,
linked-entity name resolution, activity-panel `document_events` polish
with per-event icons + tooltips) need API changes to return entity
names + a meaningful event-type icon map. Deferred so it can ship in
one focused PR.
## Smoke validations against the running dev server
- **C-02** — `/setup` is reachable (middleware lets it through; page
itself redirects to `/login` when `needsBootstrap=false`). No infinite
redirect loop.
- **M-R01** — `/portal/documents` without a portal_session cookie now
redirects to `/portal/login?redirect=/portal/documents`.
- **H-04** — sign-in 429 response carries `Retry-After: 900` plus the
full `X-RateLimit-*` triplet.
## What still needs your input
Items genuinely blocked on a decision you haven't made yet. Most exist
in the 2026-05-15 manual-testing-backlog already; surfacing here in one
place for resolution.
1. **PDF template editor / builder (MANUAL-TESTING-BACKLOG §9.Z)**
ship Phase 1 alone (in-app fill of admin-uploaded PDFs with
merge-token mapping, ~12 weeks) or wait until Phases 1+2 can land
together (also Documenso template push, ~34 weeks)?
2. **Document detail refactor (#67 in §4.10b)** — multi-deliverable
redesign. Are we shipping it as one PR or splitting?
3. **Reminders data model (§0.1 + §3.2)** — Path A (extend lightweight
columns on `interests` — note/timeOfDay/priority/recurrence) or
Path B (push richer reminders into the existing `reminders` table)?
4. **Supplemental info form (§0.2)** — CRM-hosted route or
marketing-site-hosted? Need a green light to spend ~15 minutes
tracing the route end-to-end.
5. **EOI-scoped data overrides (§4.2)** — does the override apply only
to this specific EOI document, or to ALL future EOIs on this
interest? Reopening the drawer: show original override or fall back
to canonical? Are the overrides reusable for reservation + contract
or EOI-only?
6. **`/me/ports` port-context asymmetry (M-NEW-1)** — should the
endpoint treat absent `X-Port-Id` as "list all ports the user has
access to"? Currently super-admins work without it; everyone else
gets a 400.
7. **Bounce-to-interest IMAP linking (M-EM03 / Phase 7 §14.9)**
ready to scope or stays deferred?
8. **Receipt-scanner per-port OCR config (M-IN03)** — every call site
needs `portId` threading. Confirm we should do this now vs. when a
second-port OCR config materialises?
9. **CSV export of audit logs (M-AU03)** — net-new endpoint. Ship?
10. **Documenso phases 27 (BACKLOG §A)** — still back-burnered or
ready to pick up?
---
## Migrations to apply
`pnpm tsx scripts/db-migrate.ts` (or your usual migration runner) will
pick up the single new migration `0070_h01_fk_on_delete.sql`. It's
idempotent — each ALTER drops the constraint by name first, so re-runs
are safe.
## Files touched this wave
`118 files changed, 5181 insertions(+), 1301 deletions(-)` — but note
that count rolls in the previous session's 70 uncommitted files. Run
`git diff --stat HEAD docs/AUDIT-FINDINGS-2026-05-15.md` to see only
the audit-fix diff.

View File

@@ -317,6 +317,97 @@ Future PDF-related work (carry-over from §A of the PDF overhaul spec):
---
## J. Activity / timeline copy normalization
Every "Activity" or "Timeline" surface across the app currently leaks
raw schema details — camelCase field names, UUID values, boolean
`on`/`off` — straight into the user-visible copy. Real examples seen
in production:
- `Updated owner → mEcsLxo5kyFMyhbOSehxJjYSSD7CiLvv` (user UUID)
- `Updated primary berth → a53e3b1d-d589-4f11-9f7b-3b3a3c1ebb8e` (berth UUID)
- `Updated primary berth → a53e..., isInEoiBundle → on` (raw camelCase + boolean)
Two distinct renderers need a single source of truth:
1. **`InterestTimeline`** (`src/components/interests/interest-timeline.tsx`) reads pre-built `description` strings from `/api/v1/interests/[id]/timeline/route.ts` — see `buildAuditDescription` + `describeUpdateDiff` + `formatDiffValue`. Field-label catalog is partial; FK values are unresolved.
2. **`EntityActivityFeed`** (`src/components/shared/entity-activity-feed.tsx`) — used by clients, companies, yachts, berths, residential clients, residential interests. Builds copy client-side via `sentence()` + `formatValueForField`. Catalog is even thinner (only `pipelineStage` / `source` / `leadCategory` / `outcome` get human labels).
**Plan-of-work:**
- Build a shared `src/lib/audit/format-audit.ts` with:
- `FIELD_LABELS` per entity type (interest, client, company, yacht, berth, residential\_\*) covering every column we actually surface in audits. Today's gaps: `isInEoiBundle`, `isSpecificInterest`, `isPrimary`, `assignedTo`, `currentOwnerType/Id`, `companyId`, `parentCompanyId`, `mooringNumber`, `priceCurrency`, all the `*_at`/date fields beyond the EOI/contract handful.
- Value formatter that handles: booleans contextually (e.g. `isInEoiBundle: true` → "added to EOI bundle" / `false` → "removed from EOI bundle"; never `on`/`off`), enums via the `formatEnum`/`STAGE_LABELS`/`OUTCOME_LABELS` helpers in `src/lib/constants.ts`, currency+amount pairs, dates via `formatDate`.
- FK resolution: take a `Record<fkField, displayName>` lookup that callers prefill (mooring number for berthId, user name for assignedTo, client name for clientId, etc.) so values render as "→ Anna Schmidt" not "→ mEcs…".
- Update `/timeline` (interests) AND the 6 `/activity` route handlers to: (a) collect FK ids per row, (b) batch-resolve in one query per FK type, (c) pass the lookup into the shared formatter. The audit log itself stores IDs — resolution happens at read time so historical entries stay correct even after renames/deletes (in which case fall back to "(deleted yacht)" etc.).
- Migrate `EntityActivityFeed` to call the same shared formatter on the row's `fieldChanged` + `oldValue`/`newValue` so the strikethrough+arrow rendering uses the same vocabulary.
- Audit-log writes that have meaningful application context but don't fit the column-diff model (e.g. interest-berth flag toggles, EOI bundle membership changes) probably should set `metadata.type` so the formatter can route to a dedicated phrase ("Added berth A12 to EOI bundle", "Made A12 the primary berth") instead of best-effort diffing.
Acceptance: spot-check the timeline tab on a recently-edited interest, client, yacht, company, and berth. No UUIDs visible; no camelCase field names; no `on`/`off` booleans without context; all enum values render in their human label.
**Done while scoping (cosmetic fix):**
- Vertical-connector overshoot in `InterestTimeline` and `EntityActivityFeed` — both renderers used a container-level absolute line that trailed past the last bubble. Replaced with per-item connectors that omit on `isLast`.
---
## K. Per-port branded login (multi-tenant UX)
The login / forgot-password / set-password screens currently show the
"first active port" branding via `resolveAuthShellBranding()`, because
those surfaces have no portId in the URL. With two unrelated ports
(Port Nimara + Port Amador, no umbrella company) this means whichever
port was created first wins the login screen for everyone.
**Recommended path: shared instance, Host-header branding.** Run a
wildcard subdomain (`*.crm.example.com`) into the same Next.js app and
have middleware derive the active portSlug from the `Host` header.
`resolveAuthShellBranding()` then takes an optional host argument and
resolves by slug instead of "first port". Switcher becomes a
`window.location.assign('https://other-port.crm.example.com/dashboard')`;
session cookies are scoped to the parent domain so super-admins don't
re-auth when hopping.
Open work:
- Wildcard DNS + TLS cert (Cloudflare DNS-01 with `*.crm.example.com`).
- Cookie domain change: `pn-crm.session_token` needs `Domain=.example.com`
set in better-auth config.
- Middleware: read host, resolve portSlug, attach to request headers so
the auth-shell branding resolver can use it.
- Update `resolveAuthShellBranding()` to prefer host-derived port over
"first port" fallback.
- Port-switcher UI: dropdown in topbar that lists ports the user has
access to and navigates cross-subdomain.
- Bootstrap seed: populate `branding_logo_url` / `_email_background_url`
/ `_app_name` for the default port so fresh deploys aren't blank.
Alternative considered: **N instances, one per port.** Cleaner data /
deploy isolation but no UX gain over the shared-instance path. Defer
unless an operator demands independent migrations or data residency.
Size: medium (12 days incl. cert + cookie work + seed + switcher).
---
## I. Dashboard widget wishlist
User-driven enhancements to the customizable main dashboard
(`src/components/dashboard/widget-registry.tsx`). Each entry is a new
opt-in tile users can add via the widget picker.
- **More website-analytics stats cards** — expand the dashboard widget
catalogue with additional Umami-backed tiles users can pick from
(e.g. unique visitors, avg session duration, bounce rate, top
country, top referrer of the day, mobile vs desktop split,
pages-per-visit, returning vs new). Today only `WebsiteGlanceTile`
exists. Source data already flows through
`src/lib/services/umami.service.ts` and `useWebsiteAnalytics`. Each
new tile = one `KpiTile`-shaped component + a registry entry. Size:
small per tile, scope grows with the catalogue.
---
## F. Historical audit docs (mostly resolved)
These dossiers drove the audit-fix commit waves on 2026-05-05/06. Items

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
# Post-Audit Implementation Spec — 2026-05-18
Captures the design decisions from the post-audit conversation so the
implementation can start without re-litigating the trade-offs. Each
section ends with an Effort estimate.
---
## 1. EOI document field overrides
### Goal
When generating an EOI, the rep should be able to override pre-filled
field values (contact info, addresses, yacht details) while preserving
the canonical record. Manual entries persist as tracked secondary
values so future EOIs can pick them up from a dropdown.
### Design
**Client contact channels (email, phone):**
- The EOI form's email/phone fields render as a dropdown of every
`client_contacts` row for the linked client, defaulting to the primary
for each channel.
- Rep types a brand-new value → on EOI save, a new `client_contacts`
row is created with `is_primary=false`, `source='eoi-custom-input'`,
`source_document_id=<doc-id>`. Labelled `[EOI]` on the client detail
page contacts panel.
- The current EOI uses the new value; future EOIs default to primary
unless the rep explicitly picks the new row from the dropdown.
- A "Set as default for future documents" toggle on the EOI form
promotes the new value to `is_primary=true` (demoting the prior
primary).
**Client addresses:** Same pattern via `client_addresses` (which is
already multi-value per CLAUDE.md).
**Yacht name + dimensions:** Yachts are single-valued; rep needs a
different yacht → opens a "Create yacht" modal inline, fills in name +
dims for the new yacht record, linked to the same client/interest, tagged
`eoi-generated`. The EOI uses the new yacht. The original yacht is
unchanged. (No yacht_aliases / yacht_dimension_overrides table.)
**Interest-specific fields (rare):** Same dropdown pattern via the
existing fields on the interest record. Custom entries promote-or-stay
following the toggle.
**Audit trail:** Every override action (create-non-primary, promote-to-
primary, create-yacht-from-eoi) emits an audit_log row with action
`eoi_field_override` and metadata identifying the source document.
**Per-document override (no record-side write):** Doc-level overrides
remain available as a checkbox — when ticked, the value lives only on
the doc and never touches client_contacts. Default is unchecked.
### Schema additions
- `client_contacts.source text` — extend the existing enum: `'manual'`,
`'imported'`, `'eoi-custom-input'`.
- `client_contacts.source_document_id text references documents(id)
on delete set null` — surfaces the originating EOI.
- `client_addresses.source` + `source_document_id` (mirror).
- `yachts.source` + `source_document_id` (mirror; nullable so existing
records aren't disturbed).
- `audit_actions` enum gains `eoi_field_override` + `promote_to_primary`.
### UI
- EOI Generate drawer: each editable field becomes either a `<Combobox>`
(when multi-value) or `<Input>` + "Save as new …" hint (yacht).
- Below each field: `[ ] Use only for this EOI` checkbox (default off)
- `[ ] Set as default for future docs` checkbox (default off).
- Client + Yacht detail panels: `[EOI]` badge on non-primary rows;
"Set as primary" action on each.
### Effort
~11.5 weeks. Bundle the schema + EOI form + client/yacht detail UI
into one PR (user picked "All at once").
### Open implementation questions
- The yacht-creation inline modal needs the existing YachtForm wired in;
on save it tags the new yacht with the eoi-generated marker. Tag the
yacht via `tags`? Or a dedicated `source` column? Recommend column
for queryability.
- Should `[EOI]` badges fade out after a TTL or stay forever? Recommend
forever — the rep deliberately chose this label.
---
## 2. Reminders
### Goal
Reps can: per-interest follow-up cadence with note + time, standalone
tasks (no entity), assignable-to-another-rep tasks. The existing rich
`reminders` table holds the canonical data; the per-interest cadence
on the `interests` row stays for backward compat as a quick-tick.
### Design
**Per-interest cadence (kept):**
- `interests.reminderEnabled` + `interests.reminderDays` retained.
- New: `interests.reminderNote text NULL` — surfaced in the
notification body + the inbox row.
- The cadence fires a row into `reminders` on each tick (with
`interest_id` set) instead of the current ad-hoc notification flow,
unifying the inbox.
**Standalone tasks (new):**
- Rich `reminders` table already has every column we need (title, note,
priority, due_at, assigned_to, snoozed_until, google_calendar_event_id).
- Two UI surfaces (both submit to the same dialog component):
- RemindersInbox top-right `[+ New task]` button.
- Per-entity detail page (interest, client, berth, yacht): `[+ Task]`
button inside the existing Reminders section. Linked-entity field
pre-filled and locked.
- The dialog: Title (required), Note (optional), Due date+time,
Priority, Assign to (default = current rep), Linked entity
(optional dropdown for inbox surface; locked for per-entity).
**Time-of-day:**
- New user-settings field: `digest_time_of_day time, default '09:00'`.
Stored in user_profiles.
- Per-reminder override: each reminder's `due_at` carries the exact
firing moment (existing column). The dialog defaults the time picker
to the user's `digest_time_of_day` but lets them override per row.
- Worker scheduler: a 15-min cron tick scans `reminders` for rows whose
`due_at <= now() AND fired_at IS NULL`, fires the notification, sets
`fired_at`.
**Assignment:**
- `reminders.assigned_to` (existing). Dialog has an "Assign to" picker
(port users via /api/v1/admin/users/picker), defaults to current user.
- Inbox shows the assignee chip when not me; filter `[Mine | All my port]`.
### Schema additions
- `interests.reminder_note text NULL`
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`
- `reminders.fired_at timestamptz NULL` (new — drives the worker idempotency)
- No new tables. The existing `reminders` table covers standalone tasks.
### UI
- `<CreateReminderDialog>` component (shared).
- RemindersInbox: `[+ New task]` button → dialog (linked entity blank).
- Interest / client / berth / yacht detail pages: existing Reminders
section gains `[+ Task]` button → dialog (linked entity pre-filled,
field disabled).
- Settings page: time picker for "default reminder time" → writes
`user_profiles.digest_time_of_day`.
### Effort
~34 days. Schema migration + dialog component + 4 entity-page wires
- worker scheduler refactor + inbox filter.
---
## 3. Supplemental info form — per-port setting
### Goal
The "Send supplemental info form" link in the auto-email should resolve
to the marketing site when configured; fall back to a CRM-hosted route
otherwise. Confirmed: per-port setting.
### Design
- New system_settings key: `supplemental_form_url` (per-port, optional,
text). Defaults to NULL.
- Link generator in the email service:
```ts
const url = cfg.supplementalFormUrl
? `${cfg.supplementalFormUrl}?token=${raw}`
: `${env.APP_URL}/supplemental/${raw}`;
```
- Existing `/supplemental/[token]` CRM route stays as the fallback. Add
a "Loading…" skeleton + dual-mode copy ("If you don't see your
details, contact your rep").
- Admin UI: add the field to `/admin/email/page.tsx` (or a new
`/admin/supplemental/page.tsx`) — single text input with the help
hint "Leave blank to use the built-in CRM page."
### Effort
~2 hours (single setting + 1 admin field + link resolver).
---
## 4. Documenso phases 2 → 7 → 5 (you picked Phase 7 first)
### Phase 7 — Project Director RBAC (~1h)
- Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`
pointing at the existing `developer_user_id` + `approver_user_id`
settings.
- Auto-fill name/email from the selected user (read via
/api/v1/admin/users/picker).
- Webhook handler in `src/app/api/webhooks/documenso/route.ts`: when an
event arrives for the developer or approver, also fire an in-CRM
`documenso:signed` notification routed to the linked user's CRM
notifications inbox.
### Phase 2 — Webhook handler enhancement (~34h)
- Cascading "your turn" emails: when signer N completes, fire an
invitation email to signer N+1 (sequential signing only).
- On-completion PDF distribution: when status flips to COMPLETED,
email the signed PDF to all `documents.completion_cc_emails`.
- Token-based recipient matching: prefer `signing_token` over email
for webhook → signer resolution (handles aliased emails).
- Idempotency lock: replace the current body-hash dedup with a
composite `(documensoDocumentId, recipientEmail, eventType)` unique
constraint on documentEvents.
- Schema is already in place from Phase 1 — this is pure handler logic.
### Phase 5 — Embedded signing URL verification (~12h)
- Confirm the marketing site's `/sign/<type>/<token>` page handles
every signer-role × documentType combo.
- Update `signerMessages` map in the signing-invitation email template
to surface role-specific copy.
- Apply nginx CORS block from the integration audit (constrain
Documenso webhook origin).
### Effort total
~67h across the three phases. Phase 4 (field placement UI, 1014h)
stays deferred — covered separately by the PDF template editor work
you picked Phases 1+2 for.
---
## What I'll build first
Per your sequencing:
1. Documenso Phase 7 (~1h) — unblock the linked-user signing UX.
2. Supplemental form per-port setting (~2h) — small win.
3. Documenso Phase 2 (~34h) — meaningful UX improvement.
4. Documenso Phase 5 (~12h) — security + role copy.
5. EOI field overrides + reminders (~1.5 weeks combined) — the big
ones, picked up after the Documenso quick wins land.

View File

@@ -0,0 +1,22 @@
# L-001 Legacy Stage Enum Master Grep — agent #12 (re-dispatch slice 1)
**Headline:** The 9→7 stage refactor is correctly implemented; zero bugs found across 25 files with legacy-stage-name hits.
**Counts:** 0 critical · 0 high · 0 medium
---
## Verdict
The two `stageRank` Records (`clients.service.ts:276-283`, `berth-recommender.service.ts:195-210`) intentionally include both legacy AND modern keys mapping to the same final ranks — yesterday's commit `9821106` purged the gap. The rules engine (`berth-rules-engine.ts:15-42`) and document services use legacy _trigger event_ names (`eoi_sent`/`eoi_signed`/`contract_signed`) rather than stage names — both old and new events fire correctly because they're labels for webhook/doc events, not pipeline stages.
## Legitimate / neutral hit categories
- **Historical lookup tables (designed for dual-stage support):** `clients.service.ts:276-283` `stageRank`, `berth-recommender.service.ts:195-210` `STAGE_ORDER` — both have legacy + modern keys.
- **Refactor mapping definitions:** `constants.ts:59-65` `LEGACY_STAGE_REMAP`; `dedup/migration-transform.ts:206-212` legacy-to-legacy map for NocoDB import.
- **Rules engine + service layer (legacy-aware design):** `berth-rules-engine.ts:15-42` (trigger event labels), `external-signing.service.ts:37-41`, `documents.service.ts:786/909/1503/1544/1574` (`evaluateRule('eoi_sent'|'eoi_signed'|'contract_signed', ...)`), `external-eoi.service.ts:138-151` (intentional legacy-aware advance branch).
- **Schema metadata:** `db/schema/interests.ts:61-65` field names (`dateEoiSent`, `dateEoiSigned`, `dateContractSent`, `dateContractSigned`) — historical schema column names.
- **UI display:** `email/templates/notification-digest.tsx:29` `eoi_signed: 'EOI signed'` label for historical data.
- **Comments only:** `alert-rules.ts:83`, `interests.service.ts:938/980/1095`, `berths.service.ts:175`, `db/schema/operations.ts:98`.
**No silent-failure lookup tables. No rank-0 fallthrough patterns. No raw legacy enum keys leaking to the UI without remap.**

View File

@@ -0,0 +1,28 @@
# L-002-011 Legacy Stage Rendering Surfaces — done in main thread (sub-agent context-thrashed)
**Headline:** Mostly clean. One LOW finding: report-generators stage rollup keys are raw enum without `LEGACY_STAGE_REMAP`/`canonicalizeStage` — defensive-coding gap if any active row drifts back to a legacy stage value (migration 0062 normalized, so this is theoretical).
**Counts:** 0 critical · 0 high · 0 medium · 1 low (defensive)
---
## 🟢 LOW L-008: Reports stage-revenue rollup uses raw `interests.pipelineStage` without `canonicalizeStage`
- **File:** `src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192`
- **What:** `stageRevenueMap[row.stage] = ...` and `pipelineWeights[row.stage]` use the raw enum value from the SQL `groupBy(interests.pipelineStage)`. No `canonicalizeStage()` wrap.
- **Why it matters:** Migration 0062 normalized historical data to modern values, so today active rows should all be in the 7-stage set and bucketing is correct. But if any leakage occurs (NocoDB re-import, partial migration on a future port, manual `psql` write), legacy values would be siloed into their own bucket and `pipelineWeights[legacy_value]` returns `undefined` → that bucket contributes 0 to the forecast. Silent.
- **Suggested fix:** Wrap row.stage with `canonicalizeStage(row.stage)` from `src/lib/utils/legacy-stage.ts` before keying into `stageRevenueMap` / `pipelineWeights`.
---
## ✅ Passing checks
- **L-002 audit log diff** — `audit-log-list.tsx` / `audit-log-card.tsx` don't render stage values at all (just field-name keys per agent #4's AU-08 finding). No raw-enum render path exists.
- **L-003 activity feed** — `src/components/dashboard/activity-feed.tsx:14,57` imports and uses `LEGACY_STAGE_REMAP` for the stage_change diff line.
- **L-004 email templates** — `src/lib/email/templates/notification-digest.tsx:24` `TYPE_LABELS` includes `eoi_signed` as a _notification type_ label (the doc-status event), not a pipeline stage. Legitimate.
- **L-005 Documenso payload** — `src/lib/services/documenso-payload.ts` and `src/lib/templates/merge-fields.ts` have zero `pipelineStage` / `pipeline_stage` references. EOI payload doesn't surface stage.
- **L-006 public berths status filter** — already verified clean by agent #7 (IN-17). `src/lib/services/public-berths.ts:90-97` `derivePublicStatus` only branches on `sold` / `under_offer` / else `available`. No legacy enum acceptance.
- **L-007 outbound webhook** — `webhook-dispatch.ts` is a passthrough; payload built at `interests.service.ts:919-934` (`emitToRoom` + `dispatchWebhookEvent`). New stage value is current modern (write-time enforcement). `oldStage` could be legacy if the row was historical, but that's the actual historical truth — informational.
- **L-009 search FTS on stages** — `interests` has no FTS GIN index at all (per agent #2's SC-04 finding); migration 0057 covers only clients/yachts/residential_clients. Stage searchability via FTS is moot. (SC-04 fix should add interests FTS — when added, the GENERATED expression should use `stageLabelFor` for the stage column.)
- **L-010 notifications** — `next-in-line-notify.service.ts:63-65` falls back to `i.pipelineStage.replace(/_/g, ' ')` when `STAGE_LABELS` lookup misses. STAGE_LABELS is the modern-only map; legacy values would render as "eoi signed" etc. Recommended switch to `stageLabelFor()` for legacy resilience, but: only fires for active interests where stage is modern, so functionally clean today.
- **L-011 CSV importers** — Only import services are `berth-import.ts` and `document-import.ts`; neither references `pipelineStage`. No CSV stage-import path exists, so no risk of legacy value re-entry through this vector.

View File

@@ -0,0 +1,26 @@
# L-013-020 Adjacent Enum Drift — agent #14 (re-dispatch slice 3)
**Headline:** Single medium finding (tenure type enum diverges between berths and reservations); all other enums consistent.
**Counts:** 0 critical · 0 high · 1 medium
---
## 🟡 MEDIUM L-018: Tenure type enum diverges between berths and reservations
- **Files:** `src/lib/db/schema/berths.ts:65` vs `src/lib/db/schema/reservations.ts:32`
- **What:** `berths.tenureType` documents `'permanent' | 'fixed_term' | 'fee_simple' | 'strata_lot'` (4 values). `reservations.tenureType` documents `'permanent' | 'fixed_term' | 'seasonal'` (3 values). Same column name, divergent allowed values.
- **Why it matters:** No writes indicate actual cross-table conflict yet, but the schema-comment mismatch is a trap — a future feature copying tenure between the two tables would silently accept invalid values for the receiving side.
- **Suggested fix:** Pick a single canonical enum (likely `'permanent' | 'fixed_term' | 'fee_simple' | 'strata_lot' | 'seasonal'` as the union) and update both schemas + comments. Or rename one column to disambiguate intent.
---
## ✅ Passing checks
- L-013 berth status `available/under_offer/sold` — only writes are in `berth-rules-engine.ts` respecting the 3-value set
- L-014 statusOverrideMode — `manual/automated/null`; migration 0066 normalizes legacy `'auto'` → NULL; only writers in rules-engine + reconcile-queue both respect three-state
- L-015 outcome — `won/lost_other_marina/lost_unqualified/lost_no_response/cancelled`; only writes in `interest-outcome.service.ts`; no legacy `'completed'` outcome anywhere
- L-016 lead category — `general_interest/specific_qualified/hot_lead`; no out-of-set writes
- L-017 lead source — `website/manual/referral/broker`; no out-of-set writes
- L-019 doc status (`eoiDocStatus`, `reservationDocStatus`, `contractDocStatus`) — `pending/sent/signed/declined/voided`; mark-externally-signed only writes `'signed'`; Documenso webhook routes all status updates through services consistent with the set
- L-020 reservation/contract status — `pending/active/ended/cancelled`; only writes in `reservation-state-machine.ts`

View File

@@ -0,0 +1,105 @@
# Multi-tenancy + Schema Audit (MT-01-11, SC-01-15) — agent #2
**Headline:** API port isolation structurally sound, but 5 write paths do port check in JS without re-asserting portId in WHERE (TOCTOU gaps). Schema has several FKs that are `ON DELETE NO ACTION` in DB while nullable Drizzle declarations imply SET NULL — most critically `documents.clientId` and all `berthReservations` FKs.
**Counts:** 0 critical · 1 high · 8 medium · 0 low.
---
## 🟠 HIGH SC-02: Multiple significant FKs missing `onDelete` — remain `ON DELETE NO ACTION`
- **Files:**
- `src/lib/db/schema/interests.ts:29,32``interests.portId`, `interests.clientId`
- `src/lib/db/schema/documents.ts:72,85,86``documents.clientId`, `documents.fileId`, `documents.signedFileId`
- `src/lib/db/schema/reservations.ts:18,24,25,27,28,33` — all 6 `berthReservations` FKs
- `src/lib/db/schema/operations.ts:25``reminders.clientId`
- `src/lib/db/schema/financial.ts:120``invoices.pdfFileId`
- `src/lib/db/schema/documents.ts:176``documentEvents.signerId`
- **What:** `.references(...)` without `{ onDelete: ... }` emits `ON DELETE NO ACTION`. Confirmed in migration 0000:841 (`interests_client_id_clients_id_fk ... ON DELETE no action`).
- **Why it matters:** Hard-deleting a parent (client, berth, yacht, file) blocks at FK level. `client-hard-delete.service.ts` manually nullifies but `berthReservations` (4 NO ACTION FKs) is not in the chain. Future maintenance trap.
- **Suggested fix:** Add `{ onDelete: 'set null' }` for nullable FKs that should tolerate parent deletion; explicit `{ onDelete: 'restrict' }` for those that intentionally block (e.g., `interests.clientId` — design intent is archive-first).
## 🟡 MEDIUM MT-01: `updateDefinition` UPDATE uses only `id` in WHERE, not `and(id, portId)`
- **File:** `src/lib/services/custom-fields.service.ts:136-145`
- **What:** Guard read uses `and(eq(id, fieldId), eq(portId, portId))`, but UPDATE fires with only `eq(customFieldDefinitions.id, fieldId)`.
- **Why it matters:** TOCTOU race between read check and write.
- **Suggested fix:** Mirror `updateTag`/`deleteTag`: add `and(eq(...id), eq(...portId, portId))` to the UPDATE WHERE.
## 🟡 MEDIUM MT-01: `notes.service.ts` UPDATE/DELETE missing entityId scope
- **File:** `src/lib/services/notes.service.ts:846-850, 869-873, 897-901`
- **What:** All note `update()` branches verify ownership via prior SELECT, then UPDATE/DELETE on `eq(...notes.id, noteId)` alone (no `eq(yachtNotes.yachtId, entityId)` etc).
- **Why it matters:** TOCTOU gap; risk currently low (UUIDs, no cross-entity discovery surface).
- **Suggested fix:** Add `eq(...notes.<parent>Id, entityId)` to each UPDATE/DELETE WHERE.
## 🟡 MEDIUM MT-01: `clients.service.ts::updateContact` / `removeContact` UPDATE/DELETE use only `contactId`
- **File:** `src/lib/services/clients.service.ts:737-741, 764`
- **What:** PortId verified in JS only; mutation has no portId guard.
- **Suggested fix:** Add `eq(clientContacts.clientId, clientId)` to the UPDATE/DELETE WHERE.
## 🟡 MEDIUM MT-04: `notes.service.ts::listForYachtAggregated` ownerClientId lookup has no portId guard
- **File:** `src/lib/services/notes.service.ts:276-283`
- **What:** Owner client SELECT uses only `eq(clients.id, ownerClientId)`. Yacht is verified in port but cross-port ownerClientId would still surface.
- **Suggested fix:** Add `eq(clients.portId, portId)`.
## 🟡 MEDIUM MT-06: `webhooks.service.ts::getWebhook` / `updateWebhook` / `deleteWebhook` fetch by `id` only, portId checked in JS
- **File:** `src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174`
- **What:** Fetches full webhook row (incl. encrypted secret) before JS port check.
- **Why it matters:** Defense-in-depth gap — secret briefly in app memory before authz check.
- **Suggested fix:** Move portId into `findFirst` WHERE.
## 🟡 MEDIUM SC-01: Migration 0000 (and 0001-0023) uses bare CREATE/ALTER without IF NOT EXISTS
- **File:** `src/lib/db/migrations/0000_narrow_longshot.sql`
- **What:** No `IF NOT EXISTS` guards on CREATE TABLE/INDEX. Migration 0036 also bare `ALTER TABLE ... ADD CONSTRAINT`. Later migrations (0042, 0050, 0051, 0052, 0057, 0062, 0065) use IF NOT EXISTS / DO blocks correctly.
- **Why it matters:** Drizzle tracker prevents double-runs in normal flow, but disaster-recovery partial replay would fail.
- **Suggested fix:** Document that 0000-0036 are not re-runnable without dropping schema first; standardize on IF NOT EXISTS / DO block pattern for all new migrations.
## 🟡 MEDIUM SC-03: `companies` table missing soft-delete partial index for `archivedAt`
- **File:** `src/lib/db/schema/companies.ts:39-45`
- **What:** Other entities (clients, interests, yachts, berths, residentialClients, residentialInterests) have `idx_*_archived ... WHERE archived_at IS NULL` partial indexes (migration 0046). Companies missing.
- **Suggested fix:** `CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;`
## 🟡 MEDIUM SC-04: FTS GIN indexes missing for `interests` and `berths`
- **File:** `src/lib/db/migrations/0057_search_fts_indexes.sql`
- **What:** Migration 0057 creates GIN indexes for clients/yachts/residentialClients but explicitly notes companies uses ILIKE. Interests and berths also lack GIN indexes.
- **Suggested fix:** `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_interests_fulltext ON interests USING gin (...)` and similar for berths.
## 🟡 MEDIUM SC-08: `audit_logs.searchText` declared as plain column in Drizzle but is GENERATED ALWAYS in DB
- **File:** `src/lib/db/schema/system.ts:53-54`
- **What:** Drizzle `tsvector('search_text')` without generated annotation. If any service auto-includes this column in an UPDATE, it errors on the generated column. `audit_logs` is insert-only so likely not hit in practice, but schema-DB mismatch.
- **Suggested fix:** Annotate as non-updateable or add a generated-column marker.
## 🟡 MEDIUM SC-09: `documents.clientId` Drizzle nullable but DB is `ON DELETE NO ACTION`
- **File:** `src/lib/db/schema/documents.ts:72`, migration `0000_narrow_longshot.sql:814`
- **What:** Drizzle says nullable (intent: SET NULL on parent delete); DB constraint is NO ACTION (blocks delete). Migration 0042 fixed `documents.interestId/yachtId/companyId` but missed `clientId`.
- **Why it matters:** Client hard-delete fails unless service explicitly nulls `documents.clientId` first.
- **Suggested fix:** Migration to mirror what 0059 did for `files.client_id` — drop and re-add FK with `ON DELETE SET NULL`.
---
## ✅ Passing checks
- MT-01 clean: clients/interests/invoices/documents/files/tags/companies/berth-reservations GET/PATCH/DELETE all use `and(id, portId)` SQL filter; notes-service `verifyParentBelongsToPort` correct
- MT-04 document-folders.service.ts clean (`listTree`, `createFolder`, `renameFolder`, `moveFolder`, `deleteFolderSoftRescue` all apply `eq(documentFolders.portId, portId)`)
- MT-05 audit.service.ts `listAuditLogs` filters by portId first
- MT-07 settings.service.ts clean (port-specific then global fallback by design)
- MT-08 tags.service.ts clean
- MT-09 custom-fields read/create/delete clean (only update missed; covered above)
- MT-11 seed.ts idempotent (`SELECT count(*) FROM companies WHERE port_id = $1` early-exit)
- SC-02 interestBerths.berthId/interestId, files.clientId/yachtId/companyId, documents.interestId/yachtId/companyId/reservationId all have explicit onDelete
- SC-05 doc folder sibling-name unique, entity-folder partial unique, isPrimary partial unique all present
- SC-06 idx_brochures_default partial unique present
- SC-07 chk_system_folder_shape present (tightened by migration 0052)
- SC-12 Migration 0062 normalizes legacy stages, 0066 normalizes statusOverrideMode='auto' → NULL
- SC-13 Currency code stored as text + app-level validation (consistent)
- SC-14 Address components stored as ISO 3166-2/alpha-2 text columns (consistent)
- SC-15 Polymorphic owner reads use service helpers (eoi-context.ts, interests.service.ts, berth-reservations.service.ts); raw column reads only in JOIN conditions

View File

@@ -0,0 +1,68 @@
# Routes/Middleware/Auth Audit (R-016-029, S-09-13, S-17-19) — agent #3
**Headline:** 1 critical (`/setup` unreachable on fresh DB — middleware redirect loop), 3 high (post-login `?redirect=` ignored; CRM invite token in query string leaks to access logs; missing `Retry-After` on sign-in 429), 2 medium (broad portal allowlist, no OPTIONS handlers), 13 clean.
**Counts:** 1 critical · 3 high · 2 medium · 0 low · 13 passing
---
## 🔴 CRITICAL R-021: `/setup` missing from `PUBLIC_PATHS` — bootstrap unreachable on fresh DB
- **File:** `src/proxy.ts:51-73`
- **What:** `PUBLIC_PATHS` includes `/api/v1/bootstrap/` but NOT `/setup`. Comment at lines 60-62 says login + setup pages call bootstrap status, but `/setup` itself is not exempt from the session guard. Unauthenticated user → `/setup` → middleware redirects to `/login?redirect=/setup`. Login useEffect fetches bootstrap status, calls `router.replace('/setup')` → middleware again → infinite redirect loop.
- **Why it matters:** Fresh deployment (no super admin) is functionally deadlocked. First operator cannot reach setup without already having a session (impossible on fresh DB).
- **Suggested fix:** Add `'/setup'` to `PUBLIC_PATHS`. `POST /api/v1/bootstrap/super-admin` already self-protects with `hasAnySuperAdmin()`.
## 🟠 HIGH R-017/018: CRM post-login redirect ignores `?redirect=` — deep links silently dropped
- **File:** `src/app/(auth)/login/page.tsx:79`
- **What:** Middleware redirects unauthenticated → `/login?redirect=<path>`. Login page never reads `useSearchParams()`; always `router.push('/dashboard')`.
- **Why it matters:** Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard after login.
- **Suggested fix:** Read `searchParams.get('redirect')`, validate same-origin (starts with `/`, not `//`), use as push target if valid.
## 🟠 HIGH R-023: CRM invite token in query string leaks to access logs
- **File:** `src/lib/services/crm-invite.service.ts:71,233`
- **What:** `${env.APP_URL}/set-password?token=${raw}` — raw 32-byte token in query param. Set-password page reads via `useSearchParams()`. Portal flow was migrated to `#token=` fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
- **Why it matters:** Every nginx/Caddy access log line for `GET /set-password?token=<raw>` persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
- **Suggested fix:** Change `createCrmInvite` + `resendCrmInvite` to emit `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`. Update `set-password/page.tsx` to use the fragment-reading pattern from `PasswordSetForm` (`readTokenFromUrl()`) with `?token=` back-compat for outstanding tokens.
## 🟠 HIGH R-029: `sign-in-by-identifier` 429 missing `Retry-After`
- **File:** `src/app/api/auth/sign-in-by-identifier/route.ts:47-51`
- **What:** Builds 429 response with `headers: rateLimitHeaders(rl)` which only emits `X-RateLimit-Limit/Remaining/Reset` (`src/lib/rate-limit.ts:79-85`). `enforcePublicRateLimit` adds `Retry-After`; this route uses `checkRateLimit` directly and skips it.
- **Why it matters:** RFC 6585 §4 requires `Retry-After` on 429. Automated clients can't back off correctly. Inconsistent with other public endpoints.
- **Suggested fix:** Add `'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString()`.
## 🟡 MEDIUM R-016: `/portal/` blanket allowlist removes middleware as backstop
- **File:** `src/proxy.ts:65`
- **What:** `'/portal/'` in `PUBLIC_PATHS` — every `/portal/*` is exempt from middleware session check. Per-page `getPortalSession()` is the only gate.
- **Why it matters:** Defense-in-depth gap. Per-page checks all in place today; but a future portal page added without `getPortalSession()` has no middleware backstop. Fragile vs CRM's primary middleware gate.
- **Suggested fix:** Allowlist only the unauthenticated portal routes individually (`/portal/login`, `/portal/activate`, `/portal/reset-password`, `/portal/forgot-password`). Add middleware portal-cookie check.
## 🟡 MEDIUM R-028: No explicit `OPTIONS` handlers, no CORS headers
- **File:** All `route.ts` files under `src/app/api/`
- **What:** No `OPTIONS` exports. No `Access-Control-Allow-*` headers anywhere. Next.js will 405 on unhandled OPTIONS.
- **Why it matters:** Acceptable for same-origin CRM. Becomes an issue if marketing-site browser JS calls `/api/public/berths` cross-origin.
- **Suggested fix:** Defer until cross-origin consumer exists. When marketing site lives, add explicit `Access-Control-Allow-Origin: <marketing-domain>` to public routes (not wildcard).
---
## ✅ Passing checks
- R-016 allow-list anchor — `startsWith('/api/public/')` correctly rejects `'/api/publicX-evil'` (no regex anchor concern)
- S-09 open redirect on next/redirect — CRM login ignores param (no risk because unused); portal `safeNextPath()` (portal/login/page.tsx:20-27) rejects non-`/portal/` paths and `//`-protocol-relative
- S-10 CSRF — defense-in-depth: `proxy.ts originAllowed()` (lines 104-122) rejects state-changing `/api/v1/**` where Origin/Referer don't match in prod; better-auth has its own origin check for `/api/auth/**`; dev bypass intentional
- S-11 cookie flags — CRM: `httpOnly`, `secure` (prod), `sameSite: 'strict'` (`src/lib/auth/index.ts:107-110`); Portal: `httpOnly`, `secure` (prod), `sameSite: 'lax'` (`src/app/api/portal/auth/sign-in/route.ts:43-45`)
- S-12 CSP — per-request nonce-based CSP via `proxy.ts:buildCspWithNonce()` for page routes in prod (`'nonce-<n>' 'strict-dynamic'`); fallback CSP in `next.config.ts:55-66`; `frame-ancestors: 'none'` + `X-Frame-Options: DENY`; HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy all present
- S-13 CORS — no `Access-Control-Allow-Origin: *` anywhere (correct for same-origin CRM)
- R-019/020 portal `client_portal_enabled` gate — `src/app/(portal)/layout.tsx:22` calls `isPortalDisabledGlobally()`; per-page `getPortalSession()` additionally guards
- R-022 reset-password tokens — Portal: single-use `consumeToken` setting `usedAt`, 30min TTL, SHA-256 hashed in DB. Better-auth CRM: 1h TTL, `revokeSessionsOnPasswordReset: true`
- R-023 portal half — `portal/activate/page.tsx` uses `PasswordSetForm` with `useSyncExternalStore + readTokenFromUrl()` reading `window.location.hash` client-side; SSR-safe via `null` server snapshot
- R-025 public berths cache headers `s-maxage=300, stale-while-revalidate=60` confirmed in both list + single endpoints
- R-026/027 public health: anonymous `{status,timestamp}` only never 503; `X-Intake-Secret` `timingSafeEqual` (lines 57-64); authenticated runs DB+Redis dep checks in parallel, 503 on either failure
- S-17 session fixation — better-auth creates fresh session row on every sign-in; portal sign-in always issues new JWT via `createPortalToken`
- S-18 token expiry/refresh — CRM 24h absolute, 6h sliding refresh window (`src/lib/auth/index.ts:99-103`); Portal JWT 24h checked against `passwordChangedAt` watermark per request
- S-19 audit log tamper-resistance — `audit_logs` has no `updated_at`; no `UPDATE` calls in app code (only INSERT/SELECT and time-based retention DELETE bounded by `AUDIT_LOGS_RETENTION_DAYS`)

View File

@@ -0,0 +1,92 @@
# Audit Log Audit (AU-01-14) — agent #4
**Headline:** Core write path solid; major mutations all audit; mask helper covers expected PII; FTS indexed; AU-11 fix complete. Two HIGH issues: encrypted credential ciphertext bypasses masking (key is `"value"`) and `toggleAccount` mutation is silent.
**Counts:** 0 critical · 2 high · 4 medium · 4 low
---
## 🟠 HIGH AU-01a: `toggleAccount` writes no audit row
- **File:** `src/lib/services/email-accounts.service.ts:86-116`
- **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent.
- **Why it matters:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
- **Suggested fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`.
## 🟠 HIGH AU-02: Encrypted credential ciphertext stored in audit log without masking
- **File:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299`
- **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta)`. `upsertSetting` records `newValue: { value: '<ciphertext>' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`.
- **Why it matters:** Audit log is readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store. Industry standard: store only `credentialUpdated: true` for credential changes.
- **Suggested fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean` flag) and record `newValue: { value: '[redacted]' }`.
## 🟡 MEDIUM AU-03: FTS `search_text` covers only 4 fields; placeholder text misleads
- **File:** `src/lib/db/migrations/0014_black_banshee.sql:47-55` + `src/components/admin/audit/audit-log-list.tsx:360`
- **What:** `search_text` GENERATED ALWAYS = `action || entity_type || entity_id || user_id`. Search input placeholder reads "entity id, action, vendor…" — implies you can search inside `metadata`/`new_value`. Searching "vendor" returns zero rows silently.
- **Suggested fix:** Change placeholder to "action name, entity id, user id…" OR add `metadata` to GENERATED expression with `jsonb_to_tsvector` (larger index).
## 🟡 MEDIUM AU-08: Admin audit log shows field names but no old→new diff
- **File:** `src/components/admin/audit/audit-log-list.tsx:290-305` + `src/components/admin/audit/audit-log-card.tsx:84-91`
- **What:** "Changes" column renders `Object.keys(newValue).slice(0,3).join(', ')` — no old→new diff, no row-expand. Dashboard `activity-feed.tsx` has working `buildDiffLine()` with 3 diff shapes, unused here.
- **Why it matters:** Compliance audits can't confirm before/after state from UI alone; admins must dig into raw JSON.
- **Suggested fix:** Add row-expand or detail sheet using `buildDiffLine` from activity-feed.tsx.
## 🟠 AU-10: Cascade-archived interests produce no individual audit rows
- **File:** `src/lib/services/clients.service.ts:578-618`
- **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing.
- **Why it matters:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
- **Suggested fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget).
## 🟡 MEDIUM AU-12: No audit log CSV export endpoint
- **File:** (absent — no `src/app/api/v1/admin/audit/export/route.ts`)
- **What:** No download button, no API. Expenses domain has reference impl at `src/app/api/v1/expenses/export/csv/route.ts`.
- **Why it matters:** GDPR / marina licensing audits often require exports.
- **Suggested fix:** `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs` + filter params.
## 🟡 MEDIUM AU-13: Outcome change uses `action: 'update'`, not distinct verb
- **File:** `src/lib/services/interests.service.ts:1047-1058`
- **What:** `setInterestOutcome`/`clearInterestOutcome` log `action: 'update'` with `metadata.type: 'outcome_set'/'outcome_cleared'`. No `outcome_change` in `AuditAction` or filter dropdown. `metadata.type` not in `search_text` — FTS can't isolate.
- **Suggested fix:** Add `'outcome_change'` to `AuditAction` union; use in both functions; add to dropdown; add to `DEFAULT_SEVERITY_BY_ACTION` as `'warning'`.
## 🟢 LOW AU-14: Tier map sparse; new actions default to 'info'
- **File:** `src/lib/audit.ts:220-222`
- **What:** Only 2 entries (`permission_denied: 'warning'`, `hard_delete: 'critical'`). `password_change`, `portal_activate`, `revoke_invite`, `branding.logo.uploaded`, `rule_evaluated` all default to `'info'`. Severity≥warning filter misses security-relevant events.
- **Suggested fix:** Add `password_change/portal_activate/revoke_invite: 'warning'`. `reconcile_manual` is in `metadata.type` — add `severity: 'warning'` at the call site in `berths.service.ts`.
## 🟢 LOW AU-14b: Action filter dropdown missing 12 verbs
- **File:** `src/components/admin/audit/audit-log-list.tsx:393-415`
- **What:** Dropdown has 20 actions; missing `branding.logo.*`, `rule_evaluated`, `revoke/resend_invite`, `request/send_gdpr_export`, `password_change`, `portal_invite/activate/password_reset_request/password_reset`. Free-text partially compensates.
- **Suggested fix:** Add missing action verbs.
## 🟢 LOW AU-14c: Entity-type filter missing several domains
- **File:** `src/components/admin/audit/audit-log-list.tsx:88-102`
- **What:** Missing `document_folder`, `file`, `company`, `yacht`, `email_account`, `audit_log`, `backup_job`. Free-text on `entity_type` (in tsvector) works; dropdown is convenience.
- **Suggested fix:** Add missing entity types.
## 🟢 LOW AU-14d: Dead code — `listAuditLogs` (ILIKE) in `audit.service.ts`
- **File:** `src/lib/services/audit.service.ts`
- **What:** `listAuditLogs` exported but zero import sites. Admin route uses `searchAuditLogs` exclusively. ILIKE search is dead.
- **Why it matters:** Future dev might wire it up bypassing GIN index → seq scans at scale.
- **Suggested fix:** Delete `audit.service.ts` or mark `@deprecated`.
---
## ✅ Passing
- AU-01 (10 sampled mutating endpoints all audit: clients/interests/companies/berths/documents/folders/tags/roles/settings/files create + update + archive)
- AU-02 password/token fragment masking covers `password`, `passwordHash`, `token`, `secret`, `api_key`, `apikey`, `auth`, `cookie`, `credentials` recursively up to depth 4. `email-accounts.service.ts` correctly logs only `metadata: { emailAddress, provider }`; `credentialsEnc` stripped before any JSON serialization.
- AU-04 action filter wired (exact `eq()` filter)
- AU-05 entity-type filter wired (same path)
- AU-06 user filter wired (UUID exact match)
- AU-07 date-range filter (ISO strings → Date → gte/lte; UI validates inversion)
- AU-09 reconcile_manual tag in metadata at `berths.service.ts:473`
- AU-11 permission_denied feed filter at `src/components/dashboard/activity-feed.tsx:185-189` (`i.action !== 'permission_denied'`); admin page correctly displays them with `'bg-red-800'` badge

View File

@@ -0,0 +1,52 @@
# Documents/Files Audit (D-01-22) — agent #5
**Headline:** Structurally solid across all 22 checks. One medium real-time event mismatch + 2 low documentation divergences.
**Counts:** 0 critical · 0 high · 1 medium · 2 low · 19 passing
---
## 🟡 MEDIUM D-01/02/03: Real-time invalidation event name mismatch after upload
- **File:** `src/components/documents/documents-hub.tsx:141`
- **What:** Hub subscribes to `'file:created': [['files']]`, but emitter (`files.ts:128`) and socket-events type def (`events.ts:264`) use `'file:uploaded'`.
- **Why it matters:** After remote upload (other session, webhook auto-deposit), hub Files sections don't auto-refresh. Local `FolderDropZone` upload bypasses this via direct `queryClient.invalidateQueries`, but remote uploads invisible until reload.
- **Suggested fix:** Change line 141 to `'file:uploaded': [['files']]` to match `client-files-tab.tsx:32`, `company-files-tab.tsx:32`, `interest-documents-tab.tsx:62`.
## 🟢 LOW D-13: HubRootView has 2 sections, not 3
- **File:** `src/components/documents/hub-root-view.tsx:50-100`
- **What:** Spec says 3 cards; component renders 2 ("Signing in progress" + "Recent files"). Doc-only.
- **Suggested fix:** Update CLAUDE.md to "2 sections."
## 🟢 LOW D-16: `interest.yachtId` branch in chain doc spec doesn't exist in code
- **File:** `src/lib/services/documents.service.ts:1225-1251`
- **What:** Spec is `doc.clientId ?? .companyId ?? .yachtId ?? interest.clientId ?? interest.yachtId`. Code stops at `interest.clientId` because `interests.clientId` is NOT NULL — so the yachtId fallback is unreachable. Comment line 1239 explains.
- **Suggested fix:** Update CLAUDE.md to drop the unreachable trailing branch, or annotate with `// unreachable: interests.clientId is NOT NULL`.
---
## ✅ Passing checks
- D-01 A16 fix verified — `formStr()` returns `undefined` (not `null`) for absent FormData fields; root upload omits `folderId` correctly
- D-02 entity-folder drag-drop carries `folderId`+`entityType`+`entityId`+typed FK
- D-03 file picker dialog passes `folderId` (null for root) correctly
- D-04 PDF inline preview via `PdfViewer` lazy-loaded
- D-05 image inline preview + lightbox via `<img>` for jpeg/png/gif/webp
- D-06 Word/Excel: `FileGrid` gates "Preview" with `PREVIEWABLE_MIMES.has(...)` so only "Download" shows; `FilePreviewDialog` never opened
- D-07 download endpoint wraps with `withPermission('files', 'view', ...)`; `getFileById` enforces port via `file.portId !== portId`
- D-08 `deleteFolderSoftRescue` (`src/lib/services/document-folders.service.ts:294-337`) wrapped in `db.transaction()`, re-parents folders + documents + files explicitly (no CASCADE)
- D-09 `syncEntityFolderName` called in updateClient (clients.service.ts:554), updateCompany (companies.service.ts:187), updateYacht (yachts.service.ts:167)
- D-10 `moveFolder` cycle prevention: rejects self at line 213, `pg_advisory_xact_lock` per port (line 233), walks ancestor chain with `seen` set, checks `cursor === folderId` at each step
- D-11 `assertNotSystemManaged` called in renameFolder (line 172), moveFolder (line 217), deleteFolderSoftRescue (line 299)
- D-12 `listFilesAggregatedByEntity` walks Client↔Companies (via companyMemberships INNER JOIN companies on portId)↔Yachts; cap 20 + total
- D-14 EntityFolderView uses `useAggregatedWorkflows` (filters to INFLIGHT_STATUSES `['draft','sent','partially_signed']`); files with `signedFromDocumentId` show "View signing details"
- D-15 `GET /api/v1/documents/[id]/signing-details` returns `{ data: { workflow, signers, events } }`; `getDocumentById` enforces portId
- D-16 idempotency: outer gate `doc.status === 'completed' && doc.signedFileId` returns; inner `SELECT ... FOR UPDATE` re-check inside transaction
- D-17 Defense-in-depth port at every join: `companies` INNER JOIN with `portId` (line 451), `clients` INNER JOIN with `portId` (line 497), `yachts/files` WHERE portId everywhere, LEFT JOIN `documents` with `or(eq(documents.portId, portId), isNull(documents.id))` (line 588-590). companyMemberships has no portId column but is port-scoped via INNER JOIN to companies/clients
- D-18 `?folder=<uuid>` URL state — three-state (absent → undefined hub root, `=root` → null, `=<uuid>` → uuid); `decodeFolderParam`/`encodeFolderParam` symmetric; deep folder works
- D-19 `ensureEntityFolder` race-safety: fast-path re-SELECT before insert; two distinct catch branches for `uniq_document_folders_entity` (re-SELECT winner) and `uniq_document_folders_sibling_name` (increment suffix)
- D-20 magic-byte: `bufferMatchesMime` in files.ts:58 covers 8 MIME types in-server; presign-PUT only used by berth-pdf/brochure (both stream first 5 bytes + `isPdfMagic()`)
- D-21 filename HTML-escape (`document-sends.service.ts:415-422`)
- D-22 `streamAttachmentOrLink` size-threshold + 24h presigned URL fallback; `fallbackToLinkReason: 'size_above_threshold'` audited

View File

@@ -0,0 +1,30 @@
# Security Audit (S-01-08, S-21-30) — agent #6
**Headline:** 1 medium finding (S-23 plaintext S3 access key ID), 19 clean.
## 🟡 MEDIUM S-23: S3 access key ID stored plaintext in `system_settings`
- **File:** `src/lib/storage/index.ts:136`, `src/components/admin/storage-admin-panel.tsx:80`
- **What:** S3 secret key (`storage_s3_secret_key_encrypted`) is AES-encrypted, but the access key ID (`storage_s3_access_key`) is stored/read as plaintext in `system_settings`.
- **Why it matters:** Asymmetric encryption — DB exfil exposes the IAM key ID, narrowing the attack surface for credential stuffing or confirming which IAM principal to target. The access key ID is also surfaced in admin settings API responses.
- **Suggested fix:** Apply same `encrypt()` / `*IsSet` pattern as the secret key. Migration to re-key existing rows. Update `resolveConfig` to call `decryptIfPresent`.
## ✅ Passing checks
- S-01 XSS via client.fullName (React text node)
- S-02 XSS via tag.name (React child, sanitized style object)
- S-03 XSS via note.content (plain text, no markdown rendering — `whitespace-pre-wrap` is CSS only)
- S-04 XSS via email body markdown (`src/lib/utils/markdown-email.ts` escape-then-allowlist + DOMPurify second layer in `send-document-dialog.tsx`)
- S-05 SQL injection via search query (Drizzle parameterized; `sql.raw` only on hardcoded constants in `admin/storage/route.ts:30` and `storage/migrate.ts:149`)
- S-06 Path traversal in folder name (DB-only, never used as filesystem path)
- S-07 Path traversal in file name / storage key (`validateStorageKey` in `src/lib/storage/filesystem.ts:49-69` rejects `..`/absolute/empty/non-allowlist chars; `resolveKey` does `path.resolve` prefix check)
- S-08 SSRF via webhook target URL (two-layer: `isLocalOrPrivateHost` in `src/lib/validators/webhooks.ts` blocks RFC1918+loopback+link-local+CGNAT+cloud metadata; `resolveAndCheckHost` in `src/lib/queue/workers/webhooks.ts` re-resolves DNS at dispatch — DNS rebinding-resistant)
- S-21 SMTP credential AES-256-GCM with random IV (`src/lib/utils/encryption.ts`)
- S-22 IMAP credential same path as SMTP
- S-24 Privilege escalation blocked: `updateUser` in `src/lib/services/users.service.ts:294-318` does caller-superset check; permission-overrides at `src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:203-210` enforce per-leaf + block self-target at line 160; role definition mutations require `requireSuperAdmin` not just `manage_users`
- S-25 Direct ID enumeration immune (`crypto.randomUUID` everywhere)
- S-26 Audit log read-back of own permission denials — clean (admin-only `view_audit_log`)
- S-27 Magic-byte verification verified
- S-28 Filename HTML-escape in download links (`src/lib/services/document-sends.service.ts:415-420`)
- S-29 Bounce-monitor email subject parsing — clean (no IMAP bounce worker exists yet; `email-threads.service.ts` uses parameterized `ilike` for subject matching)
- S-30 `EMAIL_REDIRECT_TO` enforced at boot via Zod `superRefine` in `src/lib/env.ts:110-117` — production with the env set causes `process.exit(1)`. Webhook worker also short-circuits to `dead_letter` when set.

View File

@@ -0,0 +1,112 @@
# Email + Integrations Audit (EM-01-19, IN-01-29) — agent #7
**Headline:** Broadly well-implemented. Primary issue: missing SMTP timeouts on sales transporter (HIGH — risks worker starvation). Plus 8 medium gaps in portal-email portId scoping, digest catalog key, receipt scanner config, presign TTL.
**Counts:** 0 critical · 1 high · 8 medium · 0 low · 30 passing
---
## 🟠 HIGH EM-XX: Sales transporter missing SMTP timeouts
- **File:** `src/lib/services/sales-email-config.service.ts:331-337`
- **What:** `createSalesTransporter` builds nodemailer transport with no timeout options. Compare `createTransporter` in `src/lib/email/index.ts:26-37` which uses `SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }`.
- **Why it matters:** Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. Without socket timeouts, one stuck TCP connection holds a worker for nodemailer's 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
- **Suggested fix:** Apply `SMTP_TIMEOUTS` constant to `nodemailer.createTransport` in `createSalesTransporter`.
## 🟡 MEDIUM EM-05a: Per-port branding not threaded into portal activation/reset emails
- **File:** `src/lib/services/portal-auth.service.ts:163-164`
- **What:** `issueActivationToken` and `issuePasswordReset` call `sendEmail(email, subject, html, undefined, text)` without the 6th `portId` argument. Without `portId`, `createTransporter()` uses global env SMTP. Branding is threaded into HTML via `getBrandingShell(portId)` but the SMTP transport falls back to global.
- **Why it matters:** Multi-port deploys: portal auth emails for port B go through global env SMTP, defeating per-port SMTP override.
- **Suggested fix:** Pass `portId` as 6th arg to `sendEmail` in both `issueActivationToken` and the reset send.
## 🟡 MEDIUM EM-07: CC/BCC not supported in main `sendEmail`
- **File:** `src/lib/email/index.ts:54-68`
- **What:** `SendEmailOptions` lacks `cc`/`bcc`. Sales send-out path also lacks them.
- **Suggested fix:** Add optional `cc`/`bcc` to `SendEmailOptions`. Low urgency.
## 🟡 MEDIUM EM-11: Bounce-to-interest linking not implemented
- **File:** `src/lib/services/sales-email-config.service.ts:13` (header comment)
- **What:** `getSalesImapConfig` exposes IMAP creds but no BullMQ worker reads IMAP. Failed deliveries don't update `document_sends.failedAt`.
- **Suggested fix:** Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs, match against `document_sends.messageId`. Phase 7 §14.9 deferred.
## 🟡 MEDIUM EM-16: Notification digest uses wrong catalog key for subject resolution
- **File:** `src/lib/services/notification-digest.service.ts:161-169`
- **What:** Calls `resolveSubject` with `key: 'crm_invite' as any` because `'notification_digest'` is not in `TEMPLATE_KEYS` in `src/lib/email/template-catalog.ts`.
- **Why it matters:** Admin-set CRM invite subject override bleeds into digest emails.
- **Suggested fix:** Add `'notification_digest'` to `TEMPLATE_KEYS`; update digest service to use it.
## 🟡 MEDIUM IN-11: Presigned URL TTL fixed at 900s for portal downloads
- **File:** `src/lib/storage/index.ts:240-254` (`presignDownloadUrl`); `src/lib/services/portal.service.ts:350` (`getDocumentDownloadUrl`)
- **What:** `presignDownloadUrl` defaults `expirySeconds=900` (15min). Sales send-out correctly overrides to 24h. `getDocumentDownloadUrl` calls without expiry → 15min default.
- **Why it matters:** Portal users opening their doc list and clicking after >15min get 403.
- **Suggested fix:** Pass `expirySeconds: 4 * 3600` for portal download links, or sign on-demand from API.
## 🟡 MEDIUM IN-21: OpenAI receipt-scanner module-level instantiation, no credential health check
- **File:** `src/lib/services/receipt-scanner.ts:4`
- **What:** `const openai = new OpenAI();` at module level reads `OPENAI_API_KEY` at import. SDK throws on first call when unset; catch returns zero-confidence empty result. No admin-visible health check.
- **Suggested fix:** Guard `OPENAI_API_KEY` upfront with clear error. Add a health-check endpoint similar to `checkDocumensoHealth`.
## 🟡 MEDIUM IN-23: Receipt OCR ignores per-port config; hardcoded `gpt-4o`
- **File:** `src/lib/services/receipt-scanner.ts:19`
- **What:** `model: 'gpt-4o'` hardcoded; per-port `getResolvedOcrConfig` not consulted; `aiEnabled` flag does nothing. Module-level singleton OpenAI client.
- **Suggested fix:** Accept `portId`, call `getResolvedOcrConfig(portId)`, check `aiEnabled`, use `config.apiKey` and `config.model`. Branch on provider for OpenAI vs Anthropic.
## 🟡 MEDIUM IN-24: Stale "pdfme" references in comments/seed
- **File:** `src/lib/db/seed-data.ts:807`, `src/lib/services/document-templates.ts:573`
- **What:** Comments still reference pdfme even though the rendering path was removed; `tiptap-validation.ts:8` confirms pdfme retired. `document-templates.ts:648-652` throws ValidationError for non-EOI templates.
- **Suggested fix:** Update comments to reference pdf-lib AcroForm fill; remove "pdfme" from seed-data description.
## 🟡 MEDIUM IN-29: Umami `testConnection` throws instead of returning typed result
- **File:** `src/lib/services/umami.service.ts:80-101, 292`
- **What:** `loadUmamiConfig` returns null gracefully; all public APIs return null when unconfigured. But `testConnection` throws `CodedError('UMAMI_NOT_CONFIGURED')` instead of returning `{ ok: false, error }` like `checkDocumensoHealth`.
- **Suggested fix:** Return `{ ok: false, error: string }` to match Documenso convention.
---
## ✅ Passing checks
- EM-01 per-port SMTP override (`getPortEmailConfig` in `port-config.ts:136`)
- EM-02/03 default send-froms cascade (explicit `from``cfg.fromAddress` → env.SMTP_FROM → `noreply@${SMTP_HOST}`)
- EM-04 EMAIL_REDIRECT_TO subject prefix `[redirected from <orig>]`; documenso-client also applies `applyRecipientRedirect`/`applyPayloadRedirect`; env.ts:110 prod boot guard
- EM-05 branded shell (`renderShell` in `src/lib/email/shell.ts:37`)
- EM-06 reply-to override applied
- EM-08 send rate limit 50/user/hour Redis sliding-window keyed `${portId}:${userId}`
- EM-09 `streamAttachmentOrLink` threshold + filename HTML-escape pre-SMTP
- EM-10 IMAP probe script + `getSalesImapConfig` AES-256-GCM decrypted
- EM-12 `document_sends` audit row in success + failure branches
- EM-13 portal activation token: 32-byte token, hash stored in `portalAuthTokens`, `#token=...` fragment to stay out of logs
- EM-14/15 reset/invite emails wired
- EM-17 EOI sent via Documenso (not as nodemailer attachment)
- EM-18/19 `renderEmailBody` escape-first + `isSafeHref` (https/mailto only) + `MERGE_VALUE_ESCAPE_MAP` neutralizes markdown chars
- IN-01 v1 template-generate path (`generateDocumentFromTemplate`)
- IN-02 v2 envelope/create multipart (FormData with `payload` JSON + `files` Blob)
- IN-03 v2 distribute returns `recipients[].signingUrl` in one round-trip
- IN-04 redistribute version-aware (v2 caveat: `recipientIds` may not target single recipient — API behavior risk, not code bug)
- IN-05 downloadSignedPdf version-aware
- IN-06 voidDocument version-aware (idempotent on 404)
- IN-07 placeFields v2 bulk `field/create-many` percent coords + `fieldMeta`; v1 one POST per field with pixel coords
- IN-08 `normalizeDocument` `id ?? documentId` for both docs and recipients (handles legacy `r.Recipient` capital-R)
- IN-09 NocoDB `pg_advisory_xact_lock` + skip rows where `updated_at > last_imported_at`
- IN-10 S3Backend with SSE AES256, all calls wrapped in `withTimeout(30_000)`, never imports MinIO directly
- IN-12 filesystem MULTI_NODE_DEPLOYMENT guard (boot-time throw)
- IN-13 BullMQ exponential backoff: email/docs 5×1s, webhooks 8×30s
- IN-14 Redis noeviction in both compose files
- IN-15 `src/worker.ts` imports all 10 workers + SIGTERM/SIGINT graceful shutdown
- IN-16 public berths cache `s-maxage=300, stale-while-revalidate=60`
- IN-17 status filter Sold > Under Offer (status OR has active is_specific_interest with isNull(end_date)+outcome) > Available
- IN-18 mooring regex `^[A-Z]+\d+$` checked pre-DB; returns 400 for malformed
- IN-19/20 dual-mode health endpoint with `timingSafeEqual`
- IN-22 berth-pdf-parser tier-2 is `unpdf` (not Tesseract — prior comment correction); 30s timeout
- IN-25 `fillEoiFormFields` flatten + metadata; missing fields warn rather than throw
- IN-26 VALID_MERGE_TOKENS allow-list including `{{eoi.berthRange}}`
- IN-27 `formatBerthRange` handles all cases (single/contig/non-contig/cross-pontoon/dedup)
- IN-28 portal magic-link rate-limited 10/h/IP via `enforcePublicRateLimit(req, 'portalToken')`

View File

@@ -0,0 +1,55 @@
# Performance + Behavioral Audit (P-05/09/13/14, B-01-22) — agent #8
**Headline:** 1 critical (B-01 INNER JOIN drops hard-deleted berth links), 1 high (B-16 AppShell remount destroys form state), 1 medium (P-09a leading-wildcard ILIKE), 17 clean.
**Counts:** 1 critical · 1 high · 1 medium · 1 low · 17 passing
---
## 🔴 CRITICAL B-01: Hard-deleted berth causes silent data loss across interest surfaces
- **File:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
- **What:** All three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. When a berth is hard-deleted, the INNER JOIN silently drops the link.
- **Why it matters:** Interest detail page shows `berthId: null`, `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty field. `archiveInterest` path that calls `getPrimaryBerth` before evaluating berth rule returns null and **skips the rule entirely**.
- **Suggested fix:** Change all three `INNER JOIN` to `LEFT JOIN berths`. Callers already handle `null` mooringNumber. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).
## 🟠 HIGH B-16: AppShell remounts children on breakpoint crossing, destroying form state
- **File:** `src/components/layout/app-shell.tsx:58-70`
- **What:** When `isMobile` flips on resize, the shell switches between `<MobileLayout>{children}</MobileLayout>` and the desktop `<div>...{children}...</div>`. React unmounts and remounts `children`, destroying any in-progress `useState` form drafts including `InlineEditableField`.
- **Why it matters:** A user editing a client name on desktop who resizes past the mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
- **Suggested fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so the children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on the shell wrappers with `children` stable via Portal.
## 🟡 MEDIUM P-09a: Leading-wildcard ILIKE in `buildListQuery` prevents index use
- **File:** `src/lib/db/query-builder.ts`
- **What:** List search uses `ILIKE '%term%'` with leading wildcard, defeating B-tree and trigram-prefix indexes.
- **Why it matters:** Sequential scan on high-cardinality text columns; degrades at scale.
- **Suggested fix:** Migrate to `pg_trgm` GIN indexes on the searched columns, or move to FTS via existing `search_text` GIN where one exists.
## 🟢 LOW P-14: List endpoint `limit` allows up to 1000 rows
- **File:** `src/lib/api/list-query.ts`
- **What:** Generic list cap = 1000. Audit log is bounded to 200 with cursor pagination (better pattern).
- **Why it matters:** A 1000-row response with relations can blow the 256 KB budget.
- **Suggested fix:** Lower default cap to ~100; require explicit cursor pagination beyond.
---
## ✅ Passing checks
- P-05 No N+1 — all secondary fetches batched via `inArray`
- P-13 Audit FTS uses `to_tsvector('simple')` + GIN index + `plainto_tsquery('simple')` consistently (`src/lib/services/audit-search.service.ts`, migration `0014_black_banshee.sql`)
- B-02 Sara Laurent contract-without-yachtId renders correctly (overview tab guards yacht section; stage-gate only fires on `changeInterestStage`)
- B-03 `activeInterestsWhere` (`src/lib/services/active-interest.ts`) used in listInterestsForBoard, getInterestStageCounts, listBerths reconcile, recommender CTE
- B-04 / B-05 `formatBerthRange` correct: single (`A1`), contiguous (`A1-A3`), non-contiguous (`A1, A3`), cross-pontoon (`A1-A2, B5-B7`), dedup, non-canonical pass-through
- B-07 Tier B fires only when `activeInterestCount===0 && lostCount>0`; `lost_count` aggregates `LIKE 'lost%' OR cancelled`; heat scoring gated by `tier === 'B'`; fall-through policy enforces cooldown/never_auto_recommend
- B-08 `withPermission` (`src/lib/api/helpers.ts:328-340`) writes `permission_denied` audit row before 403 (fire-and-forget `void`)
- B-09 Same-stage no-op `if (existing.pipelineStage === data.pipelineStage) return STAGE_NOOP;` early-returns before DB/audit/socket (`src/lib/services/interests.service.ts:847-849`)
- B-10 Documenso webhook handles empty body / malformed JSON via try/catch returning `{ ok: false }` 200 + warning log (`src/app/api/webhooks/documenso/route.ts:176-182, 202`)
- B-11 `status_override_mode` transitions (null/manual/automated) all have audit coverage; reconcile clears to null, rules engine writes 'automated', admin UI writes 'manual'
- B-13 Catch-up wizard `pipelineStage === 'contract'` sends `outcome: 'won'` (`src/components/berths/catch-up-wizard.tsx:120`); reconcile route validates `z.enum(['won']).optional()`
- B-17 Bulk-add berths wizard step state persists in `BulkAddBerthsWizard`'s `useState`; no remount between steps
- B-18 NotesList handles 6 entity types (clients/interests/yachts/companies/residential_clients/residential_interests); `companyNotes.updatedAt` substituted via `createdAt` per CLAUDE.md
- B-19 `InlineEditableField` present on client/yacht/company/interest/residential-client/residential-interest/berth tabs (11 files)
- B-22 `markExternallySigned` (`src/lib/services/external-signing.service.ts:68-72`) updates `{ docStatus: 'signed', updatedAt: now }`. Note: catalog said "documentId=null, signedAt=now" but interests table has no such columns — the service is correct relative to schema.

View File

@@ -0,0 +1,159 @@
# UX/Forms/Tables Audit (U-001-100, code-side) — agent #9
**Headline:** Generally consistent (Sheet, AlertDialog, EmptyState, requestId surfacing all good across most surfaces). 4 HIGH gaps: native `alert()` for bulk-action failures, icon-only buttons missing aria-label, unicode glyphs in portal, Vaul Drawer in mobile search overlay. Plus 14 MEDIUM gaps in form discipline + a11y + mobile nav.
**Counts:** 0 critical · 4 high · 14 medium · 0 low
---
## 🟠 HIGH
### U-059: Unicode glyphs as status icons in portal documents page
- **File:** `src/app/(portal)/portal/documents/page.tsx:85-89`
- **What:** Signer status rendered as raw Unicode (`'✓'` signed, `'✗'` declined, `'○'` pending) inside colour-coded `<span>` with no `aria-label`.
- **Why it matters:** A11y — screen readers read literal Unicode names. Per project memory: decorative unicode glyphs are explicitly flagged. `inline-stage-picker.tsx:443` comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
- **Suggested fix:** Replace with `<CheckCircle2>` / `<XCircle>` / `<Circle>` Lucide icons + `aria-label`.
### U-066: Vaul Drawer used for mobile search overlay (violates Sheet doctrine)
- **File:** `src/components/search/mobile-search-overlay.tsx:6`
- **What:** `import { Drawer as VaulDrawer } from 'vaul'` — search overlay is a full-screen overlay, not a bottom sheet, but uses Vaul Drawer. CLAUDE.md says Vaul is reserved for mobile-bottom-sheet only (currently `MoreSheet` only).
- **Suggested fix:** Convert to `<Sheet side="bottom">` or `<Dialog>` fullscreen. Visualviewport handling (lines 50-89) becomes redundant once Radix dialog primitive backs it.
### U-076: Native `alert()` for bulk-action failure feedback in 3 lists
- **Files:** `src/components/interests/interest-list.tsx:146`, `src/components/companies/company-list.tsx:73`, `src/components/yachts/yacht-list.tsx:66`
- **What:** Partial-failure feedback via `alert(...)`. `client-list.tsx:145` uses `toast.warning(...)` correctly.
- **Why it matters:** Native alert blocks main thread, can't be styled, fires in tests without suppression.
- **Suggested fix:** Replace with `toast.warning(...)` matching `client-list.tsx`.
### U-079: Icon-only buttons missing aria-label (5 sites)
- **Files:**
- `src/components/notifications/notification-bell.tsx:65` (Bell icon button)
- `src/components/files/file-grid.tsx:121` (MoreHorizontal "…" on file cards)
- `src/components/admin/forms/form-template-list.tsx:102` (Trash button)
- `src/components/email/email-accounts-list.tsx:159` (Trash button)
- `src/components/companies/company-members-tab.tsx:228` (MoreHorizontal)
- **Pattern reference (correct):** `src/components/shared/folder-actions-menu.tsx:96` uses `<span className="sr-only">More folder actions</span>`.
- **Suggested fix:** Add `aria-label` to each, following the folder-actions-menu sr-only pattern.
---
## 🟡 MEDIUM
### U-009: Audit log inline div instead of EmptyState component
- **File:** `src/components/admin/audit/audit-log-list.tsx:524`
- **What:** `<div><p className="text-muted-foreground">No audit log entries found.</p></div>` rather than `<EmptyState title="..." />`.
- **Suggested fix:** Replace with `<EmptyState title="No audit log entries found." />`.
### U-010: Two duplicate EmptyState components with incompatible APIs
- **Files:** `src/components/ui/empty-state.tsx` vs `src/components/shared/empty-state.tsx`
- **What:** `ui/` accepts `{icon: ReactNode, body, actions}`; `shared/` accepts `{icon: ElementType, description, action: {label, onClick}}`. 3 files use `ui/` (admin/reconcile-queue, documents/documents-hub, reservations/reservation-detail), 24 use `shared/`.
- **Suggested fix:** Pick `shared/` as canonical (8× usage); migrate the 3 `ui/` callers and delete `ui/empty-state`.
### U-021: Required-field marker inconsistent
- **Files:** `src/components/clients/client-form.tsx:273`, `src/components/interests/interest-form.tsx:281`
- **What:** Some fields use inline `*`, others have no marker; no `aria-required` on inputs; no consistent pattern.
- **Suggested fix:** Single pattern: `<Label>Field <span aria-hidden>*</span></Label>` + `aria-required="true"` on input.
### U-022: Help-text discoverability inconsistent
- **File:** `src/components/shared/filter-bar.tsx`, `src/components/clients/client-form.tsx`
- **What:** No tooltip pattern; some fields have always-visible muted-foreground hints, some have nothing.
- **Suggested fix:** Document a rule (always-visible for constraints/format hints; tooltips only for icons).
### U-024: Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm
- **Files:** `src/components/clients/client-form.tsx`, `src/components/yachts/yacht-form.tsx`
- **What:** `InterestForm.requestClose()` (line 123) checks `isDirty` and shows discard AlertDialog; `CompanyForm` also has it. ClientForm and YachtForm don't — sheet closes immediately.
- **Suggested fix:** Add `isDirty` guard + discard AlertDialog matching InterestForm pattern.
### U-031: FileUploadZone size limit not surfaced as client-side check
- **File:** `src/components/files/file-upload-zone.tsx:170`
- **What:** Accept attribute lists extensions; "up to 50MB" copy at line 163; no client-side size check before upload. Server-side check fails silently with "Upload failed" at line 103.
- **Suggested fix:** Wire client-side size check before upload; show clear "File too large" message.
### U-044: No jump-to-page input in pagination
- **File:** `src/components/shared/data-table.tsx:420`
- **Suggested fix:** Add small `<input type="number">` between Previous/Next.
### U-048: No column resize/reorder on DataTable
- **File:** `src/components/shared/data-table.tsx`
- **What:** Visibility supported via `ColumnPicker`; widths fixed; no drag-reorder.
- **Suggested fix:** Opt-in `enableColumnResizing` per table via TanStack Table v8 `onColumnSizingChange`.
### U-069: Invoice delete uses custom overlay, not AlertDialog
- **File:** `src/app/(dashboard)/[portSlug]/invoices/page.tsx:167`
- **What:** Hand-rolled `<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 ...">` rather than `<AlertDialog>` / `<ConfirmationDialog>`. Lacks focus trap, Escape, role="alertdialog".
- **Suggested fix:** Replace with `<ConfirmationDialog>` matching pattern elsewhere.
### U-074: Success toast missing on ClientForm + InterestForm create/edit
- **Files:** `src/components/clients/client-form.tsx:215`, `src/components/interests/interest-form.tsx:235`
- **What:** `onSuccess` invalidates queries + closes sheet, no `toast.success()`. `ComposeDialog.onSuccess:81` does fire one.
- **Suggested fix:** `toast.success(isEdit ? 'Client updated' : 'Client created')`.
### U-080: Logo preview `<img alt="">` should describe state
- **File:** `src/components/admin/shared/settings-form-card.tsx:420`
- **Suggested fix:** Use `alt="Port logo preview"` or dynamic from field label.
### U-081: Heading hierarchy inconsistent within tab components
- **Files:** `email-accounts-list.tsx:114`, `interest-contract-tab.tsx:130/251/291/364` (h2 → h3 → h2 jumps)
- **Suggested fix:** Audit each tab; standardize h2 = primary section, h3 = sub-section; never h2 after h3 at same nesting depth.
### U-086: DialogContent missing aria-describedby on minimal-content dialogs
- **File:** `src/components/email/compose-dialog.tsx:95` and ~40 other dialogs
- **What:** Only `file-preview-dialog.tsx:82` explicitly suppresses the Radix warning.
- **Suggested fix:** Add `<DialogDescription className="sr-only">...</DialogDescription>` or `aria-describedby={undefined}` to suppress.
### U-091: Mobile topbar title blank on list pages
- **Files:** `client-list.tsx`, `yacht-list.tsx`, `interest-list.tsx`, `berth-list.tsx`
- **What:** `useMobileChrome` only called from detail pages. List pages leave topbar in fallback (no title, stale from previous detail page).
- **Suggested fix:** Add `useMobileChrome({ title, showBackButton: false })` per list with cleanup pattern.
### U-093: Invoices missing from mobile navigation
- **File:** `src/components/layout/mobile/more-sheet.tsx:54`
- **What:** Not in `MORE_GROUPS`, not in bottom tabs. Mobile users can only reach via direct URL.
- **Suggested fix:** Add `{ label: 'Invoices', icon: FileText, segment: 'invoices' }` to Operations group.
---
## ✅ Sample passing checks
- U-001-008 list empty states + skeletons clean across clients/yachts/interests/berths/companies/reservations/invoices/email-threads
- U-012 FileUploadZone drag-hover with `border-primary bg-primary/5`
- U-023 field-level errors via react-hook-form `formState.errors` consistent
- U-026 BulkAddBerthsWizard + CatchUpWizard persist state across step nav
- U-027 phone E.164 via `formatAsYouType` emits `{ e164, country }`
- U-029 native `<input type="date">` provides browser calendar + keyboard
- U-033 Combobox keyboard nav inherited from Radix `<Command>` primitives
- U-040 Sort indicators via `getSortIcon` (`ArrowUpDown`/`ArrowUp`/`ArrowDown`)
- U-041/042 Filter chip dismiss + Clear-all in FilterBar
- U-043 page size selector 25/50/100/250/All
- U-049 virtual list via `@tanstack/react-virtual` (`virtual virtualHeightPx={640}` in audit log)
- U-054 STAGE_BADGE in `src/lib/constants.ts:100` — 7 distinct stages with distinct Tailwind colour families
- U-055 outcome badge: won=emerald, lost\_\*=rose, cancelled=slate
- U-057 status-pill covers all required document statuses
- U-060/061 button hierarchy + destructive red consistent
- U-065 Sheet used for forms+previews on both desktop and mobile (23 components)
- U-067 AlertDialog used for destructive confirmations (`useConfirmation`, `ArchiveConfirmDialog`, `ConfirmationDialog`, `BulkHardDeleteDialog`)
- U-070-072 click-outside, Esc, focus-trap, focus-restore all inherited from Radix
- U-073 toast position consistent (sonner top-right)
- U-075 `toastError()` (`src/lib/api/toast-error.ts:43`) surfaces requestId + Copy ID action — used in 89 files
- U-094 iOS safe-area-inset comprehensive (`pb-safe-bottom`, `pt-safe-top`, FAB `calc(env(safe-area-inset-bottom)+86px)`)
- U-097 visualViewport handling on mobile-search-overlay
- U-092 More sheet covers Documents/Interests/Yachts/Companies/Residential/Alerts/Reminders/Expenses/Reservations/Reports/Analytics/Settings/Admin

View File

@@ -0,0 +1,134 @@
# Deal Pulse & Pipeline Trigger Audit — 2026-05-18
Per MANUAL-TESTING-BACKLOG-2026-05-15 §4.15: map every place that
moves an interest's pipeline stage OR contributes to the deal-pulse
score, and call out the gaps.
---
## 1. Pipeline-stage auto-advance — call-site map
`advanceStageIfBehind(interestId, portId, target, meta, reason?)` is
the canonical "advance if not already past target" helper. The
`*Gated` variant honours the per-port `stage_advance_rules` setting
(auto / suggest / off).
| Trigger | Caller | Target | File:line | Gated? |
| ------------------------------------ | ----------------------------- | --------------------------------------------------------- | --------------------------------------- | -------------------------------- |
| EOI sent (manual rep generate) | `generateAndSign` | `eoi` | `documents.service.ts:843` | gated (eoi_sent) |
| EOI signed (all parties via webhook) | `handleDocumentCompleted` | `reservation` | `documents.service.ts:1610` | gated (eoi_signed) |
| Reservation signed | `handleDocumentCompleted` | `reservation` (no change, stage stays + status sub-flips) | `documents.service.ts:1640` | gated (reservation_signed) |
| Deposit received in full | `recordPayment` | `deposit_paid` | `payments.service.ts:134` | gated (deposit_received) |
| Sales contract signed | `handleDocumentCompleted` | `contract` | `documents.service.ts:1671` | gated (contract_signed) |
| Deposit invoice paid (alt path) | `markInvoicePaid` | `deposit_paid` | `invoices.ts:684` | gated (deposit_received) |
| Custom document upload | `confirmCustomDocumentUpload` | document-type-specific (eoi/reservation/contract) | `custom-document-upload.service.ts:354` | **NOT gated** (uses base helper) |
| External-eoi mark-as-signed | inline in handler | `reservation` | `documents.service.ts:859` | **NOT gated** |
| Externally-signed contract | inline in handler | `contract` | `documents.service.ts:971` | **NOT gated** |
| Manual stage move | `changeInterestStage` | any (with override) | `interests.service.ts:840` | manual / not gated |
### Gaps flagged
- **External-signed paths bypass the per-port rules.** A port set to
`suggest` for `eoi_signed` still gets an auto-advance when the rep
marks the doc externally signed. Decision needed: should the rules
table also gate the external-signed paths? Argument for yes: the
rep's intent ("I just want to mark this signed") is the same as
the webhook case. Argument for no: the rep is explicitly choosing
to bypass the digital flow, so an auto-advance is what they expect.
- **Custom document upload is not gated.** Same trade-off as above.
- **No stage rollback on rejection.** When a signer declines an EOI
(`handleDocumentRejected`), the doc flips to `rejected` but the
interest stays at `eoi`. Confirm: this is correct — the deal
isn't dead, the EOI is. Rep should regenerate. **Verdict: keep
as-is.**
- **No stage rollback on cancel.** When the rep cancels an in-flight
EOI, the doc flips to `cancelled` and the interest stays at `eoi`.
Decision needed: should the interest roll back to `qualified`
when the only EOI is cancelled with no replacement?
**Recommendation: NO** — keeps history honest; a cancel is the
rep's deliberate signal that they're regenerating, not retreating.
---
## 2. Deal-pulse signals — `computeDealHealth` map
Source: `src/lib/services/deal-health.ts`. Each `signals.push` site
documented with its trigger condition + score delta:
| Signal | Delta | Condition | File:line |
| ------------------- | -------------------- | --------------------------------------------------- | ------------------ |
| `active_engagement` | +5 | Any contact-log entries in last 7 days | deal-health.ts:101 |
| `contact_recent` | +20 | `dateLastContact <= 7 days` ago | deal-health.ts:115 |
| `contact_warm` | +10 | `dateLastContact <= 14 days` (else of above) | deal-health.ts:122 |
| `contact_stale` | -15 | `dateLastContact >= 30 days` | deal-health.ts:129 |
| `stage_progress` | +10/+20/+30 (capped) | Per pipelineStage index | deal-health.ts:142 |
| `stuck_top_funnel` | -10 | `firstDays >= 30` AND stage in {enquiry, qualified} | deal-health.ts:157 |
| `eoi_awaiting` | -10 | `eoiSentDays >= 14` AND not signed | deal-health.ts:173 |
| `deposit_pending` | -10 | reservation signed >= 21d AND no deposit | deal-health.ts:184 |
| `contract_awaiting` | -10 | contract sent >= 14d AND not signed | deal-health.ts:200 |
### Positive signals that are MISSING (gaps)
- **EOI sent** — no `eoi_sent_recent` signal. Sending an EOI is the
single biggest "this deal just got serious" moment but the score
doesn't move when it happens. **Recommendation: +15 at < 7 days.**
- **Deposit received** — same gap. A deposit landing should bump the
score significantly. **Recommendation: +20, decays over 30 days.**
- **Contract signed** — terminal positive event; should ladder the
deal to its max. **Recommendation: +30 at < 14 days.**
### Negative signals that are MISSING (gaps)
- **Signer declined / EOI rejected** — when the §4.13 rejection path
fires, the score should drop noticeably (the deal is suddenly at
risk). **Recommendation: -25, decays over 14 days.**
- **Interest archived-and-unarchived cycle** — zombie deals that
bounce in and out should be flagged. Detect via the audit-log
archive/restore pattern. **Recommendation: -10 if archived+restored
within last 30 days.**
- **Reservation cancelled** — similar to EOI rejected; signals the
deal is at risk. **Recommendation: -20.**
- **Berth status flipped to sold-to-other** — the deal's primary
berth was sold to a different interest. **Recommendation: -30
(catastrophic).**
- **Signer engagement** — Documenso fires `RECIPIENT_VIEWED`
webhooks (we store `openedAt`). A signer who opened but didn't
sign in 7+ days = stalling. **Recommendation: -5 per stalling
signer.**
### Cadence escalation (currently flat)
- `eoi_awaiting` and `contract_awaiting` both apply a flat -10 at
the 14-day threshold. **Recommendation: ladder to -20 at 21d, -30
at 30d** so prolonged stalling shows up more visibly.
---
## 3. Heat tooltip explainer copy
The DealPulseChip popover (`src/components/interests/deal-pulse-chip.tsx`)
references signals by name. With the gaps above closed, the
tooltip's enumerated list needs the new signals added so the in-app
copy matches the computation.
The new `/docs/deal-pulse` explainer page (shipped this wave, §7.1)
should also be kept in sync with the signal set.
---
## 4. Suggested fix wave (decisions needed from Matt)
Per the doc structure, these are the punch-list items in priority order:
1. **Ship the positive signals (eoi_sent, deposit_received, contract_signed).**
Biggest visible win. ~1.5h.
2. **Ship the rejection / risk signals (eoi_rejected, reservation_cancelled, berth_sold_to_other).**
Pairs naturally with the §4.13 rejection cascade we shipped this
wave. ~2h.
3. **Ship the cadence escalation (eoi_awaiting / contract_awaiting laddered scoring).**
~30 min.
4. **Decide on the external-signed-paths gating question.**
5. **Decide on the cancel-stage-rollback question.**
Each is small individually; combined the deal-pulse model gets meaningfully
more accurate. Suggest bundling 13 into one PR for review economy.

View File

@@ -0,0 +1,49 @@
# #71 Automated email refactor — DEFERRED
Searched the repo + git history (commits back to the initial `67d7e6e Initial
commit: Port Nimara CRM`) for legacy CRM email templates that could be
lifted verbatim or used as a tonal reference for the rewrite. **None found.**
The codebase was built from scratch; there's no archive directory, no
import dump, and no commits ever contained "old-system" template HTML.
## What this task needs
A full refactor of the four signing-lifecycle emails to a luxury-port
brand voice, with per-port branding flow:
1. **Invitation** (`signingInvitationEmail`) — currently functional but
utilitarian copy. Subject format Matt called for:
`"{firstName}, your EOI for {portName} is ready to be signed"`.
2. **Reminder** (`signingReminderEmail`) — same recipient, follow-up nudge.
3. **Completion** (`signingCompletedEmail`) — sent with the signed PDF attached.
4. **Cancelled** (`signingCancelledEmail`) — added 2026-05-15 alongside the
cancel-with-notify modal.
Each template should have **per-port** branding parameters:
- Port name + signature block
- Primary brand color (already plumbed via `BrandingShell`)
- Optional header/footer HTML overrides (`branding_email_header_html` /
`_footer_html` settings)
## Source-of-truth flow before unblocking
Matt to paste / share the legacy templates from the prior CRM (likely
NocoDB-era or a separate email tool — not committed to this repo). Once
shared, lift the copy verbatim where possible; otherwise match
**structure + tone + voice** carefully.
Current files to refactor:
- `src/lib/email/templates/document-signing.tsx` (4 templates)
- `src/lib/email/templates/portal-auth.tsx` (activation + reset)
- `src/lib/email/templates/inquiry-client-confirmation.tsx`
- `src/lib/email/templates/inquiry-sales-notification.tsx`
## Status
DEFERRED until the legacy copy is supplied or Matt approves a from-scratch
draft. The structural plumbing (per-port branding, sendEmail with
attachments, EMAIL_REDIRECT_TO safety, cancel-with-notify wiring) all
landed in earlier tasks — only the copy rewrite remains.

View File

@@ -0,0 +1,335 @@
# Full Codebase Audit — 2026-05-18
> **Companion doc:** [Alpha UAT Master](./alpha-uat-master.md) — the multi-day cross-cutting Playwright/React-Grab walkthrough doc, findings cross-referenced here as `→ confirmed in manual #N`.
>
> **Methodology:** Parallel sonnet[1m] audit team (16 narrow-scope agents), each assigned a specific subsystem with no overlap. Every finding includes file:line evidence; severity is `critical | high | medium | low | info`. Findings here are raw — triage + prioritization at the bottom.
>
> **Scope:** entire `src/` tree at commit `b3f8756` (post-audit-cleanup). Excludes `docs/`, `tests/` (covered by F3), build/Docker config, and node_modules.
>
> **Out of scope:** anything in `docs/BACKLOG.md` already triaged. This audit looks for NEW findings not on that list.
---
## Audit team composition
| Agent | Scope |
| ------------------------------- | ---------------------------------------------------------------------------------------- |
| **A1 — Schema: people/orgs** | `src/lib/db/schema/{clients,yachts,companies,users}.ts` |
| **A2 — Schema: pipeline** | `src/lib/db/schema/{interests,berths,reservations}.ts` |
| **A3 — Schema: docs+infra** | `src/lib/db/schema/{documents,email,brochures,system}.ts` |
| **B1 — Public API** | `src/app/api/public/*` |
| **B2 — Admin API** | `src/app/api/v1/admin/*` |
| **B3 — v1 entity CRUD** | `src/app/api/v1/{clients,interests,yachts,companies,berths}/*` |
| **B4 — Webhooks/auth/storage** | `src/app/api/{webhooks,auth,storage}/*` |
| **C1 — EOI/Documenso services** | `src/lib/services/{eoi-*,document-templates,custom-document-upload,documenso-client}.ts` |
| **C2 — Domain services** | `src/lib/services/{berth-*,reminders,notifications,inquiry-notifications}.ts` |
| **C3 — Observability/audit** | `src/lib/services/error-events.service.ts`, `src/lib/audit.ts`, `src/lib/storage/*` |
| **D1 — Jobs/queues** | `src/lib/queue/scheduler.ts`, `src/lib/queue/workers/*`, `src/jobs/processors/*` |
| **E1 — Admin UI** | `src/app/(dashboard)/[portSlug]/admin/*` |
| **E2 — Entity UI** | `src/components/{interests,clients,yachts,companies,berths}/*` |
| **F1 — Security cross-cut** | Auth/permission gaps, XSS/SQLi, port-isolation, secret leaks |
| **F2 — Performance** | Missing indexes, N+1 queries, unbounded fan-outs, hot paths |
| **F3 — Tests + deps** | Coverage gaps, package.json freshness, Docker/CI |
---
## Findings by agent
### A2 — Schema: pipeline (15 findings: 3 high, 4 medium, 7 low, 1 info)
| # | Severity | Title | Evidence |
| --- | -------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| 1 | high | No DB-level CHECK on `interests.pipeline_stage` | `interests.ts:44` — text col, no CHECK; legacy 'completed' / 'eoi_signed' can persist via raw SQL |
| 2 | high | No DB-level CHECK on `outcome`, `eoi_doc_status`, `reservation_doc_status`, `contract_doc_status` | `interests.ts:47-49,84` — bare text on all 4 enum-shaped columns |
| 3 | high | No DB-level CHECK on `berths.status` | `berths.ts:31``derivePublicStatus()` silently falls through to 'Available' on bad values |
| 4 | medium | No CHECK on `berth_reservations.status` — breaks `idx_br_active` invariant | `reservations.ts:34,61-64` — misspelled 'Active' bypasses the one-active-per-berth guard |
| 5 | medium | Stale `berthId` field on `Interest` domain type | `src/types/domain.ts:39``interests.berth_id` was dropped in 0029; type still declares it |
| 6 | medium | Board query missing composite partial index — bitmap-AND scan on large ports | `interests.ts:113-117` — need `(portId, pipelineStage) WHERE archivedAt IS NULL AND outcome IS NULL` |
| 7 | medium | `interestTags.tagId` + `berthTags.tagId` are comment-only FKs, no DB constraint | `interests.ts:205-207`, `berths.ts:267-269` — tag deletes silently orphan junction rows |
| 8 | medium | `berthWaitingList` lacks `port_id` column — no schema-level cross-port isolation | `berths.ts:170-192` — defense-in-depth depends entirely on service layer |
| 9 | low | No index on `interest_berths.is_in_eoi_bundle` | bundle lookups scan all rows for the interestId |
| 10 | low | `berthRecommendations` lacks `port_id` — same isolation pattern as #8 | `berths.ts:146-168` |
| 11 | low | `interests.assignedTo`, `interest_berths.addedBy`/`eoiBypassedBy` are bare text — no FK to users | dead entries accumulate on user delete |
| 12 | low | `berthMaintenanceLog.portId` FK missing onDelete — implicit NO ACTION breaks H-01 convention | `berths.ts:204-206` |
| 13 | low | `berthReservations.startDate`/`endDate` use timestamptz `mode:'date'` — TZ off-by-one risk | should be `date()` |
| 14 | low | `idx_interests_stage` is not partial — bloats with archived + closed rows | add `WHERE archivedAt IS NULL AND outcome IS NULL` |
| 15 | info | `is_primary` ≤1 per interest invariant correctly enforced via partial unique index | `interests.ts:165-167` — no action needed |
### B2 — API: admin (10 findings: 2 medium, 8 low)
| # | Severity | Title | Evidence |
| --- | -------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| 1 | medium | `GET /qualification-criteria` has no `withPermission` gate | `qualification-criteria/route.ts:9` — any authenticated user can enumerate; POST correctly gates |
| 2 | medium | Triage PATCH on website-submissions uses `view_audit_log` (read) for a write | `website-submissions/[id]/triage/route.ts:26` — semantic mismatch; should be manage_settings |
| 3 | low | `/admin/storage/route.ts` POST returns bare `result` without `{data:...}` | `storage/route.ts:64` — breaks toastError frontend hook |
| 4 | low | `/admin/ocr-settings/test` POST returns bare result without `{data:...}` | `ocr-settings/test/route.ts:26` |
| 5 | low | `/admin/ocr-settings` PUT returns `{ok:true}` — legacy success-flag pattern | `ocr-settings/route.ts:64` — should be 204 or `{data: updatedConfig}` |
| 6 | low | `/admin/custom-fields/[fieldId]` PATCH uses raw `req.json()` + manual `.parse()` not `parseBody` | `custom-fields/[fieldId]/route.ts:18-19` — generic 500 instead of structured 400 |
| 7 | low | `/admin/ai-budget` PUT — `setAiBudget` audit record missing ipAddress + userAgent | `ai-budget/route.ts:40` |
| 8 | low | `/admin/ocr-settings` PUT — `saveOcrConfig` audit record missing ipAddress + userAgent | `ocr-settings/route.ts:53` — encrypted API key swap is high-impact, deserves full context |
| 9 | low | `/admin/brochures/[id]` PATCH+DELETE pass no audit meta to service helpers | `brochures/[id]/route.ts:26,37` + brochures POST — pattern mismatch with form-templates, custom-fields, document-templates |
| 10 | low | `/admin/email-templates` PUT returns `{data:{ok:true}}` — flag body instead of entity or 204 | `email-templates/route.ts:84` |
### A3 — Schema: docs+infra (15 findings: 1 high, 7 medium, 7 low)
| # | Severity | Title | Evidence |
| --- | -------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **high** | `documents.documenso_id` has NO INDEX | `documents.ts:88` — full table scan on every webhook delivery (hottest read path); only documenso_numeric_id is indexed |
| 2 | medium | `documentSigners.signingToken` indexed but NOT unique | `documents.ts:188,193` — token collision/replay has no DB-level guard; should be partial uniqueIndex |
| 3 | medium | `audit_logs` missing 4-column inspector index | `system.ts:62-63` — neither existing index covers `(port_id, entity_type, entity_id, ORDER BY created_at)` without heap re-filter |
| 4 | medium | `system_settings NULLS NOT DISTINCT` lives in migration 0047 only — `db:push` drops it | `system.ts:144-149` — fresh `db:push` re-introduces the duplicate-global-settings bug 0047 fixed |
| 5 | medium | `documentFolders.parentId` self-FK MISSING from Drizzle schema (only in migration 0050) | `documents.ts:357-358` — fresh `db:push` skips the self-FK; orphaned folders undetectable |
| 6 | medium | `emailMessages.attachmentFileIds` text[] with no FK — dangling IDs survive RTBF wipe | `email.ts:78` + `client-hard-delete.service.ts:269-277` — RTBF wipes body/subject but not attachment file references |
| 7 | medium | `brochureVersions` missing `unique(brochureId, versionNumber)` — unlike berth_pdf_versions | `brochures.ts:79` — concurrent uploads could assign duplicate version numbers |
| 8 | medium | `documensoNumericId` indexed non-uniquely despite being globally unique | `documents.ts:94,152` — webhook resolver matches multiple docs for same numeric ID; double-processing |
| 9 | low | `emailThreads.clientId` has no `onDelete` clause — defaults to RESTRICT, inconsistent with `set null` peers | `email.ts:50` |
| 10 | low | `files.storagePath` has no unique constraint — duplicate blob paths undetected | `documents.ts:41` — migrate-storage.ts would silently double-migrate |
| 11 | low | `brochureVersions.storageKey` + `berth_pdf_versions.storageKey` lack unique constraints | same as #10 |
| 12 | low | `documentSends.berthPdfVersionId` has no index — full-scan for version-X queries | `brochures.ts:120` |
| 13 | low | C.2 dedup gap: SIGNED events with `recipient_email=NULL` fall back to broken hash-only path | migration 0075 risk note: any v2 code path emitting global SIGNED without recipient context bypasses per-recipient dedup |
| 14 | low | C.2 dedup over-eager: void-then-reinvite with same email blocks the legitimate 2nd signing | `documents.ts:230-232` — partial unique on (docId, recipientEmail, eventType) treats reinvited signing as re-delivery |
| 15 | low | `document_sends` + `emailMessages` parallel send-audit tables with no cross-reference | future IMAP-synced sent-folder → duplicate GDPR exports |
### B1 — API: public (12 findings: 1 high, 3 medium, 5 low, 3 info)
| # | Severity | Title | Evidence |
| --- | -------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **high** | `portId` is caller-controlled on `/interests` — NOT validated against existing ports | `interests/route.ts:40` — caller can inject client/yacht/interest into ANY tenant they know the UUID for; residential-inquiries DOES validate |
| 2 | medium | Health endpoint `X-Intake-Secret` comparison leaks secret byte-length via timing short-circuit | `health/route.ts:57` — length check before timingSafeEqual; website-inquiries does it right |
| 3 | medium | `X-Forwarded-For` spoofable — rate-limit keys are attacker-controlled on all public POST routes | interests/residential/website-inquiries — no x-real-ip fallback; route-helpers `clientIp()` has it but isn't used |
| 4 | medium | `/public/supplemental-info/[token]` has NO rate limiting on GET or POST | `supplemental-info/[token]/route.ts` — POST writes live client PII (name, address, email, phone) at unlimited rate |
| 5 | low | Unbounded string fields in public schemas — multi-MB payloads allowed | publicInterestSchema/publicResidentialInquirySchema — no `.max()` on phone/notes/preferences; no segment bodySizeLimit |
| 6 | low | Invalid `portId` on `/interests` causes 500 (DB FK error) not 400 | residential route has the explicit pre-check; interests doesn't |
| 7 | low | `supplemental-info` POST uses raw `req.json()` + `.parse()` instead of `parseBody()` | malformed JSON returns 500 not field-level 400 |
| 8 | low | `supplemental-info` GET missing `Cache-Control: no-store` — intermediaries may cache token-keyed PII payload | response includes primaryEmail/Phone/streetAddress |
| 9 | low | Rate limiting fails open on Redis outage — silently drops public-form protection | `rate-limit.ts:57-73` — intentional for auth, equally affects public POST |
| 10 | info | `applySubmission` distinguishes consumed vs expired token in error message | violates the conflation principle the GET path uses |
| 11 | info | Authenticated health probe discloses `APP_URL` and `NODE_ENV` | `health/route.ts:86-93` — internal URL leak via authed probe |
| 12 | info | `residential-inquiries` exposes internal UUIDs and uses deprecated `{success:true}` envelope | `residential-inquiries/route.ts:123` |
### F3 — Tests + deps + infra (15 findings: 2 critical, 3 high, 4 medium, 5 low, 1 info)
| # | Severity | Title | Evidence |
| --- | ------------ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **CRITICAL** | `client-hard-delete.service.ts` has ZERO unit or integration tests | GDPR/CCPA-critical path just modified today; no automated regression guard |
| 2 | **CRITICAL** | No CI/CD pipeline — `.github/workflows/` does not exist | every merge can silently break tests; the full vitest+playwright suite must be run manually |
| 3 | high | `alert-engine-realtime.spec.ts` permanently skips a test whose route now exists | spec skip says route not implemented; route file present at `/admin/alerts/run-engine` |
| 4 | high | `documenso-client.ts` v1/v2 routing has no dedicated unit test | every EOI + document-send path goes through it |
| 5 | high | Coverage config excludes `src/app/` — route handlers never counted | `vitest.config.ts: coverage.include: ['src/lib/**']` — misleadingly low coverage on API surface |
| 6 | medium | Two competing image-crop libraries in production deps | `react-easy-crop` + `react-image-crop` both live; one call site each |
| 7 | medium | Six PDF-related packages; pdfkit (1 usage) and unpdf (1 usage) candidate for consolidation | `pdf-lib`, `pdfjs-dist`, `pdfkit`, `react-pdf`, `unpdf`, `@react-pdf/renderer` |
| 8 | medium | CLAUDE.md lists `pdfme` as a tech-stack dep — not in package.json | removed 2026-05-12; CLAUDE.md outdated |
| 9 | medium | `playwright.config.ts` retries hardcoded to 0, not elevated in CI | should be `process.env.CI ? 2 : 0` for flaky network-bound realapi tests |
| 10 | low | No top-level `test` npm script — requires `pnpm exec vitest run` | DX gap; CI templates expect a `test` alias |
| 11 | low | Missing `test:e2e:realapi` and `test:e2e:visual` shorthand scripts | inconsistency vs `test:e2e:smoke/exhaustive/destructive` |
| 12 | low | `@hookform/devtools` devDep + `FormDevtool` wrapper component have no callers | dead code |
| 13 | low | Dockerfile builder stage uses broad `COPY . .` — secrets rely entirely on `.dockerignore` | well-structured .dockerignore mitigates, but targeted COPY is defense-in-depth |
| 14 | low | Large cluster of high-value services have no unit tests at all | interest-berths, portal-auth, alert-engine, berth-rules-engine, documenso-webhook, document-reminders, external-eoi, residential, document-sends, notifications, webhooks (~50 services) |
| 15 | info | Exhaustive e2e tests use `test.skip(true, ...)` as soft guards when fixtures absent | intentional graceful-degrade pattern; not a bug |
### C3 — Observability + infra (10 findings: 2 high, 1 medium, 5 low, 2 info)
| # | Severity | Title | Evidence |
| --- | -------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **high** | GDPR export bundles NOT deleted from storage on client hard-delete | `gdpr.ts:35` storageKey + `client-hard-delete.service.ts:241-244` — files.clientId collected, gdprExports.storageKey never queried; cascade kills DB row but blob orphans. **This is a gap in the A.7 RTBF fix shipped today.** |
| 2 | **high** | NO RTBF/hard-delete path for `residential_clients` | residential.ts schema holds equivalent PII to marina clients; zero hard-delete code path — no confirmation flow, no blob sweep, no audit, no API endpoint |
| 3 | medium | `sentTo` key bypasses audit masker — operator email stored plaintext in audit_logs.metadata | `client-hard-delete.service.ts:139,466``sent_to` doesn't contain 'email' substring. Fix: add 'sent_to' fragment, or rename to `sentToEmail` |
| 4 | low | S3Backend `presignUpload`/`presignDownload` lack `withTimeout` wrappers | `s3.ts:289-297` — every other method (put/get/head/delete) is wrapped; presigns aren't. TCP-blackhole stall risk |
| 5 | low | `error_events.errorMessage` and `errorStack` stored without PII redaction | error-events.service.ts:143-145 — ORM errors embedding WHERE-clause values persist as PII |
| 6 | low | `'auth'` fragment over-masks: `authorId`, `isAuthenticated`, etc. | `audit.ts:125``'auth'` is too broad; should be `'authorization'` or use prefix match |
| 7 | low | RTBF `website_submissions` erasure only matches top-level JSONB `email` key | `client-hard-delete.service.ts:221-224` — nested email payloads (`payload.contact.email`) survive |
| 8 | low | `hardDeleteCode` rate limiter fails open + `Math.random()` 4-digit code | combined attack surface during Redis outage; switch to `crypto.randomInt()` regardless |
| 9 | info | `bulkHardDeleteClients` emits no composite audit log for the bulk action itself | forensic correlation requires grouping N rows by timestamp; one bulk-level log entry would fix it |
| 10 | info | `requestBulkHardDeleteCode` loads ALL port clients into memory for validation | `client-hard-delete.service.ts:408-419` — should `WHERE id IN (args.clientIds)` |
### B4 — Webhooks + auth + storage (15 findings: 1 high, 6 medium, 5 low, 3 info)
| # | Severity | Title | Evidence |
| --- | -------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **high** | better-auth rate limiter uses in-memory storage — multi-replica prod bypasses limits | `auth/index.ts:128-137` — N replicas multiplies attempt budget N×; documented as known. Swap to `storage: database` |
| 2 | medium | DOCUMENT_SIGNED route-level dedup hash never matches stored events — every retry re-enters handler | `webhooks/documenso/route.ts:173 vs documents.service.ts:1184` — raw-body SHA vs prefixed-form hash, never matches; dedup intent broken |
| 3 | medium | Concurrent SIGNED webhooks both see `wasAlreadySigned=false`, both dispatch cascade invites | `documents.service.ts:1130-1131,1196-1208` — read outside tx; handleDocumentCompleted has correct SELECT FOR UPDATE pattern but handleRecipientSigned doesn't |
| 4 | medium | Rate limiter fails open on Redis outage — auth brute-force protection disabled | `rate-limit.ts:57-73` — intentional; consider fail-closed + admin-IP allowlist escape hatch |
| 5 | medium | `callbackURL` forwarded to better-auth without origin validation in sign-in-by-identifier | `auth/sign-in-by-identifier/route.ts:63-96` — potential open redirect post-auth |
| 6 | medium | `originAllowed()` returns true when both Origin AND Referer absent — non-browser CSRF check bypassed | `proxy.ts:118-136` — SameSite=Strict is the real gate but defense-in-depth has a hole |
| 7 | medium | Legacy plaintext Documenso webhook secrets may persist in `system_settings` — no migration enforcement | `port-config.ts:469-472` — ports that never rotated retain cleartext |
| 8 | low | Storage proxy token `p` port-binding field is optional — tokens without `p` skip cross-port enforcement | `filesystem.ts:184-188,95-111` — future callers that omit portSlug mint cross-port tokens |
| 9 | low | Storage proxy PUT magic-byte check is application/pdf only — other content types accepted blind | `api/storage/[token]/route.ts:222-225` — png/jpg/csv/zip not inspected |
| 10 | low | Dev HMAC fallback derives storage proxy secret from `BETTER_AUTH_SECRET` — shared key in dev | `filesystem.ts:430-432` — prod rejects but dev exposed→internet could forge tokens with auth key |
| 11 | low | CSP policy has no `report-uri`/`report-to` — XSS probes blocked silently | `proxy.ts:16-37` — adding `/api/csp-report` would give early-warning |
| 12 | low | sign-in-by-identifier timing oracle: email-format skips DB; username-format always hits DB | very low practical impact; doesn't reveal whether identifier exists |
| 13 | info | better-auth's built-in rate limiter doesn't add `Retry-After` on 429 | direct `/api/auth/sign-in/email` lacks RFC 6585 compliance; sign-in-by-identifier wrapper has it |
| 14 | info | Session cookie lacks `__Host-` prefix — subdomain binding not enforced | `auth/index.ts:106` — SameSite=Strict+Secure mitigate; `__Host-` would forbid Path other than `/` |
| 15 | info | `listDocumensoWebhookSecrets()` issues full DB SELECT on every webhook with no cache | `port-config.ts:456-501` — amplifies bad-secret flood scenario; short TTL cache fixes |
### C1 — EOI/Documenso services (15 findings: 3 high, 5 medium, 4 low, 3 info)
| # | Severity | Title | Evidence |
| --- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| 1 | **high** | `generateAndSignViaInApp` omits `portId` on all Documenso calls — per-port v1/v2 config bypassed | `document-templates.ts:705,717` — portId optional → env fallback; v2-configured port uses v1 env defaults |
| 2 | **high** | custom-document-upload: `placeFields` called AFTER `documensoSend` — v2 envelope already PENDING when fields placed | `custom-document-upload.service.ts:285,294,323` — header comment documents correct order; code inverts. v2 may reject; all v2 contract/reservation uploads land with no signature fields |
| 3 | **high** | `{{eoi.berthRange}}` and all `{{reservation.*}}` tokens in VALID_MERGE_TOKENS but resolveTemplate never populates them | merge-fields.ts:64-76 + document-templates.ts — tokens render as literal `{{...}}`; BR-140 doesn't catch because required:false |
| 4 | medium | `sendReminder` passes CRM document_signers.id (UUID) as Documenso recipient ID — v1 path sends invalid URL, v2 redistributes blindly | `document-reminders.ts:161` + `documenso-client.ts:910` — v1 reminders consistently fail with 404; schema missing `documenso_recipient_id` column |
| 5 | medium | `custom-document-upload` does not persist `documensoNumericId` — v2 webhook numeric-id resolution can't match | `custom-document-upload.service.ts:345` — contract/reservation uploads on v2 instance miss webhook events |
| 6 | medium | `generateDocumentFromTemplate` v2: distribute failure swallowed — all signer rows get signingUrl=null with no auto-recovery | `documenso-client.ts:554-560` + `document-templates.ts:843-884` — "Send invitation" button errors for every signer |
| 7 | medium | `handleDocumentCompleted`: interest side-effects (dateEoiSigned, berth-rules) run outside try/catch and are not idempotent across retries | `documents.service.ts:1574-1621` — each failed-PDF retry re-stamps dateEoiSigned |
| 8 | medium | `distributeEnvelopeV2` normalize call loses numericId — self-heal callers can't persist | `documenso-client.ts:618-623` — pattern from generateDocumentFromTemplate not followed |
| 9 | low | `voidDocument` uses raw fetchWithTimeout without pRetry — transient 5xx/429 not retried | `documenso-client.ts:1289` |
| 10 | low | `completion_cc_emails` recipients have empty name — signing-completed email greeting malformed | `documents.service.ts:1722` — "Dear ," fallback; should be email as display name |
| 11 | low | `normalizeSignerRole` maps developer slot (order-2 SIGNER) to 'signer' not 'developer' — progress panel label wrong | `document-templates.ts:863-865,930-935` |
| 12 | low | `persistDocumentOverrides` source_document_id backfill uses 1-minute window — race if generation takes >60s | `eoi-overrides.service.ts:451,463,471` — widen to 5min or backfill by returned IDs |
| 13 | info | `resolveTemplate` ValidationError catch regex includes dead branch 'interest has no (yacht | berth)' | `document-templates.ts:317-322` — dead from prior design; remove for clarity |
| 14 | info | berth-range: non-canonical (passthrough) moorings always appended after sorted canonical segments | `berth-range.ts:105-108` — cosmetic |
| 15 | info | `{{interest.notes}}` always empty in non-EOI (legacy) resolveTemplate path | `document-templates.ts:378` — silent blank in correspondence templates |
### C2 — Domain services (15 findings: 1 high, 3 medium, 6 low, 5 info)
| # | Severity | Title | Evidence |
| --- | -------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **high** | Recommender: SQL vs JS stage-scale mismatch — Tier D fires one stage too early | `berth-recommender.service.ts:212,499-502,223,554` — JS LATE_STAGE_THRESHOLD=5 (deposit_paid in JS scale) vs SQL emits 5=reservation. Tier D fires at reservation, not deposit_paid. Berths with reservation-stage active interest hidden one stage early. |
| 2 | medium | `createNotification` dedup is non-atomic SELECT-then-INSERT with no DB unique constraint (TOCTOU) | `notifications.service.ts:67-85,117` — concurrent inquiry fan-out can double-insert. Fix: partial unique on `(userId, type, dedupeKey)` + ON CONFLICT DO NOTHING |
| 3 | medium | `completeReminder` TOCTOU — concurrent calls both pass status guard, produce dup audit rows | `reminders.service.ts:317-332` — no `WHERE status='pending'` in UPDATE; no advisory lock |
| 4 | medium | `processFollowUpReminders` lacks advisory lock — concurrent workers double-insert auto-generated reminders | `reminders.service.ts:428-517` — 3 non-tx round-trips; `processOverdueReminders` has the right pattern, this one doesn't |
| 5 | low | `createNotification` with inApp=false + email=true silently drops the email | `notifications.service.ts:107-113` — acknowledged in comment but untracked gap |
| 6 | low | `public-interest` creates interest with legacy `pipelineStage='open'` instead of `'enquiry'` | `public-interest.service.ts:233` — modern stage is `enquiry`; column default agrees |
| 7 | low | `public-interest` berth lookup outside transaction — FK violation on race-deleted berth | `public-interest.service.ts:79-87,237-244` |
| 8 | low | `public-interest` no yacht dedup — re-submissions create duplicate yacht records | `public-interest.service.ts:177-203` — client + company dedup'd; yacht isn't |
| 9 | low | `inquiry-notifications.findUsersWithInterestsPermission` has no deactivated-user filter | `inquiry-notifications.service.ts:149-168` — deactivated users still receive new_registration alerts |
| 10 | low | Rules engine suggest-mode unconditionally calls `createAuditLog` — audit flood on webhook retries | `berth-rules-engine.ts:102-117,201-207` |
| 11 | low | interest-berths cross-port guard silently passes when interestId doesn't exist | `interest-berths.service.ts:232-244` — should throw NotFoundError explicitly |
| 12 | info | `processOverdueReminders` un-snooze + claim are two non-tx UPDATEs — survivable, no fix required | at-least-once semantics |
| 13 | info | Dynamic import in `removeInterestBerth` is still required (cycle break) | `interest-berths.service.ts:356-361` — not stale |
| 14 | info | Inconsistent `evaluateRule` import style — static vs dynamic across files | maintenance hazard; documenting needed |
| 15 | info | `STAGE_ORDER.completed=6` in recommender JS is dead code — SQL CASE never emits 'completed' | misleads maintainers |
### D1 — Jobs/queue/cron (8 findings: 3 critical, 1 high, 2 medium, 2 low)
| # | Severity | Title | Evidence |
| --- | ------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **CRITICAL** | `send-invoice` + `invoice-overdue-notify` dispatched to queues WITH NO WORKER HANDLER | `invoices.ts:597-600,740-743` — both fall to default branch, log "Unknown … job", complete successfully. **Every invoice send AND every overdue check is a silent no-op.** |
| 2 | **CRITICAL** | 5 maintenance cron jobs scheduled but unimplemented — silent no-ops with false-green audit | scheduler.ts: `calendar-sync`, `database-backup`, `backup-cleanup`, `session-cleanup`, `temp-file-cleanup` — workers/maintenance.ts has no case for any. **database-backup is the dangerous one.** RECURRING_JOB_NAMES contains them so audit shows green. |
| 3 | **CRITICAL** | `tenure-expiry-check` scheduled, in RECURRING_JOB_NAMES, but has no handler and no service | scheduler.ts:32 — daily 08:00 schedule; workers/notifications.ts no case; no `tenure-expiry` service exists |
| 4 | high | `processDocumensoPoll` TOCTOU race — concurrent ticks can double-fire cascading invite email | `jobs/processors/documenso-poll.ts:46-47` — wasAlreadySigned read outside tx; documents queue concurrency=3 with 5-min poll → overlapping ticks plausible |
| 5 | medium | `documenso-void` enqueued without natural-key jobId at both archive call sites | `clients/[id]/archive/route.ts:95`, `clients/bulk/route.ts:180` — double-archive enqueues two void jobs; second hits already-voided envelope → spurious dead-letter |
| 6 | medium | `report-scheduler` `nextRunAt` UPDATE not transactional with job enqueue — crash silently drops a period | workers/reports.ts — 3 separate round-trips; crash between A and C skips the period |
| 7 | low | `bounce-poll` absent from RECURRING_JOB_NAMES — no cron_run audit row on successful ticks | audit-helpers.ts:27-49 — operators can't detect stalled poller via audit log |
| 8 | low | maintenance queue concurrency=1 with HOL-blocking risk | analytics-refresh + bounce-poll can starve alerts-evaluate (every 5min) — split into fast/slow queues |
### F2 — Performance (8 findings: 3 high, 5 medium)
| # | Severity | Title | Evidence |
| --- | -------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **high** | `getClientById`: 6 independent DB queries run SEQUENTIALLY on hot client detail path | `clients.service.ts:358,363,368,374,392,415` — 7 serial round-trips per page load; should be `Promise.all([...6])` after gating client lookup |
| 2 | **high** | `notification-digest`: nested port×user loops → O(ports × users) sequential queries + emails | `notification-digest.service.ts:71,74,109,113` — per port: 6+ queries; per user: 1 query + 1 send, all serial. Ports + users are independent |
| 3 | **high** | Missing index on `interests.reminder_enabled``processFollowUpReminders` full-scans active interests per port | `reminders.service.ts:432-441` — no existing index covers `(portId, reminderEnabled) WHERE archived_at IS NULL` |
| 4 | medium | `reconcileAlertsForPort`: N individual INSERTs + N UPDATEs per alert-engine evaluation | `alerts.service.ts:53-80,89-99` — batch INSERT ... ON CONFLICT DO NOTHING RETURNING; UPDATE WHERE id IN (...) |
| 5 | medium | `client-archive-dossier`: N DB queries inside loop over `distinctBerthIds` | `client-archive-dossier.service.ts:244,252` — single query WHERE berthId IN (...) + JS group |
| 6 | medium | `email_threads`: no compound `(portId, lastMessageAt)` index — list endpoint forces filesort | `email.ts:57` — only `idx_et_port` covers portId; sort step grows with thread volume |
| 7 | medium | `createPending` (berth-reservations): 3 independent tenant-validation lookups serial | `berth-reservations.service.ts:95,100,105` — berth/client/yacht should be `Promise.all` |
| 8 | medium | `webhook-dispatch`: sequential INSERT + BullMQ enqueue per matching webhook | `webhook-dispatch.ts:47-75` — batch the inserts (RETURNING id), then Promise.all the queue.adds |
### A1 — Schema: people/orgs (audited inline; agent stuck) (12 findings: 1 high, 6 medium, 5 low)
| # | Severity | Title | Evidence |
| --- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **high** | `yachts.currentOwnerType`/`currentOwnerId` polymorphic — NO CHECK constraint on the type discriminator | `yachts.ts:44-45``currentOwnerType` is bare text; a value other than `'client'`/`'company'` silently corrupts ownership resolution downstream |
| 2 | medium | `clients.mergedIntoClientId` self-FK lives in migration 0042 only — `db:push` drift (same pattern as A3 #5) | `clients.ts:53-58` — Drizzle's table builder doesn't accept self-references in column factory; constraint missing from db:push schema |
| 3 | medium | `clients.sourceInquiryId` FK lives in migration 0065 only — `db:push` drift | `clients.ts:33-38` — comment acknowledges the gap; fresh db:push skips it |
| 4 | medium | `clientAddresses.label='Primary' default` + `isPrimary=true default` conflicts | `clients.ts:250,258` — every new address is "primary" by default; partial unique `idx_ca_primary` then rejects the second. Either flip the default or fail less surprising |
| 5 | medium | No DB CHECK on `clients.preferredContactMethod` enum (email/phone/whatsapp) | `clients.ts:27` |
| 6 | medium | No DB CHECK on `yachts.status` enum (active/retired/sold_away) | `yachts.ts:46` |
| 7 | medium | `companyMemberships.role` no DB CHECK on enum (director/officer/broker/representative/legal_counsel/employee/shareholder/other) | `companies.ts:65` |
| 8 | low | `clientNotes.authorId`, `yachtNotes.authorId`, `companyNotes.authorId` all bare text — no FK to user | `clients.ts:149`, `yachts.ts:107`, `companies.ts:126` — dangling on hard user delete |
| 9 | low | `clients.archivedBy` bare text — no FK to user; same dangling-on-delete pattern | `clients.ts:41` |
| 10 | low | `clientTags.tagId`, `yachtTags.tagId`, `companyTags.tagId` — bare text, comment-only FK to tags | `clients.ts:165`, `yachts.ts:123`, `companies.ts:142` — same gap as A2 #7 for pipeline tables |
| 11 | low | `yachtOwnershipHistory` has no DB-level guard that `startDate ≤ endDate` | `yachts.ts:83-84` — date inversion possible without CHECK |
| 12 | low | `yachts.lengthFt`/`lengthM`/`lengthUnit` denormalized triple — no DB-level invariant that lengthUnit aligns with which of (lengthFt, lengthM) is non-null | `yachts.ts:32-43` — service layer can write `lengthUnit='ft'` while `lengthFt=null`; produces broken display |
### F1 — Cross-cut: security (audited inline; agent stuck) (4 findings: 1 medium, 3 low)
The cross-cutting security audit is partly redundant with B1/B4/C3 findings already reported. Only NEW issues here:
| # | Severity | Title | Evidence |
| --- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | medium | `send-document-dialog.tsx` lines 248 + 274 use `dangerouslySetInnerHTML` for previewHtml — verify `renderEmailBody()` allowlist sanitization | `send-document-dialog.tsx:248,274` — flows from API; `renderEmailBody` documented escape-then-allowlist, but the dialog's preview path needs explicit audit to confirm no untrusted HTML leaks |
| 2 | low | Many `findFirst` queries in services without explicit `port_id` filter — depends on FK chain | examples: `notes.service.ts:767`, `email-threads.service.ts:68,101,106,144,177,255` — defense-in-depth gap; FK joins enforce isolation but a direct call from a route bypassing service wrappers could leak |
| 3 | low | 136 raw `sql\`\`` template literals in services — manual review-worthy for SQLi | full sweep not done; spot-checks at known sites (berth-recommender, search) use parameterized `${}` interpolation via Drizzle |
| 4 | info | Most other security surfaces already covered by B1/B4/C3 reports above | see `cross-references` |
### B3 — v1 entity CRUD (audited inline; agent stuck) (3 findings, structurally clean)
Spot-check across 303 v1 route files: **structurally healthy.** Sample at `/api/v1/clients/route.ts` is exactly the documented pattern — `withAuth(withPermission(resource, action, async (req, ctx) => { try { parseBody/parseQuery + service call; return {data}; } catch (error) { return errorResponse(error); } }))`. No bare route handlers found.
| # | Severity | Title | Evidence |
| --- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 1 | low | `handlers.ts` sibling pattern means grep for missing withAuth needs to skip them | not a finding per se, just a noting that the testability split documented in CLAUDE.md is honored |
| 2 | low | Pagination shape on `/api/v1/clients` returns `{data, pagination: {...}}` but list endpoints elsewhere return `{data, total, hasMore}` (CLAUDE.md convention) | `clients/route.ts:18-28` — minor shape drift; not breaking but lists aren't uniform |
| 3 | info | Most B3 quality findings already covered by B1 (port validation), C2 (race + dedup), C3 (audit gaps) | this scope was already well-covered |
### E1 — Admin UI (agent stuck; not audited)
The admin-ui agent went idle 4 times across multiple pings. The most likely interpretation is that the surface is large enough that even Sonnet 1M's context was filled before a useful answer landed. **E1 should be re-spawned with a much narrower scope (one page at a time) or audited inline in a follow-up pass.**
### E2 — Entity UI (agent stuck; not audited)
Same pattern as E1. Entity-tab UI surface across 5 entity types is large; the agent didn't complete. **Re-spawn with narrower scope (one entity-detail page per agent) or defer.**
---
## Triage + recommended order of operations
After 13 reported audits + 2 inline (A1, F1, B3 sketch), here are the items that should ship before the next deploy, grouped by impact and effort.
### 🚨 Tier S — ship-stopping production bugs (do today)
These are silently broken in production right now. Fix before any further work.
| Source | Item | Effort |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| **D1 #1** | `send-invoice` and `invoice-overdue-notify` BullMQ jobs have no handler → every invoice send is a no-op | 1-2h: add the cases to workers/email.ts and workers/notifications.ts |
| **D1 #2** | 5 maintenance cron jobs (calendar-sync, database-backup, backup-cleanup, session-cleanup, temp-file-cleanup) silently no-op with false-green audit | 2-3h each; **database-backup is the dangerous one** — implement or remove the schedule |
| **D1 #3** | `tenure-expiry-check` cron silently no-ops; service was never written | 2-3h: write the service + handler |
| **C3 #1** | A.7 RTBF gap: `gdpr_exports.storage_key` blobs NOT deleted on client hard-delete (this is a gap in code shipped today) | 30min: extend `client-hard-delete.service.ts` to collect gdprExports.storageKey alongside files |
| **C3 #2** | No RTBF/hard-delete path for `residential_clients` — full PII shadow | 4h: mirror the marina hard-delete service for residentialClients |
| **B1 #1** | `/api/public/interests` does NOT validate caller-supplied `portId` against existing ports — cross-tenant data injection | 30min: copy the residential-inquiries pre-check |
| **A3 #1** | `documents.documenso_id` has NO index — every webhook delivery is a full table scan | 30min: migration adding index |
### 🔴 Tier 1 — high severity, prioritize this week
| Source | Item | Effort |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| **B4 #1** | better-auth rate limiter is in-memory; multi-replica prod multiplies auth limits N× | 2h: switch to `storage: 'database'` after running its migration |
| **C1 #1** | `generateAndSignViaInApp` omits portId on Documenso calls → v2-configured port silently uses v1 env defaults for every in-app EOI | 30min: thread portId through 2 calls |
| **C1 #2** | custom-document-upload calls `placeFields` AFTER `documensoSend` (wrong order) — v2 may reject placement on PENDING envelope | 30min: reorder |
| **C1 #3** | `{{eoi.berthRange}}` + all 5 `{{reservation.*}}` tokens valid but unresolved — render as literal `{{...}}` | 2h: populate from EoiContext.eoiBerthRange + add reservation resolver |
| **C2 #1** | Recommender SQL vs JS stage-scale mismatch — Tier D fires at reservation, not deposit_paid | 30min: change LATE_STAGE_THRESHOLD=6 to match SQL scale |
| **F2 #1-3** | 3 high-impact perf: getClientById serial queries, notification-digest sequential loops, missing index on interests.reminder_enabled | 4h total |
| **F3 #1-2** | client-hard-delete has zero tests; no CI/CD pipeline | 4h: integration tests for the RTBF flow; add `.github/workflows/ci.yml` |
| **A2 #1-3, A1 #1** | Missing DB-level CHECK constraints on every enum-shaped text column | 2h: one consolidated migration |
### 🟠 Tier 2 — medium severity (next sprint)
Covers the bulk of remaining medium findings — too many to expand inline; see per-agent tables above. Highlights: drift between schema and migrations (A3 #4-5, A1 #2-3), idempotency gaps in webhook handlers (B4 #2-3, C1 #7, D1 #4), audit/IP/UA gaps in admin mutations (B2 #7-10), and the camelCase-key over-masking false-positive on `'auth'` fragment (C3 #6).
### 🟡 Tier 3 — low severity (rolling)
Index optimizations, validation tightening, schema metadata gaps, log cleanup. The detailed tables per agent above carry the per-item file:line evidence.
### 📋 Tier 4 — re-spawn or inline-audit
- E1 (admin UI) and E2 (entity UI) agents failed; the surface is too large for a single Sonnet 1M spawn. Re-spawn narrower (one page or one entity per agent), or audit inline in a follow-up.
---
## Total finding counts
| Severity | Count |
| ------------------ | ------- |
| CRITICAL | 5 |
| high | 15 |
| medium | 36 |
| low | 53 |
| info | 19 |
| **Total findings** | **128** |
Across **13 of 16 agent reports** + 3 inline (A1/F1/B3). E1 + E2 are missing; should be re-attempted later.

View File

@@ -0,0 +1,242 @@
# Remaining UAT Master Doc — Work Plan
> **STATUS (2026-05-21 23:55):** Groups AT worked through end-to-end.
> Group U (EOI bundle UX rework) explicitly deferred — see note at the
> bottom. Per-group commits:
>
> - **A** `e33313b` + doc annotations `670ca16`
> - **B** `7ecf4ee` + doc annotations `a0a4a5d`
> - **C** `991e222`
> - **D + E** `431375d`
> - **F + G + H** `94c24a1`
> - **I** `989cc4d`
> - **J + K** `03a7521`
> - **L** `65ff596`
> - **M** `0ddaf46`
> - **N** `a147cbc`
> - **O** `a7cbee0`
> - **P** `0ed03fc`
> - **Q** `c14f80a`
> - **R + T** `aa1f5d2`
> - **U** parked
>
> Each commit message documents what shipped vs. what stayed parked.
> Vitest 1454/1454 and tsc clean across every group.
>
> **Source:** `alpha-uat-master.md` (Bucket 1-4) as of commit `d879188`. Survey done 2026-05-21 after the PDF report exporter ship.
>
> **Status:** scaffold for sequential execution. Each item has a scope summary, file pointers (copied from the source entry where helpful), effort estimate, and explicit ordering notes (blocks-on / pairs-with). Items are grouped so logically-related work lands as one PR rather than scattered.
## How to use this doc
- Items are in **suggested execution order** (top → bottom). Order optimises for (a) unblocking other items, (b) low-cost-high-impact wins first, (c) defer-until-design large features to the end.
- Each item is one of:
- **Q** — quick fix (< 30 min)
- **M** — medium (30 min 2 h)
- **L** — large (2 h+)
- **DEFERRED** — captured but blocked / waiting on external decision
- We work top to bottom. When an item lands, annotate it in `alpha-uat-master.md` with the SHIPPED-in-commit line AND tick it off here.
---
## Group A — Tiny copy / UI fixes — [SHIPPED in e33313b]
All 12 items closed. 7 new ships + 5 verified pre-shipped (annotation gap in master doc).
1. **[SHIPPED — e33313b]** Admin Documenso settings env-fallback pills — collapsed legacy SettingsFormCard blocks into RegistryDrivenForm sections (`documenso.behavior` + `documenso.templates`).
2. **[SHIPPED — e33313b]** WatchersCard empty-state padding — `mb-3``mb-4 pb-1`.
3. **[SHIPPED — 52342ee, verified]** EOI "Mark as signed without file" button — already in place.
4. **[SHIPPED — e33313b]** /invoices/upload-receipts copy rewrite — ~50% body-copy reduction, terse luxury-CRM voice.
5. **[SHIPPED — e33313b]** Pageviews X-axis ticks — `interval="preserveStartEnd"` + `minTickGap={52}`.
6. **[SHIPPED earlier, verified]** Pageviews vs Sessions explainer — Info popover already in `website-analytics-shell.tsx`.
7. **[SHIPPED — e33313b]** Inbox section order — docstring fixed; JSX already had Reminders before Alerts.
8. **[SHIPPED earlier, verified]** BulkAddBerthsWizard CurrencySelect — already wired at apply-to-all + per-row.
9. **[SHIPPED — e33313b]** CommandList scroll-cap — `max-h-[min(300px,var(--radix-popover-content-available-height,300px))]`.
10. **[SHIPPED — e33313b]** DropdownMenu max-h cap — `max-h-[min(24rem,var(--radix-dropdown-menu-content-available-height,24rem))]`.
11. **[SHIPPED — e33313b]** Residential InterestsTab whole-row navigate — `<tr onClick>` + first-cell Link stopPropagation.
12. **[SHIPPED — e33313b]** StageStepper visible stage names — stage-name row below the bar; `size="xs"` hides labels.
---
## Group B — Interest detail polish (~2 h total)
Surfaces all touch `interest-tabs.tsx` / `interest-overview` / linked-berths. Grouping keeps the diff focused on one entity.
13. **[M] Inbox → Reminders: move filter row inline with the "New Reminder" button (embedded mode)** — _src/components/reminders/reminders-list.tsx_. Add an `embedded?: boolean` prop that consolidates the filter row + the New button into one row when set. ~45 min.
14. **[M] Interest Overview Email + Phone rows: combobox picker across client's contacts + quick-add new contact** — _src/components/interests/interest-tabs.tsx_ + _src/components/clients/client-contacts-picker.tsx (new)_. The Email + Phone rows on the Overview currently show only the primary; reps want to pick any of the client's contacts and add new ones inline. ~1 h.
15. **[M] Inline phone editor on the Contact row** — adjacent to #14; add `InlineEditableField variant="phone"` (or similar) using the country-code + national-number split. ~30 min.
16. **[M] Client Overview should summarize current interest's requirements** — one-line "current interest needs L × W × D, source X" on the Client detail Overview tab. ~30 min.
17. **[M] Notes Latest-note teaser missing round / stage context pill** — _src/components/interests/interest-tabs.tsx_ around the latest-note teaser. Pull the stage at the time of the note (from `audit_logs`) and render as a chip next to the timestamp. ~45 min.
18. **[M] InterestBerthStatusBanner: name + link the competing deal** — _src/components/interests/interest-berth-status-banner.tsx_. Today says "this berth is also linked to another interest"; should name the client + link to the interest. ~30 min.
19. **[M] Qualification auto-confirm "intent confirmed" once stage ≥ EOI (extend `computeAutoSatisfied`)** — _src/lib/services/qualification.service.ts_. Add the auto-confirm rule. Most of the work shipped earlier; this is the final tightening. ~30 min.
**Commit shape:** one PR titled `feat(uat-batch): Interest detail polish (Group B — 7 items)`.
---
## Group C — Berth list features (~2.5 h)
20. **[M] Berth list: hide "Rates (USD)" + "Pricing valid" columns by default (or remove)** — _src/components/berths/berth-columns.tsx_ + `BERTH_DEFAULT_HIDDEN`. Short-term rental fields irrelevant to purchase/long-term ports. Update default visibility; do not remove columns (other ports may still use them). ~10 min.
21. **[M] Dimensions columns: add ft↔m toggle in the column header (persisted to user prefs); skip per-row entry-unit indicator** — _src/components/berths/berth-columns.tsx_, _src/components/yachts/yacht-columns.tsx_, _src/components/clients/client-yachts-tab.tsx_, _src/components/companies/company-owned-yachts-tab.tsx_, plus _new_ `src/lib/utils/dimensions.ts` for the conversion + format helper, and _src/lib/db/schema/users.ts_ `user_profiles.preferences` for the persisted preference key. ~1 h.
22. **[M] ft ↔ m unit switching on Berth Requirements** — _src/components/interests/interest-tabs.tsx_ — the three inline-editable dim rows hard-code `(ft)` in the label. The interest already carries `desiredLengthUnit`; honour it. ~30 min.
23. **[L] Berth list: bulk-edit affordance (parity with bulk-add)** — _src/components/berths/_, _src/lib/services/berths.service.ts_, _new endpoint_ `POST /api/v1/berths/bulk`. Backend mirrors `/interests/bulk` shape; UI gets a `DataTable bulkActions` toolbar. ~5-7 h. **Pairs with:** Bucket 3 #2 Bulk-price editing UI — the inline-price-edit + bulk-price-sheet should land alongside this. Combined effort ~7-10 h.
**Commit shape:** two PRs — `feat(berths): dimensions column toggle + hide rental columns` (B-20/21/22), `feat(berths): bulk-edit + bulk-price UI` (B-23 + Bucket 3 #2).
---
## Group D — BulkAddBerthsWizard polish (~1.5 h)
24. **[M] BulkAddBerthsWizard + single-berth editor: toggleable input units (ft/m) for dimension fields** — _src/components/admin/bulk-add-berths-wizard.tsx_ + _src/components/berths/berth-form.tsx_. Tiny segmented toggle above the dimension inputs (ft / m). Convert on submit so the canonical column stays consistent. ~45 min.
25. **[M] BulkAddBerthsWizard: allow defining new dock/pontoon letters in-flow (or surface the admin path)** — _src/components/admin/bulk-add-berths-wizard.tsx_. Currently fixed to A/B/C/D/E. Add "+ New letter" affordance or a clear "manage letters in /admin/vocabularies" link. ~30 min.
**Commit shape:** one PR titled `feat(berth-admin): wizard polish (Group D)`.
---
## Group E — Supplemental-info-request (~1 h)
26. **[M] Supplemental-info-request: distinct Regenerate vs Resend actions + issue history** — _src/components/interests/supplemental-info-request-button.tsx_. Today's UI has a single Generate + Send button; add: Regenerate (new token, invalidates old), Resend (re-email existing token), and a small history list of past issuances + their status. Builds on what `a4e30ea` already shipped (generate vs send split). ~1 h.
**Note:** Supplemental-info-request _separate generate link and send email_ + _link reusable_ already SHIPPED (a4e30ea, b74fc56).
---
## Group F — DocumentsHub + signing flow polish (~3 h)
27. **[M] DocumentsHub: hide breadcrumb on root "All documents" view, move PageHeader up** — _src/components/documents/hub-root-view.tsx_ + the surrounding shell. Conditional render. ~30 min.
28. **[M] Past-milestones strip → expandable history with inline doc preview** — _src/components/interests/interest-tabs.tsx_ around line 863 (past-milestones strip). Convert to accordion; each past milestone expands to show its associated docs + sub-status timeline + inline PDF preview using the existing pdf-viewer primitive. ~3-4 h.
29. **[M] Watchers configurable at document creation time** — _src/components/documents/eoi-generate-dialog.tsx_, _src/components/documents/upload-for-signing-dialog.tsx_, _src/components/interests/external-eoi-upload-dialog.tsx_, _src/components/documents/create-document-wizard.tsx:157_ + service-side defaults. ~1.5 h.
---
## Group G — Admin sections consolidation (~6 h)
30. **[L] Merge `/admin/invitations` into `/admin/users`** — _src/app/(dashboard)/[portSlug]/admin/users/page.tsx_, _src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx_ (to be removed), _src/components/admin/users/_, _src/components/admin/admin-sections-browser.tsx:90-95_. Add a state filter `All | Active | Invited (pending) | Disabled | Archived`. Default to Active. ~3-4 h.
31. **[L] Consolidate every AI-feature admin control onto `/admin/ai`** — _src/app/(dashboard)/[portSlug]/admin/ai/page.tsx_ + per-feature embedded forms. Berth PDF parser AI fallback, AI/OCR pipeline, plus deferred sections (recommender embeddings, contact-log extraction, inquiry parsing). Berth PDF parser AI fallback is the only currently-LLM-using feature without a section — surface its provider override, confidence threshold, per-call budget cap. ~2 h for the present one + UI hooks for the deferred sections.
---
## Group H — Email + branding (~2 h)
32. **[M] Email settings page: add explainer copy clarifying why sales send-from and noreply have separate credentials** — _src/app/(dashboard)/[portSlug]/admin/email/page.tsx_ — small description block. ~15 min.
33. **[L] Supplemental-info-request email: branded HTML styling** — _src/lib/email/templates/_ — rebuild the template to match the table-based, max-width 600, logo + blurred overhead background look. ~1-2 h.
---
## Group I — Residential parity (~10 h, single coordinated PR)
34. **[M] Residential client detail header: match the main ClientDetailHeader layout** — _src/components/residential/residential-client-detail-header.tsx_ + _src/components/clients/client-detail-header.tsx_. Restructure. ~1 h.
35. **[L] Residential interests list: visual + functional parity with the main InterestList** — _src/components/residential/residential-interests-list.tsx_ vs _src/components/interests/interest-list.tsx_. Card / table / kanban view modes, full FilterBar, ColumnPicker, bulk actions, realtime invalidation, kebab actions. ~6-8 h.
36. **[L] Residential inquiry → auto-forward to external partner email(s)** — _src/lib/services/residential.service.ts_ + admin settings UI + new template + BullMQ enqueue. ~2-3 h.
37. **[L] Auto-link residential interests to existing main-client records (same person)** — schema migration + service join + UI surfaces on both sides + backfill script. ~3-4 h.
---
## Group J — Activity feed + EntityActivityFeed (~2 h)
38. **[M] EntityActivityFeed: rewrite per-row rendering to surface _what_ changed** — _src/components/shared/entity-activity-feed.tsx_. Current rows are flat "user X did Y"; rewrite to show the field-level diff (`old → new`) using the existing audit-log diff shape. ~2 h.
39. **[M] Client → Companies tab: add CTA to link or create a company membership** — _src/components/clients/client-companies-tab.tsx_. Empty-state CTA + dialog. ~1 h.
---
## Group K — OnboardingChecklist + nudges (~6-8 h, single big PR)
40. **[L] OnboardingChecklist: auto-check resolver-chain fix + super_admin discoverability** — _src/components/admin/onboarding-checklist.tsx_ + _src/lib/services/port-config.ts_ + new dashboard tile + new topbar banner. Two linked issues:
- **(a)** Replace each `autoCheckSettingKey` with an `autoCheckResolver` function that runs the full resolver chain and returns `true` when the functional config is complete. Belt-and-braces: surface what's resolving from where ("Email: ✓ Using global SMTP" vs "Per-port override").
- **(b)** Topbar banner (slim chip "Setup X% complete · Continue →" dismissible per-session), dashboard rail tile "Continue setup", in-app weekly notification, 🎉 100% celebration. Gate all on `super_admin`.
---
## Group L — UploadForSigningDialog comprehensive rework (~12-16 h, dedicated PR)
41. **[L] UploadForSigningDialog comprehensive rework — 4 linked issues** — Documenso PDF preview rebuild, metadata + draft persistence, dialog width responsive sizing, field-placement UX. Bundles with Documenso v2 follow-ups. Single coordinated PR.
---
## Group M — Universal preview + form-templates (~12-16 h)
42. **[L] Universal in-system preview for every file type** — extend FilePreviewDialog beyond PDF + images. .docx / .xlsx / .pptx via google-doc-viewer iframe or libreoffice headless; .txt / .csv / .md inline; .eml / .msg via mailparser; .zip see-into. ~6-10 h.
43. **[L] Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail** — _src/lib/db/schema/documents.ts:290-309_ (`formTemplates.fields` JSONB) + the New-form-template dialog UI + supplemental-forms.service.ts + new `interest_field_history` table + Interest/Client detail surfaces. ~8-12 h.
---
## Group N — Dashboard upgrades (~10-14 h)
44. **[L] Pipeline Value tile should respect dashboard timeframe** — Dashboard-wide timeframe context (Zustand store or React Query keyed by range); forecast/KPI service variants accept a `range`; "realized vs forecast" line. ~3-4 h.
45. **[L] "Clients by country" dashboard widget** — compact ranked list with mini bars per row, deep-link `/clients?country=DE`. ~2-3 h.
46. **[L] Drag-and-drop rearrangable dashboard widgets** — extend `useDashboardWidgets` to read a `dashboardWidgetOrder` preference; `@dnd-kit/core` + `@dnd-kit/sortable`; persist via PATCH `/api/v1/me/preferences`. ~4-6 h.
---
## Group O — Umami analytics phases 3 / 4 / 5 (~14-18 h)
47. **[L] Umami Phase 4a — Marketing-site instrumentation** — _BLOCKS Phase 3 + Phase 5._ Wire `umami.track()` calls into the marketing site for every CRM event we want to surface (inquiry submitted, brochure download, contact-form, etc.). ~3-4 h on the marketing-site repo + alignment with this repo.
48. **[L] Umami Phase 4c UI — Tracked-link composer button** — _src/components/email/email-composer.tsx_ or wherever the rep writes a templated email; add a button that opens a tracked-link composer + injects the resulting URL. ~2-3 h.
49. **[L] Umami Phase 3 — Events tab** — _src/components/website-analytics/events-list.tsx (new)_. Blocked on 4a. ~3-4 h.
50. **[L] Umami Phase 5 — Funnels + Journeys** — Funnel builder + journey-flow sankey. Blocked on 4a. ~6-8 h.
51. **[M] Umami: Empty-state nudges on quiet ranges** — _src/components/website-analytics/_. Stable copy when the range has < N events ("Nothing happened here; try a wider range"). ~30 min.
52. **[M] Umami: Apple Mail privacy disclaimer copy** — _src/components/email/email-open-rate-pill.tsx_ — small tooltip explaining that Apple Mail Privacy Protection inflates open rates. ~15 min.
53. **[M] Umami: Open-rate column on the document_sends list** — _src/components/documents/document-sends-list.tsx_. New column reading the per-send open count. ~30 min.
54. **[M] Umami: Click-to-filter the page from the world map** — _src/components/website-analytics/visitor-world-map.tsx_. Wire `onCountryClick(iso2)` into a new country filter store + thread through every `useUmami*` hook. ~2-3 h.
55. **[M] Umami: Verify pixel + tracked-link end-to-end with a real send** — manual UAT. ~15 min once 4a is live.
---
## Group P — Nested document subfolders — phases 2/3 (~5-6 h)
56. **[L] Nested document subfolders — phases 2 and 3** — foundation shipped in `e91055f`. Remaining:
- **(a)** UploadZone gains `scopeOptions` radio: "This deal (Interest <name>)" vs "Client-level (all deals)". Single-scope contexts (client/yacht/company) hide the radio.
- **(b)** Lifecycle hooks: interest outcome → folder rename (`Deal A1-A3 (Won)`); soft-rescue on outcome change.
- **(c)** `listFilesAggregatedByEntity` rewrite — surface BOTH "This deal" subheading + "From client" subheading on the InterestDocumentsTab "Attachments" list.
- **(d)** Documents Hub tree rendering for nested interest folders + outcome chip per interest folder.
- **(e)** Backfill script `pnpm tsx scripts/backfill-nested-document-folders.ts --apply` — idempotent, per-port advisory-locked.
---
## Group Q — Platform-wide refactors (~14-18 h, do as coordinated passes when time allows)
57. **[L] Platform-wide chart library migration: recharts → ECharts** — port the 8 existing recharts components to ECharts. ~6-10 h.
58. **[L] SelectTrigger height (`h-9`) doesn't match Input height (`h-11`)** — _src/components/ui/select.tsx_. Introduce `size` variant; default to `h-11`. Audit compact-context call sites for explicit `size="sm"` override. ~1 h.
59. **[L] Platform-wide table density: column min-widths + nowrap defaults** — _src/components/shared/data-table.tsx_ + per-table column definitions. Add a `widthPx` / `nowrap` field to column defs; default text cells to `whitespace-nowrap`; surface horizontal scroll only when content actually exceeds. ~2-3 h.
60. **[L] Platform-wide admin-settings tooltip audit** — _src/components/admin/_. Sweep every admin setting; add `FieldLabel` + tooltip wherever the setting isn't self-explanatory to a basic admin user. Use the FieldLabel primitive shipped in PR4.2 / `552b966`. ~3-4 h.
61. **[L] Platform-wide error message audit for prod debuggability** — _cross-cutting_. The Documenso 502 / "Invalid token" diagnosis loop showed errors don't self-describe in prod. Two layers: (a) service-side: wrap upstream errors with the resolver chain that's actually in effect; (b) UI: render the wrapped error verbatim in the toast / dialog so operators can see "fell back to env, env value is stale" without reading logs. ~4-6 h.
---
## Group R — Documenso-first templates (~6-8 h)
62. **[L] Documenso-first templates: pull templates from Documenso instead of uploading through CRM** — _src/components/admin/document-templates/template-form.tsx_ + new admin endpoint `GET /api/v1/admin/documenso/templates` + per-template field-mapping editor + "Sync now" button + template-list badges. Generalizes the existing per-port EOI sync. ~5-7 h. **Pairs nicely with:** Group L (UploadForSigningDialog rework) — they share the same Documenso-side surface area.
---
## Group S — AI assistance + extraction (~10-14 h, deferred until user asks)
63. **[DEFERRED] AI-assisted action extraction from contact-log entries** — _src/components/interests/interest-contact-log-tab.tsx_ + new LLM service. "Extract action items" button next to Save; LLM-parses body + returns proposed follow-ups; rep approves each individually. ~6-10 h. Defer until a user is genuinely asking.
---
## Group T — Deferred bugs (~1 h each, do when surfacing)
64. **[DEFERRED] Duplicate row for berth E17 in port-nimara + missing unique index** — DB cleanup + partial unique index `(port_id, mooring_number) WHERE archived_at IS NULL`. Deferred per session call.
65. **[DEFERRED] Stage advance allowed without berth price** — `ValidationError` gate in `changeInterestStage` for stages ≥ eoi. Deferred per session call.
---
## Group U — EOI bundle UX rework (~10-14 h)
66. **[L] EOI bundle UX rework (multi-berth interests)** — _src/lib/services/interest-berths.service.ts_, _src/components/interests/linked-berths-list.tsx_, _src/components/documents/eoi-generate-dialog.tsx_. Per the locked design notes in master line ~492. Refines the "which berths sign the EOI?" UX now that the underlying schema supports it. ~10-14 h. Partially shipped (a) in `05e727f`; remaining is the picker-inside-generate-dialog.
---
## Execution discipline
For each item we tackle:
1. **Quote the master-doc bullet** so we're aligned on scope.
2. **Verify it isn't already shipped** — re-read the master entry for sub-bullets with SHIPPED markers I may have missed.
3. **Implement to production quality** — tests where the feature has logic worth testing; tsc clean; vitest 1454+/1454+; commit with a descriptive message.
4. **Annotate the master doc** — add `**SHIPPED in <sha>:**` line under the original entry.
5. **Tick off this plan** — once a group lands, mark the item as `[SHIPPED]` here.
When in doubt about an item's scope, surface the question first rather than guessing — several items already locked design decisions in the source entry that we should reuse verbatim.

View File

@@ -0,0 +1,829 @@
# Alpha UAT Master — Multi-Day Findings
> **Status:** living doc — _started 2026-05-18, evolving across many sessions_. Single source of truth for everything the manual Playwright + React-Grab UAT pass surfaces, regardless of which day it landed on.
>
> **Companion to:** [2026-05-18 Full Codebase Audit](./2026-05-18-full-codebase-audit.md)
>
> **Methodology:** Live Playwright + React Grab walkthrough of the running CRM (default viewport). Findings dropped into chat are appended here in the matching bucket with file:line evidence where available. Cross-references annotated as `see Audit X#N` (and back-referenced in the audit doc as `→ confirmed in manual #N`).
>
> **Severity legend (for bugs):**
>
> - `critical` — data loss, security breach, multi-tenant leak, or hard block on a core flow
> - `high` — broken golden path, visible-to-customer regression, or silent prod no-op
> - `medium` — UX regression, partial functionality, recoverable error
> - `low` — cosmetic, copy, polish
---
## Bucket 1 — Quick fixes (<15 min)
_Copy tweaks, alignment, single-prop edits, obvious typos._
<!-- Append findings as: `1. **Title** — _path:line_ — description. (see Audit X#N if applicable)` -->
> **Outstanding quick-fixes (rapid UAT capture — not yet shipped):**
>
> - **Rename "Mark in EOI bundle" + add tooltip** — _src/components/interests/linked-berths-list.tsx (or wherever the toggle lives)_ — the toggle controls `interest_berths.is_in_eoi_bundle` (per CLAUDE.md), which decides _which_ of the deal's berths the signed EOI document actually commits to. Today the rep sees a label they can't decode. Rename to something like "Include in EOI" + add an info-tooltip popover explaining "Berths flagged here are covered by the EOI signature. A deal can flag a subset (e.g. 2 of 3 linked berths)." ~10 min. **SHIPPED in db51106:** label renamed to "Include in EOI"; existing tooltip already explained the bundle-vs-signature distinction.
> - **Lower supplemental-info-request link TTL to ~2 weeks** — _src/lib/services/_ (token model) — link currently expires ~1 month out (`Wed, 17 Jun 2026` shown for an email sent May 18 = ~30 days). User wants ~14 days. Single constant change. ~5 min. **SHIPPED in db51106:** `TOKEN_TTL_DAYS` 30 → 14 in supplemental-forms.service.
> - **Admin Documenso settings: surface env-fallback state** — _src/app/(dashboard)/[portSlug]/admin/_ (Documenso settings page) — `getPortDocumensoConfig` already does the right thing (`adminValue ?? env.DOCUMENSO_API_KEY ?? ''`), but the admin UI doesn't show which fields are filled by the admin entry vs. silently falling back to env. Caused an in-session diagnosis loop where the operator had entered creds on Port Amador but was generating EOIs on Port Nimara — Port Nimara's admin row was empty, so it fell back to a stale env key and threw 401. Recommend a small "Using fallback from env" / "Per-port override active" pill next to each Documenso settings field so the operator can see at a glance which scope is in effect. ~30 min. **SHIPPED in e33313b:** collapsed `V2_FEATURE_FIELDS` + `CONTRACT_RESERVATION_FIELDS` (legacy `SettingsFormCard`) into `RegistryDrivenForm` sections (`documenso.behavior` + the existing `documenso.templates`). Every Documenso setting now flows through the registry path that surfaces the env-fallback / port / global source badge per field via `/api/v1/admin/settings/resolved`. EOI generation card retitled to "Templates & signing pathway" since `documenso.templates` covers EOI + reservation + contract template IDs.
> - **InterestDocumentsTab label clarity** — _src/components/interests/interest-documents-tab.tsx_ — the tab has two sections: "Legal documents" (Documenso envelopes — EOI / Reservation / Contract, signature-driven) and "Attachments" (general file uploads). "Legal documents" is misleading — the section is scoped to _signature envelopes_, not any legal doc. A rep uploading externally-signed PDFs (lawyer-prepared addenda, etc.) currently goes into Attachments — fine, but the label gap suggests reps expect "Legal documents" to accept external uploads too. Two paths: (a) rename "Legal documents" → "Signature documents" (or "Contracts & EOI") to scope it correctly, OR (b) allow external uploads into that section (more disruptive — needs file-classification metadata). ~15 min for rename + tooltip; ~2 h for upload route. **SHIPPED (a) in 552b966:** section heading renamed to "Signature documents".
> - **Berth recommender: drop the "Tier X" prefix, keep plain-English label + add tooltip** — _src/components/interests/berth-recommender-panel.tsx:181_ (the pill render) and _:94-99_ (`TIER_LABELS` map) — the pill currently renders `Tier A · Open` / `Tier B · Fall-through` / `Tier C · Active interest` / `Tier D · Late stage`. The four tier letters are internal taxonomy from `berth-recommender.service.ts` (A = never had interest, B = past fall-through, C = active interest, D = active in late stage); reps don't speak in tier letters and the suffix label already carries the meaning. Fix: (1) drop the `Tier {rec.tier} · ` prefix in the rendered pill — show just `tier.label` (e.g. "Open" / "Fall-through" / "Active interest" / "Late stage") so the chip is self-explanatory. (2) Wrap the pill in a `Popover` (click) or `Tooltip` (hover) that explains the four-state ladder in plain English: "Recommender state — **Open**: never had interest. **Fall-through**: prior interest didn't close (warm). **Active interest**: another deal is in play. **Late stage**: another deal is near-sold." (3) Optional: a small `?` icon next to the chip so the tooltip is discoverable without hovering. The internal `Tier` type stays as-is in the service (it has semantic value in the SQL ladder + admin settings); only the UI label changes. ~15 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543:** pill is now a Popover trigger with the plain-English label + HelpCircle icon; popover content explains the 4-state ladder.
> - **ChartCard: center the chart vertically when grid row is taller than the chart** — _src/components/dashboard/chart-card.tsx_ — every chart widget (`pipeline-funnel`, `occupancy-timeline`, `lead-source`, `berth-status`, `source-conversion`, …) wraps a fixed-height `ResponsiveContainer` (240-280px) inside `ChartCard`. The Card is `h-full` (stretches to its grid-row height) but the inner content keeps its 240-280px and pins to the top — when a neighbour card in the same row is taller (e.g. Pipeline Value with its full per-stage breakdown), the chart card has visible empty space below the chart. Fix: convert `ChartCard` to a flex-column (`<Card className="h-full flex flex-col">`); `CardHeader` keeps natural height; `CardContent` gets `flex-1 flex items-center` so the chart's wrapping div sits vertically centered in the remaining space. ResponsiveContainer stays at its declared fixed height. Affects all chart widgets via one wrapper change — no per-chart edits. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543.**
> - **UploadForSigningDialog feels cramped — fix inner content distribution + right-size the dialog** — _src/components/documents/upload-for-signing-dialog.tsx:166_ (currently `max-w-5xl` = 1024px) + the recipient-row + form fields inside DialogBody. Visual symptom: dialog renders at full 5xl width but inner content clusters on the left ~60% with truncated email field (`email@examp...` clipped), narrow Document title input, tiny 4-row Optional message textarea, and massive whitespace to the right. Combination makes the dialog feel narrow AND empty.
> - **Fix:**
> - **(a) Right-size the dialog:** drop to `max-w-3xl` (768px) — content fills naturally instead of swimming in 5xl.
> - **(b) Recipient row flex distribution:** `Name` input → `flex-1`, `email` input → `flex-[2]` (~2x name's width — emails are longer), role select → `w-32 shrink-0`, delete icon → `shrink-0`. Today every field is at its intrinsic width with no flex hint, so the row doesn't fill horizontal space.
> - **(c) Document title + Optional message inputs:** make sure they have `w-full` on the wrapper so they span the dialog's content width.
> - **(d) Optional message textarea:** bump rows from 4 → 6 minimum (`rows={6}` or `min-h-[8rem]`) so reps writing real messages have room.
> - **(e) Audit the other steps of the wizard** (select-file, place-fields) for the same content-distribution issues since they share DialogBody.
> - **Effort:** ~20-30 min. Captured 2026-05-21 from UAT. **Pairs nicely with:** the platform-wide form-error UX work (Bucket 2) — both touch how form content is presented in dialogs.
> - **SHIPPED (width + recipient row + textarea) in 203f543:** dialog widened to `max-w-[1400px] w-[95vw]` so the place-fields step gets the room it needs; recipient row swapped from `grid-cols-12` to a flex layout (Name `flex-1`, Email `flex-[2]`, Role `w-40 shrink-0`, delete `shrink-0`); invitation-message textarea bumped from 3 → 6 rows. Step-adaptive sizing skipped — the new wider dialog works for all three steps without per-step gymnastics.
> - **ColumnPicker: add "Hide all columns" symmetric to "Show all columns"** — _src/components/shared/column-picker.tsx:58-60 (`showAll()`) + 116-123 (button render)_ — current picker has a "Show all columns" footer item that clears the hidden set. Add a parallel `hideAll()` that sets `hidden = columns.filter(c => !c.alwaysVisible).map(c => c.id)` — hides every toggleable column while preserving `alwaysVisible` ones. Render a "Hide all columns" footer item next to "Show all columns" with the same visibility gate (only shown when ≥1 toggleable column is currently visible, mirroring the `canShowAll` logic). Since column-picker is shared across every DataTable surface (berths, clients, interests, yachts, companies, reservations, invoices, audit-log, expenses), the fix lands platform-wide automatically. ~5 min. Captured 2026-05-21 from UAT. **SHIPPED in 8f42940:** `hideAll()` + symmetric `canHideAll` gate added; both items render under the same separator.
> - **OnboardingChecklist: auto-check uses raw setting-row presence, not resolver chain → ports using env fallback or global config never auto-tick + super_admin discoverability gap** — _src/components/admin/onboarding-checklist.tsx:32-105 (STEPS def)_ + _src/lib/services/port-config.ts_ (the resolver chain like `getPortDocumensoConfig`) + new dashboard tile + new topbar banner for the discoverability half. Two linked bugs surfaced UAT 2026-05-21.
> - **(a) [bug] Auto-check sentinels are too strict.** Examples:
> - Email step (line 46) checks `smtp_host_override` — only fires when port has its own override row. Ports using global SMTP (the common case) never auto-tick even though email works.
> - Documenso step (lines 58-63) requires ALL of 4 port-level overrides. Per CLAUDE.md, Documenso supports env fallback (`getPortDocumensoConfig` does `adminValue ?? env.DOCUMENSO_API_KEY`), so a working port using env config registers as not-onboarded forever.
> - Same pattern likely for storage, settings, etc. — any setting with a resolver chain falls into this trap.
> - **Fix:** replace each `autoCheckSettingKey` with an `autoCheckResolver` function (named import from `src/lib/services/port-config.ts` etc.) that runs the full resolver chain and returns `true` when the functional config is complete. New OnboardingStep shape: `{ id, label, description, href, autoCheckResolver?: (portId) => Promise<boolean> }`. Sentinels stay for steps where direct setting-row presence IS the truth (e.g. branding logo URL).
> - Belt-and-braces: surface what's resolving from where directly in the step row (e.g. "Email: ✓ Using global SMTP" vs "Email: ✓ Per-port override"). Closes the "why is this checked?" gap for admins later.
> - **(b) [feature] Super_admin discoverability — nudge until onboarding hits 100%.** Today the checklist only appears on the one admin onboarding page; a super_admin who skips that page never sees it. Multi-surface nudges:
> - **Topbar banner** when onboarding < 100% — slim chip showing "Setup X% complete · Continue →" (links back to /admin/onboarding). Dismissible per-session (returns next login). Only visible to super_admin.
> - **Dashboard rail tile** "Continue setup" — small card on the dashboard widget rail showing the next incomplete step + a button. Disappears entirely at 100%.
> - **In-app notification (existing notification infra)** — fires once per week per super_admin until 100%, with a deep-link back to the checklist. "Your setup is X% complete — N items remaining."
> - **Onboarding-complete celebration** — small toast + a one-time 🎉 highlight when the 100th item ticks. Acknowledges the finish-line so the nudges going silent feels intentional, not just a bug.
> - **Permission gating:** all surfaces gate on `super_admin` (or whatever role the onboarding page itself is gated on) so non-super-admins don't see noise about settings they can't change.
> - **Effort:** ~3-4h for (a) (resolver-chain audit + 6-8 step migrations + tests) + ~3-4h for (b) (topbar banner + dashboard tile + notification job + celebration). Total ~6-8h. Captured 2026-05-21 from UAT.
> - **Agent audit (a11y + i18n) — 2026-05-21 — 27 findings bundled** — read-only Opus-agent pass over login/dashboard/interest-detail/client-detail/berth-detail/public-form/portal/admin surfaces. Ship as themed sub-PRs, not one mega-PR.
> - **a11y — discrete fixes (~3-4h total):**
> - Add `aria-label="Row actions for {name}"` on icon-only kebab triggers — _interest-columns.tsx:296_, _client-columns.tsx:301_, _berth-columns.tsx:175_. ~10min.
> - Add `aria-label` + `aria-pressed` on Table/Board view toggle — _interest-list.tsx:187-202_. ~5min.
> - Add `aria-expanded` + `aria-controls` on the "Show/Hide upcoming milestones" disclosure — _interest-tabs.tsx:484-494_. ~5min.
> - Same for recommender "Hide/Add filters" — _berth-recommender-panel.tsx:466-471_. ~3min.
> - Fix BrandedAuthShell logo `alt` default (`'Sign in'` shows on every page) — use `alt=""` when no port name OR pass per-page override — _branded-auth-shell.tsx:32,58_. ~10min.
> - Mark PDF logo crop image decorative (`alt=""`) — _pdf-logo-uploader.tsx:312-318_. ~3min.
> - Add `scope="col"` on raw `<th>` cells (or migrate to shadcn `<TableHead>`) — _berth-interests-tab.tsx:149-154_, _bulk-hard-delete-dialog.tsx:185-186_, _bulk-add-berths-wizard.tsx:226-231_. ~10min. **SHIPPED in 72d7803.**
> - Wrap "Loading…" auth fallbacks in `role="status" aria-live="polite"` — _set-password/page.tsx:107_, _portal/activate/page.tsx:9-11_, _supplemental-info/[token]/page.tsx:140-147_. ~10min. **SHIPPED in 05e727f:** all three sites wrapped; supplemental-info also gains sr-only "Loading" copy since only a spinner was visible.
> - Add `aria-live` region on supplemental-info async state swaps — _supplemental-info/[token]/page.tsx:150-186_. ~10min.
> - Add `<Label>` (or `aria-label`) on recommender filter selects — _berth-recommender-panel.tsx:306, 325, 343_. ~10min.
> - Make `<legend>` styling visually distinct in supplemental-info — _supplemental-info/[token]/page.tsx:200, 249_. ~5min. **SHIPPED in 72d7803.**
> - Link set-password hint via `aria-describedby` — _set-password/page.tsx:147_. ~3min. **SHIPPED in 05e727f:** password input now `aria-describedby="password-hint"` linked to the requirements `<p>`.
> - **a11y — contrast/visual issues (Bucket 4 candidates):**
> - `text-[#007bff]` 12px link below AA contrast on auth pages — darken to `#0058b3` or always-underline — _login/set-password/reset-password pages_. ~5min. **Severity: medium** (WCAG 1.4.1 violation). **SHIPPED in ae8867d:** darkened to `#0058b3` AND always-underlined (belt + braces). Button backgrounds left at `#007bff` since white-text-on-blue at button sizes passes AA.
> - `text-muted-foreground/{40-70}` opacity stacking puts text below AA on muted bg — _interest-detail-header.tsx:493_, _client-detail-header.tsx:173,184_, _contacts-editor.tsx:280,292_, _client-interests-tab.tsx:160_, _berth-interest-pulse.tsx:165_, _invoice-card.tsx:149_. Audit + replace with semantic tokens. ~1h. **Platform pattern.**
> - `text-[10px]` / `text-[11px]` micro-type on stage chips, pipeline counts, badges across 20+ surfaces — bump to 12px min — _client-pipeline-summary, client-card, dedup-suggestion-panel, contacts-editor, bulk-hard-delete-dialog, berth-interest-pulse, kpi-tile_. ~1h. **Platform pattern.**
> - **i18n — discrete fixes (~1.5h total):**
> - Fix invalid locale tag `'en-EU'` → use `undefined` (honour user) or proper BCP-47 — _payments-section.tsx:66_. ~3min. **SHIPPED in 72d7803.**
> - Calendar month dropdown passes `'default'` instead of resolved locale — _ui/calendar.tsx:35_. ~5min. **SHIPPED in 72d7803.**
> - Date formatting hardcoded `en-GB`/`en-US` across 10+ document/template surfaces — centralize via `formatDate()` helper honouring `useLocale()` — _documents-hub.tsx:373_, _document-list.tsx:83_, _document-detail.tsx:271_, _signing-details-dialog.tsx:81,103_, _entity-folder-view.tsx:81_, _template-list.tsx:132,224_, _reservation-detail.tsx:285_. ~1h.
> - Currency formatter hardcoded `'en-US'` on all invoice/expense totals — same fix pattern — _invoice-columns.tsx:81_, _invoice-detail.tsx:232_, _expense-columns.tsx:87,103_, _expense-detail.tsx:191,200_. ~30min.
> - `currency.ts` hardcodes English currency labels — delete, let Intl resolve — _src/lib/utils/currency.ts:11-29_. ~30min.
> - **i18n — platform decisions (Bucket 3 candidates):**
> - `next-intl` is wired but NEVER used — zero `useTranslations()` calls in src/. Decision: commit to i18n migration OR rip out the dead infrastructure. Holding both is tech-debt. ~scope depends on commitment.
> - Naive ternary pluralization (`count === 1 ? 'X' : 'Xs'`) across 15+ surfaces — won't translate to Polish/Arabic/Russian. Route through `Intl.PluralRules` / next-intl's `t.rich`. ~1h after i18n decision lands.
> - **Zero use of CSS logical properties — 1,173 instances of `ml-/mr-/pl-/pr-/text-left/text-right` and zero `ms-/me-/ps-/pe-/text-start/text-end`.** RTL support would require global refactor. If RTL is roadmap-bound: adopt logical properties going forward + add lint rule. ~30min for the lint guard; multi-day if RTL is real. **Note only for now.**
> - **Platform patterns (Bucket 3):**
> - **Form validation never sets `aria-invalid` / `role="alert"` / `aria-live`** across every react-hook-form caller. SR users get zero feedback on validation failure. Build a shared `<FieldError>` component emitting both visible text + ARIA. Sweep all forms. ~2h. **Bundles with the Bucket 2 form-error UX finding** — same surfaces, same primitive.
> - **Icon-only buttons inconsistent — ~50% have `aria-label`, rest have nothing or only `sr-only` text.** Add `jsx-a11y/control-has-associated-label` lint rule + sweep. ~1h.
> - **Sweep: remove em-dashes from all user-facing copy (toast messages, button labels, helper text, banners, dialog descriptions, empty states)** — em-dashes (`—`) feel AI-generated and add visual noise; user reads them as "Claude wrote this." Replace with periods, commas, colons, or simple hyphens depending on context. **Scope:** _src/components_ (every UI string), _src/lib/email/templates_ (email body copy), _src/lib/templates_ (merge-field labels + EOI body), _src/app_ (page-level copy), public form copy, error messages from `src/lib/errors`. **Out of scope (keep em-dashes):** code comments, JSDoc, audit-log entries, structured logging, this UAT findings doc itself (internal docs are fine). **Method:** grep `—` across `src/`, manually triage each match (some are inside JSX, some inside string literals); replace per context. Heuristic: if a user could see the character, replace it. **Effort:** ~2-3h depending on hit count (rough estimate 200-400 instances). Captured 2026-05-21 from UAT. **Going forward:** add an ESLint rule banning `—` in JSX text + string literals inside `src/components` so new code doesn't reintroduce them.
> - **SHIPPED (lint guard only) in 52342ee:** `no-restricted-syntax` rule on `JSXText[value=/—/]` scoped to `src/components` + `src/app`, set to `warn`. 111 existing instances flagged as warnings — sweep remains parked.
> - **SHIPPED (full sweep) in f0dbefc:** 176 em-dashes replaced with " - " across 49 files in `src/components` + `src/app`, skipping pure-comment lines (// /\* \* \*/). Two `&mdash;` HTML entity cases (system-monitoring-dashboard + interest-stage-picker) caught separately. Lint rule bumped from `warn` → `error` so new code reintroducing em-dashes in JSX text fails the gate. Templates / audit-logs / structured logging stayed untouched per scope.
> - **Custom-field form: "Sort Order" needs an explainer tooltip — example of a broader gap** — _src/components/admin/custom-fields/custom-field-form.tsx:298-308_ — surfaces a specific instance of a platform-wide gap: see the next finding for the full sweep. **SHIPPED in 552b966:** Sort Order now uses the FieldLabel primitive (PR4.2) with explainer tooltip. First adoption of the primitive; platform-wide sweep remains parked.
> - **DocumentList DocRow kebab: add "Download" action** — _src/components/documents/document-list.tsx:86-109_ — current kebab has Send-for-Signing (draft only), Move-to-folder, Delete. No Download. Reps reviewing a signed doc from the interest's documents tab have to navigate into the document detail to download. Add a `<DropdownMenuItem>` at the top of the menu when `doc.signedFileId` is set (or `doc.fileId` for non-Documenso docs like manual uploads), wired to the same `apiFetch('/api/v1/files/[id]/download')` + anchor-click pattern used elsewhere. Permission-gate by `files.download` if that perm exists. ~10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee:** DocRow now renders Download at the top of the kebab when `signedFileId` is set; wired via the existing `triggerUrlDownload` helper from PR1.
> - **InterestEoiTab "Open" link too ambiguous — relabel to "Open in Documents"** — _src/components/interests/interest-eoi-tab.tsx:163_ — the link in the EOI history list goes to `/${portSlug}/documents/${d.id}` (Documents Hub doc detail) but the label just says "Open" + an external-link icon. Rep can't tell where it goes until they hover. Change to `Open in Documents` (or `View in Documents`). Apply the same idiom anywhere else a cross-section navigation link uses bare "Open" — quick grep + sweep. ~5 min. Captured 2026-05-21 from UAT. **SHIPPED in c6dcf49.**
> - **PaymentsSection: deprioritize layout — move below milestones + collapse-by-default at Reservation** — _src/components/interests/interest-tabs.tsx:633 + 846-852_ (current `showPaymentsSection = reservationStageReached` + renders at line 847, ABOVE the milestone strip at line 859+) + _src/components/interests/payments-section.tsx_ (the section component itself). Today: hidden pre-Reservation (correct ✓), shows as a full expanded card at Reservation+ sitting above the milestone strip. Section is reference/history once expected — milestone work (active step) should be the rep's primary visual focus, not deposits-tracking.
> - **Fix (three states):**
> - **Pre-Reservation:** keep hidden (no change).
> - **Reservation+ stage, no deposits recorded yet:** render as a slim collapsed bar at the bottom of OverviewTab (below the milestone strip + below the Berth requirements / Tags / Latest note grid). Bar shows `Deposits · Not received yet` + a `Track deposit →` CTA that expands the section in place. Sits last on the page so it doesn't pull eye away from the active milestone.
> - **Reservation+ stage, deposits exist:** same below-the-milestones placement, but the collapsed bar carries a summary chip: `Deposits · $10,000 received · 2 payments · Expand`. Click expands the full PaymentsSection inline. The summary chip uses the existing currency-format helper.
> - **Render order change in interest-tabs.tsx:** lift the PaymentsSection mount from its current position (line 846-852, above milestones) to AFTER the milestone strip + AFTER the OverviewTab grid (below "Latest note", Tags, Berth requirements). It becomes the last visual element on the OverviewTab.
> - **Collapse state:** persist per-interest via Zustand or react-query cache (so re-opening the same deal remembers the rep's last expand/collapse). Default collapsed unless a deposit was added in this session.
> - **Effort:** ~1-1.5h (layout reorder + collapsed-bar state + summary chip + render-order verification). Captured 2026-05-21 from UAT. **SHIPPED (layout reorder) in f39f0aa:** PaymentsSection moved below milestones (was above). Collapsed-bar + summary-chip refinement parked.
> - **WatchersCard empty state missing bottom padding** — _src/components/documents/document-detail.tsx:546_ — `<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>` has no margin while the sibling populated `<ul>` at line 548 has `mb-3 space-y-1`. Empty state text sits flush against the add-watcher form below. Add `mb-3` to the empty-state `<p>` to match. ~30s. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.** Further bumped to `mb-4 pb-1` in **e33313b** after a follow-up UAT noted the lines still read tight.
> - **DocumentDetail Interest link should show berth(s), not duplicate the client name** — _src/components/documents/document-detail.tsx:96 (type) + 237-241 (linked-entity row builder)_ + the document-detail API service that hydrates `linked.interest`. Today renders `Client: Matthew Ciaccio · Interest: Matthew Ciaccio` — visually redundant, and the Interest link carries no distinct information. Should be `Client: Matthew Ciaccio · Interest: A1-A3, B5-B7` (berth range via the existing `formatBerthRange()` helper from `src/lib/templates/berth-range.ts`, same idiom as the locked folder-naming convention and the external-EOI default title).
> - **Backend:** swap the response payload's `interest: { id, clientName }` → `interest: { id, berthLabel }` where `berthLabel` is derived in the service layer from the interest's primary or in-bundle berths. Falls back to "No berths linked" when no berths are attached.
> - **Frontend:** change line 241 from `sub: linked.interest.clientName` → `sub: linked.interest.berthLabel ?? 'No berths linked'`.
> - **Effort:** ~15-20 min including type updates + a vitest covering the multi-berth + no-berths paths. Captured 2026-05-21 from UAT. Cross-ref: pairs with the shared title-derivation helper note in the external-EOI bundle (Bucket 2) — single `deriveBerthLabel(interest)` helper used everywhere.
> - **SHIPPED in c6dcf49:** documents.service derives `berthLabel` from `interest_berths` (in-EOI-bundle subset → primary → all linked), `DocumentDetailLinkedEntities` shape gains `berthLabel`, frontend renders `linked.interest.berthLabel ?? clientName ?? 'No berths linked'`.
> - **Platform-wide `<FileInputButton>` primitive — replace 7 raw `<Input type="file">` instances with native browser-default styling** — _new_ `src/components/ui/file-input-button.tsx` + sweep — `<input type="file">` rendered without a wrapper shows the browser-default "Choose File / No file chosen" UI, which looks raw and inconsistent across Chromium / Safari / Firefox / Comet. We already use the correct idiom in `expense-form-dialog.tsx:389` (Button + hidden input + filename row) and `file-upload-zone.tsx`, but 7 other call sites still use the raw pattern.
> - **Affected files:** `external-eoi-upload-dialog.tsx:92`, `template-editor.tsx:486 + 526`, `brochures-admin-panel.tsx:213`, `berth-documents-tab.tsx:176`, `won-status-panel.tsx:200`, `pdf-logo-uploader.tsx:278`, `settings-form-card.tsx:486`.
> - **Component shape:** `<FileInputButton accept={...} multiple={...} onFilesPicked={(files) => ...} label="Upload PDF" icon={<Upload />} variant="outline" size="sm" />`. Renders a styled Button (Upload icon + label) + hidden `<input type="file">` underneath. Optional: after-pick filename row with X to clear, mirroring the expense form's pattern.
> - **Sweep:** drop-in replacement at each of the 7 sites. Pair with the platform-wide file-preview work (Bucket 3) so picker-then-preview becomes consistent everywhere.
> - **Effort:** ~10 min for the primitive; ~30-45 min for the 7-site sweep. Total ~1h. Captured 2026-05-21 from UAT.
> - **SHIPPED (primitive) in 8f42940:** `src/components/ui/file-input-button.tsx` lands with the shape the queue asked for + an optional `showSelectedFilename` mode. external-eoi-upload-dialog migrated. The 5 other queued sites were re-audited — they already use the hidden-input + Button-trigger pattern (no browser-default UI visible), so no migration was needed; the primitive is in place for any new caller.
> - **EOI empty state: add "Mark as signed without file" button (parity with Reservation + Contract tabs)** — _src/components/interests/interest-eoi-tab.tsx:553-562_ (`EmptyEoiState` only renders Generate + Upload paper-signed) — `MarkExternallySignedDialog` already supports `docType: 'eoi'` (mark-externally-signed-dialog.tsx:37-41) with full copy ("Flips the EOI sub-status to 'signed' without uploading a file…"); the reservation tab uses the same dialog via a third ghost-button row (interest-reservation-tab.tsx:378-380). EOI tab's empty state just never grew the button. Add it as a third ghost-variant Button, wired to a `setMarkExternalOpen(true)` state hook + the existing dialog. ~5-10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.**
> - **Activity feed: "See all" link to the full audit log** — _src/components/dashboard/activity-feed.tsx_ (ActivityFeedInner, around line 175) — the card lists the most recent audit events but has no jump-off to the full audit-log page. Add a "See all" link in the card header (or as a trailing row underneath the list). Confirm the target route (likely `/{portSlug}/admin/audit-log`) and permission-gate the link by the same `audit_log.view` perm the admin sidebar uses, so non-admin reps see the card but not the link. ~10 min. **SHIPPED in 203f543:** link points at `/<port>/admin/audit` and is gated by `admin.view_audit_log`.
1. **Dev-mode banner dismissible**_src/components/shared/dev-mode-banner.tsx:23_ — added X close button + localStorage persistence keyed by redirect address. Fixed in this session.
2. **KPI tile top padding collapsing at ≥640px**_src/components/dashboard/{pipeline-value,active-deals}-tile.tsx_ — shadcn `CardContent` default `sm:pt-0` (assumes a `CardHeader` above) was overriding the tile's `pt-5`. Added `sm:pt-5 sm:pb-5`. Fixed in this session.
3. **Client create form: Source defaults to "Manual"**_src/components/clients/client-form.tsx_ — Source select rendered with no default in create mode, so reps had to remember to pick "Manual" every time. Now defaults to `'manual'` unless `prefill.source` is set (inquiry-inbox flow overrides to `'website'`). Fixed in this session.
4. **Client create form: primary address fields**_src/components/clients/client-form.tsx_ — drawer previously had no address inputs, so reps had to create the client then click into the Addresses tab. Added a collapsible "Primary Address" section (street, city, postal, country, region/state) shown only in create mode; on submit, after the client POST returns the new id the form chains a `POST /api/v1/clients/{id}/addresses` with `isPrimary: true`. Address errors don't unwind the client create — a toast directs the rep to the Addresses tab. Edit mode keeps using the AddressesEditor in the detail tab. Fixed in this session.
5. **SupplementalInfoRequestButton card top padding**_src/components/interests/supplemental-info-request-button.tsx_ — same shadcn `sm:pt-0` default-overriding bug as the KPI tiles. Replaced `p-4` with `p-4 pt-4 sm:p-6 sm:pt-6` so the header has symmetric padding on both base and `sm:` breakpoints. Fixed in this session.
6. **Qualification checklist shows evidence behind auto-ticks**_src/lib/services/qualification.service.ts_, _src/components/interests/qualification-checklist.tsx_ — the "Dimensions confirmed" row was auto-ticking based on `desiredLengthFt/widthFt/draftFt` (or a linked yacht's dims) but never showed the rep WHAT data drove the tick, so it felt mysterious. Added an `evidence: string` field to the qualification API row + a new `computeEvidence()` helper mirroring `computeAutoSatisfied()`; UI renders `"Yacht: L × W × D ft"` or `"Desired: L × W × D ft"` in emerald under the row description when auto-satisfied. Closes the "why is this checked?" UAT finding. Fixed in this session.
7. **Recommendations tab renamed to "Berth Recommendations"**_src/components/interests/interest-tabs.tsx_ — "Recommendations" was ambiguous once a berth was already linked (am I looking for replacements? more for the bundle?). "Berth Recommendations" reads the same regardless of state — no conditional rename needed. Fixed in this session.
8. **Berth requirements editable on Interest Overview**_src/components/interests/interest-tabs.tsx_ — added a new "Berth requirements" section to the OverviewTab grid showing desired length / width / draft as inline-editable rows (text variant of `InlineEditableField`); expanded `InterestPatchField` to include the three dim keys. Reps can now capture / correct dims without leaving Overview, and the qualification checklist's evidence string updates in lockstep. Fixed in this session.
9. **Reminder form: preset date chips**_src/components/reminders/reminder-form.tsx_ — Due Date input was a bare `<input type="datetime-local">`; reps had to manually pick a date/time for the 80% common cases. Added a row of quick-pick chips above the input (`In 1 hour`, `In 4 hours`, `Tomorrow`, `In 3 days`, `Next week`, `In 2 weeks`) — same idiom as the existing `snooze-dialog.tsx` presets. Day-based presets honour the user's `digestTimeOfDay` preference for hour-of-day. Fixed in this session.
10. **Consolidate "Next step" guidance into milestone card**_src/components/interests/interest-tabs.tsx_, _src/components/interests/stage-guidance-card.tsx_ — the separate `StageGuidanceCard` and the active `MilestoneSection` had overlapping intent (both said "do X next") and the guidance card's action buttons were silently never rendered (callbacks were never wired). Removed the StageGuidanceCard mount from OverviewTab; made the milestone card's existing `Next` pill more prominent — brand-600 background, white text, "NEXT STEP" copy with a leading dot. The milestone card already owns the workflow actions (Generate EOI, etc.), so the consolidation eliminates the dual surface. Nurturing keeps a slim inline helper ("Deal is on nurture — schedule a follow-up reminder or log a contact…") since no milestone is naturally "current" while a deal is paused. `stage-guidance-card.tsx` left in the tree for potential future use but no longer mounted. Fixed in this session.
11. **Interest create form: Source defaults to 'manual'**_src/components/interests/interest-form.tsx_ — same gap as the client form (#3). Added `source: 'manual'` to the form's RHF `defaultValues` so the Select renders with "Manual" selected on create. Inquiry / website conversion flows can later override via prefill when that path lands. Fixed in this session.
12. **Qualification checklist: highlight open items**_src/components/interests/qualification-checklist.tsx_ — confirmed and unconfirmed rows rendered with near-identical styling, making it hard for reps to scan what's outstanding. Confirmed rows now sit in muted-foreground (still readable but de-emphasized); unconfirmed rows get a subtle amber left-border accent + `bg-warning-bg/40` tint so the rep's eye jumps to what still needs attention. Auto-satisfied rows follow confirmed styling (functionally complete). Fixed in this session.
13. **BerthRecommenderPanel: collapsible on Overview when a berth is linked**_src/components/interests/berth-recommender-panel.tsx_, _src/components/interests/interest-tabs.tsx_ — added a `linkedBerthCount` prop; when ≥ 1 the panel mounts collapsed (header-only with a "Show recommendations" toggle button), so the LinkedBerthsList card dominates the rep's attention once a berth is picked. Network call is gated on `!collapsed && hasDimensions` so the recommender doesn't fetch options the rep won't see. The dedicated Recommendations tab keeps `linkedBerthCount` unset → always expanded (the rep navigated there explicitly). Fixed in this session.
14. **Pipeline Value tile moved from rail → chart grid**_src/components/dashboard/widget-registry.tsx:130_ — the tile shipped in the narrow rail column but its per-stage breakdown + headline numbers + info popover needed more horizontal room to read, and the rail's reserved for reminders/alerts/glance tiles. Changed `group: 'rail'``'chart'` so it sits alongside the funnel/timeline/lead-source tiles. Fixed in this session.
15. **Umami v3.x integration fixed end-to-end**_src/lib/services/umami.service.ts_, _src/app/api/v1/website-analytics/route.ts_, _src/components/website-analytics/use-website-analytics.ts_, _src/components/website-analytics/website-analytics-shell.tsx_, _src/components/website-analytics/pageviews-chart.tsx_, _src/components/dashboard/website-glance-tile.tsx_, _src/components/dashboard/widget-registry.tsx_, _src/components/ui/kpi-tile.tsx_ — entire Umami integration was built against the v1 nested response shape; v2 + v3 use a flat shape with a sibling `comparison` block. Every consumer was reading `.pageviews.value` → undefined → falling back to `0`. Probed the live instance with the configured port creds and verified the real shape, then rewrote types + readers + the dashboard tile end-to-end:
- **`UmamiStats` type** flipped from nested `{pageviews: {value, prev}, ...}` to flat `{pageviews: number, ..., comparison?: {pageviews: number, ...}}` matching Umami v3.1.0.
- **`UmamiMetricType` enum** dropped `'url'` (returns 400 on v3) and added `'path'`; route accepts `top-url` as a back-compat alias mapping to `path` server-side.
- **`UmamiPageviewsSeries.sessions`** marked optional — Umami v3 only returns it when the request includes a `compare` directive (we don't).
- **`WebsiteGlanceTile`** now accepts a `range` prop (was hardcoded `'today'`); widget registry passes the dashboard range through. Distinguishes error from no-data — renders "Umami unavailable" with warning icon and tooltip instead of silently showing `0` when the upstream call fails.
- **`KPITile`** delta chip now includes a `TrendingUp`/`TrendingDown`/`Minus` lucide icon so the direction is visible at a glance alongside the colour.
- **Top countries** column maps ISO codes → full country names via `getCountryName()` (was rendering raw `GP`, etc.).
- **Top pages** column maps `/` → "Homepage" inline for the root-site row.
- Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake.
- `tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session.
16. **Revenue Breakdown widget removed end-to-end**_src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session.
---
## Bucket 2 — Medium (15 min 2 h)
_Component refactors, multi-file edits, single-service tweaks, new validators._
> **[Umami] Follow-ups parked at end of 2026-05-19 build session:**
>
> - **[Umami] Empty-state nudges on quiet ranges** — _src/components/website-analytics/{top-list.tsx, sessions-list.tsx, weekly-heatmap.tsx, visitor-world-map.tsx}_ — every card currently renders a flat "No data in this range" string when Umami returns nothing. Replace with a guided message that nudges the operator to expand the range — e.g. "No data in the last 7 days. Try 30d or 90d." plus a one-click button that flips the active `DateRange`. The hook stack already accepts a range setter via the URL search params, so this is purely component-level copy + a Button. ~45 min across the 4 cards. Captured 2026-05-19.
> - **[Umami] Apple Mail privacy disclaimer copy** — _src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx_ — the "Track email opens" toggle helper text mentions Apple Mail pre-fetch in passing. Promote it to a bullet list under the field so admins can't miss it (Apple Mail Privacy = over-count; image-blocking clients = under-count; pixel won't fire when EMAIL_REDIRECT_TO is set). ~15 min. Captured 2026-05-19.
> - **[Umami] Open-rate column on the document_sends list** — _src/components/documents/_ (find the list that renders document*sends rows; might be inside the interest detail Documents tab or in a dedicated sends-list surface), \_src/lib/services/document-sends.service.ts (listSends extension)* — Phase 4b shipped the data (`open_count` + `first_opened_at` on `document_sends`); the list UI doesn't surface it. Add an "Opened" column showing either a check + relative-time ("Opened · 2h ago · 3 opens") or an em-dash. Sort affordance optional. ~1-2 h depending on how many list surfaces exist. Captured 2026-05-19.
> - **[Umami] Verify pixel + tracked-link end-to-end with a real send** — _manual_ — flip the admin toggle on (`email_open_tracking_enabled = true` for port-nimara), send a real sales email to your own address, open it in Mail.app and Gmail web, then confirm: (a) `document_send_opens` row appears, (b) `open_count` + `first_opened_at` increment on the parent row, (c) Umami records an `email-opened` event. Same drill for `/q/<slug>` once the composer button (Bucket 3) ships. Cannot be automated — needs a real inbox. Captured 2026-05-19.
> **Outstanding (gaps on shipped work + rapid UAT capture):**
>
> - **Platform-wide admin-settings tooltip audit — add explainers wherever a setting isn't self-explanatory to a basic admin user** — _src/components/admin/_ (every settings/form component) + _src/components/admin/shared/registry-driven-form.tsx_ (the unified driver many admin pages use) + _src/components/ui/_ (new `<FieldLabel>` primitive bundling label + optional info-icon tooltip). The custom-field form's "Sort Order" label is one example of a recurring problem across admin pages: many fields carry ambiguous labels (Weight, Priority, Display order, Threshold, Confidence, TTL, Cap, etc.) with no inline explainer. Basic admins are forced to guess, ask another team member, or read source code.
> - **Approach (single audit pass, lots of surface area):**
> - **(a) Convention via shared primitive** — new `<FieldLabel htmlFor={...} tooltip={...}>{label}</FieldLabel>` component that renders the `<Label>` + (when `tooltip` is set) a small `<Info>` icon button that opens a `<Tooltip>` (hover on desktop, tap on mobile via Radix). Drop-in replacement for every `<Label>` in admin forms. Eliminates inconsistent tooltip styling and makes future additions trivial.
> - **(b) Audit pass surface-by-surface** — sweep every admin page + dialog:
> - `src/components/admin/custom-fields/` (Sort Order — confirmed)
> - `src/components/admin/settings/settings-manager.tsx` (any setting with non-obvious unit/scale)
> - `src/components/admin/shared/registry-driven-form.tsx` — when a registry entry has a `description` already defined, it should auto-flow into the tooltip; sweep the registry definitions for missing descriptions
> - `src/components/admin/email/` (email send-from / IMAP setup — bounce-poller, attachment threshold, ...)
> - `src/components/admin/branding/` (PDF logo scale, brand naming convention, ...)
> - `src/components/admin/users/` (role-permission matrix, override hierarchies, ...)
> - `src/components/admin/roles/` (permission scope semantics)
> - `src/components/admin/vocabularies/` (per-port vocabulary overrides — how cascades work)
> - `src/components/admin/ai/` (model selection, confidence thresholds, budget caps)
> - `src/components/admin/storage/` (S3 vs filesystem, when each makes sense, migration warnings)
> - `src/components/admin/templates/` (template merge fields, allowed-tokens semantics)
> - `src/components/admin/forms/` (form-template field types, public form behavior)
> - `src/components/admin/documenso/` (per-port API key vs env fallback, v1 vs v2, sendMode)
> - `src/components/admin/audit/` (retention, severity filters)
> - Anywhere using `<Switch>` + `<Label>` together (often pure toggle with no context)
> - **(c) Tooltip-writing guidelines** (put in a brief CLAUDE-style note inline near `<FieldLabel>`):
> - 1-2 sentences max, plain English, end with a usage tip when applicable
> - State the unit explicitly when applicable ("...in days", "...in MB", "...in feet")
> - Mention default behavior when relevant ("Leave 0 to use the system default")
> - For dangerous settings, lead with the risk ("Changing this triggers a re-index of every berth — schedule for low-traffic hours")
> - Don't restate the label; explain the **why** and **how to choose a value**
> - **(d) i18n-ready** — tooltip text routes through the existing i18n catalog so future localization passes don't need a re-audit. Where i18n keys don't exist yet, create them on the fly.
> - **Acceptance criteria:** every admin form field without an obvious meaning has a tooltip. Definition of "obvious": a label like "Name" or "Email" is self-explanatory; "Sort Order" / "Weight" / "Threshold" / "Cap" / "TTL" are not.
> - **Effort:** ~6-10h end-to-end (FieldLabel primitive + audit ~15-20 admin pages × ~10-15 fields each, write 1-2 sentence tooltips per ambiguous field, sweep registry-driven-form description gaps). Worth a focused half-day. Captured 2026-05-21 from UAT.
> - **Platform-wide form-error UX: scroll-to-first-error + focus + summary banner (29 form surfaces)** — _new_ `src/hooks/use-form-scroll-to-error.ts` + _src/components/forms/_ (form-error-summary component) + audit pass over every `useForm` + `zodResolver` caller in `src/components` (29 files including expense-form-dialog, client-form, interest-form, yacht-form, company-form, reservation forms, admin forms, …). Today's pattern: a form with validation errors renders per-field messages via `{errors.X && <p className="text-xs text-destructive">{errors.X.message}</p>}` (good), but on submit-with-errors there's no scroll-to-first-error, no focus-the-failed-field, and no summary banner — so the user just gets dropped at the top of the form with no indication of what failed. Especially bad on tall drawers/dialogs where the failing field is below the fold. Surfaced via expense-form-dialog UAT 2026-05-21.
> - **Fix shape:**
> - **(a) Shared hook** `useFormScrollToError(formMethods)` — wraps `handleSubmit` to add an `onError` callback that: (i) reads `errors` from react-hook-form, (ii) finds the first errored field's DOM node by `name` attribute (or `id`), (iii) `scrollIntoView({ block: 'center', behavior: 'smooth' })`, (iv) focuses the input (`.focus()`). For drawer/dialog content, scroll inside the scrolling container rather than the page.
> - **(b) FormErrorSummary component** — renders at the top of the form when there are ≥ 2 validation errors: a small red banner listing each failed field as an anchor link ("Amount is required · Currency is required") that on click scrolls + focuses that field. For a single error, hook-only (no banner needed — scroll handles it).
> - **(c) Audit pass:** verify every zod schema has explicit error messages on required fields (`.min(1, 'Amount is required')` not bare `.string()`); fix the bare cases. The default zod "Required" message is generic and unhelpful.
> - **(d) Consistent inline error rendering:** standardize the per-field error block into a small `<FormFieldError errors={errors} name="amount" />` helper so we don't keep open-coding the `{errors.X && <p ...>{errors.X.message}</p>}` block in every form. Migrate the existing 29 surfaces opportunistically.
> - **Behavior on success:** unchanged — submit proceeds, drawer/dialog closes, toast fires.
> - **Mobile consideration:** on tall mobile-bottom-sheet forms, scroll-to-first-error needs to scroll the sheet content, not the page (otherwise nothing visible changes). The hook detects the scrolling ancestor at runtime.
> - **Effort:** ~3-4h end-to-end (hook + summary component + 29-form audit + zod-message fixes). Captured 2026-05-21 from UAT. **SHIPPED (primitives + first adoption) in ec6f90f:** new `useFormScrollToError` hook (handles drawer/dialog scrolling-ancestor detection) + new `<FormErrorSummary>` component (top-of-form alert, renders only when ≥2 errors). Expense-form-dialog adopts both as the validation site. Remaining ~28 form surfaces parked for follow-up sweep.
> - **Berths list "Active interests" column: static count → click/hover popover with interest details + stage-colored count chip** — _src/components/berths/berth-columns.tsx:288-297_ (current static number cell) + _src/lib/services/berths.service.ts (list endpoint extension)_ + new component `<BerthInterestsPopover berthId={...} count={...} highestStage={...} />`. Today renders just `1` / `3` / `—` — unscannable when a rep wants to know WHO has interest in a berth.
> - **Design (locked recommendation, can revisit at remediation):**
> - **Cell:** count chip (`1`, `3`) with subtle outline + hover/focus indicator. Color-coded by the **highest-active-stage** interest on the berth (e.g., border-red-500 if any at Contract, border-amber-500 at Reservation, border-emerald-500 at EOI+, neutral when only at earlier stages). Encodes stage urgency without expanding.
> - **Click/hover (desktop and mobile via Radix Popover):** opens a popover listing each active interest. Each row: client name (link to client detail) · stage badge · berth label (this berth's mooring + role: primary / in EOI bundle / specific interest) · created date · "Open interest →" link to the interest detail. Sort by stage desc so the most-progressed deal sits at top.
> - **Empty state (count = 0):** column shows `—` (no popover trigger). Today's behavior, unchanged.
> - **Mobile:** tap-to-open via Radix Popover's built-in mobile UX. Width capped at `min(360px, calc(100vw - 32px))` so the popover stays usable on small screens.
> - **Service-side:** extend the berths-list response to include `topActiveInterests: Array<{interestId, clientId, clientName, pipelineStage, isPrimary, isInEoiBundle, isSpecificInterest, createdAt}>` (cap at top 5, "View all" link in the popover footer when > 5). Single query that returns this alongside the count via `array_agg` in the existing correlated subquery — no N+1.
> - **Permission gating:** the popover row's "Open interest →" link respects `interests.view`. Client name link respects `clients.view`. Hide entire popover when neither perm is held (count chip becomes static for view-only roles).
> - **Effort:** ~2-3h end-to-end (service extension + popover component + stage-color logic + tests). Captured 2026-05-21 from UAT.
> - **SHIPPED in 292a8b5:** new `GET /api/v1/berths/[id]/active-interests` endpoint returns up to 20 non-archived non-terminal interests linked to the berth (client name, stage, primary/EOI-bundle flags), sorted most-recently-updated first. New `<ActiveInterestsPopover>` lazy-loads on click (30s stale); rows link to the interest detail with a stage-badge tint + primary-star icon. Berth-columns cell now wraps the count number in the popover trigger. Stage-by-highest-stage cell border-tint deferred; popover delivers the same info with one click instead. **Also shipped (companion task — table density):** `DataTable` gained a `density: 'comfortable' \| 'compact'` prop; berth-list toolbar exposes a Rows3/Rows4 toggle that persists per-entity to `user_profiles.preferences.tablePreferences[entityType].density`.
> - **Interest Overview Email + Phone rows: combobox picker across client's contacts + quick-add new contact** — _src/components/interests/interest-tabs.tsx:958-1000 (Email + Phone EditableRow blocks)_ + _src/components/interests/interest-tabs.tsx:122-129 (`clientPrimaryEmail/Phone[ContactId]` types)_ + _src/lib/services/interests.service.ts (getInterestById)_ + _src/lib/services/client-contacts.service.ts_ + _new_ component `<ClientContactPicker channel="email|phone" clientId={...} selectedContactId={...} onSelect={...} />`. Today's surface shows ONLY the client's primary email + primary phone via inline editor. Two real gaps surfaced UAT 2026-05-21:
> - **Gap 1 — empty state has no quick-add:** when client has no primary contact for the channel, line 976-978 renders `<span>—</span>` with no affordance. Rep has to navigate to the Client Detail's Contacts tab to add one. Should expose `+ Add email` / `+ Add phone` inline that POSTs a new `client_contacts` row + marks isPrimary=true.
> - **Gap 2 — no multi-contact picker:** clients with multiple contacts per channel (e.g. 3 emails — personal, work, assistant) get only the primary shown. Rep can't pick which one applies to THIS deal. Picker needs a dropdown listing every contact for the channel, pre-selecting the current primary, with each row showing the value + label (Personal / Work / etc.) + a `Set as primary` action + a `+ Add new email` / `+ Add new phone` row at the bottom that POSTs a new client_contacts row.
> - **Inheritance clarification — current model already does this:** there's no separate `interests.contactEmail/Phone` column today. The displayed Email/Phone ARE the client's primary contacts (resolved server-side, edited in place via PATCH to `client_contacts`). So edits at the interest level auto-update the client. The user's "vice versa" framing assumes per-interest contact overrides exist — they don't.
> - **Two design options for the picker semantics:**
> - **Design A (recommended, single source of truth):** picker just chooses which contact to set as `isPrimary=true` for this client. Affects every other surface that reads `clientPrimaryEmail`. No schema change. Simpler.
> - **Design B (per-interest contact override):** add `interests.preferred_email_contact_id` + `preferred_phone_contact_id` nullable FK to a specific `client_contacts` row. Each interest can pin a non-primary contact for itself; falls back to client's primary when null. Schema change + service-layer fallback logic + UI to mark "use this for this deal only". Useful only if a single client routinely buys multiple deals with different contact preferences per deal — uncommon for marina sales.
> - **Decision-pending:** lean Design A unless leadership confirms the multi-deal-per-client divergence case is real.
> - **Effort:** ~3-4h for Design A end-to-end (picker component + empty-state quick-add + service-side `setPrimary` action + tests + accessibility). ~5-7h for Design B with the schema + fallback logic. Captured 2026-05-21 from UAT.
> - **SHIPPED (Design A) in 7ecf4ee:** new `<ClientChannelEditor>` combobox. Primary value renders inline (free-text for email, `<InlinePhoneField>` for phone with country code split). Chevron opens a popover listing every contact in the channel — `Make primary` button per non-primary row, delete for non-primaries, inline "Add an email / phone number" with optional Set-as-primary toggle. Backed by existing `/clients/[id]/contacts` CRUD + `promote-to-primary`. Wired into the Email + Phone rows on `interest-tabs.tsx`.
> - **Inline phone editor on the Contact row** — _src/components/interests/interest-tabs.tsx:973_ — current implementation uses a plain `InlineEditableField` text variant on Phone, so reps can't pick a country code from a dropdown or get AsYouType formatting (both available via `<PhoneInput>` in `src/components/shared/phone-input.tsx`). Wrap `PhoneInput` in a display-vs-edit toggle and PATCH both `value` (national string) + `valueE164` + `valueCountry` to `/api/v1/clients/{id}/contacts/{contactId}`. ~30-60 min. **SHIPPED in 7ecf4ee:** the phone branch of `<ClientChannelEditor>` uses `<InlinePhoneField>` (existing primitive); PATCH writes `value` / `valueE164` / `valueCountry` together. `interests.service.ts` now returns `clientPrimaryPhoneCountry` so the editor can preserve the ISO-3166-1 alpha-2 round-trip.
> - **ft ↔ m unit switching on Berth Requirements** — _src/components/interests/interest-tabs.tsx_ — the three inline-editable dim rows hard-code `(ft)` in the label. The interest already carries `desiredLengthUnit` ('ft' | 'm'); other surfaces (BerthRecommenderPanel) honour it. Add a small unit toggle that flips the rendered display (and converts on save so the canonical `desired*Ft` column stays in feet). Same pattern as elsewhere in the app (per CLAUDE.md mooring/berth dims model). ~30-45 min.
> - **Client Overview should summarize current interest's requirements** — _src/components/clients/_ — one-line "current interest needs X × Y × Z" summary on the client detail Overview tab; reps currently have to drill into Interests tab to see what a client wants. ~30 min. **SHIPPED in 7ecf4ee:** `PanelVariant` of `<ClientPipelineSummary>` renders a one-line "Wants L × W × D · Source" under each interest's header when constraints / source are captured. `<ClientInterestRow>` type extended with the new fields; the existing `/api/v1/interests` query already returned them.
> - **Duplicate Reminder surfaces on Interest Overview** — _src/components/interests/interest-tabs.tsx_ — the legacy "Reminder" panel (driven by `interest.reminderEnabled / reminderDays / reminderLastFired`) and the new "REMINDERS" section (driven by the `reminders` table via the bell-in-header) both render on the same tab and tell different stories. The legacy field still drives a real backend worker (`processFollowUpReminders` in `reminders.service.ts:428` — creates auto-follow-up reminders when no activity in N days), so we can't just delete the field. Approach: hide the legacy "Reminder" panel from the OverviewTab grid; surface the recurring-follow-up config either as a slim row inside the REMINDERS section or as a setting on the interest detail header. Keep the worker untouched. ~1 h. **SHIPPED in f39f0aa:** legacy panel hidden from Overview; worker untouched. Surfacing the recurring-follow-up config on the detail header is parked.
> - **LinkedBerthsList: no "add another berth" affordance from the card** — _src/components/interests/linked-berths-list.tsx_ — multi-berth interests are first-class (`interest_berths` is the source of truth per CLAUDE.md) but the LinkedBerthsList card doesn't expose an inline "Add a berth" button. Reps have to use the BerthRecommenderPanel below — discoverability gap. Add a CTA button to the card header (gated by `berths.edit`) that opens a picker / sheet to add another `interest_berths` row. ~45 min. **SHIPPED in 3999d4b:** "Add berth" button on the card header; opens a `<Dialog>` with a Command-primitive searchable picker backed by `/api/v1/berths/options`. Berths already linked are filtered out client-side so reps can't double-add. Mutation hits `POST /api/v1/interests/[id]/berths` with `isSpecificInterest` flag; invalidates interest-berths + berth-recommendations caches so the row appears immediately and the recommender drops the just-added berth.
> - **Supplemental-info-request: link should be reusable, not single-use** — _src/lib/services/supplemental-info_ (token model) — current email says "can only be used once"; user wants it valid until expiry so a partial submission can be revisited. Drop the single-use guard, keep TTL gate. Audit the public endpoint to ensure no token-fingerprint reuse risk before lifting the limit. ~30 min. **SHIPPED in b74fc56:** `applySubmission` drops the `isNull(consumedAt)` filter; TTL is the sole validity check. Public form's "already submitted" lockout screen replaced with a soft amber banner noting that re-submission overwrites the previous data. `consumedAt` still stamped for last-submitted context.
> - **Supplemental-info-request: distinct Regenerate vs Resend actions + issue history** — _src/components/interests/supplemental-info-request-button.tsx:83_ (the current "Resend" label) + _src/lib/services/_ (the issue endpoint that today mints a new token on every POST) — once the link becomes reusable-until-expiry (per the "should be reusable, not single-use" finding above), the single "Resend" button conflates two semantically different actions: (a) mint a NEW token (invalidates the previous one — needed when the old one expired, was leaked, or the client deleted the email), and (b) re-email the EXISTING still-valid token (needed when the client just lost the email — same token, same form-state, just push through SMTP again so they can pick up where they left off). The current implementation always does (a) — the "Resend" copy is misleading. Plus once we have reusable tokens, the rep loses visibility into "what token did we send when?" — the inline `link` state only holds the last-minted one.
> - **Fix:**
> - **(a) Service split:** `regenerateSupplementalLink(interestId)` mints a new token + invalidates outstanding ones for the same interest (or keeps them parallel — design call; recommendation: invalidate, so one client only has one valid link at a time and the rep doesn't have to reason about which one is which). `resendSupplementalLinkEmail(tokenId)` emails the named existing token via SMTP without mutating the token table. Two API routes: `POST /api/v1/interests/{id}/supplemental-info-request` for regenerate, `POST /api/v1/interests/{id}/supplemental-info-request/{tokenId}/resend` for resend.
> - **(b) UI:** swap the single button for a small action group that surfaces the most recent valid token's metadata (`Issued <relative time> · expires in <N days>`) with two buttons next to it — `Resend email` (primary, fires resend on the existing token) + `Regenerate link` (ghost, mints new). If no valid token exists, show only `Generate link`. Pair this with the "separate generate + send" finding below so the rep can also generate-without-sending (e.g. share through WhatsApp).
> - **(c) History:** small expandable section "View past requests" listing the last 3-5 issued tokens with timestamp + status (active / expired / submitted / revoked). Each row gets a "Revoke" action for the active ones (defensive — covers the "we sent it to the wrong email" case). Schema-wise this is just rendering existing rows in the supplemental-info-tokens table.
> - **Effort:** ~2-3h end-to-end including the service split, two API routes, UI rework, audit-log entries on each action, and a vitest covering the resend-doesn't-mutate-token guarantee. Captured 2026-05-21 from UAT. Cross-ref: ties into the "link should be reusable, not single-use" + "separate generate link and send email" findings — best done as one coherent rework.
> - **Supplemental-info-request: separate "generate link" and "send email"** — _src/components/interests/supplemental-info-request-button.tsx_ — currently one button auto-generates + sends. User wants two steps: button 1 generates + shows the link (rep can copy / share manually); button 2 sends the templated email through SMTP. Backend change: split the existing service into `generateSupplementalLink()` and `sendSupplementalLinkEmail(linkId)`. UI change: replace single-click action with two-step UI showing link state. ~1 h. **SHIPPED in a4e30ea:** API route now accepts `{ sendEmail?: boolean }` (defaults true for back-compat); UI shows two distinct buttons — "Generate link" and "Send by email" (becomes "Regenerate link" + "Generate + email" depending on state). Email body copy also drops the "can only be used once" sentence since PR15 made tokens reusable.
> - **Past-milestones strip → expandable history with inline doc preview** — _src/components/interests/interest-tabs.tsx_ (the past-milestones strip at ~line 863) — currently a one-line collapsed summary per past milestone (just title + summary). Reps want to drill into the history of a specific milestone (e.g. see which EOI round was signed, the doc contents, who signed, when). Convert the strip into an accordion: each past milestone expands to show its associated docs + sub-status timeline + inline PDF preview using the existing pdf viewer primitive. Useful for deals with multiple EOI rounds (rework after rejection, re-sent reservation agreements, etc.) where audit trail matters. ~3-4 h.
> - **InterestBerthStatusBanner: name + link the competing deal** — _src/components/interests/interest-berth-status-banner.tsx_ — the banner that surfaces when a linked berth is under offer to a different active deal currently just says "this berth is under offer elsewhere" without identifying which interest. Reps want a small inline detail: client name + deal stage + a link button to the competing interest, so they can size up the situation (e.g. "this lead won't make it, treat ours as backup"). Service-side: extend the `getInterestBerthStatus()` (or equivalent) response with a `competingInterest: { id, clientName, pipelineStage, ... } | null` field, then surface in the banner. Permission-gate the link by `interests.view`. ~1 h. **SHIPPED in 7ecf4ee:** reuses `/berths/[id]/active-interests` endpoint (already-shipped 292a8b5) instead of extending the original `getInterestBerthStatus()` call. One query per conflicting berth via `useQueries`; picks the `isPrimary` competing interest (falls back to first non-self row); renders inline `<Link>` to the competing detail page. Falls through gracefully when the endpoint hasn't resolved yet.
> - **Notes Latest-note teaser missing round / stage context pill** — _src/components/interests/interest-tabs.tsx_ (the "Latest note" block around line 1029-1064) — notes created during a specific stage / EOI round should display a small "Round 2" or stage pill next to the timestamp so reps can see at a glance which phase a note belongs to. Currently shows author + time only. Schema: notes table doesn't carry round info today — would need a derived display from the interest's stage at note creation time (cheapest) or a stamped `created_during_stage` column (more reliable). ~45 min for derived display, ~1.5 h with migration for stamped column. (Same need likely applies to all notes lists, not just the Overview teaser.) **SHIPPED (current-stage variant) in 7ecf4ee:** stage-badge chip next to the timestamp using `STAGE_BADGE` colour map. Shows the deal's CURRENT pipelineStage — historical "stage-at-note-time" lookup would need a per-render `audit_logs` read, over-engineered for a context hint. NotesList rows on other surfaces (full Notes tab) deferred until a real need surfaces.
> - **Dimensions columns: add ft↔m toggle in the column header (persisted to user prefs); skip per-row entry-unit indicator** — _src/components/berths/berth-columns.tsx:306_, _src/components/yachts/yacht-columns.tsx:102_, _src/components/clients/client-yachts-tab.tsx:63_, _src/components/companies/company-owned-yachts-tab.tsx:106_ (any current/future Dimensions column), plus _new_ `src/lib/utils/dimensions.ts` for the conversion + format helper, and _src/lib/db/schema/users.ts_ `user_profiles.preferences` for the persisted preference key — five table surfaces render "Dimensions" in feet today; reps used to metric units have to convert in their head.
> - **Recommendation on the per-row indicator question:** **column-level toggle alone is enough.** The schema already stores per-dimension entry-unit discriminators (`lengthUnit`, `widthUnit`, `draftUnit` on berths + same pattern on yachts/interests, default `'ft'`) and even keeps separate `_M` numeric columns where metric originals exist (`nominalBoatSizeM`, `waterDepthM`) — so the _data_ knows what was entered. But surfacing that on every row in the table creates visual noise (a small "m" pill next to half the rows) that doesn't help the rep complete a task. The right time to surface entry-unit fidelity is at **EOI / contract / quote generation** time — the merge field renderer should pull the unit + value as entered so the legal document matches the rep's original input verbatim. So: column toggle for UI display, entry-unit honoured in document generation (which already happens for the EOI dialog via `effectiveDimensionUnit`).
> - **Implementation:**
> - (a) Helper: `src/lib/utils/dimensions.ts` exporting `convertFt(value, to: 'ft' | 'm')`, `formatDimension(value, unit)` (with locale-aware decimals: 1.5 m vs 4.9 ft), and `formatDimensions(l, w, d, unit)` for the L × W × D triple. Tiny, deterministic, unit-tested.
> - (b) Preference: extend `user_profiles.preferences` (JSONB) with a `dimensionUnit: 'ft' | 'm'` key (default `'ft'`); already a JSON column so no migration needed beyond a TS type extension.
> - (c) Hook: `useDimensionUnit()` returning `{ unit, setUnit }` backed by React Query + a PATCH to `/api/v1/me/preferences` on change. Optimistic update.
> - (d) UI: replace the literal `"Dimensions"` header string in each column definition with a small `<DimensionUnitToggle />` component (label + segmented toggle `ft | m`). Column body cells render via the formatter. Apply to all 5 surfaces in one pass for visual consistency.
> - (e) Document-generation path: leave EOI / contract / template merge-field rendering untouched — it already pulls entry-unit values per `effectiveDimensionUnit` in the EOI dialog (per CLAUDE.md merge-field architecture).
> - **Effort:** ~1.5-2h end-to-end (helper + pref + hook + toggle component + 5 column-definition swaps + a vitest for the formatter). The toggle persists across page reloads + tabs by virtue of going through `/me/preferences`. Captured 2026-05-18 from UAT.
> - **Berth list: "Rates (USD)" + "Pricing valid" columns hidden by default (or removed) — short-term rental fields irrelevant to purchase/long-term ports** — _src/components/berths/berth-columns.tsx:391-417_ + _src/lib/db/schema/berths.ts:62-69_ + the NocoDB import that populates them. These columns surface daily/weekly slip rental rates + a `pricing_valid_until` date — relevant for marinas that lease berths by the day/week (transient marinas), irrelevant for Port Nimara's sales-only model. Visible by default in `DEFAULT_VISIBLE_COLUMNS` (line 123-124), so every Port Nimara user sees two columns of `—` cluttering the table.
> - **Path (recommended): hide by default, keep available in column picker.** Drop `'rates'` + `'pricingValidUntil'` from the default-visible array; reps at a transient-rental port can enable via the existing Columns picker. Preserves the schema + import paths for future ports without removing functionality. ~5 min.
> - **Smarter alternative (Path 3 in the chat thinking):** conditional default-visibility — only include `'rates'` + `'pricingValidUntil'` in the default-visible set if the port has at least one non-null rate value. Auto-shows for ports that use them, auto-hides for ports that don't. ~30 min including the port-level data check + cache invalidation when rates land. More polished but heavier.
> - **Aggressive alternative (Path 1):** delete the columns + the four `*_usd` schema columns + the import paths if no port ever plans to use them. Decision: defer until we know whether ANY port in the roadmap does transient rentals. For now, hide-by-default is the right call.
> - **Bundle with:** the "trim default-visible columns" recommendation in the platform-wide table-density finding below — same audit pass, same author.
> - **Effort:** ~5 min (Path: hide-by-default). Captured 2026-05-21 from UAT.
> - **Platform-wide table density: cells shrink-wrap content instead of triggering horizontal scroll — columns need min-widths + nowrap defaults** — _src/components/ui/table.tsx:7_ (wrapper is already `overflow-auto`, good ✓) + _src/components/ui/table.tsx (TableCell base — missing `whitespace-nowrap`)_ + _src/components/berths/berth-columns.tsx_ (no `size`/`minSize` on any column except line 447's `size: 48` outlier) + every other DataTable column definition in the app. Surfaced on the berths list (UAT 2026-05-21): with ~14 columns visible, every cell wraps into 3-6 lines because the table tries to fit everything in viewport. Example pain: "Bull bollard type B · 40 ton break load" wraps into 6 lines; "63m × 14.19m (draft 4.42m)" wraps into 3 lines; "Car (3t) to Vessel" wraps into 3 lines. Result: row height bloats to 200px+, the table becomes nearly unusable.
> - **Fix (platform-wide, single PR):**
> - **(a) TableCell base default:** add `whitespace-nowrap` to the base TableCell className in `src/components/ui/table.tsx`. Single-line content stays single-line. Cells that genuinely need wrapping (long note teasers, etc.) opt-out via `className="whitespace-normal"` per-cell.
> - **(b) Per-column `min-w-[X]` token system:** define a small set of width tokens in a shared helper based on content type — `colW.short` (status badges, count chips), `colW.medium` (mooring numbers, short labels), `colW.long` (dimensions, addresses), `colW.money` (price columns). Apply via TanStack `size: ...` or via cell className `min-w-[X]`. Reuse across every DataTable.
> - **(c) Truncate-with-tooltip for verbose cells:** the Cleat / Bollard / Access columns carry strings like "Bull bollard type B · 40 ton break load" — too long for any reasonable column width. Apply `truncate max-w-[200px]` + `title={value}` so the cell shows ellipsis + full text on hover. Optionally wrap in a `<Tooltip>` for touch parity on mobile.
> - **(d) Audit visible-by-default columns:** with 14 columns showing on the berth list, even with correct widths the table is overwhelming. Trim the default-visible set to 7-8 essentials (Mooring, Area, Latest deal stage, Active interests, Dimensions, Boat size, Price, Status) and move the rest behind the existing Columns picker (already wired per CLAUDE.md). Reps who need bollard/cleat/access details can enable those columns explicitly.
> - **Apply to all DataTable surfaces:** berths list, interests list, clients list, yachts list, companies list, reservations list, invoices list, audit-log list, expenses list. Each has its own column file; single audit pass tags the min-w token per column.
> - **Effort:** ~3-4h end-to-end (TableCell base + width token helper + column-def sweep + truncate-tooltip on verbose cells + default-visible audit). Captured 2026-05-21 from UAT.
> - **Berth list "Latest deal stage" column: make sortable by pipeline-stage rank** — _src/components/berths/berth-columns.tsx:273-287_ + _src/lib/services/berths.service.ts:80-120_ — the column currently has `enableSorting: false`; sorts by status / area / active interests / etc. already work via the existing `sortColumn` switch + `customOrderBy` correlated-subquery pattern (see `activeInterestCount` at lines 107-120). latestInterestStage isn't a column on `berths` — it's the highest-ranked active interest's stage, populated in a two-pass post-fetch.
> - **Fix:** (a) drop `enableSorting: false` on the column. (b) Add a `'latestInterestStage'` case to the sortColumn switch returning `null` (handled in customOrderBy, like `activeInterestCount`). (c) Add a `stageSort` correlated subquery mirroring `demandSort`: select the rank of the highest-active-stage interest per berth via a `CASE i.pipeline_stage WHEN 'enquiry' THEN 1 WHEN 'qualified' THEN 2 ... WHEN 'contract' THEN 7 END` ladder, then `ORDER BY ... ASC/DESC` per `query.order`. Filter same as demandSort (`port_id`, `archived_at IS NULL`, `outcome IS NULL`). Berths with no active interest → NULL; use `NULLS LAST` (ascending) / flip per direction so they land at the bottom regardless.
> - **Effort:** ~45 min. Pure additive — no schema work, no API contract change. Captured 2026-05-18 from UAT. **SHIPPED in ca51000:** column toggles to `enableSorting: true`; service-side adds a `stageSort` correlated subquery via the existing `customOrderBy` pattern (ranking 1=enquiry through 7=contract; NULLS LAST regardless of direction).
> - **Berth list: bulk-edit affordance (parity with bulk-add)** — _src/components/berths/berth-list.tsx_ + _berth-columns.tsx_ + _src/lib/services/berths.service.ts_ + _new_ `src/app/api/v1/berths/bulk/route.ts` — bulk-add for berths exists; bulk-edit doesn't, so any cross-row mutation (status flip on a row range, price re-tier on a pontoon, tag application, area rename, archive a season's worth) is a 50× one-row-at-a-time grind. **Cross-reference:** the Bucket 3 finding "Bulk-price editing UI" already shipped the price-specific backend (`POST /api/v1/berths/bulk-update-prices`); this is the broader sibling covering every other column reps want to edit in bulk. Coordinate the two as a single rollout.
> - **Scope:** (a) Row-select infra on `<DataTable />` — checkbox column, "select all on page" / "select all matching filters" header, persistent selection across pagination (~1h, mirror `InterestList`'s `bulkActions` pattern). (b) Bulk-actions bar on ≥1 row selected: change status, change area, set price / % adjust (folds in the already-built endpoint), add/remove tags, archive/restore, export selection CSV — each opens a small confirm/edit dialog (~2-3h). (c) Unified backend `POST /api/v1/berths/bulk` (mirror `/interests/bulk`) taking `{ action, ids, ...args }`, port-validates IDs, per-row transactional with per-row failure summary so the rep sees which of 50 berths failed and why; per-row audit + realtime fan-out; cap 500 IDs (~2-3h incl tests). (d) Each action gated by the appropriate berth perm (`berths.edit`, `berths.update_prices`, `berths.archive`, `tags.manage`); endpoint enforces the most-restrictive perm of the requested action (~30min).
> - **Effort:** ~5-7h end-to-end. Captured 2026-05-18 from UAT.
> - **BulkAddBerthsWizard: block proceed when any mooring already exists in the port** — _src/components/admin/bulk-add-berths-wizard.tsx_ + _src/lib/services/berths.service.ts_ (new pre-flight check) — the wizard's review/preview step should validate the to-be-added mooring numbers against existing rows for the port and block "Submit" if any duplicates are found (rather than relying on a DB-constraint error mid-insert, which today doesn't even fire because there's no partial unique index on `(port_id, mooring_number)` — see Bucket 4 #1 "Duplicate E17 row" which captured the missing constraint).
> - **Fix:** (a) on entering the preview step, fire a `GET /api/v1/berths/check-moorings?port=<id>&moorings=A1,A2,...` (cap ~500 per call) that returns `{ existing: [{ mooringNumber, id }] }`. (b) If non-empty, show an inline error panel listing the conflicts (linked to the existing berths) and disable Submit; offer a "Remove conflicts and continue" button that drops the dupes from the wizard payload before re-enabling Submit. (c) Pair this with the partial unique index fix from Bucket 4 #1 so the DB-level guard exists as a backstop — UI validation prevents the friction; DB constraint prevents the silent dup. (d) Same pre-flight should run on per-row "single add" flow for parity.
> - **Effort:** ~1.5h (endpoint + index + UI panel + tests). Captured 2026-05-18 from UAT.
> - **SHIPPED in ca172fa:** `POST /api/v1/berths/check-duplicates` accepts up to 500 mooring numbers, returns the subset that already exist as non-archived berths (canonical `^[A-Z]+\d+$` regex validation, `berths.import` perm). Wizard fires the check during the Step 1 → Step 2 transition with a "Checking…" Continue button. Step 2 banner lists first 8 duplicates + a "Remove all duplicates" action; duplicate rows render amber-tinted with a "Dup" pill. Submit disabled while any dup remains, tooltip explains. Partial unique index `(port_id, mooring_number) WHERE archived_at IS NULL` follow-up (Bucket 4 #1) parked.
> - **Yacht Overview: verify bidirectional ft↔m auto-convert is visually reflecting (logic exists; UI may not be updating)** — _src/components/yachts/yacht-tabs.tsx:99-137_ (`saveDimension`) + _src/components/yachts/yacht-tabs.tsx:68-80_ (`useYachtPatch` cache invalidation) — the bidirectional auto-conversion IS already implemented: `saveDimension()` patches both the primary field and the converted counterpart in one PATCH, and `onSuccess` invalidates `['yachts', yachtId]`. User report ("needs to autofill with auto-converted measurements") suggests the UI isn't visually updating after save — most likely the parent that passes the `yacht` prop into `OverviewTab` either (a) doesn't share the `['yachts', yachtId]` cache key (invalidation fires, no consumer refetches), (b) is hydrated via server-component `initialData` with no client refetch, or (c) the `InlineEditableField` for the counterpart memoizes its initial value and doesn't re-render when the upstream prop changes.
> - **Verify path:** (i) confirm the yacht detail page's `useQuery` cache key matches `['yachts', yachtId]` exactly — any mismatch (`['yacht']` singular, `['yacht-detail']` wrapper) makes the invalidation a no-op. (ii) Confirm `staleTime` / `refetchOnMount` allow refetch on cache bust. (iii) If the parent refetches but the field still doesn't visually update, force-re-render via `key={yacht.lengthM}` on the counterpart InlineEditableField.
> - **Apply to sibling surfaces:** the same bidirectional save belongs on **berth detail OverviewTab** — berth schema has `lengthM`/`widthM`/`draftM` + `_unit` discriminators and likely shows the same dual ft/m sections (verify); copy the `saveDimension()` pattern. Use the shared `src/lib/utils/dimensions.ts` helper from the earlier Dimensions-column toggle finding so the conversion ratio is centralized.
> - **Effort:** ~20-30 min for the yacht debug + visual-update fix, +30 min if a berth equivalent needs the same logic. Captured 2026-05-18 from UAT.
> - **SHIPPED (round-trip lossless + dedup) in 8e9efe5:** three copies of the conversion logic existed (yacht-dimensions.ts canonical, yacht-form.tsx local, yacht-tabs.tsx local) — the form-side ones used 2dp precision so `1 ft → 0.30 m → 0.98 ft` lost data on the round-trip. Consolidated both surfaces onto `feetToMeters`/`metersToFeet` from yacht-dimensions.ts and bumped precision to 4dp. New `tests/unit/yacht-dimensions.test.ts` proves round-trip lossless on the canonical 12.5 ft ↔ 3.81 m UAT case + sweeps 1/5/12.5/25/50/120/250 ft and 0.5/1/3.81/7.62/15.24/36.58 m (29/29 ✓). UI cache-key + InlineEditableField re-render path are independent debug items — flag for separate verification.
> - **Merge `/admin/invitations` into `/admin/users` — single "people with access" surface** — _src/app/(dashboard)/[portSlug]/admin/users/page.tsx_, _src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx_ (to be removed), _src/components/admin/users/_, _src/components/admin/admin-sections-browser.tsx:90-95_ (drop the Invitations card from the Access section), _src/lib/services/_ (invitations service likely already separate — keep it) — active users and pending invitations are the same lifecycle (a person who has or should have port access). Splitting them across two admin pages forces admins to bounce between surfaces to answer "who has access here?". Merging gives them one canonical "people" page.
> - **Approach:**
> - **(a) Page shape:** keep route at `/admin/users`. Add a state filter at the top: `All | Active | Invited (pending) | Disabled | Archived`. Default to `Active`. The existing Users table extends to render invitation rows alongside active users, distinguished by a "Pending" badge + last-sent timestamp + "Resend" / "Revoke" kebab actions. Active-user kebab keeps current actions (edit role, reset password, disable). One unified `+ Invite user` button in the page header opens the existing invitation form. Search across both populations (name / email / role).
> - **(b) Data shape:** the users table already returns user rows; extend the list endpoint (or add a parallel one that the page composes) to also yield pending invitations as a discriminated-union row type `{ kind: 'user' | 'invitation', ... }`. Keep the underlying tables separate (no schema change); the page just stitches both query results into one table. Filter at the API layer when `state=active` excludes invitations, etc.
> - **(c) Removal:** delete `/admin/invitations/page.tsx`, the Invitations card from the Access section, any sidebar/search-catalog entries pointing at the old route. Add a `redirect()` from the old route to `/admin/users?state=invited` so any bookmark / external link lands in the right place.
> - **(d) Roles & Permissions stays separate** — different concept (template vs individual), low edit frequency, would bury both if merged. Cross-link: each user row's role chip → opens role edit page; role detail page → "N users with this role" with a link back.
> - **Permission gating:** confirm the unified page enforces the OR of permissions for both surfaces (`users.view` for the user rows, `invitations.manage` for sending/revoking). The "Invite" button gates on `invitations.manage`; the kebab actions per-row gate appropriately.
> - **Effort:** ~3-4h end-to-end — table extension + state filter + invitation rows + the API stitch + redirect + sidebar/catalog cleanup + tests. Captured 2026-05-18 from UAT.
> - **Consolidate every AI-feature admin control onto `/admin/ai` — REMOVE from current scattered locations (reinforced UAT 2026-05-21)** — _src/app/(dashboard)/[portSlug]/admin/ai/page.tsx_ + _src/components/admin/_ (new per-feature embedded forms) + _src/lib/db/schema/ai-usage.ts_ (existing ai*usage table for spend rollup) + \_src/components/admin/ocr-settings-form.tsx* (pattern to mirror) — the AI admin page already has master controls (`ai.master`), provider credentials (`ai.providers`), and the Receipt OCR settings embedded inline. The "Per-feature settings" card I just removed pointed at two dead routes (`../berth-pdf-parser`, `../recommender`) — surfacing the gap that AI feature-tuning isn't consistently centralized. User wants every AI-using feature's admin knobs reachable from one page.
> - **Scope (only include features that actually call an LLM today; don't include aspirational ones):**
> - **Berth PDF parser AI fallback** — 3-tier parse per CLAUDE.md (AcroForm → OCR → optional AI on low confidence). Knobs to expose: provider override (per-feature override of the global `ai.providers` choice), confidence threshold below which the AI tier fires, per-call budget cap, prompt template (advanced/optional). New embedded form `<BerthPdfParserAiSettingsForm embedded />` reading registry section `ai.berth_pdf_parser`.
> - **Receipt OCR** — already there ✓
> - **Future-feature placeholders explicitly NOT included until they ship:** berth recommender (currently "Pure SQL (no AI)" per CLAUDE.md — surfacing it as an AI setting today would mislead admins into thinking they're tuning an LLM); AI-assisted contact-log action extraction (Bucket 3 #7 future feature); AI inquiry intake parsing if/when it ships. Add each to `/admin/ai` only when the underlying feature lands.
> - **AI spend dashboard at the bottom of the page** — new card showing: current month spend total (across all AI features), top 3 features by spend, recent expensive calls (model, feature, cost, timestamp). Reads from `ai_usage` table. Helps admins debug cost spikes without leaving the AI page. Optional but high-leverage for an admin who just saw a budget alert.
> - **Cross-linking principle:** each per-feature AI section on `/admin/ai` shows a small "Non-AI settings for this feature live at →" link to the corresponding admin page (e.g. for berth PDF parser, link to wherever the OCR confidence + AcroForm overrides live). Vice-versa: each feature page gets a "AI fallback settings live at /admin/ai →" link in the relevant section. Keeps the split-brain risk in check — admins always have a one-click path between the two.
> - **Effort:** ~30 min for the berth PDF parser embedded section + registry definition, ~1.5h for the AI spend dashboard, ~30 min for the cross-link sweep, ~30-45 min for the explicit removal-from-other-surfaces audit (grep every admin page for AI toggle / API-key field / model-selector / temperature-slider and migrate to /admin/ai). Total ~3h. Captured 2026-05-18 from UAT, reinforced 2026-05-21 (user spotted yet another scattered AI setting in `src/components/admin/settings/settings-manager.tsx:241` — confirms the consolidation work needs explicit "delete from old location" alongside "add to new location" to avoid drift). Captured 2026-05-21 reinforcement.
> - **Explicit removal scope** — audit and remove (not just add to /admin/ai):
> - Any AI-related setting inside `settings-manager.tsx` SettingsManager cards
> - Any model/temperature/provider fields inside per-feature admin pages (OCR settings, berth-PDF settings, template-editor settings)
> - Any AI-related env-resolver fields exposed via RegistryDrivenForm on non-AI admin pages
> - Cross-link replaced original location with a small banner: "AI settings for this feature live at `/admin/ai` →" (per the cross-linking principle already in the entry).
> - **Password-reveal eye toggle silently no-ops when value resolves from env (or anywhere outside port/global)** — _src/components/admin/shared/registry-driven-form.tsx:440-463_ (eye-toggle click handler) + _src/app/api/v1/admin/settings/[key]/reveal/route.ts_ (server endpoint that intentionally refuses to leak env-resolved secrets per its docstring) — user clicks the eye on a sensitive field and the dots stay, no toast, no error. Root cause: the click handler only fires `reveal.mutate()` when `resolved?.isSet && resolved.source ∈ {'port', 'global'}`. When the value is resolved from `env` (legacy `.env` fallback) or `default`, the handler skips the reveal call and just sets `setShowSecret(true)`. The Input then flips `type` from `password` to `text` — but the draft is still empty, so the placeholder `'••••••••'` (set unconditionally for `sensitive` fields at line 555) keeps rendering. Net effect: indistinguishable from "the toggle is broken."
> - **Fix options:**
> - **(a) Best UX:** show a clear inline message + tooltip on the eye button when `resolved.source === 'env'` (or `'default'`): "Value comes from the environment — cannot reveal in-app. Configure in admin to view." Disable the button or change its tooltip so the user knows why nothing happens. ~15 min.
> - **(b) Optional:** allow env-reveal under a stricter permission (e.g. `admin.reveal_env_secrets`) — defaults off, super-admin only. The server endpoint's "refuses to reveal env" guard would honour the permission as an override. Riskier; only do this if there's an operational need. Capture as Bucket 3 if pursued.
> - **(c) Diagnose path:** add a console.warn / dev-mode toast when the click is swallowed silently so the next person debugging this can see what's happening.
> - **Sibling check:** the server-side route comment at lines 21-22 says it "refuses to reveal values resolved from env or default," but the implementation at lines 39-52 just calls `getSetting()` and returns whatever it gets — there's no actual refusal check in the route handler. If `getSetting()` reaches into the env fallback the endpoint would leak env values. Verify the refusal is enforced upstream in `getSetting()` (or in the registry resolver) — if not, that's a separate finding (low/medium severity bug: env secrets leakable via API to anyone with `admin.manage_settings`). Worth running through to confirm.
> - **Effort:** ~15 min for (a) UI message + tooltip; ~30 min if the route's env-refusal check needs to be added too. Captured 2026-05-18 from UAT. **SHIPPED (a) in ca51000:** eye toggle now `disabled` + `title` tooltip when value resolved from env/default. Sibling check on the route's env-refusal guard deferred to a security-side follow-up.
> - **Email settings page: add explainer copy clarifying why sales send-from and noreply have separate credentials** — _src/app/(dashboard)/[portSlug]/admin/email/page.tsx_ (the page) + _src/components/admin/sales-email-config-card.tsx_ (the sales card) + the existing noreply transport card — the admin page renders two cards with overlapping field names (SMTP host/port/user/pass on both, plus IMAP on the sales card) and zero context for why both exist. Operators reasonably ask "why am I configuring this twice?" The two streams are intentionally separated (per CLAUDE.md "Send-from accounts (sales send-outs)"): sales = human-initiated rep emails with IMAP-bounce-poll monitoring; noreply = fire-and-forget automation (portal invites, password resets, signing reminders, inquiry confirmations). Reasons to keep them separate include sender reputation (mixing transactional volume with human sends hurts deliverability), reply handling (reps need replies in a monitored mailbox; automation shouldn't generate reply threads), and the practical pattern of using a transactional provider (Postmark/SendGrid) for noreply + Google Workspace / Outlook for the sales mailbox.
> - **Fix:** add an explanatory header block at the top of the email-settings page (above the two cards) summarizing the split in plain language: 2-3 sentences max + a small table (sales vs noreply, what each sends, why split). Each card's CardDescription gets a one-liner anchoring to its role ("Used for rep-initiated emails (berth PDFs, brochures, manual follow-ups). Replies land in this mailbox and are bounce-monitored via IMAP." / "Used for automated emails (portal invites, password resets, signing reminders). Replies bounce."). Optional: a "Quick setup" toggle/button — "Use one mailbox for both streams" — that auto-mirrors SMTP creds from sales → noreply (or vice versa) for ports that don't need the split. Default state stays split (preserves the design intent for ports that have grown into it).
> - **Effort:** ~30 min for the explainer copy + per-card descriptions; +1h for the "Quick setup" mirror affordance if pursued. Captured 2026-05-18 from UAT.
> - **Email / SMTP admin: add a "Send test email" affordance** — _src/components/admin/shared/registry-driven-form.tsx_ (or a dedicated email-settings card adjacent to the RegistryDrivenForm) + _src/lib/email/_ (transport) + _new endpoint_ `POST /api/v1/admin/email/test-send` — once an admin configures SMTP creds + From address on the Email Settings page, they have no in-app way to confirm "did I actually wire this up correctly?" without finding a workflow that triggers a real transactional email. Add a "Send test email" button on the email settings card that pops a small dialog: input for destination address (defaults to the operator's own email), optional message body, submit fires the test via the configured transport. Server endpoint returns success / SMTP-error-with-detail so the admin sees exactly why it failed (auth fail, TLS handshake, sender-rejected) without digging into server logs.
> - **Implementation:** (a) UI: small "Send test email" button in the card actions, opens a Dialog with a single email-validated input + "Send" button. (b) Endpoint: `POST /api/v1/admin/email/test-send` with `{ to: string, subject?: string }`, gated by `admin.manage_settings`. Body: brief branded test ("This is a test from <Port Name> admin — if you got this, SMTP is working."). (c) On the server: pull the live transport config via the resolver chain (port-override → env), construct via nodemailer, send, return `{ success: true, messageId }` or `{ success: false, error: ... }` with the raw SMTP error reason. (d) Audit log a `test_email_sent` row so operators can see who tested and when.
> - **Honour the dev `EMAIL_REDIRECT_TO`** — same as production transactional emails: if set, prefix subject and reroute so QA doesn't spam users.
> - **Cross-ref:** related to the Documenso-config diagnosis loop (Bucket 3 #8 platform-wide error message audit) — same pattern of "configure-then-verify-without-real-workflow." Apply the same idiom to other integrations: Documenso test-send, S3 ping, Redis ping, IMAP test-connect.
> - **Effort:** ~1.5h for email (UI + endpoint + audit + dev-redirect honour). +1-2h each for the sibling integration test-ping buttons if pursued in the same pass. Captured 2026-05-18 from UAT.
> - **SHIPPED in 7881da6:** `POST /api/v1/admin/email/test-send` route gated on `admin.manage_settings`; reuses `sendEmail(..., ctx.portId)` so per-port SMTP overrides + `EMAIL_REDIRECT_TO` honour are exercised the same way a real notification would. Sends a minimal plaintext-only message (no logo, no branded shell) so the failure mode is pure transport — distinct from the branding-page "Send a test" which exercises the rendering pipeline. New `<SmtpTestSendCard>` on `/admin/email` between the SMTP overrides form and the SalesEmailConfigCard. Errors surface inline below the input (auth failure, ENOTFOUND, connection refused, etc.) rather than as a passing toast — whole point is to read them. Sibling test-ping buttons for Documenso / S3 / Redis / IMAP parked.
> - **YachtPicker: opening returns no yachts (empty `q` → empty list); should return a default list** — _src/app/api/v1/yachts/autocomplete/handlers.ts:10-12_ + _src/components/yachts/yacht-picker.tsx:56-60_ — the autocomplete handler short-circuits with `{ data: [] }` when `q` is empty: `if (!q) { return NextResponse.json({ data: [] }); }`. The picker fires the query the moment it opens with `debounced=''` → user opens, sees empty state, has to start typing before any options appear. Dead-end UX.
> - **Fix:** (a) handler: when `q` is empty, return the top 20-30 yachts for the port (most-recently-updated default; if `ownerType`/`ownerId` query params are provided, filter server-side to that owner). Trivial — just drop the early-return and pass `q` as optional to the `autocomplete()` service, which defaults to an empty search term meaning "no name filter". (b) Picker: extend the query string to include the owner filter so server-side filtering works (currently the picker filters client-side post-fetch, which means a yacht owned by someone other than the current `ownerFilter` may not even reach the client if it's outside the default-20). (c) UX nicety: the picker's `placeholder` could include "or search…" so the user knows typing also works.
> - **Effort:** ~30 min. Captured 2026-05-18 from UAT. **SHIPPED (a) in 2bcf544:** autocomplete handler drops the early-return; service returns top 20 most-recently-updated yachts when `q` is empty. (b) owner-side server filtering remains client-side as before; (c) deferred.
> - **YachtPicker: selected yacht renders as `Yacht <uuid-prefix>` when not in the autocomplete results** — _src/components/yachts/yacht-picker.tsx:75-79_ — the trigger button label is `match?.name ?? `Yacht ${value.slice(0, 8)}``— the fallback fires whenever the currently-selected yacht isn't in`rawOptions`(e.g. picker was opened with a pre-set value from a URL param / parent default and the autocomplete results don't include it, OR the user typed a search that filtered it out). Result: reps see`"Yacht 3bd83076"` instead of the yacht's name.
> - **Fix:** add a second `useQuery` keyed on `['yacht-detail-label', value]` that fetches `/api/v1/yachts/{value}?fields=name` when `value` is set AND not present in `rawOptions`. Use its result as the fallback label in priority order: `match?.name ?? fallbackQuery.data?.name ?? `Yacht ${value.slice(0, 8)}``. Cache hit on repeat opens; tiny request. (b) Also pre-select the currently-managed yacht as the default `value`for any picker rendered in a context where "the current yacht" makes sense — that's a parent-prop concern; this picker handles whatever`value` it's given. (c) Sweep for the same pattern in other pickers (ClientPicker, CompanyPicker, BerthPicker if they exist) — same root cause + same fix shape.
> - **Effort:** ~20 min per picker; ~1h with the sweep. Captured 2026-05-18 from UAT. **SHIPPED (YachtPicker) in 2bcf544:** fallback `useQuery(['yacht-detail-label', value])` against `/api/v1/yachts/{value}` enabled only when value isn't in `rawOptions`. ClientPicker/CompanyPicker sweep deferred until UAT confirms the same pattern needs fixing there.
> - **CommandList (cmdk) inside a Popover: scroll caps short of the bottom — applies to ALL dropdowns using the Command primitive** — _src/components/ui/command.tsx:57-75_ — `CommandList` has `max-h-[300px] overflow-y-auto overscroll-contain` plus a custom wheel handler (lines 68-72) that re-implements scrolling because "native wheel scrolling is intercepted by the focus-scope and never reaches the cmdk list" (per the inline comment). User reports they can scroll a short distance, then the list stops responding before reaching the bottom — and notes this is the case for **every dropdown on the drawer they're looking at**, so it's the shared primitive, not a per-picker bug. **SHIPPED in e33313b:** `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. The list now consistently reaches the bottom of the visible popover regardless of where the trigger sits in the viewport.
> - **Suspected causes (likely a combination):**
> - **(i) cmdk auto-scroll-to-highlighted-item** fights the manual scroll: when the user wheels past the currently-highlighted item, cmdk's internal handler snaps the scroll back so the highlighted item stays visible. Net effect: user can scroll up to a few items past the highlight, then it bounces back. **Fix attempt:** on wheel/scroll, clear the cmdk highlight (or set it to a non-highlighted state) so cmdk doesn't re-snap. cmdk exposes a `value` prop on `Command` for controlled-highlight; set it to `undefined` on scroll, restore on hover/keyboard nav.
> - **(ii) Manual wheel handler ignores trackpad-momentum + keyboard:** `event.currentTarget.scrollTop += event.deltaY` only handles wheel events. Trackpad-flick momentum continues firing wheel events with diminishing `deltaY`, but if cmdk traps the events the user's input bounces. Touch / keyboard arrow keys may have similar interception issues. **Fix attempt:** prefer letting cmdk handle scroll natively (newer cmdk versions fixed the popover-focus-scope issue) and remove the manual handler. Check `package.json` for `cmdk` version; if < 1.0.0, upgrade.
> - **(iii) The `max-h-[300px]` hard cap** clips longer lists. While the cap exists, scrolling SHOULD still reach the end — but combined with (i)/(ii) it caps the effective scroll distance. **Fix attempt:** use a height-aware token: `max-h-[min(400px,var(--radix-popper-available-height,400px))]` so the list grows when the popover has room and caps at 400px otherwise.
> - **Investigation order:** (1) check cmdk version + upgrade if old → may auto-fix the focus-scope issue and let us remove the manual wheel handler. (2) Test with manual handler removed. (3) If still buggy, add the controlled-highlight reset on scroll. (4) Bump the max-h as the easy win.
> - **Effort:** ~30-60 min including upgrade + testing across the YachtPicker, ClientPicker, CompanyPicker, command-search topbar, and any other Command consumers. Captured 2026-05-18 from UAT — affects every Command-based dropdown app-wide; high-leverage single-component fix.
> - **DECIDED 2026-05-21 (do not adopt Documenso embed editor):** evaluated `@documenso/embed-react`'s `EmbedCreateEnvelope` / `EmbedUpdateEnvelope` as a replacement for our custom field-placement UI. Per Documenso V2 editor docs (callout block): _"Embedded editor is included with Enterprise plans. It is also available as a paid add-on for the Platform Plan. Contact sales for access."_ Enterprise licensing is a hard no for us. Custom rebuild is the path. We're already ~70% there with `upload-for-signing-dialog.tsx`; remaining scope is the 4-item bundle below (~12-16h total). Full V2-editor parity (multi-file envelopes, Assistant/Viewer roles, dictate-next-signer, all envelope settings) would be ~30-40h but is not justified by our actual marina-CRM flows. Skip multi-file/assistant role; defer per-document envelope settings (expiration / redirect / custom reply-to) until a rep actually asks for them.
> - **Smart search: fuzzy-match pipeline stage names, surface inline mini-list of interests at that stage** — _src/components/search/command-search.tsx_ + _src/lib/services/search.service.ts_ + _src/lib/constants.ts:31 (`STAGE_LABELS`)_ + _src/hooks/use-search.ts_. Today's command-K search includes each interest's stage in result rows (rendered via `STAGE_LABELS`) but doesn't search BY stage — typing "Reservation" only matches interests with that text in their fields. User wants: type "reservation" → see a dedicated "STAGE: Reservation (N deals)" section at the top of the dropdown listing the top 5-10 interests at that stage, with each row showing client + berth label so the rep can click directly. Bottom of section: "View all N in Reservation →" link to the filtered interests list.
> - **Design (locked 2026-05-21):** inline mini-list in the search dropdown (Option A from the design clarification). Top 5-10 interests per matched stage; "View all" link jumps to `/interests?stage=<canonical>`.
> - **Backend:** new search section `'stage-matches'` returning `{ stage: PipelineStage, label: string, totalCount: number, sampleInterests: Array<{id, clientName, berthLabel, ...}> }[]`. Fuzzy match the query against `STAGE_LABELS` values + common aliases ("res" → reservation, "eoi" → eoi_signed/eoi_sent, "dep" → deposit_paid, "qual" → qualified, "won"/"contract" → contract_signed, etc.). Use `fuse.js` or a tiny custom ranker on the labels — there are only ~9 stages so even O(n) scan is fine.
> - **Frontend:** new section in the command-search dropdown rendered above the text-match "Interests" section. Borrow the existing `SectionHeading` + result-row idioms. Use the existing `berthLabel` helper (the same one used in the DocumentDetail Interest link fix and the external-EOI title default) so naming is consistent across surfaces.
> - **Alias catalog (lives next to `STAGE_LABELS`):** add a small `STAGE_SEARCH_ALIASES: Record<PipelineStage, string[]>` map for non-obvious matches ("hot lead" → qualified? probably not, leave it conservative). Aliases stay short and unambiguous — prefer false negatives over false positives.
> - **Effort:** ~2-3h end-to-end (backend section + fuzzy ranker + frontend render + alias catalog + a vitest covering "res" matching reservation but not "reservation_signed" if that's a thing). Captured 2026-05-21 from UAT.
> - **SHIPPED in d912f02:** new `searchStages(portId, query, limit)` in search.service.ts. Three match flavours (case-insensitive): (1) modern label token-prefix on `STAGE_LABELS`, (2) substring on the raw enum slug, (3) legacy-alias prefix via `LEGACY_STAGE_REMAP` so "eoi_sent" / "deposit_10pct" / "contract_signed" still land on the modern 7-stage equivalent. Each result carries a live `COUNT(*)` of non-archived interests in that stage (single grouped query). New `StageSuggestionResult` bucket added to `SearchResults` + `BucketType` union; gated on `interests.view`. Command-search dropdown renders matched rows under a "Stages" header pointing at `/interests?pipelineStage=<stage>`. Mobile overlay reuses `buildFlatRows` so the same surface appears on mobile. Sample-interests-per-stage variant from the original design was deferred — the count + filter-link does the same job with one click vs. inline preview.
> - **Watchers configurable at document creation time (currently post-creation only)** — _src/components/documents/eoi-generate-dialog.tsx_, _src/components/documents/upload-for-signing-dialog.tsx_, _src/components/interests/external-eoi-upload-dialog.tsx_, _src/components/documents/create-document-wizard.tsx:157_ (hardcoded `watchers: []`), _src/lib/services/documents.service.ts (create paths)_, _src/lib/services/document-watchers.service.ts_ (or wherever the watcher CRUD lives). Today watchers can only be added AFTER creation via WatchersCard on the document detail. Reps usually know upfront who needs visibility (manager, developer, legal) and shouldn't have to navigate to the doc after creating it.
> - **Server-side defaults (fires on every create path):**
> - **Creating user** — always auto-added. The person who just created the doc almost certainly wants notifications about events on it.
> - **Interest's `assignedTo`** — if different from creator, auto-add. The deal owner gets visibility on doc events even if a different rep generated/uploaded.
> - **Per-port admin setting `default_document_watcher_user_ids: string[]`** — admin configures org-wide watchers (sales manager, legal, etc.). Apply on every create. Configurable in `/admin/settings` under a new "Document defaults" section.
> - **UI in each creation dialog:** small "Watchers" section (collapsed by default — "X watchers · Edit"), opens to show:
> - Each auto-added user with an `(auto)` badge so the rep can see who's already included without redundant clicks.
> - A user-picker to add additional watchers from the port's user roster.
> - An "X" to remove an auto-added watcher for this specific doc (e.g. rep wants this confidential, removes the default sales-manager). Doesn't affect the global default.
> - **Apply to ALL creation dialogs uniformly:** Documenso EOI generate, external EOI upload, Documenso upload-for-signing (reservation/contract), generic create-document wizard. Build the section as a shared `<DocumentWatchersField>` primitive so each dialog mounts it the same way.
> - **Service:** extend the document-create endpoints to accept `watcherUserIds: string[]` (replacing the current `[]` hardcode). On create, server: (i) inserts the explicit user IDs from the request, (ii) inserts the per-port default IDs, (iii) inserts creator + assignedTo if not already in the union. Dedupe by user_id + document_id (existing unique index, presumably).
> - **Effort:** ~3-4h end-to-end (admin setting + UI section + 4 dialog wirings + service-side defaults + tests). Captured 2026-05-21 from UAT. **Cross-ref:** ties into the external-EOI bundle below — the watchers section sits naturally next to the signatories editor in the same dialog. Build them in one pass.
> - **External-EOI upload: per-signatory role tagging + email auto-fill + "Email copy" distribution** — _src/components/interests/external-eoi-upload-dialog.tsx_ (current free-text `signerNames` field; needs structured rows) + _src/components/documents/document-detail.tsx:208-214 + 297-299_ (current "Email signatories" placeholder stub) + _src/lib/services/system-settings/_ (new `default_developer_email` + `default_developer_name` per port) + _new_ `src/components/documents/email-copy-dialog.tsx` + _src/lib/services/document-sends.service.ts_ (already exists per CLAUDE.md, extend for the new dispatch path). Three linked feature gaps surfaced during UAT 2026-05-21 on the external-EOI flow:
> - **(a) Per-signatory role tagging at upload time** — today's dialog has a free-text "Signer names" CSV input only. No structured concept of WHO each person is (Client vs. Developer vs. Rep vs. Witness vs. CC), so the system can't auto-fill emails downstream or build a proper recipient list for the email-copy flow. **Fix:** replace the freetext field with a recipient list editor (same idiom as the Documenso upload-for-signing-dialog's recipients step — name + email + role per row + add/remove buttons). Add a `signatories: Array<{ name, email, role }>` field to the service's input shape; persist on the document row (existing `documents.metadata` JSONB or a dedicated `document_signatories` table — TBD by scope, JSONB is cheaper for v1). Role enum: `'client' | 'developer' | 'rep' | 'witness' | 'cc'`.
> - **(b) Smart email + name auto-fill based on role** — when a rep adds a row and selects a role, the dialog pre-populates name + email from the right source. Rep can still edit. Sources:
> - **Client** → `interests.clientId` → `clients.contacts` where `channel='email' AND isPrimary=true`, fallback to first email. Name from `clients.fullName`.
> - **Developer** → new per-port system settings `default_developer_name` + `default_developer_email` (admin-editable in `/admin/email` or a new "Default signatories" section). Surfaces consistently across EOI / Reservation / Contract upload flows.
> - **Rep** → `interests.assignedTo` → `users.email` + `users.fullName`.
> - **Witness / CC** → no auto-fill, manual entry. Rep optionally types or picks from a contacts autocomplete.
> - **UI:** when role is selected and a row's email/name is empty, fire `setValue` with the resolved default. If the resolved data is missing (e.g. no clientId on the interest, no developer configured), show a small "No default available — enter manually" hint inline.
> - **(c) "Email signatories" → "Email copy" with multi-select + actual send** — _document-detail.tsx:208-214_ — current button is a placeholder. Build the real flow:
> - **Rename** "Email signatories" → "Email copy" (clearer intent: "send a copy of the signed document").
> - **Dialog UX:** click opens a dialog listing every signatory on the document (from (a)'s structured list) as a checklist. **All checked by default.** Optional "Add other recipient" row at the bottom for emailing someone not on the original signing list (lawyer, accountant, etc.). Optional message field (plain text, like the existing send-out compose UI).
> - **Send pipeline:** uses the existing sales send-out infrastructure (per CLAUDE.md "Send-from accounts" section): nodemailer transport with per-port `default_developer_email` → no wait, that's the sales send-from. Send-from is the configured `sales_send_from` mailbox. Body is rendered via `renderEmailBody()` (per CLAUDE.md "Audit → document_sends" section). Each send creates a `document_sends` row keyed to the document + recipient, supporting bounce tracking + reply monitoring.
> - **Attachment:** PDF threshold check (per the existing `email_attach_threshold_mb` setting) — under threshold → attached inline; over → 24h signed-URL link (escapes filename per the existing XSS protection).
> - **Audit trail:** each recipient gets a `document_sends` row. Existing "Recent sends" / activity surfaces light up automatically.
> - **Rate limit:** existing 50-sends-per-user-per-hour cap applies.
> - **(d-prereq) Create `document_signers` rows on external upload so "X / Y signed" badge works** — _src/components/documents/document-detail.tsx:278_ reads `signers.filter(s => s.status === 'signed').length / signers.length` from the `DetailSigner[]` array. For manually-uploaded external EOIs the array is empty (the upload writes only freetext `signerNames` metadata) → badge renders `0 / 0 signed` even with 3 signers entered in the dialog. Fix is downstream of (a): when migrating from freetext to the structured `signatories: Array<{name, email, role}>` shape, the service should also insert `document_signers` rows (one per signatory), all pre-stamped `status='signed'`, `signedAt=input.signedAt`, `signingOrder=index+1`, `invitedAt=null` (no invitation was sent — this is a backfill of an external signing event). Counter then renders `3/3 signed` correctly. ~15 min on top of (a)'s service work. Captured 2026-05-21 from UAT.
> - **(d) Default document title should reference client + berth(s), not just date** — _external-eoi-upload-dialog.tsx:103_ (current placeholder `'External EOI - <date>'`) — when the rep accepts the default, the document lands as `External EOI — 2026-05-21`, which is unscannable in any document list when a port has multiple deals closing on the same day. **Fix:** derive the default at dialog open time using the same `formatBerthRange()` helper that powers the locked folder-naming convention (Bucket 4 #5). Format: `External EOI — <Client name> — <berth range> — <YYYY-MM-DD>` (e.g. `External EOI — Matthew Ciaccio — A1-A3, B5-B7 — 2026-05-21`). When no client or berths are linked, gracefully fall back to the current minimal form. Apply the same idiom to the Reservation + Contract external-upload dialogs for consistency. ~15 min.
> - **Effort:** ~5-7h end-to-end. ~1.5h for (a) — structured recipient editor + service shape change + migration if a dedicated table is preferred. ~1h for (b) — auto-fill resolver + admin setting for developer defaults + UI wiring. ~3-4h for (c) — dialog + send service + branded email template + audit + attachment-vs-link logic. ~15min for (d). Captured 2026-05-21 from UAT. **Cross-ref:** the broader UploadForSigningDialog rework (item below) needs the same role-tagging UI — build the recipient-list editor once and reuse on both dialogs. The default-title derivation in (d) also belongs as a shared helper since Reservation/Contract uploads should match.
> - **SHIPPED (a) + (d-prereq) in 301375a:**
> - (a) Structured `signatories: Array<{name, email, role}>` lands on the service input, the API multipart payload, and the dialog UI. Role enum: `client/developer/rep/witness/cc`. Auto-seeds the client row from `interestData.clientName + clientPrimaryEmail` via a signatoriesOverride/null pattern (React-Compiler safe).
> - (d-prereq) `document_signers` rows inserted inside the transaction for every non-CC signatory, pre-stamped `status='signed'`, `signedAt=input.signedAt`. The document-detail "X / Y signed" badge now renders the right count.
> - **Remaining (b) + (c) + (d) deferred:** developer-default settings, "Email copy" multi-recipient dialog, send pipeline + branded template, role-based email auto-fill beyond the client row — bundles with the broader Documenso send-flow work in Wave 4.
> - **UploadForSigningDialog comprehensive rework — 4 linked issues** — _src/components/documents/upload-for-signing-dialog.tsx_ — surfaced together during UAT 2026-05-21 of the Reservation Agreement send flow. All four touch the same dialog and should ship as one coherent pass.
> - **(a) [bug] "Failed to load PDF file" on the place-fields step** — the place-fields step uses `URL.createObjectURL(file)` (line 265) as `fileUrl` and passes it to react-pdf inside `FieldPlacementStep`. `pdf-viewer.tsx:149` `onLoadError` fires when react-pdf can't parse the blob. Likely causes to check: (i) the uploaded file isn't a PDF (PNG, DOCX, etc. — select-file step likely doesn't enforce `application/pdf` mime check); (ii) PDF.js worker URL misconfigured (every PDF fails the same way); (iii) blob revoked too early (`useEffect` cleanup at line 266-270 — though the deps look right); (iv) react-pdf version-incompatible with the worker bundle. **First debug step:** check browser devtools console for the actual error message — currently it's collapsed into a generic "Failed to load PDF file." string. Surface the underlying error to the UI ("Couldn't parse PDF — check that you uploaded a `.pdf` file, not an image or Word doc.") so the rep can self-diagnose.
> - **(b) [ux] Dialog way too small for the place-fields step** — dialog is `max-w-5xl` (1024px, line 166) which is fine for the recipients step, but the place-fields step has a 176px-wide field palette + 200px-wide recipients list on the left and only ~650px for the PDF preview on the right. A US Letter page at fit-width in 650px is barely legible, and field placement requires precision. **Fix:** make the dialog adaptive per-step: `max-w-3xl` for select-file + configure-recipients steps (768px is plenty for forms), but expand to `max-w-[1400px]` or `max-w-[95vw]` on the place-fields step where horizontal PDF space matters most. Alternative: full-screen modal pattern for the place-fields step only (escape exits, top bar shows step indicator + Back/Send). Also shrink the field palette from `w-44` (176px) to `w-32` (128px) by using icon-only buttons with tooltips — recovers ~50px of PDF width.
> - **(c) [feature gap] PlacedField shape missing `defaultValue` + `fieldMeta` (no UI to configure dropdown options, pre-fills, field labels, validation)** — _line 85-96, PlacedField interface_ — the current shape carries position + type + recipientIndex only. Documenso v2 `field/create-many` accepts per-field metadata that today's UI can't set:
> - **Dropdown:** options array. Today: rep places a Dropdown field → recipient sees an empty dropdown at signing time → blocked.
> - **Radio:** group label + option array. Same issue.
> - **Pre-filled defaults:** e.g., place a Name field assigned to "Matt Ciaccio" recipient + auto-populate with `interest.client.fullName` so the rep doesn't have to retype. Maps to Documenso's `defaultValue` per field.
> - **Text validation:** regex, minLength, maxLength — for fields like "passport number" or "phone".
> - **Field label:** custom label shown above the field at signing time (today defaults to the type name).
> - **Fix:** extend `PlacedField` with `defaultValue?: string`, `fieldMeta?: { options?: string[]; label?: string; required?: boolean; validation?: { regex?: string; minLength?: number; maxLength?: number } }`. Right-side properties panel on field selection (the selected-field UI already exists per the FieldPlacementStep code) gets new inputs per type:
> - Dropdown / Radio: textarea for "Options (one per line)".
> - Text / Name / Email / Number: input for "Default value" + optional "Pre-fill from" picker (Client name / Client email / Berth mooring / Interest date / …).
> - All types: "Required" checkbox + custom Label override.
> - The "Pre-fill from" picker is essentially a **per-field merge token** — borrowed from the EOI template merge-field catalog (`src/lib/templates/merge-fields.ts`). Reuse that token list + resolver so the same {{tokens}} that work in EOI templates work as field defaults here. Stitches the two flows conceptually: signer fields can be pre-filled from the same data sources EOI merge fields use.
> - **Backend wiring:** extend the v2 `field/create-many` payload in `documenso-client.ts` to pass `defaultValue` + `fieldMeta` (Documenso v2 supports these per their field API).
> - **(d) [behavior] Reservation flow should save as draft, not auto-distribute — match EOI pattern** — _line 361 + 477_ (the dialog reads `defaults?.data?.sendMode === 'auto'` system setting + changes the Send button label). User wants reservation agreement to ALWAYS save as Documenso draft so the rep can review in Documenso (preview email copy, double-check field placement, etc.) before manually triggering send. Per CLAUDE.md doc audit, EOI already uses this pattern (v2 `/template/use without distribute` → DRAFT envelope → rep distributes separately). Reservation should mirror.
> - **Fix:** option A (per-document choice, recommended) — add a small radio above the Send button in the dialog footer: `⦿ Save as draft (review in Documenso, send later) · ◯ Send immediately`. Default to "Save as draft" for reservation agreements (and contracts, by parity), since these high-stakes documents merit a review step. EOIs already follow the draft pattern; this just brings reservation/contract in line.
> - Option B (force manual) — hardcode `sendMode='manual'` for reservation / contract document types regardless of system setting. Less flexible but simpler.
> - **Lift the system `sendMode` setting to a per-document-type setting** so admins can independently configure auto-send for EOI / reservation / contract.
> - **Effort:** ~6-9h end-to-end for the full bundle (a + b + c + d + sweep of EoiGenerateDialog for parity on items b/c if applicable + tests). The dialog-width fix alone is ~30min; the rest of the work is the field-metadata schema + UI extension which is the heaviest piece. Captured 2026-05-21 from UAT.
> - **Skip-ahead backfill flow: surface real backfill controls below the banner (date pickers + signed-doc upload per gap)** — _src/components/interests/skip-ahead-banner.tsx:71_ (banner copy says "Backfill ... below" but nothing renders below), _src/components/interests/interest-tabs.tsx_ (the MilestoneSection past-phase render), _src/lib/services/interests.service.ts_ (PATCH path for date*eoi_sent / date_eoi_signed / date_reservation_signed / date_deposit_received), \_src/components/interests/interest-documents-tab.tsx* (existing upload flow we can lift from) — when a rep manually jumps a deal forward (e.g. Qualified → Reservation via the stage dropdown), the SkipAheadBanner fires and tells them to backfill, but the milestone card immediately below shows checkmarks with no controls to actually (a) set the historical date or (b) upload the signed PDF as evidence. The current `MilestoneAdvanceButton` has the date popover affordance, but it's only rendered for the NEXT unchecked step — past-but-undated steps render as a static checkmark + "—" with no edit affordance.
> - **Fix:**
> - (a) When a milestone is in the past phase AND its date column is null, render an inline "Set date" button next to the checkmark that opens the same Popover used by `MilestoneAdvanceButton` (date input defaulting to today, accepts any past date). On confirm, PATCH the relevant `date_*` column. No stage transition fires — just a date stamp.
> - (b) When a milestone is in the past phase AND its doc-status is not `'signed'` (or there's no associated `files.id` for the signed PDF), render an "Upload signed PDF" button next to "Set date" that opens a file picker, posts to the existing storage path, and flips the matching `*DocStatus` column to `'signed'` (mirrors what the Documenso webhook does on completion). For EOI specifically, the upload should link to the `documents` row representing the EOI so the file lands in the Documents hub via the same auto-deposit flow.
> - (c) Banner copy: convert the gap names from passive text into clickable jump-targets that scroll-into-view the corresponding past milestone card (e.g. "EOI sent date · EOI signed date" become anchor links). Reduces the "where is 'below'?" friction.
> - **Effort:** ~3-4h. Captured 2026-05-21 from UAT. (Bundles findings #1, #2, #3 below into one coherent backfill UX.)
> - **SHIPPED (date backfill control) in d8da1f6:** new `<MilestoneBackfillButton>` lands in the past-milestones strip whenever a date column is null (eoi/reservation/deposit/contract). Opens a DatePicker popover and PATCHes the relevant date\_\* column without firing a stage transition. **Signed-PDF upload per gap + clickable banner-gap anchor links remain parked** for the larger Documents-hub bundle.
> - **Current-stage milestone hidden under "Upcoming milestones" when its sub-steps are already checked off (active phase mislabelled)** — _src/components/interests/interest-tabs.tsx:611-624_ (`milestoneCompletion` map + `firstIncompleteKey` derivation) — the phase classifier marks a milestone as `'past'` whenever ALL its sub-steps are complete, so when the interest is at Reservation stage with reservation-agreement-signed already ticked (via the manual stage-jump), the Reservation milestone is `past` and EOI (which still has gaps because the rep hasn't backfilled) becomes the `firstIncompleteKey` → flagged as "NEXT STEP". Net effect (image 23): EOI shows as "NEXT STEP" + Reservation gets buried in the "Upcoming milestones" accordion even though it's the actual current stage.
> - **Fix:** introduce a third concept besides `past | current | future` — the milestone that owns the CURRENT pipeline stage (regardless of completion) should always be `current` and never be collapsed into the past-strip nor the upcoming-accordion. Compute the rep's "true current" milestone by mapping `interest.pipelineStage` → milestone key (eoi/eoi_sent/eoi_signed → 'eoi'; reservation → 'reservation'; deposit_paid → 'deposit'; contract_sent/contract_signed → 'contract'). The `firstIncompleteKey` rule still works for nurturing / qualified stages where no milestone naturally owns the stage. Past-but-fully-done milestones BEFORE the current stage go in the past-strip; future milestones go in the upcoming-accordion. Pair with the backfill-controls fix above so a "current" milestone with missing dates still has the affordances to fill them.
> - **Effort:** ~30-45 min. Captured 2026-05-21 from UAT.
> - **SHIPPED in d8da1f6:** introduced a `STAGE_TO_MILESTONE` map. When a stage owns a milestone (eoi/reservation/deposit_paid/contract), that milestone is forced to `'current'` regardless of sub-status completion; earlier-than-stage milestones bucket to `'past'` (so backfill controls render); later slots stay `'future'`. The legacy firstIncompleteKey rule still applies in stages without an owning milestone (enquiry/qualified/nurturing).
> - **Qualification auto-confirm "intent confirmed" once stage ≥ EOI (extend `computeAutoSatisfied`)** — _src/lib/services/qualification.service.ts:342-360_ (`computeAutoSatisfied` only branches on `'dimensions'` — `'intent_confirmed'` falls through to `false`) + the call-site context build at lines 296-316 (needs `pipelineStage` added) — when a rep manually advances the deal past Qualified to EOI/Reservation/Deposit/Contract, "Intent confirmed" still requires an explicit tick. The act of signing an EOI is itself the strongest signal of intent — leaving the row unchecked makes the checklist feel like noise. Extend the auto-satisfaction context with `pipelineStage`, add an `if (key === 'intent_confirmed') return stageIdx > qualifiedIdx;` branch, and `computeEvidence` returns "Stage advanced past Qualified" when triggered. Rep can still untick to overrule. **SHIPPED in 51ca875.**
> - **Effort:** ~30 min including the evidence string + an integration test. Captured 2026-05-21 from UAT.
> - **Qualification: stale explicit-tick survives removal of underlying auto-evidence (esp. dimensions)** — _src/lib/services/qualification.service.ts:296-334_ (`confirmed: explicit || autoSatisfied`) — `autoSatisfied` is recomputed at fetch time, but `explicit` persists in `interestQualifications.confirmed` once a rep has manually ticked the row. Result: if dims were present at one point, the rep clicked the box (or the auto-tick happened alongside an explicit click somewhere in the flow), then dims are later removed, the row STAYS ticked because `explicit=true` covers for `autoSatisfied=false`. The `AUTO` badge disappears so it now looks like a manual confirmation — but the rep may have no memory of making it. Footgun: checklist claims "Dimensions confirmed" with no underlying data.
> - **Fix (recommended — strict for derived-only criteria):** for keys where there's no rep judgement involved (`dimensions` today; future similar "does X data exist" checks), make the row purely derived — ignore `explicit`, return `confirmed: autoSatisfied`. Removing dims always unticks. Keep `explicit || autoSatisfied` for judgement-based keys like `intent_confirmed`. Implement by marking each criterion with a `derivedOnly: boolean` flag (lives next to the auto-rule) and branching in the merge.
> - **Alt (lenient with warning):** keep the OR but surface an `inconsistent` flag (`explicit && !autoSatisfied`) — UI renders the row with an amber "Evidence missing — re-verify" annotation, lets the rep re-confirm or untick.
> - **Effort:** ~45 min for strict (incl. integration test covering the remove-dims-after-tick flow); ~1h for lenient (annotation + amber styling). Captured 2026-05-21 from UAT. **SHIPPED (strict variant) in 51ca875:** `DERIVED_ONLY_KEYS` Set sentinel; merge branches on `isDerivedOnly(key)` to ignore explicit ticks for `dimensions`.
> - **Qualification checklist: collapse to one-line summary once "All confirmed"** — _src/components/interests/qualification-checklist.tsx_ — once every row is confirmed (explicit + auto combined), the full card stops being a gate and just occupies prime Overview real estate. Replace the expanded card with a single-row summary: `✓ Qualification — all confirmed (dimensions · intent)` + a chevron to expand on demand. Audit trail stays one click away. While expanded the rep can still untick or add notes; on next page load the card re-collapses if fully confirmed. Pairs naturally with the auto-confirm-on-stage-advance change above — deals at Reservation+ stage land with a collapsed Qualification block instead of a full card. Don't redesign the checklist content per stage (cognitive load); just change the visual weight once it's no longer informationally hot.
> - **Effort:** ~30 min. Captured 2026-05-21 from UAT. **SHIPPED in 51ca875:** card header is now a button-style toggle; aria-expanded; when fully confirmed it collapses to "✓ All confirmed (label · label)" + chevron; rep clicks header to inspect/untick.
> - **Yacht Ownership History tab: flesh out the controls; don't remove (carries real semantic load)** — _src/components/yachts/yacht-ownership-history.tsx_ + _src/components/yachts/yacht-tabs.tsx:333_ + _src/components/yachts/yacht-form.tsx:337-345_ (existing Transfer affordance) + _src/lib/services/yachts.service.ts:215_ (`transferOwnership` service) + _src/lib/db/schema/yachts.ts:72-96_ (`yachtOwnershipHistory` table with partial unique index `(yacht_id) WHERE end_date IS NULL`).
> - **Why keep:** the table isn't decorative — (i) partial unique index enforces one active owner at a time; (ii) berth reservation logic (`berth-reservations.service.ts`) gates "active company_membership on the owning company", so the yacht's ownership chain materially affects berth standing; (iii) the data is **already auto-populated** by `createYacht`, `transferOwnership`, and `public-interest.service.ts` — no rep effort required to maintain; (iv) audit trail value for disputed deals, EOIs generated under prior ownership, etc. Removing the tab AND/OR the table would lose audit fidelity and force reservation logic to derive ownership some other way. The "no way to enter/change" perception is a UI gap, not a missing concept.
> - **Flesh-out scope (recommended):**
> - (a) **Surface the existing Transfer flow on this tab** — the yacht form has a Transfer button (comment at line 345 confirms); add the same button to the Ownership History tab header (e.g. `"Transfer ownership →"`). Permission-gated by whatever the existing Transfer flow uses.
> - (b) **Empty-state CTA** — current empty state reads `"No ownership history"`. Replace with copy + a Transfer button so the tab is actionable on first visit, not dead-end.
> - (c) **Backfill / "Add historical entry"** — admin-only button that opens a small form (owner type/id, start date, end date, reason, notes) and inserts a row directly. Useful for backfilling pre-CRM ownership history for yachts brought over from NocoDB or legacy records. Permission: `yachts.edit_history` (new perm).
> - (d) **Edit controls on existing rows** — admin-only edit for `transferReason`, `transferNotes`, and `startDate`/`endDate` (with a strong confirm + audit log entry — these dates feed downstream logic). Don't allow editing `ownerType`/`ownerId` post-insert (use a Transfer/correction flow instead).
> - (e) **Link each row to the involved entity** — each row's `ownerType: 'client' | 'company'` + `ownerId` should render as a click-through link to the entity detail page. Right now likely a raw ID or just a label.
> - (f) **"Why was this entered?" trailing note on each row** — pull from `transferReason` (already in schema) + display `createdBy` (link to user) and `createdAt` (relative time). Tells the rep both what happened and who recorded it.
> - **Out-of-scope alternative:** if leadership concludes the audit value doesn't justify the UI cost, hide the tab from the rep-facing UI but **keep the table** + auto-populate hooks + admin-only access via `/admin/yachts/[id]/ownership-history` for the dispute case. Tab disappears from yacht detail; reservation logic continues to work. **User noted (2026-05-18):** if the tab is removed, the Transfer modal would also need to be removed — confirming that removing the tab is a coupled change with broader UI impact. Reinforces the recommendation to keep + flesh-out rather than remove.
> - **Recommendation:** ship (a) + (b) + (e) as the minimum-viable polish (~1.5h) — makes the tab feel intentional. (c) + (d) become admin-side work when there's actual demand for backfill or historical correction (~3-4h). Skip the "hide it" path unless explicit leadership ask.
> - **Effort:** ~1.5h for the minimum polish, ~5h for the full flesh-out. Captured 2026-05-18 from UAT (user weighed in towards "remove altogether"; the queue entry argues against because of the reservation-logic coupling + auto-population — final call still with the user). **SHIPPED (a) + (b) + (e) in 552b966:** "Transfer ownership" button on the tab header (perm-gated by `yachts.edit`); EmptyState action wired through to the dialog; existing OwnerLink rendering verified as link-through (e). Backfill / edit-controls (c)+(d)+(f) parked.
> - **Yacht Overview: replace single-textarea notes with the threaded `<NotesList>` (parity with clients / interests)** — _src/components/yachts/yacht-tabs.tsx:227-236_ (the legacy single-text-field at the bottom of OverviewTab) + _src/components/yachts/yacht-tabs.tsx:351_ (the full `<NotesList entityType="yachts" />` already rendered in the dedicated Notes tab) + _src/components/shared/notes-list.tsx_ — Overview today shows `<InlineEditableField variant="textarea" value={yacht.notes} ... />` — a single `yachts.notes` string column, last-edit-wins. The dedicated Notes tab has the full threaded `<NotesList>` (one entry per note, author + timestamp + edit/delete + aggregate). Clients and interests already surface threaded notes without leaving Overview.
> - **Fix:** replace the OverviewTab notes block (lines 227-236) with `<NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />`. The `yachtNotes` table already exists (per CLAUDE.md polymorphic notes architecture: `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes`) so no backend work.
> - **Legacy `yachts.notes` column:** verify (a) anything else writes it (other than this textarea); (b) anything reads it (EOI / contract / template merge fields). If unused elsewhere, deprecate the column and stop surfacing it on Overview; the threaded NotesList becomes the canonical write path. If still in use, leave the column but stop surfacing on Overview.
> - **Companion decision:** with NotesList on Overview, the dedicated Notes tab may become redundant — same tradeoff applies to clients/interests today. Defer that decision; ship the inline NotesList first.
> - **Effort:** ~30 min for the swap + verify `currentUserId` is plumbed through to OverviewTab. Captured 2026-05-18 from UAT. **SHIPPED in c6dcf49:** OverviewTab now renders `<NotesList entityType="yachts" parentInvalidateKey={['yachts', yachtId]}>`; `currentUserId` plumbed through. Legacy `yacht.notes` column retained for EOI/contract merge-field path; decision on the dedicated Notes tab deferred.
> - **`/invoices/upload-receipts` guide: copy rewrite — terse, professional, in the luxury-CRM voice** — _src/components/invoices/upload-receipts-guide.tsx_ (the whole page; ~190 lines, ~75% of which is body copy) — current copy reads like a friendly onboarding tutorial: hand-holding ("Snap a photo of the receipt with your phone"), explanatory tangents ("The behind-the-scenes part is called OCR..."), throwaway pleasantries ("Most of the time everything is correct", "No typing. No spreadsheets. No chasing finance for the form."), and parenthetical asides ("the square with the arrow pointing up"). Tone is out of step with the rest of the CRM — the platform's brand voice is precise, restrained, declarative; this page reads warm-blog. Rewrite passes:
> - **PageHeader description** (line 31) — currently `"When you spend your own money on a business expense for the marina, use this to log it. Snap a photo of the receipt with your phone, the system reads it for you, and finance approves it on the parent company's side."` → suggested: `"Capture out-of-pocket expenses for reimbursement. The system extracts vendor, date, total, and currency from each receipt and routes the claim to finance."`
> - **"What does it actually do?" section** (lines 51-65) — replace title with `"Overview"`. Replace the two paragraphs with one line: `"Submit a photographed receipt; the system populates the expense form via OCR with AI-assisted field extraction, then forwards the claim for parent-company approval."` Drop the OCR explanation entirely — the audience is internal staff, not customers.
> - **Step 1 ("Add the scanner to your phone")** — retitle `"Install the scanner"`. Description → `"One-time setup. The scanner launches from the home screen thereafter."` Per-platform steps: drop parentheticals ("the square with the arrow pointing up"), drop the "Confirm the name 'Scanner'..." cruft, drop the trailing "Done." block in `PlatformBlock`.
> - **Step 2 ("Snap a photo of a receipt")** — retitle `"Capture a receipt"`. Description → `"Open the scanner from the home screen."` Each list item to one short sentence: `"Tap the camera tile and frame the receipt."` / `"The system extracts vendor, date, total, and currency."` / `"Review the populated fields; tap to amend."` / `"Tap Save to submit for approval."`
> - **"Tips for the best results"** — retitle `"Tips"`. Drop conversational asides; cap to 3-4 bullets, each one sentence.
> - **Target length:** ~60-70% reduction. Reads in 30 seconds instead of 3 minutes; the rep gets the workflow, not a friendly essay.
> - **Companion audit:** flag for review across other guide / help / empty-state copy that may have drifted into the same warm-blog voice (consumers of `src/components/shared/empty-state.tsx`, any `*-guide.tsx` pages, onboarding flows, longer Toast copy). One pass for tone consistency platform-wide — captured as a deferred follow-up; this page is the most visible offender.
> - **Effort:** ~45 min for this page; ~3-4h for the platform-wide tone audit if pursued. Captured 2026-05-18 from UAT.
> - **SHIPPED in e33313b:** page rewritten to terse luxury-CRM voice. PageHeader description, "What it does" section, Step 1 ("Install the scanner"), Step 2 ("Capture a receipt"), best-practices list, and PlatformBlock trailing line all tightened. Dropped the OCR mini-essay, "fancy phone camera" / "No typing. No spreadsheets" pleasantries, and parenthetical asides. ~50% size reduction in body copy. Platform-wide tone audit still parked.
> - **Expenses page header copy: drop "port" from the description** — _src/app/(dashboard)/[portSlug]/expenses/page.tsx:33_ → PageHeader at _src/components/shared/page-header.tsx:38_ — description currently reads `"Track and manage port expenses"`; user wants the word `"port"` removed. Suggested copy: `"Track and manage expenses."` (or, if the team wants to keep the "manage" verb spelled out, `"Track and manage business expenses."`). Trivially small. ~30 sec. Captured 2026-05-18 from UAT — likely indicative of a broader "remove the word 'port' from user-facing copy where it's redundant" pass; the portSlug already scopes everything, so user-facing strings shouldn't restate it. Worth a quick grep for `port expenses`, `port clients`, `port settings`, etc. in component strings. **SHIPPED in c6dcf49:** "port" dropped. Platform-wide grep sweep deferred to follow-up.
> - **Topbar search: widen + center against the _viewport_ (including sidebar space), not the topbar's middle grid slot** — _src/components/layout/topbar.tsx:57_ (grid template) + _src/components/layout/topbar.tsx:77-84_ (search container) + _src/components/search/command-search.tsx:103_ (the input itself) + _src/app/globals.css:114_ (`--width-sidebar: 256px` token already available) — current behaviour: the topbar uses `grid grid-cols-[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)]` inside the AppShell's main area (right of the sidebar), so the search bar centers within _the topbar_ — visually it sits offset to the right of the screen by half the sidebar width because the topbar itself starts after the sidebar. User wants the search visually centered against the full viewport (sidebar inclusive) and wider.
> - **Two coordinated changes:**
> - **(a) Wider:** bump the search container's `max-w-md` (448px) at line 81 to `max-w-2xl` (672px) or `max-w-3xl` (768px), and bump the topbar grid's middle slot from `minmax(360px,640px)` to `minmax(420px,800px)`. Cap to whatever still leaves room for the left breadcrumbs + right action row on common laptop widths (1280px - 256px sidebar = 1024px main area minus padding). 672-720px is a comfortable upper bound.
> - **(b) Viewport-centered:** the surgical trick uses the existing CSS variable. Apply a `translate-x` on the search wrapper that shifts it left by half the sidebar width: `style={{ transform: 'translateX(calc(var(--width-sidebar) / -2))' }}` (or a Tailwind arbitrary class `-translate-x-[calc(var(--width-sidebar)/2)]`). With the sidebar at 256px, the search shifts 128px left, landing its centre at viewport-50%. Works because the topbar's grid + `mx-auto` already centers the search within the post-sidebar area; subtracting half the sidebar width re-centers against the full viewport.
> - **Edge cases to handle:**
> - **Sidebar collapsed (64px):** wire the transform to use the collapsed-aware width. Cleanest: expose a single `--current-sidebar-width` CSS variable on the sidebar root that flips between `var(--width-sidebar)` and `var(--width-sidebar-collapsed)` based on collapse state. Topbar's search wrapper reads `--current-sidebar-width` so the shift adjusts automatically with no React state plumbing. ~10 min to add the variable + ~5 min to wire the transform.
> - **Mobile (< sm):** the sidebar is hidden and the layout is different (`MobileLayoutProvider` with bottom-tabs); the transform should only apply on `sm:` and up. Use `sm:-translate-x-[calc(var(--current-sidebar-width)/2)]`.
> - **Left column doesn't get visually overlapped:** since the search shifts via transform (paint-only, doesn't affect layout flow), the breadcrumbs in the left grid slot retain their declared width — but the search will visually overlap them. Solution: reduce the breadcrumbs slot's effective width (e.g. `minmax(0,0.6fr)` instead of `1fr`) OR add `pointer-events: none` to the breadcrumbs when the search is focused. Easier: hide breadcrumbs on narrower laptop widths and rely on the back-chevron + page-h1 for context (also addresses the breadcrumb-wrap finding above).
> - **Effort:** ~30-45 min total — the `--current-sidebar-width` variable + the transform + the grid bump + verifying behaviour at collapsed/expanded/mobile. Captured 2026-05-18 from UAT. **SHIPPED in 8fcbe45:** grid middle slot bumped from `minmax(360,640)` → `minmax(420,800)`; search wrapper `max-w-md` → `max-w-2xl`; `sm:-translate-x-[calc(var(--width-sidebar)/2)]` centers against the full viewport. Collapsed-sidebar-aware `--current-sidebar-width` variable parked.
> - **Pageviews chart: X-axis date ticks too cramped — drop the time component** — _src/components/website-analytics/pageviews-chart.tsx_ (recharts `XAxis`) — current bucket labels render in `YYYY-MM-DD HH:MM:SS` format from Umami's `x` field, which the chart's X-axis prints verbatim. On a 30-day range the labels overlap into an unreadable strip. Fix: pass a `tickFormatter` to `XAxis` that parses `row.x` and renders just the date portion (`MMM d` or `M/d`), keeping the timestamp available via Tooltip's full-precision render. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED (formatter) earlier (already in place as "MM-DD"); thinning in e33313b:** added `interval="preserveStartEnd"` + `minTickGap={52}` to `XAxis` so multi-week ranges anchor first/last ticks and Recharts thins out the middle automatically instead of overlapping.
> - **Pageviews chart: inline note explaining Pageviews vs Sessions** — _src/components/website-analytics/pageviews-chart.tsx_ + the Card's CardHeader subtitle slot — add a small `?` info popover (matching the pattern on the Pipeline Value tile) next to the chart title that explains: "Pageviews = total page hits including refreshes. Sessions = distinct visitor sessions (a single visitor browsing multiple pages = 1 session, many pageviews)." Helpful because the chart shows both series and the distinction is non-obvious. ~10 min. Captured 2026-05-18 from UAT.
> - **Inbox page: swap section order — Reminders above Alerts** — _src/components/inbox/inbox-page-shell.tsx:84-111_ — current order is `Alerts` (line 84) then `Reminders` (line 99). User wants the order reversed so Reminders is the top section. Swap the two `<section>` blocks; ids (`inbox-section-alerts`, `inbox-section-reminders`), URL-hash deep-link logic, and the localStorage open-state keys all remain untouched (they're keyed on section id, not order). PageHeader copy "Alerts & Reminders" should also flip to "Reminders & Alerts" to mirror the new visual order. ~3 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543.**
> - **Inbox → Reminders: move filter row inline with the "New Reminder" button (embedded mode)** — _src/components/reminders/reminder-list.tsx:298-315_ — in embedded mode (used by Inbox), the "New Reminder" button renders on its own line at line 298-311 (`<div className="mb-3 flex justify-end">`), and the filters row (My/All tabs + status filter + priority filter) renders separately below at line 315. The two should share one row: filters left, button right. Fix: merge the two into a single `<div className="mb-4 flex flex-wrap items-center gap-3 sm:gap-4">`, keep the filter controls in their current order at the start, and append the "New Reminder" button with `className="ml-auto"` (or wrap the filters in a container + put the button as a sibling and use `justify-between`). Non-embedded mode (PageHeader path at lines 282-297) is unaffected. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543.**
> - **Breadcrumb wrap looks broken: orphaned separator + back-chevron misaligned** — _src/components/ui/breadcrumb.tsx:15-27_ + _src/components/layout/topbar.tsx:55-75_ — when the breadcrumb wraps (e.g. `Administration Berths Bulk Add` in the narrow left topbar slot), three visual issues stack: (1) trailing `` separator after "Berths" hangs at the end of line 1 with nothing after it (orphaned, because separators are siblings of items in the `<ol>` so the flex-wrap break can land between an item and its separator); (2) "Bulk Add" wraps to line 2 indented; (3) the back-chevron `<` sits left of the wrapped line and is taller than the wrapped line, throwing off vertical alignment. Together it reads as a layout bug, not a wrap.
> - **Three coordinated fixes — ship (a) at minimum, do (b) for the real polish:**
> - **(a) Quick: make separator inline with the preceding item so wrap can't strand one** — restructure so each `<li>` contains both the label AND its trailing separator (single inline-flex unit), except the last crumb which has no separator. Drop the standalone `<BreadcrumbSeparator>` `<li>` from `Breadcrumbs` consumer. The primitive's `BreadcrumbSeparator` stays exported for backcompat. Wrap then breaks between full crumbs cleanly. ~15 min.
> - **(b) Better: ellipsis-collapse middle crumbs on overflow** — industry-standard pattern. When crumb count > 3 OR available width can't fit all crumbs single-line (detect via `ResizeObserver` on the `<nav>` or a CSS `:has(+ overflow)` trick), collapse middle crumbs to a `<BreadcrumbEllipsis>` button that opens a dropdown listing the hidden crumbs. First (root) + last (current page) always visible. Primitive already exports `BreadcrumbEllipsis` — just wire it. ~45 min. Result: breadcrumb stays single-line at every width, no wrap at all.
> - **(c) Layout polish: top-align the back-chevron** — _topbar.tsx:59_ — change the wrapping `<div className="min-w-0 flex items-center gap-1.5">` to `items-start` so even if the breadcrumb does wrap, the back-button stays top-aligned with the first crumb line instead of vertical-centering across the wrapped block. Also worth considering: hide the back-button when meaningful breadcrumbs are visible (the breadcrumb's parent link already does "go back"; two affordances is one too many). ~10 min.
> - **Topbar grid sizing observation:** topbar columns are `[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)]` — left slot competes for space with the centered search bar's `minmax(360px,640px)`. When search hits its max width, left slot is squeezed → breadcrumb wraps sooner. Consider bumping to `minmax(0,1.5fr)` OR letting the search shrink below 360px when needed. Optional, evaluate after (a)+(b) land.
> - **Effort:** ~15 min for (a), ~45 min for (b), ~10 min for (c). Bundle ~1h. Captured 2026-05-18 from UAT. **SHIPPED (a) in 8fcbe45:** each crumb + its trailing ChevronRight now share a single `<BreadcrumbItem>`; flex-wrap can no longer strand a separator. Ellipsis-collapse (b) + back-chevron alignment (c) parked.
> - **BulkAddBerthsWizard: currency field should use `<CurrencySelect>` (already exists, used elsewhere)** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the `priceCurrency` `<Input>` in the apply-to-all row at ~lines 282-290, and the per-row instance below it) — currently a free-text `<Input>` that uppercases on blur, defaulting to `USD`. Reps can type any string (including invalid codes); no auto-complete; no consistency with other forms. The `<CurrencySelect>` component already exists at _src/components/shared/currency-select.tsx_, backed by the curated `SUPPORTED_CURRENCIES` list in _src/lib/utils/currency.ts_, and is used by the single-berth edit form (_berth-form.tsx:414_) + the expense form dialog (_expense-form-dialog.tsx:238_). Quick fix: import `CurrencySelect`, replace both the apply-to-all and per-row currency inputs with the dropdown bound to the same handlers (`applyToAll('priceCurrency', v)` / `setRowField(idx, 'priceCurrency', v)`). ~10 min. Captured 2026-05-18 from UAT. **SHIPPED in 2bcf544.**
> - **BulkAddBerthsWizard + single-berth editor: toggleable input units (ft/m) for dimension fields** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the "Width (ft)" / "Length (ft)" / "Draft (ft)" inline-table headers + input parsing), _src/components/berths/berth-form.tsx_ (or equivalent single-edit) — the wizard's column headers and input parsing are hard-coded to feet. The schema supports per-dimension entry-unit discriminators (`lengthUnit`, `widthUnit`, `draftUnit` on `berths`, all defaulting to `'ft'`) plus separate `_M` numeric columns where metres-original values live — but neither the bulk wizard nor the single editor lets the rep pick which unit they're typing in. Reps who think in metres convert manually and the entry-unit discriminator never gets set.
> - **Fix:** (a) add a small `ft | m` toggle in the wizard header (and on the single-berth edit form) that flips the column header labels (e.g. "Width (ft)" → "Width (m)") and the parser. The toggle should default to whichever unit the user's `dimensionUnit` preference is set to (see the Dimensions-column-toggle finding earlier — same preference). (b) On submit, if entered unit is `'m'`, convert to ft for the stored numeric (`berths.lengthM` is the canonical metres column; `lengths.lengthFt` would be the feet column — verify the actual column names) AND set `lengthUnit='m'` so downstream document generation honours the rep's original input. Same for width / draft / nominalBoatSize / waterDepth. (c) Reuse the `src/lib/utils/dimensions.ts` helper from the Dimensions-column finding so conversion is centralized.
> - **Why this matters beyond UX:** document-generation merge fields (EOI / contract) already pull entry-unit values per `effectiveDimensionUnit` so the legal doc matches the rep's intent. Hard-coding ft on input silently coerces metric reps' values through a mental conversion, then renders the resulting ft figure on documents — losing fidelity for European customers.
> - **Effort:** ~1.5-2h end-to-end (wizard toggle + single-form toggle + parser + tests). Coordinate with the Dimensions-display toggle finding so both UI surfaces use the same preference key + helper. Captured 2026-05-18 from UAT.
> - **BulkAddBerthsWizard: allow defining new dock/pontoon letters in-flow (or surface the admin path)** — _src/components/admin/bulk-add-berths-wizard.tsx:78_ + _the dock/area model_ — current wizard appears to assume the dock letter already exists (per CLAUDE.md the mooring format is `[A-Z]+\d+` like `A1`, `B12` — the letter prefix is a dock/pontoon identifier). When a rep is adding berths for a _new_ dock, there's no inline way to introduce the new letter; they have to abandon the wizard, create the dock elsewhere, then come back. Two possible models — confirm which one applies in this codebase before building:
> - **(a) Dock letters are free-form / inferred from `berths.mooring_number`** (no separate `docks` table): then the wizard just needs to allow a new letter prefix in its input. UI fix: replace the letter input (or dropdown) with a combobox-style "pick existing or type a new letter" control — same idiom as Tag picker. Backend: nothing — first insert with the new prefix establishes the dock. ~30 min.
> - **(b) Docks are a first-class entity** (separate `docks` table with `port_id` + `letter` + metadata like `position`, `pontoon_type`, `power_capacity`): then the wizard needs a "+ New dock" affordance opening a small dock-create dialog (letter + name + optional metadata), then returning to the wizard with the new dock pre-selected. Permission: `berths.manage_docks` (or whichever owns dock metadata). The user's question — "_or is this an admin setting?_" — suggests they're not sure either; if it IS an admin-only concern (docks are infrastructure not data the rep should touch), then keep it admin-side and just surface a contextual link in the wizard ("New dock? Add it in Admin → Docks first → [link]"). ~1-2h depending on the model.
> - **Action item:** check whether `docks` / `pontoons` / `marina_sections` table exists in the schema (`grep -r "docks\|pontoons" src/lib/db/schema/`); shape the fix accordingly. If no dedicated table, the wizard fix is trivial; if there is one, decide admin-only vs in-wizard-create with the team. Captured 2026-05-18 from UAT.
> - **DropdownMenu content stretches to fill viewport — cap it** — _src/components/ui/dropdown-menu.tsx:66_ — the shadcn `DropdownMenuContent` primitive uses `max-h-(--radix-dropdown-menu-content-available-height)` (Radix's CSS variable that exposes the room between the trigger and the viewport edge). On long lists the menu visually stretches all the way to the viewport bottom even though the items don't need that height; reads as a wall of options. Internal `overflow-y-auto` is already on so scrolling works. Fix: replace the Radix `max-h-(...)` token with a fixed `max-h-96` (384px) or `max-h-[28rem]` (448px) so the menu caps at a comfortable height regardless of available space, scrolling internally for longer lists. Global change in the base primitive — affects every dropdown in the app, which is the right call (no consumer currently relies on the "fill the viewport" behaviour). ~2 min. If a specific dropdown needs the old behaviour, it can pass `className="max-h-[var(--radix-dropdown-menu-content-available-height)]"` to opt back in. Captured 2026-05-18 from UAT. **SHIPPED in c6dcf49.** Subsequent tightening in **e33313b:** `max-h-96` now `max-h-[min(24rem,var(--radix-dropdown-menu-content-available-height,24rem))]` so on small viewports the menu caps at the available space (not just the static 24rem) before falling back to internal scroll.
> - **DocumentsHub aside column: flush-left with the app sidebar (kill the AppShell padding for this page)** — _src/components/documents/documents-hub.tsx:246_ + _src/components/layout/app-shell.tsx:113-121_ — the desktop `<main>` wrapper applies `px-6 pt-3 pb-6` to all dashboard pages, so the DocumentsHub two-pane (`ResizablePanelGroup` with the `<aside>` folder column on the left) gets 24px of whitespace between the global app sidebar and its own border. The folder column should sit flush against the app sidebar — it reads as "an extension of the navigation," not "a card inside the page." Fix (surgical): change DocumentsHub's root `<div className="h-full">` at line 246 to `<div className="h-full -mx-6 -mt-3 -mb-6">` (mirror the AppShell desktop padding so the hub renders full-bleed inside the main viewport). Add a comment explaining the intentional escape. The right-pane content keeps its own internal `p-4` so it doesn't run flush with the viewport edge. **Alternative (cleaner long-term):** make the AppShell padding route-aware via a prop on `<main>` (or a layout-level opt-out for hub-style pages); but (a) is the right call until a second page needs the same treatment. ~5 min for the negative-margin fix. Captured 2026-05-18 from UAT. **SHIPPED in 8fcbe45:** `sm:-mx-6 sm:-mt-3 sm:-mb-6` on the wrapper (mobile layout unchanged).
> - **DocumentsHub: hide breadcrumb on root "All documents" view, move PageHeader up to fill the space** — _src/components/documents/documents-hub.tsx:196-209_ — the top row currently always renders the `FolderBreadcrumb` (and conditionally the `NewDocumentMenu` when a folder is selected); on the root view (`selectedFolderId === undefined`) the breadcrumb shows only a "Home / All documents" label with no useful navigation, eating vertical space above the `PageHeader` that already says "Documents" + description. Fix: wrap the entire breadcrumb row at line 196-209 in `{selectedFolderId !== undefined && ( … )}` so the row is gone on the root; the PageHeader becomes the top element. When the rep navigates into a folder, the row reappears with both breadcrumb + NewDocumentMenu (the existing folder views don't render PageHeader, so the breadcrumb is the wayfinding cue). ~5 min. Captured 2026-05-18 from UAT. **SHIPPED in 2bcf544.**
> - **Residential InterestsTab: whole row should navigate to the interest, not just the "View" link** — _src/components/residential/residential-client-tabs.tsx:273-289_ — current `<li>` lays out `[stage chip] [preferences/notes truncated text] [View → link]` and only the "View" text on the right is clickable. The whole row should be a target, matching the idiom used in the main client's `InterestRowItem` (`src/components/clients/client-interests-tab.tsx:53`) — the entire card is a `<button>`/`<Link>` so reps can tap anywhere. Fix: wrap the `<li>`'s flex container in `<Link href={…}>` (`className="block w-full"` to preserve layout), drop the trailing "View" link, add `hover:bg-muted/50` to make the affordance discoverable. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED (client-detail tab) in c6dcf49.** **SHIPPED (standalone list page) in e33313b:** `<tr onClick={router.push(...)}>` on `residential-interests-list.tsx`; first-cell `<Link>` stops propagation so middle-click / Cmd-click still opens in a new tab.
> - **Residential namespace breadcrumb link is 404** — _src/components/layout/breadcrumbs.tsx_ (the breadcrumb generator splits the URL and makes every segment a link) + missing _src/app/(dashboard)/[portSlug]/residential/page.tsx_ — on any `/{portSlug}/residential/clients` or `/{portSlug}/residential/interests` page, the breadcrumb renders "Residential" as a link to `/{portSlug}/residential` but no `page.tsx` exists at that path (only `clients/` and `interests/` subdirectories). Clicking the breadcrumb yields a 404. Two reasonable fixes:
> - **(a) Quickest:** create `src/app/(dashboard)/[portSlug]/residential/page.tsx` as a server component that calls `redirect(`/${portSlug}/residential/clients`)`. Single file, ~5 min, breadcrumb works immediately. Same pattern works for any other namespace-only segment that lacks a real landing page.
> - **(b) Cleaner long-term:** add a "namespace" concept to the breadcrumb generator — segments that exist only as URL parents (residential, admin if applicable, …) render as plain text (`<BreadcrumbPage>`) rather than `<BreadcrumbLink>`. Centralized in `breadcrumbs.tsx`'s `SEGMENT_LABELS` map by extending the value to `{ label, namespace?: boolean }`. ~30 min, fixes the class of problem instead of one instance.
> - **Recommendation:** ship (a) now, queue (b) if/when a second namespace-only segment hits the same issue.
> - Captured 2026-05-18 from UAT. **SHIPPED (a) in c6dcf49:** new `src/app/(dashboard)/[portSlug]/residential/page.tsx` server-redirects to `/${portSlug}/residential/clients`. (b) namespace concept queued for the second-instance case.
> - **Residential client detail header: match the main ClientDetailHeader layout** — _src/components/residential/residential-client-detail-header.tsx_ vs _src/components/clients/client-detail-header.tsx_ — the main client header is rich (`Email` / `Call` / `WhatsApp` deeplink button row using primary contact channels, `PortalInviteButton`, `GdprExportButton`, tag chips, top-right action menu with Bell/reminder + Archive/Restore state-aware + perm-gated hard-delete, archived badge with conditional dialog routing). The residential header (33 lines vs 244) shows only an eyebrow, an inline-editable name, a status badge, and place-of-residence — visually orphaned next to the main client experience.
> - **Data-model gap to bridge:** residential clients store contacts inline (`email`, `phone`, `phoneE164`, `phoneCountry` columns on `residentialClients`) rather than via the polymorphic `clientContacts` table the main model uses. Action buttons can still be wired by synthesizing a `[{ channel: 'email', value, isPrimary: true }, { channel: 'phone', value: phone, valueE164, isPrimary: true }]` shape from the inline columns. Other features need verification per residential: tags table exists? portal invite (`residential_clients` has no `clientPortalEnabled` flag → likely N/A); GDPR export (yes — applies to any natural person in EU residence; need a `residential-gdpr-export` route if not already there); archive/restore (residential uses its own service; verify the dialog component expects a `residentialClientId` or needs a separate `ResidentialSmartArchiveDialog`).
> - **Approach options:**
> - **(a) Copy-and-adapt the JSX shape, residential-specific dialogs** — fastest path. Rebuild `residential-client-detail-header.tsx` with the same layout: title row (truncated name + archived badge), meta line (country · added date), action button row (Email / Call / WhatsApp synthesized from inline columns + optional GDPR export), tag chips (if/when residential gets tags), top-right Bell + Archive/Restore + perm-gated hard-delete. Skip features that don't apply to residential (PortalInviteButton). Parallel residential-specific dialogs where the existing client dialogs don't accept a residential type. ~1.5h.
> - **(b) Extract a shared `EntityDetailHeader` primitive** — better long-term. Refactor the main `ClientDetailHeader` to consume a generic `EntityDetailHeader` that takes `{ title, eyebrow?, meta[], contacts[], tags[], topRightActions[], archived }` and renders the layout. Both client headers become thin wrappers that map their entity to the shape. ~3-4h, eliminates the divergence that just got reported, and future entity headers (companies, yachts) can adopt it too — the visual idiom would propagate for free.
> - **Recommendation:** ship (a) now for fast visual parity; queue (b) as a separate Bucket 3 refactor when there's appetite for cross-cutting work. Captured 2026-05-18 from UAT.
> - **StageStepper: surface stage names visibly on reached slices** — _src/components/clients/client-pipeline-summary.tsx:43-82_ (the shared `StageStepper`, used on every client → Interest row card via `InterestRowItem` at `src/components/clients/client-interests-tab.tsx:87`, in the hero/panel variants of `ClientPipelineSummary` — including the per-interest links rendered by `PanelVariant` — and any other caller; fix-once-in-the-shared-component means every surface benefits) — the bar today is a 6px segmented track where each of the 7 pipeline stages is an equal-width slice (filled = reached, hollow = pending). Stage names live only in the `title=` attribute (hover tooltip), so reps have to mouse over to know which slices are filled. User wants the names visible — at least for stages the interest has reached or is currently in.
> - **Recommended approach (concise):** Keep the segmented bar exactly as-is (preserves the visual rhythm + works in narrow cards). Render an inline breadcrumb row underneath with one chip per _reached_ stage — chronological left-to-right, last chip = current stage (filled-emphasis using the stage's `STAGE_BADGE` colour), prior chips in the muted variant of the same colour family with a connecting `→`. Pending stages are not labelled (the bar carries that info). Reads as: `Enquiry → Qualified → EOI` for a deal currently in EOI. ~45min.
> - **Alternative (verbose):** Convert `StageStepper` to a true horizontal stepper layout — text label above each tick, current stage bolded, past stages muted, pending stages greyed. More familiar pattern but takes more vertical space and wraps awkwardly on narrow containers (a client card with 4-5 active interests stacks them all). ~1.5h, including a `compact` prop so the hero variant can keep the dense form.
> - **Recommendation:** ship the inline breadcrumb (concise) — solves the "I can't tell what stage this is at without hovering" complaint with minimum visual footprint, and the existing `STAGE_BADGE` colour map provides the per-stage tint for free. Add a `showLabels?: boolean` prop to `StageStepper` so the dense rail-tile variants (`size="xs"`) can opt out. Captured 2026-05-18 from UAT.
> - **SHIPPED in e33313b:** chose the "verbose" stepper variant — stage-name row underneath the bar showing every stage's `STAGE_SHORT_LABELS`. Reached stages render in foreground text; future stages in muted/60 so the rep still sees the ladder ahead. `size="xs"` keeps the dense rail-tile variant intact (no labels row). `STAGE_SHORT_LABELS` re-exported through `pipeline-constants.ts`. Inline-breadcrumb chips variant deferred — the verbose row reads better at the typical container widths we see in practice.
> - **EntityActivityFeed: rewrite per-row rendering to surface _what_ changed** — _src/components/shared/entity-activity-feed.tsx_ (the shared per-entity timeline used on Clients / Yachts / Berths / Residential / Interest detail pages) — each row currently reads `"<actor> updated the <field>"` with the old→new values dropped on a second line, often null or rendered as a truncated JSON dump. Reps can see _something_ changed but not _what_. Several coordinated fixes — pick the subset that's worth doing in one pass:
> - **(a) Bake the value into the sentence line.** Replace `sentence()` (lines 70-77) so when both `fieldChanged` and `newValue` are present the row reads `"<actor> set <field> to <new>"` (with `(was <old>)` appended in muted text on the same line if `oldValue` exists). Eliminates the separate strikethrough line in 80% of cases and reads like a sentence, not a diff. Keeps the separate diff line only for long-form changes (notes body, descriptions) where truncation matters.
> - **(b) Type-aware value formatting beyond the four enums already handled.** `formatValueForField()` (lines 48-66) special-cases `pipelineStage`, `source`, `leadCategory`, `outcome`. Extend with: user-FK fields (`assignedTo`, `ownerId`, `createdBy`) resolved to display names via the same bulk-resolution pattern queued in the actor/diff UUID finding above; berth-FK fields (`berthId`, `primaryBerthId`) resolved to mooring number; yacht-FK / company-FK fields resolved to entity name; date columns (`outcomeAt`, `dueDate`, `startDate`) formatted as `MMM d, yyyy`; currency columns (`price`, `total`) formatted via `formatCurrency` with the row's currency code from `metadata`; boolean toggles rendered "enabled" / "disabled" instead of "true" / "false"; JSON / object values get a one-line summary (e.g. address → `"Address updated: 123 Main St → 456 Elm St"` rather than the JSON dump).
> - **(c) Compound-action verbs.** The seven `ACTION_VERBS` (lines 26-34) cover only the generic CRUD set. Many real audit-log entries use compound actions (`linked`, `unlinked`, `signed`, `sent`, `viewed`, `archived`, `set_primary`, `merged_into`, `reassigned`, …) that fall back to printing the raw action verb. Audit `audit_logs.action` distinct values for the active port and add a verb + sentence template per case, e.g. `linked` → `"<actor> linked <related-entity-label>"` (reads metadata for the related entity's id + type and renders a clickable link). Templates per action keep the sentence rendering type-safe instead of a giant switch in `sentence()`.
> - **(d) Use `metadata` for create rows.** `create` rows currently say `"<actor> created this record"`. Pull the entity's name/mooring/identifier out of `metadata` (or a small lookup if metadata's empty) so it reads `"<actor> created client <Name>"` / `"<actor> created berth <A12>"`.
> - **(e) Collapsed-session preview text.** The `SessionGroupItem` collapse (lines 245-260) currently reads `"<actor> made N changes in this session"`. Show a one-line preview of _which_ fields were touched (e.g. `"Matt changed pipeline stage, owner, and 2 more fields"`) so reps can see if the session is worth expanding without clicking.
> - Effort: ~2h for (a)+(b)+(d) (the most user-visible wins, all in this one file plus a thin bulk-resolution helper in the activity-feed service). ~1h for (c) (registry of action templates). ~30min for (e). Total ~3.5h for the full bundle, or pick (a)+(b)+(d) as the high-value MVP at ~2h. Captured 2026-05-18 from UAT — same surface as the activity-feed UUID resolution finding above (the bulk-resolution helper introduced for that finding is the prereq for (b)'s user-FK resolution; do these in one pass).
> - **Client → Companies tab: add CTA to link or create a company membership** — _src/components/clients/client-companies-tab.tsx_ (the tab, including the EmptyState at lines 44-51 and the table-populated branch at 53-101) — the tab currently shows a list of company memberships pulled from `company_memberships`; the EmptyState literally tells the rep "Add a membership from a company's detail page" — a backwards workflow that forces them to leave the client they're working on, navigate to a company, then come back. The populated view also has no "Add another" affordance.
> - **Backend ready:** `POST /api/v1/companies/[id]/members` already exists (with corresponding `PATCH` and `DELETE` on `/members/[mid]`, plus `POST /members/[mid]/set-primary`) and accepts a `clientId` in the body. No new schema work needed.
> - **UI work:** (1) Add a primary "Link or add company" button in the tab header (next to the `Company affiliations` heading), gated by `memberships.manage`. (2) Sheet with two modes — **(a) Link existing**: combobox-search across companies (use existing `/api/v1/companies/autocomplete`) + role select + isPrimary toggle + optional startDate; on submit calls `POST /api/v1/companies/{selectedCompanyId}/members` with this client's `clientId`. **(b) Create new + link**: opens `CompanyForm` in create mode (drawer-in-drawer or step 2 of the sheet); on successful create, chains the same membership POST. Toast on completion, invalidate `['client', clientId]` so the tab refreshes. (3) Replace the EmptyState's copy with one matching the new CTA ("No company memberships yet — link this client to a company below.") and surface the same button there too. (4) Each row in the populated table gets a kebab menu: "Set as primary" (POST set-primary), "Edit role / dates" (PATCH), "Remove" (DELETE with confirm).
> - **Symmetry note:** The "Companies → Members" tab already has the inverse flow (add a client to a company) — same UI primitives should be reusable; consider lifting the membership form into a shared `MembershipForm` if the divergence is small. ~1.5-2 h end-to-end. Captured 2026-05-18 from UAT.
> - **Activity feed: resolve actor + diff UUIDs to display names** — _src/components/dashboard/activity-feed.tsx (ActivityFeedInner ~line 175)_, plus the activity-feed service that loads `audit_logs` rows, plus the diff-rendering helper that produces the `"old → new"` strings — two related findings from UAT, both UUIDs leaking into the rendered card:
> - Diff entries with FK columns (e.g. `assignedTo: "—" → "mEcsLxo5kyFMyhbOSehxJjYSSD7CiLvv"`) print the raw user UUID instead of the user's display name. Root cause: `audit_logs.fieldChanged='assignedTo'` rows store the new column value as a raw string; the formatter has no type info that tells it to resolve as a user FK.
> - Actor / subject identifiers in the row meta (e.g. `"d62aadbf"` — short UUID prefix) also render raw. Same root cause: the renderer falls back to a UUID slice when the row's `actorName`/`subjectLabel` is empty.
> - Fix shape: (1) extend the audit-logs schema (or the activity-feed service) with a typed-field registry — `{ field: 'assignedTo', kind: 'user_fk' }`, `{ field: 'ownerId', kind: 'user_fk' }`, `{ field: 'reassignedTo', kind: 'user_fk' }` etc. (2) When the service hydrates rows for the feed, bulk-fetch every referenced user (`SELECT id, firstName, lastName, email FROM users WHERE id IN (…)`) and replace the raw UUID strings with display names in both the diff `old`/`new` AND the `actorName`/`subjectLabel` columns. (3) Render fallback: if the user no longer exists (deleted/never-existed), show `"Unknown user (#<short-uuid>)"` so the feed remains useful for forensics. (4) Same treatment for any _other_ FK fields that may have slipped in (yacht IDs, berth IDs, etc. — audit at finding time).
> - ~1.5-2 h end-to-end (schema-light approach via a per-field registry in code, no migration). If we ever expand to non-user FKs, generalize the registry to dispatch by entity type. Captured 2026-05-18 from UAT. **SHIPPED in 2cb0b99:** `getRecentActivity` now collects all userIds from `auditLogs.userId` + user-FK `oldValue`/`newValue` (assignedTo, ownerId, reassignedTo, createdBy, addedBy, changedBy, transferredBy), bulk-fetches `user_profiles`, and returns rows with display-name replacements + an `actorName` field. Unknown / deleted users fall back to `Unknown user (#short-uuid)`. ActivityItem client type extended.
> - **EOI bundle UX rework (multi-berth interests)** — _src/lib/services/interest-berths.service.ts_, _src/components/interests/linked-berths-list.tsx_, _src/components/documents/eoi-generate-dialog.tsx_ — **DESIGN CONFIRMED 2026-05-18.** Workflow assumption: half+ of interests are multi-berth; typically one signed EOI covers many berths (e.g. A1-A10) but only the website-entry / "main" berth (e.g. A2) should show "Under Offer" on the public map. The current schema defaults (`is_specific_interest=true`, `is_in_eoi_bundle=false`) invert this — every linked berth shows publicly + nothing is bundled until ticked. Three coordinated changes:
> - **(a) Smarter insert-time defaults** in `addInterestBerth()`:
> - `is_in_eoi_bundle` → default **`true`** (any linked berth is presumed covered by the signed EOI; rep unticks for the rare carve-out case).
> - `is_specific_interest` → default **`false`** for non-primary rows; **`true`** only when the row is primary (matches "only the main berth gets publicly marked Under Offer").
> - ~30 min including unit-test coverage for the new defaults and a clarifying comment.
> - **(b) Rename + tooltip on LinkedBerthsList toggle** — "Mark in EOI bundle" → "Include in EOI" + an info popover explaining the bundle-vs-public distinction (matters more now that the two flags routinely diverge). ~15 min.
> - **(c) "EOI berth scope" picker inside the EOI Generate dialog** — at the moment of EOI generation, surface every linked berth as a row with **two** checkboxes: "In EOI bundle" and "Show on public map". Pre-fill from current flag state (which, post-(a), is mostly already correct). The picker forces the rep to consciously confirm signature scope + public visibility at the moment that question is live in their head, instead of relying on them having visited the LinkedBerthsList toggles upstream. Saving the dialog updates all `interest_berths` rows in one call before kicking off the Documenso envelope. ~1.5-2 h.
>
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
>
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. (c) EOI-berth-scope picker inside generate dialog parked.
1. **Berth-demand widget visual overhaul**_src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
2. **First-class "demand" sort on the berths list**_src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
3. **Pipeline Value tile expanded with per-stage breakdown**_src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
5. **Bulk + inline berth price editing — backend complete**_src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
---
## Bucket 3 — Features / larger (> 2 h)
_New UI surfaces, new endpoints, schema migrations, multi-step flows._
> **[Umami] Larger follow-ups parked at end of 2026-05-19 build session:**
>
> - **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
> - **[Umami] Funnels + Journeys (Phase 5)** — _src/components/website-analytics/funnel-builder.tsx (new)_ + _src/components/website-analytics/journey-flow.tsx (new)_ — Umami's `/api/websites/:id/reports/funnel` and `/journey` endpoints are wrapped (`runFunnelReport`, `runJourneyReport`). Funnel builder = pick N steps (URL or event), see per-step conversion. Journey flow = sankey-style visualisation of where visitors go after a chosen entry page. BLOCKED on Phase 4a for the event-driven half. Cap: ~6-8 h. Captured 2026-05-19; deferred to end per earlier scoping.
> - **[Umami] Click-to-filter the page from the world map** — _src/components/website-analytics/visitor-world-map.tsx_ + new `country` filter store + thread through every `useUmamiTop*` hook — `VisitorWorldMap` already accepts an `onCountryClick(iso2)` prop that's unused. Wire it to a page-wide country filter (Zustand store or URL search param `country=US`) that scopes every card on the page to that country's data. Mirrors Umami's own click-through behaviour. Cap: ~2-3 h. Captured 2026-05-19.
> - **[Umami] Per-rep `identify()` calls for attribution** — _src/components/auth/use-session.tsx (or wherever the session is hydrated)_ + _src/lib/services/umami.service.ts (new `identifyRep` wrapper)_ — call `umami.identify({sessionId, role: 'rep', repId: user.id})` on every authenticated CRM session so Umami's Sessions list can show "this lead came in while Matt was working hours". Privacy-gated: only fires for super-admin / sales-manager / sales-agent roles, never for residential-partner, never for portal-side users. Captured 2026-05-19; deferred as the privacy/value trade-off needs a product call before building.
0. **Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail**_src/lib/db/schema/documents.ts:290-309 (`formTemplates.fields` JSONB)_ + the New-form-template dialog UI (admin/forms) + _src/lib/services/supplemental-forms.service.ts_ (resolve + submit paths) + new `interest_field_history` table (or extend `audit_logs` with a dedicated `source='supplemental_form'` tag) + Interest detail + Client detail views (surface the override trail). Substantial feature touching the template builder, the public-facing supplemental form, and two record views.
> - **(a) Template-builder: bind each field to an Interest/Client data point via dropdown.** Today's Field row asks for a freetext `key` + `label` + `type`. Replace `key` with a dropdown listing every bindable data point keyed by a stable token, e.g.:
> - Interest-scoped: `interest.desiredLengthFt`, `interest.desiredWidthFt`, `interest.desiredDraftFt`, `interest.notes`, `interest.source`, `interest.tags`, ...
> - Client-scoped: `client.fullName`, `client.dateOfBirth`, `client.nationality`, `client.passportNumber`, `client.residentialAddress`, ...
> - Client-contact-scoped (per channel): `client.contacts.primaryEmail`, `client.contacts.primaryPhone` (resolved server-side to the `client_contacts` row with `isPrimary=true`).
> - Yacht-scoped (when interest has a linked yacht): `yacht.name`, `yacht.lengthFt`, `yacht.makeAndModel`, ...
> - Custom (no binding): freetext `key` for fields that don't map to any record column. Submission stored as-is in `form_submissions.data` JSONB, surfaced for rep review but not written back to any record.
> - **Field shape extension:** `{ key, label, type, required, bindingPath?: string }` where `bindingPath` is the dotted-token from the bindable catalog. `key` stays as the JSONB submission key (so existing templates keep working — `bindingPath` is purely additive).
> - **Catalog source:** define once in `src/lib/services/form-bindings-catalog.ts` exporting `BINDABLE_FIELDS: Array<{ path, label, entity, resolveCurrentValue, writeBack }>` — each entry knows where the value lives, how to read it, and how to write it back. Reuses the existing merge-fields infra (per CLAUDE.md `src/lib/templates/merge-fields.ts`) so the same vocabulary powers EOI templates AND supplemental forms.
> - **(b) Public form autofill.** When the client opens the supplemental URL, server-side resolver:
> - Loads the interest + client + linked yacht for the token.
> - For each field with a `bindingPath`, calls `resolveCurrentValue()` to get the current stored value.
> - Returns each field with a `currentValue` so the public form mounts pre-filled. Client reviews → edits if needed → submits.
> - Fields without a binding stay empty (client-provided input).
> - **(c) Submit handler: diff + override-preservation history.** On submit, for each bound field:
> - Compare submitted value against current value (case-sensitive for free-text; deep-equal for arrays/objects).
> - **Unchanged** → no-op. Don't write back, don't audit (saves noise).
> - **Changed** → (i) call `writeBack(submittedValue)` to update the underlying interest/client/contact column. (ii) Append a history row: `{ portId, interestId, clientId, fieldPath, oldValue, newValue, source: 'supplemental_form', submissionId, providedAt, providedBy: 'client' }`. (iii) Audit log entry for the same change (existing audit infra) so org-wide audit reports see it.
> - **New schema:** `interest_field_history` table — `id, port_id, interest_id, client_id (nullable, denormalized for client-detail queries), field_path text, old_value jsonb, new_value jsonb, source text ('supplemental_form' | 'rep_edit' | 'system_inferred'), submission_id (FK to form_submissions, nullable), created_at, created_by` + indexes on `(port_id, interest_id, created_at desc)` and `(port_id, client_id, created_at desc)` for the dual-surface lookups. Alternative: stuff in `audit_logs` with `source='supplemental_form'` and reuse the existing diff schema — cheaper but harder to query for the "show me the override history for this field" UX.
> - **(d) UI surfacing on both record views.**
> - **Interest detail:** small "i" icon next to each field that has history. Hover/click opens a popover: `Previous value: <X> · Updated by client via supplemental form on <date>`. Stacks multiple history rows in chronological order.
> - **Client detail:** same UX, with an additional context line: `Updated via supplemental form for interest <berth label> on <date> → [Open interest]`. Cross-link goes to the source interest. Reuses the same `berthLabel` helper from the document-detail Interest link fix.
> - Bonus: a dedicated "Field override history" section on the interest detail's Activity tab listing every override sourced from supplemental forms (or rep edits) for that interest — gives compliance + dispute resolution a single audit surface.
> - **(e) Edge cases to think through:**
> - **Required fields that resolve to existing values** — should they bypass `required` validation since they're pre-filled? Yes; required = "must have a value at submit time", not "must be re-entered by client".
> - **Multi-value paths** (e.g. `client.contacts.primaryEmail` — what if client has none?) — `resolveCurrentValue` returns null, field renders empty, client provides one, submit writes a new client_contacts row marked isPrimary=true.
> - **Type coercion mismatches** — bind path returns a number (`desiredLengthFt`), form field type is `text`. Catalog defines the canonical type per path; template builder validates compatibility at save time.
> - **Sensitive fields** (passport, DOB) — `BINDABLE_FIELDS` entries flag `sensitivity: 'pii' | 'public' | 'internal'`; the supplemental form template builder warns / blocks selecting PII fields without explicit admin override (avoids accidental public-form data leak).
> - **Effort:** ~12-16h end-to-end. ~2-3h for the catalog + resolver/writer infra. ~2h for the template-builder dropdown UI. ~2-3h for the autofill resolver in the public form service. ~3-4h for the submit diff + history table + audit + writeback. ~2h for the dual-surface UI (interest + client detail history popover). ~1h for sensitive-field gating + edge cases. Captured 2026-05-21 from UAT. **Cross-ref:** ties into the existing supplemental-info-request findings in Bucket 2 (reusable-not-single-use, generate+send split, regenerate+resend) — ship the binding/autofill/history work AFTER those land so the supplemental form is mature enough to carry the additional complexity.
1. **Universal in-system preview for every file type (extend FilePreviewDialog beyond PDF + images)**_src/components/files/file-preview-dialog.tsx:60-120_ — today only `mimeType?.startsWith('image/')` and `mimeType === 'application/pdf'` render; everything else falls through to a blank preview surface (no message, no fallback). User wants every document previewable in-system without forcing a download. Today's gaps: Office documents (.docx / .xlsx / .pptx), plain text (.txt / .csv / .md), email exports (.eml / .msg), video / audio, archives (.zip — see-into).
- **Coverage tiers:**
- **Tier 1 (cheap, native-browser):** plain text (`text/plain`), CSV, Markdown → fetch + render in a styled `<pre>` or via a small markdown renderer (`react-markdown` already a likely dep — verify); video (`video/*`) → `<video controls src=…>`; audio (`audio/*`) → `<audio controls src=…>`. ~1-2h for all four.
- **Tier 2 (lib-based, no server work):** DOCX → `mammoth.js` (~25KB gzipped) renders to HTML in-browser, good fidelity for text/headings/tables, loses complex formatting; XLSX → `sheetjs` (`xlsx` package) renders to an HTML table; PPTX → tricky, browser-side support is poor (recommend skip → fall back to "Download to view"). ~3-4h.
- **Tier 3 (server-side conversion):** for fidelity on complex Office docs, route through a headless LibreOffice or `gotenberg` service to convert to PDF, then preview with the existing PdfViewer. Adds infra cost (Docker container for the converter). ~6-10h including ops setup. **Recommendation:** defer Tier 3 to a follow-up; ship Tier 1 + 2 first and accept the fidelity loss for Office docs.
- **Fallback UX:** when the mime type isn't in any tier, render an empty-state card: file icon + filename + size + "Preview not supported for this file type. [Download to view]" button. Today's silent-blank surface is the bug.
- **Recent Files preview-click fix** (Bucket 4 #7) folds into this: as we audit every preview surface, wire click handlers consistently on FileGrid / RecentFilesList / DocumentList rows. Don't ship preview support without making sure every list surface is actually clickable.
- **Effort:** ~5-7h for Tier 1 + Tier 2 + fallback + clickability audit. Tier 3 deferred. Captured 2026-05-21 from UAT.
0. **Platform-wide date picker primitive (desktop popover + mobile native) — replace 22 `<input type="date|datetime-local">` sites**_new_ `src/components/ui/date-picker.tsx` + `src/components/ui/date-time-picker.tsx`, then sweep 22 call sites (see list below). Native browser date/datetime inputs render with inconsistent, ugly UI on desktop (varies by Chromium/Safari/Firefox; Comet shows the worst variant). Mobile system pickers are the opposite — touch-friendly wheel/spinner UX that we want to keep. Build a wrapper that switches based on viewport.
> - **Design (no new deps needed):** we already have `react-day-picker@10`, `date-fns@4`, and `src/components/ui/calendar.tsx`. Follow the canonical shadcn pattern (verified via Context7 against current shadcn docs):
> - `<DatePicker>` — desktop: trigger Button shows formatted date + chevron, opens Popover containing `<Calendar mode="single" captionLayout="dropdown" />` (the dropdown caption gives month/year nav for fast jumping to historical dates — critical for the backfill UX). Mobile: native `<input type="date">` for the system picker.
> - `<DateTimePicker>` — desktop: same Popover with Calendar plus a native `<input type="time" step="60">` in the popover footer (shadcn-canonical approach — hides webkit-picker-indicator via `[&::-webkit-calendar-picker-indicator]:hidden` and surfaces a `Clock` icon). Mobile: native `<input type="datetime-local">`.
> - **Mobile detection:** use existing `useIsMobile` hook (if absent, add one via `window.matchMedia('(max-width: 640px)')` + `useSyncExternalStore` so SSR works). CSS-only show/hide is an alternative but DOM duplication wastes a tiny amount; hook-based is cleaner.
> - **Same prop shape as today's `<Input type="date">`** so call-site migration is `<Input type="date" value=… onChange=… />` → `<DatePicker value=… onChange=… />` — minimal surface area change.
> - **Optional polish (defer to v2):** add a `naturalLanguage` flag using `chrono-node` (~2KB) so users can type "next Tuesday" / "in 3 days" — particularly nice on the reminder form's due-date field. Skip for v1 to keep scope tight.
> - **Call sites to migrate (22 files found via `grep "datetime-local|type=\"date\""`):** `src/app/(dashboard)/[portSlug]/invoices/new/page.tsx`, `src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx`, `src/components/berths/berth-form.tsx`, `src/components/invoices/invoice-detail.tsx`, `src/components/yachts/yacht-transfer-dialog.tsx`, `src/components/reservations/reservation-detail.tsx`, `src/components/reservations/berth-reserve-dialog.tsx`, `src/components/expenses/expense-form-dialog.tsx`, `src/components/admin/audit/audit-log-list.tsx`, `src/components/shared/inline-editable-field.tsx`, `src/components/shared/filter-bar.tsx`, `src/components/scan/scan-shell.tsx`, `src/components/dashboard/date-range-picker.tsx`, `src/components/interests/payments-section.tsx`, `src/components/interests/interest-tabs.tsx` (incl. the `MilestoneAdvanceButton` popover at line 318), `src/components/interests/interest-contact-log-tab.tsx`, `src/components/interests/external-eoi-upload-dialog.tsx`, `src/components/reminders/snooze-dialog.tsx`, `src/components/companies/add-membership-dialog.tsx`, `src/components/reminders/reminder-form.tsx`, `src/components/companies/company-form.tsx`, `src/components/reports/generate-report-form.tsx`. Several callers (e.g. `filter-bar.tsx`, `inline-editable-field.tsx`, `date-range-picker.tsx`) wrap the input and need slightly more care — small refactor of the wrapper, not a 1-line swap.
> - **Effort:** ~45 min to build the two wrappers + `useIsMobile` (if needed); ~2-3h to sweep all 22 call sites + visual verification in browser. Total ~3-4h. Captured 2026-05-21 from UAT.
> - **SHIPPED (primitives + highest-leverage migrations) in 8f42940:** `<DatePicker>` + `<DateTimePicker>` land in `src/components/ui`. Migrated: `MilestoneAdvanceButton` (Interest backfill UX), `reminder-form`, `snooze-dialog`, `external-eoi-upload-dialog`, `payments-section`. **Remaining ~17 sites parked** for a follow-up sweep — several use react-hook-form `register` patterns that need the controlled-value migration done carefully (expense-form-dialog, invoice/new, reservation/berth-reserve dialogs, company/yacht/audit forms, etc.).
> - **SHIPPED (remaining 14 sites) in 0c6e7b7:** completed the sweep. Migrated: `audit-log-list.tsx`, `reports/generate-report-form.tsx`, `scan/scan-shell.tsx`, `reservations/reservation-detail.tsx`, `shared/filter-bar.tsx`, `berths/berth-form.tsx`, `reservations/berth-reserve-dialog.tsx`, `companies/add-membership-dialog.tsx`, `yachts/yacht-transfer-dialog.tsx`, `invoices/invoice-detail.tsx`, `expenses/expense-form-dialog.tsx`, `companies/company-form.tsx`, `interests/interest-contact-log-tab.tsx` (datetime-local x2). RHF `register` sites wrapped with `<Controller>` + the picker's `value`/`onChange` bridge; Date-typed schemas (expenseDate, incorporationDate) get an inline Date↔YYYY-MM-DD bridge. Skipped because they ARE primitives or internal date variants: `ui/date-picker.tsx`, `ui/date-time-picker.tsx`, `shared/inline-editable-field.tsx`, `dashboard/date-range-picker.tsx` (own popover with min/max gating). Removed 4 now-unused `Input` imports.
1. **Platform-wide chart library migration: recharts → ECharts**_src/components/dashboard/_ + _src/components/website-analytics/_ + _src/components/berths/_ — we now run two chart libraries side-by-side: ECharts (just adopted for the world choropleth + tree-shaken, canvas renderer, d3-geo projection) and recharts (everything else: berth-status donut, occupancy-timeline line, pipeline-funnel bar, lead-source pie, source-conversion bar, berth-heat-widget bars, pageviews-vs-sessions area, pipeline-value-tile mini-bars — ~8+ components). **Trade-off analysis (done 2026-05-19 during analytics build):** ECharts wins on visual polish (better default styling, smoother animations, native legend/tooltip behaviour), comprehensive chart types (sunbursts, sankeys, parallel coords, heatmaps, geo all out of the box), and canvas-renderer performance on dense series; recharts wins on React-idiom (declarative `<Area>` / `<Bar>` children vs imperative option objects) and bundle size for the very simplest charts. **Migration cost:** ~610 h to port the existing 8 components; each is a 50150 LOC swap from `<ResponsiveContainer><AreaChart>…` to an `<ReactEChartsCore option={…} />` with tree-shaken module imports. **Pre-reqs already in place:** `transpilePackages: ['echarts', 'zrender', 'echarts-for-react']` added to `next.config.ts`, `d3-geo` installed, dynamic-import + canvas-renderer pattern proven on the world map. **Recommendation:** do as a single coordinated pass (consistency wins over piecemeal), gated on a free afternoon — none of the existing recharts components are buggy, this is purely about platform-wide visual + capability parity with the new analytics surfaces. Captured 2026-05-19 during the Umami flesh-out work.
2. **Bulk-price editing UI**_src/components/berths/_, _src/components/berths/berth-columns.tsx_ — backend shipped this session (new `berths.update_prices` permission across schema + 6 role maps + admin UI + factories; validators `updateBerthPriceSchema` + `bulkUpdateBerthPricesSchema`; services `updateBerthPrice` + `bulkUpdateBerthPrices` — both per-row audited with `fieldChanged='price'`; routes `PATCH /api/v1/berths/[id]/price` + `POST /api/v1/berths/bulk-update-prices`, ≤500 berths per batch). UI work pending: (a) wire `InlineEditableField` into the price cell of `berth-columns.tsx` (click → input → PATCH) gated by `can('berths', 'update_prices')`; (b) add `bulk-price-edit-sheet.tsx` (right-side Sheet, per-row inputs, "Set all to" + "Apply % adjust" shortcuts) wired to `bulkActions` on the `<DataTable />` in `berth-list.tsx`. ~23 h to ship the UI.
3. **Pipeline Value tile should respect dashboard timeframe**_src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — the dashboard has a Today / 7d / 30d / 90d / Custom filter at the top (`Last 30 days` shown beside the greeting) but the Pipeline Value tile shows an absolute snapshot regardless. Should be constrained to the active timeframe: e.g. "Pipeline as of end of range" + "Revenue actually realized in range" (closed-won × berth price for interests whose `outcome_at` falls in the window). Needs: dashboard-wide timeframe context (Zustand store or React Query keyed by range), forecast/KPI service variants that accept a `range`, and a "realized vs forecast" line in the tile. ~34 h.
3a. **Remove `/admin/reports` entirely (redundant with configurable Dashboard) + integrate PDF-report exporter into the Dashboard header**_src/app/(dashboard)/[portSlug]/admin/reports/page.tsx + src/components/admin/reports-dashboard.tsx_ (DELETE) + _src/components/dashboard/dashboard-shell.tsx (or wherever the dashboard header lives)_ (ADD "Export to PDF" button) + the PDF exporter dialog (next entry). Today's `/admin/reports` page renders Pipeline funnel + Berth occupancy + activity feed — every card is also a Dashboard widget, and the Dashboard is configurable while this page is fixed. Surfaced UAT 2026-05-21 as "feels useless since we have the dashboard" + user follow-up 2026-05-21: "the pdf report exporter we will need to integrate into the dashboard — or make a dedicated reports page with even more charts/stats (though i think this may be redundant)." > - **Decision (locked 2026-05-21):** integrate PDF exporter into the Dashboard, remove `/admin/reports`. Path: (a) delete `src/app/(dashboard)/[portSlug]/admin/reports/page.tsx` + `src/components/admin/reports-dashboard.tsx`; (b) drop the "Reports" link from admin nav + search-nav-catalog (cross-ref the duplicate-key dedupe finding in Bucket 4 — same catalog file); (c) add a redirect from `/admin/reports``/dashboard` so any bookmark/external link lands sensibly; (d) add "Export to PDF" button in the Dashboard header (right-hand controls cluster, next to the date-range picker). > - **Why not a dedicated more-charts reports page:** a separate Reports page with "even more charts" inevitably duplicates Dashboard data. Either the Dashboard lags behind, or the Reports page becomes a copy. Better to invest that effort in adding more widgets directly to the Dashboard (which is configurable, so reps who don't want the extra cards can hide them). > - **What if leadership later wants a fixed read-only exec view?** revisit then — by that point we'll know whether reps actually use it or just print the Dashboard. YAGNI for now. > - **Effort:** ~30 min for the route removal + redirect + nav cleanup. PDF exporter itself is feature #3 below — that's where the substantive work is. Captured 2026-05-21 from UAT.
4. **Stylized branded PDF report exporter — Dashboard-integrated (locked 2026-05-21)**_src/components/dashboard/_ (new `<ExportReportDialog>` + Dashboard header trigger) + _src/lib/services/dashboard-report.service.ts (new)_ + the existing `pdfme` (templates) and `pdf-lib` (filling) infra plus per-port branding from `system_settings`. **Location decision locked:** lives on the Dashboard, NOT on a separate `/admin/reports` page (which is being removed — see 3a above).
> - **UX flow:**
> - Trigger: "Export to PDF" button in the Dashboard header (right-hand cluster, next to the date-range picker).
> - Modal: widget toggle list pre-populated with every widget the user has currently visible on their Dashboard + the option to add hidden ones for this export. Each toggle row shows a thumbnail/preview of the widget for visual confirmation.
> - Range: defaults to the Dashboard's current date range; can be overridden in the modal.
> - Optional fields: report title, subtitle, custom subheader (e.g. "Q1 2026 board review"), optional commentary text block at the top.
> - Branding: auto-pulls port logo + primary colour + header/footer from `system_settings` (per CLAUDE.md branding section). No per-export branding override (matches the locked "don't duplicate branding everywhere" principle).
> - **Available widgets at export time** (any widget visible to the user on their Dashboard, gated by their permissions):
> - KPI tiles (pipeline value, active deals, website analytics tile)
> - Pipeline funnel
> - Occupancy timeline
> - Revenue breakdown REMOVED — already deleted in Bucket 1 #16 cleanup, exclude from export catalog too
> - Source attribution / Lead source
> - Berth demand / Hot deals
> - Recent activity (capped at top N)
> - Website analytics widgets (pageviews, sessions, visitors, top pages/countries) when Umami is configured
> - Clients by country (when Bucket 3 #7 lands)
> - World-map visitor heatmap (when Bucket 3 lands)
> - **Server-side rendering approach:** lean toward **`pdfme` templated rendering** (already used per CLAUDE.md, no headless-Chromium ops cost). Each widget gets a `WidgetExportTemplate` definition mapping its data to a pdfme schema fragment. Composed at export time based on which widgets the user toggled on. Falls back to a simple text-table rendering for widgets without a dedicated template (gives partial coverage on day 1, fancy charts shipped iteratively).
> - **Charts as PNG fallback** — pdfme can't render Recharts/ECharts components natively. Server-side: render each chosen widget to a PNG via a headless renderer (puppeteer or playwright running against the same chart components), then embed the PNG in the pdfme template. Pre-cache PNGs per widget per range to avoid regenerating on every export.
> - **Export-history table** (`exported_reports`): id, port_id, user_id, file_id, widgets_included, date_range_from, date_range_to, title, created_at. Reps can re-download past exports without regenerating.
> - **Effort:** ~10-14h end-to-end. ~3h for the dialog + widget toggles + modal. ~3-4h for the server-side composition + pdfme template fragments per widget. ~2-3h for chart-to-PNG rendering pipeline. ~1-2h for the export-history table + list UI. ~1-2h for the per-widget template definitions. Captured 2026-05-21 from UAT. **Cross-ref:** 3a (location decision); existing branding infra (per CLAUDE.md); chart-library migration to ECharts (Bucket 3 #00) — if that lands first, the PNG-rendering pipeline gets simpler (ECharts has a native server-side PNG export via canvas).
> - **SHIPPED (full 4-phase build) across 3b199c2, 47c2ba9, 1cdc2fd, 5a9b5f6:**
> - **Stack decision:** dropped pdfme in favor of `@react-pdf/renderer` (already a dep). Rationale: React component model matches the codebase, server-side `renderToBuffer` slots into the existing `/api/v1/*` route pattern, no Chromium dep (smaller image, lower memory), built-in `<PDFViewer>` for the preview modal. Charts render as data tables here — printed reports prioritize the actual numbers over chart shapes, accessible to screen readers, holds up if OCR-scanned. Recharts-as-SVG embedding can be added later if needed; Puppeteer hybrid stays available as a fallback.
> - **Phase A (3b199c2):** foundation. `BrandedReportDocument` page wrapper (logo + title + footer with port name + page numbers), `makeReportStyles(branding)` keyed off the port's primary color with a luminance check for the accent foreground (AA contrast on dark brands). `DashboardReport` with KPI grid + per-widget tables (KPI overview / pipeline funnel / berth status / source conversion / hot deals). Server-side data fetchers via the existing `dashboard.service.ts` — only the selected widget IDs trigger their fetch. `POST /api/v1/reports/generate` with zod-validated discriminated-union schema, `reports.export` perm gate, audit log on success, RFC 5987 Content-Disposition for unicode filenames. UI: `<ExportDashboardPdfButton>` on the Dashboard header. 3 unit tests prove the renderer emits `%PDF-` bytes.
> - **Phase B (47c2ba9):** clients / berths / interests list reports. Shared `<ReportTable>` zebra-striped primitive with no-break rows. Three data resolvers (`resolveClientReportData`/`resolveBerthReportData`/`resolveInterestReportData`) in `src/lib/services/list-report-data.service.ts` with primary-email/phone subqueries for clients, primary-berth left-join for interests, all capped at 1000 rows with "Showing top N of <total>" notice. Route schema widened to 4-arm discriminated union with exhaustiveness `_exhaustive: never` check. `<ExportListPdfButton>` reusable component wired into ClientList / BerthList / InterestList toolbars. 3 more render tests.
> - **Phase C (1cdc2fd):** saved templates. Migration `0079_report_templates.sql` + drizzle schema with sibling-name uniqueness scoped `(port_id, kind, LOWER(name))`. CRUD service + REST routes (`GET/POST /api/v1/reports/templates`, `GET/PATCH/DELETE /api/v1/reports/templates/[id]`) with `reports.export` perm + audit. `<SavedTemplatesPicker>` reusable component wired into both export dialogs — apply a template hydrates the form (widget selection / filters / title); save-as-template inline expands to a name input.
> - **Phase D (5a9b5f6):** preview modal. `<PdfPreviewModal>` POSTs the current form payload, renders the returned Blob in a sandboxed iframe via `URL.createObjectURL`, caches the Blob so the Download button doesn't re-fetch. Re-fetches when the rep tweaks config and re-opens preview. Object URL revoked on close + unmount. Eye button between Cancel and Download on both dialogs. Memoised previewPayload prevents unrelated re-renders from refiring the fetch.
> - **Final shape:** 4 report kinds, per-port logo + primary-color branding, customizable widget picker (dashboard) + include-archived toggle (lists), custom title, save-as-template, apply saved template, preview modal with cached Blob for download, 1000-row export cap, `reports.export` perm, audit-logged, RFC 5987 unicode filenames. **1454/1454 vitest pass; 6 PDF render tests included.** Manual end-to-end (open dashboard → preview → download) is the next gate.
5. **Web analytics integration (companion to #3)**_new feature_ — per-port web analytics provider config in admin (GA4 / Plausible / Umami / Cloudflare), surfaced as widgets on the dashboard and ingestable into the branded PDF report. Needs: settings UI, provider adapter layer (`src/lib/integrations/analytics/`), dashboard widgets, and inclusion in the report exporter. ~812 h.
6. **Supplemental-info-request email: branded HTML styling**_src/lib/email/templates/_ — the email is plain HTML (logo missing, no header card, no blurred background), inconsistent with the other branded transactional emails (portal activation / reset / login wrap content in a `BrandedAuthShell`-equivalent HTML layout per CLAUDE.md). Rebuild the template to match the table-based, max-width 600, logo + blurred overhead background look, pulling port branding from `system_settings`. ~1-2 h.
7. **Residential interests list: visual + functional parity with the main InterestList**_src/components/residential/residential-interests-list.tsx_ vs _src/components/interests/interest-list.tsx_ + _interest-card.tsx_ + _interest-columns.tsx_ + _interest-filters.tsx_ — the residential interests page today is a slim search + stage-filter list (~200 lines). The main InterestList (~700 lines + supporting files) carries the bulk of the product idiom: card / table / kanban view modes (kanban is desktop-only), `usePaginatedQuery` with sort + saved views, full `FilterBar` (search, stage, tags, owner, source, date ranges), `ColumnPicker` for table mode, bulk actions wired to `/interests/bulk` (archive, change stage, add/remove tag), realtime invalidation across multiple event names, per-row archive flow, kebab actions, `InterestCard` rich row component. Reps switching between berth interests and residential interests today get two visually-divergent experiences for what is effectively the same conceptual surface.
> - **Scope breakdown:**
> - **(a) Card view + visual parity (highest leverage)** — replace the table-style `<li>`-per-row layout with a `ResidentialInterestCard` mirroring `InterestCard` (header with client name + stage chip + last-activity, body with preferences/notes preview, footer with quick actions). Reuse the existing `<DataTable />` primitive for the table mode so column picker + sort + bulk-select come for free. ~3-4h.
> - **(b) Export to PDF + CSV** — match the export affordance the main page has (or, if the main page lacks it, add it to both surfaces in the same pass — captured here so it lands on both). PDF: render rows + summary header via `pdfme` / `pdf-lib` (existing infra per CLAUDE.md), branded with port logo. CSV: server-side endpoint `/api/v1/residential/interests/export?format=csv|pdf` (or client-side generation if the dataset is bounded — residential volumes are typically small). Trigger from a kebab menu on the page header. ~2h.
> - **(c) Filter / sort / pagination parity** — extend the residential interests endpoint to accept the same `FilterDefinition` shape (stage, source, assignee, date range, tags) and wire `usePaginatedQuery` + `FilterBar` on the page. ~2-3h.
> - **(d) Bulk actions + saved views** — only if residential workflows actually use them (verify with the external partner team first — residential volumes may be low enough that bulk-mutate is unused). ~2h if needed, skip if not.
> - **Refactor opportunity:** much of the InterestList scaffolding is generic — there's a latent opportunity to extract an `EntityList<T>` primitive that takes `{ endpoint, columns, cardComponent, filterDefinitions, bulkActions }` and renders the whole shell. Both surfaces become thin configs. ~6-8h for the extraction + porting both lists, but pays off the next time a similar list ships (companies, yachts already have parallel lists that could adopt it). Out-of-scope for this finding; capture as a follow-up if appetite exists.
> - **Recommendation:** ship (a) + (b) in one ~5-6h pass for the high-visibility wins (cards + export). Defer (c) until the residential team complains about filter gaps. Skip (d) unless verified-needed.
> - **Companion fix:** see Bucket 1 finding "Residential namespace breadcrumb link is 404" — if the parity work lands a `/residential` landing page, that breadcrumb finding folds into this.
> - Captured 2026-05-18 from UAT.
8. **Residential inquiry → auto-forward to external partner email(s)**_src/lib/services/residential.service.ts_ (`createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts:97_ (public intake), _src/lib/services/settings.service.ts_ + admin settings UI, _src/lib/email/templates/_ (new template), BullMQ enqueue — residential clients are managed by an external partner; every new residential inquiry needs to be forwarded automatically to one or more configured email addresses so the partner can act on it.
> - **Settings model:** new per-port `system_settings` keys: `residential_forward_enabled` (bool, default false), `residential_forward_recipients` (JSON array of email addresses — `to`), `residential_forward_cc` (JSON array, optional), `residential_forward_filter` (optional discriminator — e.g. only forward inquiries with certain `source` values or above a price/size threshold; v1 ships without this and forwards everything).
> - **Admin UI:** new section in `src/app/(dashboard)/[portSlug]/admin/settings/` ("Residential routing") with: enable toggle, recipient list editor (add/remove emails, drag-reorder, per-row "primary" flag for the To field vs CC), template preview ("Send sample to me"), and a small "Last forwarded N inquiries in the past 7 days" stat for confidence. Permission-gated by `admin.manage_settings`.
> - **Email template:** new branded HTML template `residential-inquiry-forwarded.tsx` in `src/lib/email/templates/` matching the existing branded-shell idiom (port logo + table-based layout per CLAUDE.md) — body includes inquiry fields (client name, contact channel, preferences, notes, source, submission timestamp, link to the residential interest in the CRM if the partner has portal access; otherwise a "view in CRM" stub).
> - **Send pipeline:** enqueue a BullMQ job in `createResidentialInterest` (don't send inline — keeps public intake fast + retries handle SMTP flakes). Job: render template with port branding + inquiry payload, send via existing nodemailer transport, audit a `document_sends` row per recipient for forensics. Honour the dev-only `EMAIL_REDIRECT_TO` envar (per CLAUDE.md) so QA doesn't spam the real partners.
> - **Edge cases:** retry on SMTP failure (BullMQ default retry policy); de-dup if the same inquiry triggers create twice within the dedup window (already a residential-intake concern — verify); skip forwarding when forwarding is disabled mid-flight (settings read at job time, not enqueue time, so toggle takes effect immediately).
> - **Effort:** ~3-4h for settings + template + service hook + BullMQ wiring; +1h for admin UI + sample-send button. Captured 2026-05-18 from UAT.
>
> - **Related:** see Feature 6 below — auto-link residential to existing main-client records, which fires at the same moment in the create pipeline; build (5) and (6) in one pass so the forwarded email can carry the "matched to existing CRM client X" context if a link was found.
9. **Auto-link residential interests to existing main-client records (same person)**_src/lib/services/residential.service.ts_ (`createResidentialClient` + `createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts_, new schema migration adding _src/lib/db/schema/residential.ts_ join table, _src/components/residential/residential-client-detail-header.tsx_ + _src/components/clients/client-detail-header.tsx_ (surface the link on both sides), new admin/dev script for backfill — when the same person who exists in the main berth client list registers a residential interest (or vice-versa), the two records should auto-link so reps can see the full relationship at a glance.
> - **Why a link, not a merge:** the two pipelines are operationally distinct (different team handles residential, different lifecycle stages, different downstream services). A hard merge would conflate records that should remain queryable separately. A symbolic link preserves both records while making the relationship discoverable.
> - **Schema:** new join table `residential_client_links (id, port_id, residential_client_id, client_id, linked_at, linked_by_user_id, link_method enum('auto_email_match' | 'auto_phone_match' | 'manual'), confidence numeric(3,2), notes text)` — composite unique on `(port_id, residential_client_id, client_id)` so the same pair can't be linked twice. Both FKs ON DELETE CASCADE so dropping either side cleans the link automatically.
> - **Match logic** (at residential client/interest create time): normalize the residential `email` to lowercase and check against `client_contacts.value` WHERE `channel='email'`; normalize `phoneE164` and check against `client_contacts.valueE164` WHERE `channel='phone'`. Email match → confidence 0.95 (auto-link, log audit); phone match → confidence 0.80 (auto-link with a "candidate match" badge so the rep can confirm); both match → confidence 0.99. If multiple candidate main-clients match (shared email — family/spouse case), DO NOT auto-link; instead surface all candidates in a UI banner for the rep to pick. Same logic runs in reverse when a new main-client is created (look for matching residential client).
> - **UI surface:** on residential client detail header — small "Linked to <Main client name>" pill below the name, click-through to the main client; if a candidate match was surfaced but not auto-linked, a banner: "Possible match: <Name> (same email/phone). [Link] [Dismiss]". Mirror on the main client header. Add a "Link to existing residential client" / "Link to existing main client" button on each side for manual link creation (combobox-search across the other side). Add an "Unlink" affordance with confirm — useful when an auto-match was wrong (e.g. shared family email).
> - **Audit + telemetry:** every auto-link writes an `audit_logs` row with `action='auto_linked'`, `metadata={method, confidence}` so the org can audit auto-link accuracy. Optional admin dashboard tile showing "N residential links auto-created / manually overridden this week" for ongoing confidence in the match logic.
> - **Backfill script:** `pnpm tsx scripts/backfill-residential-links.ts` — one-pass scan of existing residential_clients vs clients for matching email/phoneE164; idempotent (skips pairs already linked); dry-run by default, `--apply` to commit. Required because the join table is new and existing records won't be auto-linked retroactively.
> - **Effort:** ~4-6h end-to-end (migration + service hook with match logic + UI on both header sides + backfill script + tests + audit). Significant scope but high-leverage: gives reps a single mental model of "this person across our two product lines" instead of two parallel records. Captured 2026-05-18 from UAT.
- **World-map heatmap of Umami visitor origins** — _new file_ `src/components/website-analytics/visitor-world-map.tsx` (heatmap card) + extend _src/lib/services/umami.service.ts_ (already returns `top-country` data via `getMetric(type: 'country')`) + viz lib choice (e.g. `react-simple-maps` + Natural Earth TopoJSON, or `@visx/geo`, or a simple SVG world from D3) — render a world choropleth colour-scaled by visitor count per country, surfaced on the Website Analytics page (and optionally on the dashboard as a separate rail widget). Hover any country to see the visitor count tooltip; click to filter the page's other widgets to that country (uses Umami `filters` query param if we extend the route to support it). Implementation notes: ISO 3166-1 alpha-2 codes map cleanly to country features in Natural Earth; cache the topojson in `public/` to avoid per-load fetch. Bundle weight ~50-80KB gzipped depending on lib choice; dynamic-import to keep it off the dashboard bundle when the widget is collapsed. ~4-6h end-to-end. Companion / overlap candidate: the "Clients by country" widget below — a single map could surface both data sources via a toggle (Umami visitors vs CRM clients/prospects) instead of two separate widgets. Captured 2026-05-18 from UAT.
7. **"Clients by country" dashboard widget** — _src/components/dashboard/_ (new file `clients-by-country-widget.tsx`), _src/components/dashboard/widget-registry.tsx_, _src/lib/services/dashboard.service.ts_ (or `analytics.service.ts` if it should live in the snapshot-cached family), _new endpoint or extension to `/api/v1/dashboard/...`_ — surface a per-country breakdown of clients (and optionally prospects — interests with `outcome` still open) so leadership can see geographic distribution at a glance. Data shape: aggregate `client_addresses` (or `clients.country` if that column exists) by `country_code` for clients that are non-archived and (for the prospect overlay) join through interests-with-open-outcome. UI options to pick from at build time: (a) compact ranked list with mini bars per row (matches `BerthHeatWidget` / `HotDealsCard` idiom — fits the rail), or (b) a choropleth/world-map (heavier; needs a viz lib like `react-simple-maps` + a topojson; better fit for the chart grid). Pick (a) by default — same footprint as existing rail tiles, no new bundle weight, and clicking a country could deep-link `/clients?country=DE`. Permissioning: gate on `clients.view`. Registry: defaultVisible: true. Effort: ~2-3 h for variant (a) + endpoint + tests; ~6-8 h for variant (b) with a real map. Captured 2026-05-18 from UAT (user request: "add a widget that breaks down prospects/clients by country as a card on the dashboard").
8. **Drag-and-drop rearrangable dashboard widgets**_src/components/dashboard/dashboard-shell.tsx_, _src/components/dashboard/widget-registry.tsx_, _src/hooks/use-dashboard-widgets.ts_ (assumed name), _src/lib/db/schema/users.ts_ (`user_profiles.preferences`), _src/app/api/v1/me/preferences/route.ts_ — today widget order is hard-coded by registry array order, and visibility is the only user-controllable axis (persisted in `user_profiles.preferences.dashboardWidgets` as a `{ [id]: boolean }` map). Reps want to choose **which analytics show where** on their dashboard (e.g. push Pipeline Funnel to the top, demote Berth Status, swap rail order). Approach: (a) introduce a parallel `dashboardWidgetOrder: string[]` preference (ordered list of widget IDs; missing IDs render after the list in registry order so newly-added widgets always surface); (b) extend `useDashboardWidgets` to return `visibleWidgets` already sorted by this order; (c) keep the three-group layout (`chart` / `rail` / `feed`) — drag-reorder is scoped _within_ a group so the rail's narrower min-col doesn't get a chart-sized tile dropped into it (and vice versa) — moving a widget between groups stays a registry-level concern (the move-out-of-rail request that triggered this entry is an example); (d) add `@dnd-kit/core` + `@dnd-kit/sortable` (lightweight, RSC-safe, already shadcn-adjacent); (e) wrap each group's grid in a `SortableContext`, render a small grip handle on each card header that's only visible in "rearrange mode" (toggle in the existing Customize dropdown — keeps casual users from accidentally grabbing tiles); (f) on drop, optimistic-update the preference and PATCH `/api/v1/me/preferences` with the new order array; (g) realtime: not needed (per-user state). Tests: vitest for the order-merge helper, Playwright smoke for drag-drop + persistence across reload. ~4-6 h end-to-end. Captured 2026-05-18 from UAT after moving the Pipeline Value tile from rail → chart group exposed that re-shuffling widgets is currently a code change, not a user action.
9. **AI-assisted action extraction from contact-log entries**_src/components/interests/interest-contact-log-tab.tsx_, new LLM service — current dialog already has quick-template buttons that seed `"Called the client. Discussed:\n\n• \n\nNext step: "` (and similar for in-person / email) into the summary textarea — soft structure without enforcement. Adding rigid form fields ("Topic", "Next step", "Outcome") risks killing rep adoption (sales reps notoriously avoid form-y CRMs). Better path: keep the freeform textarea + templates exactly as-is, add an **"Extract action items"** button beside Save that LLM-parses the body and returns proposed follow-ups — `create reminder for {datetime}`, `update desiredLengthFt to {n}`, `suggest stage advance to deposit_paid`, etc. Each proposal lands as a confirm-each list; rep approves individually. **AI assists, rep approves** — never silently mutates the record. Scope: ~6-10 h end-to-end (prompt engineering + LLM client + extraction schema + per-action confirm UI + audit logging of accepted/rejected proposals). Privacy considerations: contact-log entries can contain PII / financial details — route through an in-region LLM provider per the existing email/storage approach. Defer until a user is genuinely asking for it; the current template-seed pattern is fine for now.
10. **Documenso-first templates: pull templates from Documenso instead of uploading through CRM (admin UI gap)**_src/components/admin/document-templates/template-form.tsx_ (template create/edit UI, currently uploads source PDF/HTML), _src/lib/db/schema/documents.ts:254_ (`documensoTemplateId` column already exists), _src/lib/services/document-templates.ts:611_ (`pathway: 'documenso-template'` already routes through Documenso), _src/lib/services/documenso-template-sync.service.ts_ (existing per-port EOI sync; needs generalization), _src/lib/services/documenso-client.ts_ (need a `listTemplates()` wrapper) — the schema and signing pathway support Documenso-hosted templates (the CRM stores only the Documenso template ID, Documenso owns rendering), but the admin UI today assumes the source PDF/HTML lives in the CRM. Reps who maintain their templates in Documenso can wire ONE per port (the EOI, via the existing per-port sync) but can't add other types (welcome letter, handover checklist, correspondence) as Documenso-hosted entries without DB-level intervention. Real product gap — closes the "is Documenso the source of truth, or is the CRM?" question for ports that prefer to author in Documenso.
> - **Scope:**
> - **(a) Template-source toggle** in `template-form.tsx`: radio between "Upload to CRM" (current behaviour) and "Pull from Documenso". Selecting the latter changes the form below.
> - **(b) Documenso template picker** — new combobox that calls a new `GET /api/v1/admin/documenso/templates` endpoint backed by `listTemplates()` (new wrapper in `documenso-client.ts` — v1: `GET /api/v1/templates`; v2: `GET /api/v2/envelope/template`). Lists Documenso-side templates by name + id; selecting one populates `documensoTemplateId` and `templateFormat='documenso_render'`. Cache the list for ~5 minutes per port.
> - **(c) Per-template field-mapping editor** — once a Documenso template is picked, show its field labels (pulled via `getTemplate(id)` — already exists in the sync service) alongside a select-from-merge-tokens dropdown per row. Save the mapping into the `fieldMapping` JSONB column (currently used for AcroForm; reuse the shape: `{ documensoFieldLabel: mergeToken }`). Validate against `VALID_MERGE_TOKENS` on save so the field map can't reference a non-existent CRM token.
> - **(d) "Sync now" button** — re-fetch the Documenso template, diff field labels against the saved `fieldMapping`, surface added / renamed / removed fields so the admin can update the mapping when the Documenso template changes. Generalizes the existing per-port EOI sync (`documenso-template-sync.service.ts`) to per-template.
> - **(e) Template-list page treatment** — each template row in the list shows a small badge "Hosted in Documenso" vs "CRM-managed source" so admins can tell at a glance which is which.
> - **(f) `generateAndSign` already handles this** — `pathway: 'documenso-template'` skips CRM PDF generation and calls Documenso's template-generate endpoint. No service-layer work needed beyond the new admin UI plumbing.
> - **Migration consideration:** the existing per-port EOI sync (single Documenso template ID stored in port settings) becomes redundant once per-template mapping ships — migrate the per-port pointer into a row in `document_templates` with `templateFormat='documenso_render'` + the existing `templateType='eoi'`. Then deprecate the port-setting key. Single-port-EOI flow continues to work via the same templateType lookup; admins gain the ability to add additional Documenso-hosted templates (welcome letter, etc.) using the same UI.
> - **Webhook + auto-file integration:** untouched — signing webhooks (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) key on document/envelope ID, not template source, so Documenso-first templates inherit the same signing-status tracking + auto-deposit into the entity folder.
> - **Effort:** ~5-7h end-to-end (toggle + picker + listTemplates wrapper + field-mapping UI + sync button + list-row badge + migration of the per-port EOI pointer + tests). Smaller (~3-4h) if (d) sync button is deferred. Captured 2026-05-18 from UAT in answer to "what happens if we upload templates straight to Documenso? Can we pull the template through?" — answer: yes, but only the EOI flows through today; this finding closes the UI gap for the other template types.
- **[Deferred — blocked on embeddings-based recommender] Berth recommender AI admin section on `/admin/ai`** — _src/app/(dashboard)/[portSlug]/admin/ai/page.tsx_ + _src/lib/services/berth-recommender.service.ts_ — the berth recommender is currently pure SQL (per CLAUDE.md: "Rule-based today; future versions will optionally use embeddings for soft preference matching"). When/if the embeddings-based version ships, surface its admin controls on `/admin/ai` alongside the other AI-feature sections: provider override, embedding model, similarity threshold, per-call budget cap. Until then, the recommender does not call an LLM — including it under `/admin/ai` today would mislead admins into thinking they're tuning an LLM. **Action: revisit when an AI/embeddings tier is added to the recommender.** Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2. Captured 2026-05-18 from UAT.
- **[Deferred — depends on Bucket 3 #7 contact-log action extraction] Contact-log AI admin section on `/admin/ai`** — when "AI-assisted action extraction from contact-log entries" (Bucket 3 #7) ships, add its admin controls to `/admin/ai`: provider override, prompt-template editor, per-call budget cap, accepted/rejected proposal stats. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2 + Bucket 3 #7 "AI-assisted action extraction from contact-log entries". Captured 2026-05-18 from UAT.
- **[Deferred — no design exists] AI inquiry-intake parsing admin section on `/admin/ai`** — if/when AI-assisted inquiry intake parsing is built (e.g. LLM normalizes inbound web-form / email inquiries into structured fields before the rep sees them), surface its admin controls on `/admin/ai`: provider override, confidence threshold for auto-accept vs human-review, fallback behaviour when the AI tier fails, per-call budget cap. No design or scope exists for this feature today — captured as a placeholder so the thought isn't lost when the AI-feature page expands. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2. Captured 2026-05-18 from UAT.
8. **Platform-wide error message audit for prod debuggability**_cross-cutting_ — triggered by the Documenso-config diagnosis loop: the user got a generic 502 + "Invalid token" upstream message when the real cause was "no Documenso creds entered for this port (silently fell back to a stale env value)." Operators in prod can't see logs the way we can in dev; the error surface should self-describe. Two layers of work:
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
---
## Bucket 4 — Bugs (severity-tagged)
_Functional defects. Tag each with `[critical|high|medium|low]` prefix._
-1. **[high] BulkAddBerthsWizard side-pontoon dropdown uses a wrong, locally-defined enum (not the canonical / admin-editable vocabulary)** — _src/components/admin/bulk-add-berths-wizard.tsx:42_ — the wizard hard-codes `const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', '']` (nautical directions). The **actual** canonical list in _src/lib/constants.ts:187_ `BERTH_SIDE_PONTOON_OPTIONS` is: `'No', 'Quay SB', 'Quay PT', 'Quay SB, Yes PT', 'Quay PT, Yes SB', 'Yes SB', 'Yes PT', 'Yes SB, PT', 'Finger SB', 'Finger PT'` — these match the original NocoDB enum + the single-berth edit form + EOI/contract surfaces. Reps using the bulk wizard end up writing `side_pontoon='Port'` / `'Starboard'` etc. to the DB — values that no other surface in the app produces or filters on. Filtering / reporting / search across the same column gives misleading results because the data has two parallel vocabularies.
> - **Additional problem:** the codebase has a full per-port vocabularies system (_src/lib/vocabularies.ts_) where `berth_side_pontoon_options` is registered as admin-editable, with defaults sourced from `BERTH_SIDE_PONTOON_OPTIONS`. The wizard not only uses the wrong list — it bypasses the admin-editability entirely. Even after fixing the values, admins won't be able to tune the list per-port unless the wizard reads through `getVocabulary('berth_side_pontoon_options')` like other surfaces should.
> - **Fix:** (a) delete `SIDE_PONTOON_OPTIONS` at line 42. (b) Replace the two `SIDE_PONTOON_OPTIONS.filter(Boolean).map(...)` blocks (lines 264 + 334) with a call to the vocabulary hook — confirm the pattern used by `BerthForm` / single-berth edit (likely `useVocabulary('berth_side_pontoon_options')` or a server-component read). (c) Audit every other dropdown in the wizard for the same pattern: `BERTH_MOORING_TYPES`, `BERTH_CLEAT_TYPES`, `BERTH_BOLLARD_TYPES`, `BERTH_ACCESS_OPTIONS` are all registered as admin-editable vocabularies — verify the wizard reads through `getVocabulary` for all of them, not a local constant. (d) **Data backfill:** the four wrong values (`Port` / `Starboard` / `Bow` / `Stern`) may already be in production rows added via this wizard — write a one-off script to either remap them (`Port → Quay PT` or similar based on the port team's intent) or null them out + flag for manual review. Coordinate with the port team before running.
> - **Effort:** ~30min for the wizard fix + dropdown audit, ~30min for the backfill script + dry-run. Total ~1h plus a stakeholder check on the remap mapping. **Severity high** because (i) silently writing out-of-vocabulary data is a long-tail data-integrity problem and (ii) it shadows the existing admin-editability infra (operators may not realize the vocab is overridable for this field because the wizard ignores it). Captured 2026-05-18 from UAT.
> - **SHIPPED in 2d57417:** wizard now reads `useVocabulary('berth_side_pontoon_options')` instead of the wrong hard-coded enum; admin-editable per-port overrides honoured automatically. Data-backfill script + cross-vocab audit (mooring/cleat/bollard/access — none currently surfaced in the wizard but registered as editable) parked as follow-up.
>
> 0. **[high] All file downloads land with a blob-UUID filename + no extension** — _src/components/dashboard/chart-card.tsx:34_ (PNG/CSV exports), _src/app/(dashboard)/[portSlug]/expenses/page.tsx:95_ (CSV/XLSX export), _src/components/clients/client-files-tab.tsx:42_, _src/components/companies/company-files-tab.tsx:42_, _src/components/interests/interest-documents-tab.tsx:72_, _src/components/interests/interest-eoi-tab.tsx:597_, _src/components/admin/backup-admin-panel.tsx:90_ — 7 separate download sites share a near-identical anchor-click pattern that creates `<a download="<name>">`, calls `.click()`, and revokes the URL — but **the anchor is never appended to the document**, so Chromium-based browsers (Comet/Arc/Chrome) silently ignore the `download` attribute and fall back to using the blob URL's UUID for the filename (no extension). Captured UAT screenshot: dashboard chart "Download PNG" lands as `939c78df-48cc-466c-a22e-53e9dea69294` 35.5 KB instead of `<chart-name>.png`. Fix: extract a single `triggerBlobDownload(blob, filename)` helper into `src/lib/utils/download.ts` that (1) `document.body.appendChild(a)`, (2) `a.click()`, (3) `a.remove()`, (4) `URL.revokeObjectURL(url)` on a microtask/next-tick so Chrome has time to read the URL. Refactor all 7 call sites to import it; delete the local copies (and the chart-card-local `triggerBlobDownload` declared at chart-card.tsx:34). ~20-30 min including manual verification of each download surface. **Affects every file-export flow** — bumping severity to high. Captured 2026-05-18 from UAT. **SHIPPED in 2d57417:** added `src/lib/utils/download.ts` with `triggerBlobDownload(blob, filename)` (DOM-attached anchor + deferred URL revoke) + sibling `triggerUrlDownload(url, filename)` for presigned-URL paths; refactored all 7 call sites, dropped the chart-card-local copy.
1. **[high] Duplicate row for berth E17 in port-nimara** — DB: two `berths` rows with `mooring_number='E17'`, both with `price=NULL`. The canonical mooring format is meant to be unique per port (see CLAUDE.md "Mooring number canonical format"). Surfaced by the dashboard tile via the new "berth price missing" chip but the root cause is missing/leaked unique constraint. Recommend: dedupe + add partial unique index on `(port_id, mooring_number) WHERE archived_at IS NULL`. Deferred per session call (warning-only UI ships now).
2. **[medium] Stage advance allowed without berth price** — Service-level: `changeInterestStage` lets an interest reach EOI/Reservation/Deposit Paid/Contract on a primary berth whose `price` is NULL. EOI doc generation downstream presumably renders blank/$0 for the quote field. Cross-port impact unknown. Recommend: add a `ValidationError("Berth price must be set before advancing past Qualified")` gate in `changeInterestStage` for stages eoi+. Deferred per session call.
3. **[medium] Smart search renders duplicate React keys for `/admin/templates` — console warning + potential render glitch** — _src/lib/services/search-nav-catalog.ts:89-94 + 275-280_ + _src/components/search/command-search.tsx:587_. Two entries in the nav catalog both point at `/:portSlug/admin/templates` ("settings" category at line 89 with EOI/Documenso keywords, "admin" category at line 275 with PDF/email-template keywords). The search renderer keys rows by `navigation:${href}` → React fires the "Encountered two children with the same key" warning. Visible in console as `navigation:/port-nimara/admin/templates`. Behavior is unsupported — could cause omitted/duplicated rows.
- **Fix (layered):**
- **(a) Catalog dedupe:** merge the two entries — keep the "settings" one (line 89, matches surrounding /admin/branding + /admin/storage cluster), absorb the admin-version's keywords (`'pdf templates'`, `'email templates'`, `'merge fields'`, `'eoi template'`), delete the duplicate at line 275.
- **(b) Defensive render-side key:** even after dedupe, change command-search.tsx:587 to compose keys as `navigation:${href}:${category}` (or filter duplicates by href at catalog-load time). Protects against the same bug recurring when new nav entries land.
- **Audit:** grep the catalog for any other href that appears twice — likely candidates around /admin/email, /admin/users, /admin/settings if similar consolidations happened. Single dedupe sweep at the top of the catalog file.
- **Effort:** ~15 min. Captured 2026-05-21 from UAT console.
- **SHIPPED in 2d57417:** dedupe lives at the catalog-search layer (`searchNavCatalog` keeps the highest-scoring entry per href via a Map) so any future intentional cross-category re-entries are safe; the two `/admin/templates` rows were also merged into a single richer-keyword entry.
4. **[medium] Overview "Latest note" teaser is stale after creating a note in the Notes tab (no cross-query invalidation)** — _src/components/shared/notes-list.tsx:164-184_ (create/update/delete mutations) + _src/components/interests/interest-tabs.tsx:1083-1104_ (teaser reads `interest.recentNote` + `interest.notesCount` from the parent interest detail object). The notes-list mutations invalidate `[entityType, entityId, 'notes', 'own' | 'aggregated']` but not the parent `['interests', interestId]` query that hydrates `recentNote` / `notesCount`. Net effect: rep adds a note in the Notes tab → switches to Overview → teaser still shows the previous note + the old count until a hard refresh. Same gap presumably affects Client / Company / Yacht detail Overviews if they have similar embedded latest-note teasers.
- **Fix:** add an optional `parentInvalidateKey?: QueryKey` prop to `NotesList`; on each mutation's `onSuccess`, invalidate it alongside the notes query key. The interest tab passes `['interests', interestId]`; the client/company/yacht tabs pass their equivalent. Belt-and-braces: also invalidate inside the parent entity's note-related mutations if any exist directly.
- **Effort:** ~20-30 min (prop + 4 call sites + a vitest covering the invalidation chain). Captured 2026-05-21 from UAT.
- **SHIPPED in 2d57417:** `NotesList` now takes `parentInvalidateKey?: QueryKey`; wired through 5 callers (interests, clients, yachts, companies, residential_clients, residential_interests). Create / update / delete mutations invalidate the parent detail query alongside the notes query key.
5. **[high] InterestDocumentsTab uploads land with `client_id=NULL` — invisible in Attachments + no client subfolder auto-created** — _src/components/interests/interest-documents-tab.tsx:141-147_ (caller passes `entityType="client"` + `entityId={clientId}` but NOT `clientId` separately) + _src/components/files/file-upload-zone.tsx:63_ (only appends `clientId` to the form body when given as a prop) + _src/lib/services/files.ts:85-101_ (`uploadFile` reads `data.clientId ?? null` literally — does not derive it from `entityType==='client' + entityId`). Net effect: upload POST hits `/api/v1/files/upload` with `entityType=client&entityId=<UUID>` but no `clientId` form field, so the `files` row lands with `client_id = NULL`. Cascading bugs: (a) the Documents tab's "Attachments" list (`GET /api/v1/files?clientId=<UUID>`, filters on `eq(files.clientId, clientId)`) returns empty — file vanishes from the interest's Documents tab; (b) Documents Hub auto-deposit can't `ensureEntityFolder` for the client (it walks `files.clientId`), so the `Clients/<client-name>/` subfolder under the system root is never created — file lives at root in "All documents" but isn't filed by entity. The file IS reachable via the port-wide "All documents" view because that query has no clientId filter.
- **Fix (recommended at service layer — durable):** in `src/lib/services/files.ts:uploadFile`, when `data.entityType==='client'` AND `data.clientId` is not set, default `data.clientId = data.entityId`. Same for `entityType==='company'``companyId`, `entityType==='yacht'``yachtId`. Catches any other caller making the same mistake. Plus `ensureEntityFolder` should fire on every upload that lands with an entity FK, not only when explicit clientId was provided.
- **Caller fix (belt + braces):** pass `clientId={interest.clientId}` alongside `entityType` + `entityId` in interest-documents-tab.tsx:141-147. Audit other FileUploadZone call-sites for the same pattern (client-files-tab, yacht-files-tab, company-files-tab).
- **Backfill needed:** existing rows uploaded via this path have `client_id=NULL` despite having `entity_type='client'` + `entity_id=<UUID>`. One-off script to backfill `client_id` from `entity_id` where entity_type='client' AND client_id IS NULL; same for company/yacht. Then re-run `ensureEntityFolder` for affected rows so the Documents Hub tree catches up.
- **Effort:** ~30 min for service-layer fix + caller audit + backfill script. **High severity** — affects every interest-tab upload on the platform, breaks the Documents Hub filing model for those files. Captured 2026-05-21 from UAT.
- **SHIPPED (service layer + caller) in 2d57417:** `uploadFile` in `src/lib/services/files.ts` now derives `clientId/companyId/yachtId` from `(entityType, entityId)` when the explicit FK isn't passed. Interest-documents-tab also passes `clientId={interest.clientId}` belt-and-braces. **Backfill script + nested-folder migration remain outstanding** — those bundle with the larger Bucket 4 #6 "nested document subfolders" feature in PR Batch 4.
6. **[medium] External EOI upload — 3 linked bugs: lying toast + broken View button + UUID-named download** — _src/components/interests/external-eoi-upload-dialog.tsx:60_ + _src/components/interests/interest-eoi-tab.tsx:589-605 (`SignedPdfActions`)_ + _src/app/api/v1/files/[id]/download/route.ts_ + _src/lib/services/files.ts (`getDownloadUrl`)_. Surfaced together during UAT 2026-05-21 of the backfill flow.
- **(a) Toast lies about stage advance** — server (`external-eoi.service.ts:142-160`) only advances stage when current is `open|details_sent|in_communication|eoi_sent`; at Reservation+ it correctly leaves the stage alone. But the client toast hardcodes `"External EOI uploaded — interest advanced to EOI Signed"` regardless of what the server did. **Fix:** have `uploadExternallySignedEoi` return `{ stageChanged: boolean, newStage?: PipelineStage }`; client toasts conditionally: stageChanged → "External EOI uploaded — stage advanced to EOI Signed"; else → "External EOI uploaded — filed against this deal (stage unchanged)". ~20 min.
- **(b) "View" button downloads instead of previewing in-app** — `SignedPdfActions.open('view')` opens the presigned URL via `window.open`. Browser behavior depends on `Content-Disposition` header from MinIO/S3 — defaulting to `attachment` triggers download every time. **Fix:** swap `window.open` for the existing `FilePreviewDialog` component (already supports PDFs + images per `file-preview-dialog.tsx:60-61`). Lift a `[previewFile, setPreviewFile]` state to the parent EOI tab and render `<FilePreviewDialog open={!!previewFile} fileId={previewFile?.id} ... />` once. SignedPdfActions's View button just sets the preview state. Pairs with the platform-wide "preview-everything" Bucket 3 feature so the same inline-preview surface gets full file-type coverage. ~30 min.
- **(c) Download filename is the storage-key UUID** — same root cause: `Content-Disposition` doesn't include a filename, so the browser uses the URL's last path segment (the UUID per `generateStorageKey`). **Fix:** generate the presign in `getDownloadUrl` with `response-content-disposition: attachment; filename="<files.filename>"` (S3/MinIO presign param). Honors the original filename stored in `files.filename`. ~15 min including a sweep of other download call sites — `client-files-tab.tsx`, `company-files-tab.tsx`, `interest-documents-tab.tsx`, `interest-eoi-tab.tsx` all hit the same endpoint. Consider also adding a sibling `response-content-disposition: inline` mode (e.g. `GET /api/v1/files/[id]/download?disposition=inline`) for the cases where we DO want native browser preview as a fallback to FilePreviewDialog.
- **(d) [high] Server discards `dateEoiSigned` + `eoiStatus` when stage is past EOI — skip-ahead banner falsely persists** — _src/lib/services/external-eoi.service.ts:142-160_ — when current stage is past `eoi_sent` (e.g. `reservation`, `deposit_paid`, `contract_*`), the `else` branch (lines 157-160) only updates `updatedAt`, ignoring the `signedAt` from the form. So even though the user uploaded an externally-signed EOI with a valid date, `interests.dateEoiSigned` stays NULL → the SkipAheadBanner keeps demanding the rep backfill the EOI signed date with no way to satisfy it.
- **Fix:** split the two concerns. Document metadata (dateEoiSigned + eoiStatus='signed') should ALWAYS be written from the upload — only the pipelineStage advance is gated:
```ts
const shouldAdvanceStage = ['open', 'details_sent', 'in_communication', 'eoi_sent'].includes(
interest.pipelineStage,
);
await tx
.update(interests)
.set({
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
eoiStatus: 'signed',
pipelineStage: shouldAdvanceStage ? 'eoi_signed' : interest.pipelineStage,
updatedAt: new Date(),
})
.where(eq(interests.id, interestId));
```
- **Also audit:** `interest-rules-engine` / `evaluateRule('eoi_signed', ...)` should fire on this path too (a manually-uploaded external EOI is still an EOI-signed event for the rules engine — berth-rules like "auto-mark berth Under Offer" depend on it).
- ~30-45 min including the audit + integration test.
- **(e) [medium] No edit affordance for uploaded-EOI metadata post-upload (signedAt / signerNames / notes / title locked)** — the EOI tab's history list at `interest-eoi-tab.tsx` shows uploaded documents but exposes no edit button. Once a rep uploads with a typo in signerNames or the wrong signedAt date, they can't correct it — they'd have to delete and re-upload (losing the audit log link).
- **Fix:** add an "Edit metadata" icon-button next to View / Download on each external-EOI row in the EOI tab. Opens a small dialog with the same fields the upload dialog has (signedAt, signerNames, notes, title), pre-filled. Submit PATCHes the document's metadata JSONB + the interest's `dateEoiSigned` (when changed) in one transaction. Audit-log the change with old→new diff.
- Permission gate: `documents.edit_metadata` or reuse `documents.upload_signed` (the same permission that allowed the upload).
- Side concern: the same edit affordance probably belongs on signed Reservations and signed Contracts too — but those are typically Documenso-bound (signedAt is webhook-attested), so editing should be more restricted there. For external EOIs the rep is the source of truth for signedAt anyway, so editing is safe.
- ~1-1.5h including dialog component + service PATCH + audit log + permission gate.
- **Effort:** ~3-4h total for all five sub-issues (was 1-1.5h before (d) + (e) landed). Captured 2026-05-21 from UAT.
- **SHIPPED (a) + (b) + (c) + (d) + default-title in 6cdb9af:**
- (a) `uploadExternallySignedEoi` returns `{ stageChanged, newStage }`; client toast branches on the flag.
- (b) `SignedPdfActions` now takes an `onView` callback; `InterestEoiTab` lifts a single `<FilePreviewDialog>` and forwards the callback to both call sites (active doc + history list).
- (c) S3 backend's `presignDownload` now sets `response-content-disposition: attachment; filename="<name>"; filename*=UTF-8''<encoded>` + `response-content-type`. `getDownloadUrl` threads `file.filename` through. Filesystem backend already honoured the param.
- (d) Service splits metadata write (always: `dateEoiSigned ?? signedAt ?? now()`, `eoiStatus='signed'`) from stage advance (gated on past-EOI). Also fires `evaluateRule('eoi_signed', …)` so berth rules stay in lockstep.
- **Default title** for the external-EOI dialog now derives `External EOI — <Client> — <berth range> — <date>` via the existing `formatBerthRange` helper; rep can override.
- **(e) Edit-metadata UI deferred** to a later wave so it can share infra with the broader signing-flow rework (queued as task #14).
- **SHIPPED (e) in 235e064:** new `updateExternalEoiMetadata` service function patches `documents.title`, `documents.notes`, `interests.dateEoiSigned`, and full-replaces `document_signers` (rows with `id` are UPDATEd, rows without are INSERTed, existing rows whose id isn't in the array are DELETEd). Refuses Documenso-managed docs (`isManualUpload=false`) and non-EOI types with ConflictError. New `PATCH /api/v1/documents/[id]/metadata` route. `<ExternalEoiEditDialog>` mirrors the upload-dialog's signatory affordance (name + email + role + add/remove) plus title / signed date / notes. Document detail page gains an "Edit metadata" button (Pencil icon) that renders only when `isManualUpload && documentType === 'eoi'`. Edit trail recorded in `document_events` as `metadata_updated`.
7. **[high] Expense form: zod refine on `receiptFileIds` fires invisibly — Create button does nothing because the error renders nowhere** — _src/components/expenses/expense-form-dialog.tsx:64-77_ (form registers `useForm` + `zodResolver(createExpenseSchema)`) + _src/lib/validators/expenses.ts:40-47_ (schema-level `.refine()` requiring `receiptFileIds.length > 0 || noReceiptAcknowledged === true`, attached to `path: ['receiptFileIds']`). The form keeps `uploadedReceipt` + `noReceipt` in local React state, never injecting them into the form values via `setValue`. They're spliced into the payload INSIDE `onSubmit` (lines 188-189) — but `onSubmit` is never reached because validation fails first: zodResolver sees `receiptFileIds: undefined` in form values, the refine fails, `errors.receiptFileIds` is set. The form has NO `{errors.receiptFileIds && <p>...}` block, so the error is invisible. Browser scrolls to top of failed form. User reports "I filled everything in and uploaded a receipt — clicking Create does nothing."
- **Fix (recommended — single source of truth in react-hook-form):**
- When `handleFileChange` succeeds: `setValue('receiptFileIds', [uploadedReceipt.id], { shouldValidate: true })`.
- When the "no receipt" checkbox toggles: `setValue('noReceiptAcknowledged', noReceipt, { shouldValidate: true })`. Optionally also `setValue('receiptFileIds', undefined)` when noReceipt is checked.
- When `clearReceipt` runs: `setValue('receiptFileIds', undefined, { shouldValidate: true })`.
- Then drop the local `uploadedReceipt` / `noReceipt` state and read `watch('receiptFileIds')` / `watch('noReceiptAcknowledged')` instead for the UI (or keep them as a UI-only mirror for filename display, but make form state authoritative).
- **Alt (lighter touch):** keep the local state but drop the schema-level refine; move that validation into `onSubmit` manually after merging local state. Loses the form-error idiom — discouraged.
- **Belt + braces (sweep):** audit every form that has `.refine()` rules on fields NOT registered with the form. Same pattern likely exists elsewhere (any form with file uploads or sub-components managing their own state). Add a defensive check: on submit, log/toast a developer warning if a zod error fires on a field that has no error-rendering surface — would have surfaced this bug.
- **Effort:** ~30 min for the expense form fix; ~2-3h for the broader audit of similar refines + state-sync gaps. Captured 2026-05-21 from UAT. **Cross-ref:** the platform-wide form-error UX work in Bucket 2 (scroll-to-first-error + summary banner) would have surfaced this bug visibly — bundle the two as a single rollout so each form audited gets both the missing setValue + the missing error-render surface fixed in one pass.
- **SHIPPED (expense form only) in 2d57417:** `handleFileChange` / `clearReceipt` / `noReceipt` checkbox now mirror to form state via `setValue`; edit-mode `reset()` pre-fills `noReceiptAcknowledged` from the existing expense row. The platform-wide refine-vs-error-surface audit + the broader form-error UX work remain in Wave 3.
8. **[high] Documents filing model needs nested entity subfolders (Interests under Clients; Yachts/Companies parity) — design decisions locked 2026-05-21** — _src/lib/db/schema/files.ts_ (add nullable `interest_id` FK + indexes) + _src/lib/db/schema/document_folders.ts_ (extend entity-folder model to support nested entity folders) + _src/lib/services/files.ts_ (`uploadFile`, `ensureEntityFolder`) + _src/lib/services/document-folders.ts_ + _src/components/files/file-upload-zone.tsx_ (accept + forward `interestId`) + _src/components/interests/interest-documents-tab.tsx_ (caller wires interestId) — companion to bug #4 above. Today's schema can't represent per-interest filing: `files` has no `interest_id` and `document_folders.ensureEntityFolder` only knows top-level client/company/yacht roots. Reps want `Clients/<Client name>/<Interest folder>/<file>` so they can find "everything for this specific deal" in one place — including across multiple historical deals for the same client.
- **Locked design decisions (from UAT 2026-05-21):**
- **D1. Folder naming pattern (single-berth):** `<mooring> · <created month>` (e.g. `A1 · 2026-04`). Stable for the deal's lifetime — does NOT update on stage transitions. Only renames once, on close: appends ` (Lost)` / ` (Won)`. Bookmark / email references stay valid.
- **D2. Folder naming pattern (multi-berth):** `<berth range> · <created month>` using the existing `formatBerthRange()` helper from `src/lib/templates/berth-range.ts` — same idiom as the EOI Berth Number field (per CLAUDE.md). Example: `A1-A3, B5-B7 · 2026-04`.
- **D3. Default upload scope from an Interest page:** radio with two options, **default selected = "This deal (Interest <folder name>)"**, alternate = "Client-level (all deals)". Rep flips to client-level when uploading general docs like passport scans from the interest page.
- **D4. Scope of nesting:** apply to **Interests + Yachts + Companies** (full hierarchy). Yacht folders nest under their owner (Client or Company) per `yachts.current_owner_type/id`. Company-owned yachts nest under their company folder.
- **D5. Rename triggers:** ONLY on close (Won/Lost) or archive. Active deals keep stable names. Primary-berth changes during active life do NOT re-derive (avoids churn).
- **D6. Storage backend (S3 / MinIO / filesystem):** zero implications. Documents Hub folder tree is metadata-only (`document_folders` in Postgres); object keys stay UUID-based (`<portSlug>/<entity>/<entityId>/<uuid>.<ext>` per `generateStorageKey`) and never move on folder rename. Soft-rescue delete is also metadata-only.
- **Schema changes:**
- Add `files.interest_id uuid` nullable FK + index on `(port_id, interest_id) WHERE archived_at IS NULL`. Existing rows stay NULL (= client-level, no interest scope).
- Extend `document_folders.entity_type` to accept `'interest'` (and confirm `'yacht'`, `'company'` are already supported per CLAUDE.md). Existing partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL` still applies. Nested rows: `parent_id` points to the parent client/company folder (not the system root) so the tree carries the hierarchy.
- **Folder-name derivation helper:** new `src/lib/services/document-folder-naming.ts` exporting `deriveInterestFolderName(interest, interestBerths)`:
- Read `interest.dateCreated` (or `createdAt`) → format as `YYYY-MM`.
- Resolve berths via `interestBerths.filter(b => b.isInEoiBundle ?? b.isPrimary)` (fall back to all linked berths if none flagged).
- Single berth → `<mooring> · <month>`. Multiple berths → `${formatBerthRange(moorings)} · <month>`. No berths linked → `Deal <short-id> · <month>` fallback.
- Append ` (Won)` / ` (Lost)` when `interest.outcome` is set; ` (Archived)` when `interest.archivedAt` is set without outcome.
- Pure function, unit-tested.
- **Service-layer wiring (combines with the #4 service-layer fix):**
- `uploadFile`: when `entityType==='interest'` OR `interestId` is set → resolve parent client via `interests.clientId`, call `ensureEntityFolder('client', clientId)`, then `ensureEntityFolder('interest', interestId, parentFolderId: clientFolderId, name: deriveInterestFolderName(...))`, file the row at the interest folder. Three-tier: PORT root → client subfolder → interest sub-subfolder.
- `uploadFile`: when `entityType==='yacht'` OR `yachtId` set → resolve owner (`yachts.currentOwnerType` + `currentOwnerId`), ensure owner folder, ensure yacht subfolder under it.
- `uploadFile`: when only `clientId` set (no interestId, no yachtId) → file at client folder (today's behavior).
- **The #4 derive-clientId-from-entityType fix collapses into this:** `uploadFile` now always derives the FK from `entityType + entityId` if not explicitly passed. The bug-#4 hot-fix is the trivial 1-line version; this larger work is the durable version.
- **Upload-time UI affordance (D3):**
- `FileUploadZone` accepts a new `scopeOptions?: Array<{ id, label, entityType, entityId }>` prop + a `defaultScopeId?: string`. Renders a small radio above the dropzone when ≥ 2 options.
- InterestDocumentsTab passes `scopeOptions = [{ id: 'interest', label: 'This deal (Interest <name>)', entityType: 'interest', entityId: interestId }, { id: 'client', label: 'Client-level (all deals)', entityType: 'client', entityId: clientId }]` with `defaultScopeId='interest'`.
- YachtDocumentsTab (when it lands) passes 2 options: `'yacht'` (default) + `'owner'` (client/company-level).
- Client / Company / Yacht detail pages with no parent context render the dropzone without the radio (single-scope upload).
- **Lifecycle hooks (D5):**
- Interest outcome lands (Won / Lost): rename folder via a service helper that re-runs `deriveInterestFolderName` and `UPDATE document_folders SET name=...`.
- Interest archived: append ` (Archived)` if no outcome set.
- Soft-rescue per CLAUDE.md — never hard-delete folders even on `archive`.
- Primary-berth changes mid-deal: NO rename (per D5 — stable during active life). The folder name reflects creation-time berths; current berths are visible elsewhere in UI.
- **List query updates:**
- InterestDocumentsTab "Attachments" section: surface BOTH (i) files with `files.interest_id === interestId` under a "This deal" subheading + (ii) files with `files.client_id === clientId AND interest_id IS NULL` under a "From client" subheading. Mirrors the aggregated-projection idiom (per CLAUDE.md).
- Documents Hub tree: render interest subfolders inside parent client folder. Add a small outcome chip per interest folder (Won / Lost / Active).
- **Backfill (combines with #4 backfill):**
- Files with `entity_type='interest' + entity_id=<UUID>` but missing `interest_id` column → backfill `interest_id = entity_id`; derive parent `client_id` from `interests.client_id`; run `ensureEntityFolder` for both levels.
- Files with `entity_type='yacht'` + `entity_id` but missing `yacht_id` → mirror.
- Files with only `client_id` set pre-feature stay at client-folder level — no interest scope retroactively (can't infer which interest they belonged to).
- One-off script `pnpm tsx scripts/backfill-nested-document-folders.ts --apply` — idempotent, per-port advisory-locked.
- **Effort:** ~6-8h end-to-end (migration + service rewrites + folder-name derivation + upload-zone affordance + tree rendering + lifecycle hooks + backfill + tests). Bundles bug #4 — both touch the same code paths. Captured 2026-05-21 from UAT.
- **SHIPPED (foundation only — phase 1/3) in e91055f:** migration `0078_files_interest_id.sql` adds `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL + indexes `idx_files_interest` + `idx_files_port_interest`. Drizzle schema picks up the column + `interestId` field. `EntityType` widened to include `'interest'` — `ensureEntityFolder('interest', ...)` recursively ensures the parent client folder first so the tree reads `Clients/<Name>/Deal <mooringNumber>/` nested. `resolveEntityDisplayName` derives the deal label from the primary berth via dynamic-import of `getPrimaryBerth` (circular-dep dodge), falling back to `Deal <YYYY-MM-DD>`. **Remaining (phase 2/3):** UploadZone `scopeOptions` radio for "This deal" vs "Client-level", lifecycle hook for interest outcome → folder rename ("Deal A1-A3 (Won)"), `listFilesAggregatedByEntity` rewrite to surface "This deal" / "From client" subheadings, Documents Hub tree rendering for nested interest folders, backfill script for existing files with `entity_type='interest'` but no `interest_id`.
9. **[medium] SelectTrigger height (`h-9`) doesn't match Input height (`h-11`) — platform-wide visual inconsistency** — _src/components/ui/select.tsx:22_ (SelectTrigger default `h-9` = 36px) + _src/components/ui/input.tsx:18_ (Input default `h-11` = 44px). Every form where an Input sits next to a Select has an 8px height mismatch. Surfaced specifically on _src/components/expenses/expense-form-dialog.tsx:222-247_ (the Amount + Currency two-column row) but affects ALL such combinations across the platform. Fixing locally with `className="h-11"` on each call site is a sweep over dozens of spots and creates drift the next time someone copies the pattern.
- **Fix (platform-wide):** introduce a `size` variant on SelectTrigger mirroring Button's idiom — `<SelectTrigger size="default" | "sm">`. Default to `"default"` = `h-11` so it pairs with the Input default out of the box. Migrate explicitly-compact uses (filter bars, dense table headers) to pass `size="sm"` = `h-9` to preserve their current density.
- **Audit step:** grep every `<SelectTrigger>` and `<Select>` call site; flag the ones in compact contexts (FilterBar, DataTable header dropdowns, dense admin lists) for the `size="sm"` override; everything else inherits the new h-11 default.
- **Effort:** ~1h for the component change + audit + sweeping the explicit `size="sm"` overrides. Higher upside: enforces visual parity for every future form. Captured 2026-05-21 from UAT.
10. **[medium] Platform-wide: every file-row surface should be click-to-preview by default (currently action is hidden behind kebab on FileGrid; Recent Files rows don't respond at all)** — confirmed on _src/components/files/file-grid.tsx:103-150_ (card body is a static `<div>` with no `onClick`; Preview action lives inside `MoreHorizontal` kebab → opacity-0 unless hovered) + _src/components/documents/_ "Recent Files" rendering surface (rows entirely non-clickable per earlier UAT — preview AND download both dead). Same UX gap repeats across every file-row surface; ship one fix pattern everywhere instead of per-component patches.
- **Fix shape (apply uniformly):**
- **Click target = preview** — the card/row body becomes a `<button onClick={() => onPreview(file)}>` (or accessible `<div role="button" tabIndex={0}>` with keyboard support). Click opens `FilePreviewDialog` directly. Hover state already implies clickability via `hover:border-primary/50 hover:shadow-sm` — wiring the click matches the visual affordance.
- **Kebab stays as "More actions"** — Download, Rename, Delete remain in the dropdown. Drop the redundant "Preview" entry from the kebab once the body click does it.
- **Non-previewable mime types** — still click-to-preview, but `FilePreviewDialog` renders its fallback empty state ("Preview not supported for this file type. [Download to view]"). Pairs with the universal-preview feature already queued in Bucket 3.
- **Affected surfaces** (audit during the sweep):
- `src/components/files/file-grid.tsx` — interest/client/company documents grid (confirmed UAT)
- `src/components/documents/document-list.tsx` `DocRow` — table-row name cell should be click-to-preview (confirmed UAT 2026-05-21: clicking on the "External EOI — 2026-05-21" filename does nothing)
- `src/components/documents/aggregated-section.tsx` — the "Recent Files / Inflight Workflows" panels
- `src/components/documents/entity-folder-view.tsx`
- Any list surface that takes a `files` array + an `onPreview` callback
- **Title cell specifically:** wrap the filename cell in a button-styled span with `onClick={() => onPreview(row)}` so the rep's natural click target works. Keep the row's other action cells (View, Download, kebab) untouched — they're secondary affordances.
- **Bundle with Bucket 3 #000 (universal preview)** — pointless to make every row click-to-preview if half the file types render a blank dialog. Ship the two together: file-row surfaces all click-to-preview AND `FilePreviewDialog` handles every mime type (or shows a graceful fallback).
- **Effort:** ~1-1.5h for the click-target sweep across 4-5 surfaces; ~5-7h with the universal-preview piece bundled. Captured 2026-05-21 from UAT (FileGrid surfaced specifically; Recent Files captured earlier).
- **SHIPPED (FileGrid + DocumentList) in 52342ee:** FileGrid card body is now a `<button onClick={onPreview}>`. DocumentList title cell on rows with `signedFileId` opens `FilePreviewDialog`; kebab keeps More Actions, gains Download.
- **SHIPPED (EntityFolderView + HubRootView) in ded16f4:** filename cells on the entity-scoped aggregated Files panel and the Documents Hub root "Recent files" panel now wrap the name in a `<button>` that opens `FilePreviewDialog`. `HubRootFile` shape extended to include `mimeType` (already returned by `/api/v1/files`). Click-to-preview sweep across file-row surfaces is now complete.
11. **[high] Supplemental-info form blocked by portal kill-switch (route nested under `(portal)` group)** — _src/app/(portal)/public/supplemental-info/[token]/page.tsx_ (current location) + _src/app/(portal)/layout.tsx:25-37_ (`isPortalDisabledGlobally()` short-circuit returns "Client portal unavailable" screen for ALL children). The supplemental-info form is token-protected and conceptually independent of the portal login concept — it's a one-shot URL emailed to a client to fill in extra info for an EOI, and should always work as long as the token is valid. But because the route lives inside the `(portal)` route group, it inherits the layout's "portal disabled?" gate. Net effect: any port that hasn't opted into the client portal (the default state for most ports right now) cannot use the supplemental-info flow at all — clients see the "Client portal unavailable" screen when they click the emailed link, even though the rep just sent it successfully.
- **Fix:** move the file from `src/app/(portal)/public/supplemental-info/[token]/page.tsx` → `src/app/public/supplemental-info/[token]/page.tsx` (out of the route group). URL stays identical (`/public/supplemental-info/<token>`) because Next route groups don't affect URLs — the route group's only effect was layout inheritance, and moving it drops the portal gate. Verify the API route at `/api/public/supplemental-info/[token]` doesn't have a similar nesting issue (likely fine — `/api/` paths don't share the `(portal)` layout).
- **Sweep:** audit `src/app/(portal)/` for any other anonymous token routes that should be outside the group. Currently `find` only returns the one file, but worth verifying as new public flows are added (password-reset tokens, magic-link tokens for non-portal flows, etc.).
- **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT.
- **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/<token>` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested.
12. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
---
## Bucket 5 — Cross-references to active audit doc
_Manual findings that confirm or extend a finding from the full codebase audit. Format: `manual #N ↔ Audit X#N — note`._
_None yet._
---
## Append protocol
- Add new findings to the matching bucket as bullet points.
- Where a finding overlaps an audit entry, note `(see Audit X#N)` and add a back-reference line `→ confirmed in manual #<N>` in the corresponding row of `2026-05-18-full-codebase-audit.md`.
- Keep entries terse — one line where possible, file:line evidence inline.
- When promoted to a task or PR, append the commit hash inline (`fixed in <sha>`).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
# Env-to-Admin Migration — Design Spec
**Date:** 2026-05-15
**Status:** Draft (awaiting user review)
**Author:** Brainstorm session, Matt + Claude
## Goal
Move every tenant-configurable environment variable into the per-port admin UI, leaving env exclusively for boot-time / build-time / chicken-and-egg secrets. Eliminate the silent drift that produced two of the audit's findings (S-23 plaintext S3 access key; Documenso API key stored plaintext per its own admin form description).
## Non-goals
- **Not** moving boot-time secrets (DATABASE_URL, BETTER_AUTH_SECRET, etc.) — they're needed before the DB is reachable.
- **Not** building a Google OAuth admin form — feature is not in use.
- **Not** changing the existing per-port `system_settings` storage table — only adding columns / rows.
- **Not** silently mutating `.env` files at runtime (rejected as too footgun-y).
## Scope decisions (from brainstorming)
| Decision | Choice |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Which env vars move | Anything tenant-configurable (option 2). Boot-time + build-time stay in env. |
| Env-fallback policy | Env stays as runtime fallback when admin field is blank. Vars are commented out in `.env.example`, with dev + prod templates committed to repo. |
| Per-port vs global | Per-port with global fallback (`port_id IS NULL`) for credentials and shared infrastructure. Resolution: port → global → env → registry default. |
| Encryption | All credential-class fields AES-256-GCM via `EMAIL_CREDENTIAL_KEY`. Fixes S-23 + Documenso plaintext as part of this migration. |
| Migration UX | "Using env fallback" badge per field + "Copy current value from env" one-click button. Operator-driven; nothing happens automatically at boot. |
| Implementation | Settings registry + uniform resolver (approach A). |
## Architecture
The current code has 4 places that "know" about each setting:
1. Env validation schema (`src/lib/env.ts`)
2. Per-domain resolver (`src/lib/services/port-config.ts` for Documenso/email; ad-hoc reads for others)
3. Admin form definition (`SettingFieldDef[]` in each `admin/<integration>/page.tsx`)
4. Encryption call site (per service)
These drift independently and produce drift bugs. Replace those 4 sites with **one registry entry per setting**. The registry is consumed by:
- **Resolver** (`getSetting(key, portId)`) — port → global → env → default; decrypts on read if `encrypted: true`.
- **Admin form generator** — renders inputs from `type` + `label` + `description`; auto-attaches the "Using env fallback" badge + "Copy from env" button. Encryption is transparent (resolver returns `*IsSet: true` for credential fields, never the cleartext).
- **Validator** — Zod schema attached to each entry, used by both the admin write endpoint AND env validation at boot.
- **Encryption helper** — registry says `encrypted: true` → resolver wraps in `encrypt()`/`decrypt()`.
Existing per-port settings table (`system_settings`) stays — no schema migration beyond adding `_encrypted` suffix to a few previously-plaintext columns and one new column for webhook secret.
```
┌─────────────────────────────────────────────────────────┐
│ src/lib/settings/ │
│ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ registry.ts │ │ resolver.ts │ │
│ │ - one entry per key │───▶│ getSetting(k, port) │ │
│ │ - type, encrypted, │ │ writeSetting(k, v) │ │
│ │ scope, validator │ │ envFallbackFor(k) │ │
│ └──────────────────────┘ └──────────┬──────────┘ │
│ │ │
│ ┌──────────────────────┐ ┌──────────▼──────────┐ │
│ │ encryption.ts │◀───│ system_settings │ │
│ │ AES-256-GCM │ │ (existing table) │ │
│ └──────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌──────────────────────┴──────────────────────────────────┐
│ RegistryDrivenForm (React component) │
│ Input: { sections: ['documenso.api', ...] } │
│ Output: <Form> with badges + Copy-from-env buttons │
└─────────────────────────────────────────────────────────┘
```
## Registry shape
```ts
// src/lib/settings/registry.ts
export interface SettingEntry {
/** Stable key written to system_settings.key */
key: string;
/** Human-readable section the admin form groups by */
section: string;
/** UI label */
label: string;
/** UI description (markdown allowed) */
description: string;
/** Type drives both validation and form input */
type: 'string' | 'password' | 'number' | 'boolean' | 'select' | 'url' | 'email';
/** select-only */
options?: Array<{ value: string; label: string }>;
/** Zod schema — overrides type-default validator if provided */
validator?: z.ZodTypeAny;
/** Defaults applied when port + global + env all absent */
defaultValue?: string | number | boolean | null;
/** Encrypt at rest with AES-256-GCM */
encrypted?: boolean;
/** Per-port (default) or global-only (super-admin) */
scope: 'port' | 'global';
/** Env var name to consult as fallback when port + global blank */
envFallback?: string;
/** Optional value transformer applied after resolution */
transform?: (raw: unknown) => unknown;
/** Sensitive: never surface cleartext via admin API; emit `<key>IsSet: boolean` instead */
sensitive?: boolean;
}
export const REGISTRY: SettingEntry[] = [
// Documenso
{
key: 'documenso_api_url',
section: 'documenso.api',
label: 'API URL',
type: 'url',
scope: 'port',
envFallback: 'DOCUMENSO_API_URL',
description: 'Bare host only — never include /api/v1.',
},
{
key: 'documenso_api_key',
section: 'documenso.api',
label: 'API key',
type: 'password',
scope: 'port',
encrypted: true,
sensitive: true,
envFallback: 'DOCUMENSO_API_KEY',
description: 'AES-encrypted at rest.',
},
{
key: 'documenso_api_version',
section: 'documenso.api',
label: 'API version',
type: 'select',
options: [
{ value: 'v1', label: 'v1' },
{ value: 'v2', label: 'v2' },
],
scope: 'port',
envFallback: 'DOCUMENSO_API_VERSION',
defaultValue: 'v1',
},
{
key: 'documenso_webhook_secret',
section: 'documenso.api',
label: 'Webhook secret',
type: 'password',
scope: 'port',
encrypted: true,
sensitive: true,
envFallback: 'DOCUMENSO_WEBHOOK_SECRET',
description: 'Used to verify inbound webhook deliveries via X-Documenso-Secret header.',
},
// ... continued for every migrated key
];
```
Resolver:
```ts
// src/lib/settings/resolver.ts
export async function getSetting<T = unknown>(
key: string,
portId: string | null,
): Promise<T | null> {
const entry = registryFor(key);
if (!entry) throw new Error(`Unknown setting: ${key}`);
// 1. port-specific
if (portId && entry.scope === 'port') {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
});
if (row?.value != null) return decryptIf(entry, row.value) as T;
}
// 2. global (port_id IS NULL)
const globalRow = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
});
if (globalRow?.value != null) return decryptIf(entry, globalRow.value) as T;
// 3. env fallback
if (entry.envFallback && process.env[entry.envFallback]) {
return (
entry.transform?.(process.env[entry.envFallback]) ?? (process.env[entry.envFallback] as T)
);
}
// 4. registry default
return (entry.defaultValue ?? null) as T;
}
```
The existing `getPortDocumensoConfig` etc. become thin convenience wrappers that batch a few `getSetting` calls and return a typed object:
```ts
export async function getPortDocumensoConfig(portId: string) {
const [apiUrl, apiKey, apiVersion, webhookSecret, ...rest] = await Promise.all([
getSetting<string>('documenso_api_url', portId),
getSetting<string>('documenso_api_key', portId),
getSetting<DocumensoApiVersion>('documenso_api_version', portId),
getSetting<string>('documenso_webhook_secret', portId),
// ...
]);
return { apiUrl, apiKey, apiVersion, webhookSecret, ...mapRest(rest) };
}
```
## Admin UI generation
```tsx
// src/components/admin/registry-driven-form.tsx
interface Props {
sections: string[]; // e.g. ['documenso.api', 'documenso.signers']
portId: string | null; // null = global tab
}
export function RegistryDrivenForm({ sections, portId }: Props) {
const entries = REGISTRY.filter((e) => sections.includes(e.section));
const { data: resolved } = useResolvedValues(entries, portId);
return entries.map((entry) => (
<FormField key={entry.key}>
<Label>{entry.label}</Label>
{entry.description && <p className="text-xs text-muted-foreground">{entry.description}</p>}
<Input
type={entry.type === 'password' ? 'password' : entry.type}
value={
entry.sensitive
? resolved[entry.key]?.isSet
? '••••••••'
: ''
: (resolved[entry.key]?.value ?? '')
}
/>
{resolved[entry.key]?.source === 'env' && (
<div className="flex gap-2">
<Badge>Using env fallback</Badge>
<Button onClick={() => copyFromEnv(entry.key, portId)}>Copy from env</Button>
</div>
)}
</FormField>
));
}
```
The existing per-integration admin pages become 5-line wrappers:
```tsx
// admin/documenso/page.tsx (replaces the current 410-line file)
export default function DocumensoAdmin() {
return (
<>
<PageHeader title="Documenso" />
<RegistryDrivenForm
sections={['documenso.api', 'documenso.signers', 'documenso.templates']}
/>
<DocumensoTestButton />
</>
);
}
```
## API endpoints
Two endpoints replace the current ad-hoc per-section endpoints:
| Method | Path | Purpose |
| ------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET | `/api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers` | Returns `{ key, value, source: 'port' \| 'global' \| 'env' \| 'default', isSet }` per requested entry. Sensitive fields never include cleartext. |
| PUT | `/api/v1/admin/settings/:key` | Body `{ value }`. Validates against registry's Zod schema. Encrypts if `encrypted: true`. Writes to `system_settings`. Audit-logged with `action: 'update'`, `entityType: 'setting'`, `metadata: { key }`, secrets masked. |
| DELETE | `/api/v1/admin/settings/:key` | Removes the row → reverts to global → env → default. |
| POST | `/api/v1/admin/settings/:key/copy-from-env` | One-click migration. Reads env var named in `entry.envFallback`, writes to `system_settings`, returns the resulting resolved state. |
Existing `PUT /api/v1/admin/settings` (the generic upsert) stays for backward compat with the few non-registry writers; new fields use the typed endpoint.
## Encryption integration
- Reuse existing `encrypt()` / `decrypt()` from `src/lib/utils/encryption.ts` (AES-256-GCM, random IV per encryption, GCM auth tag).
- Resolver auto-wraps encrypt on write when `entry.encrypted === true`, decrypt on read.
- `system_settings.value` is `JSONB`. For encrypted values, store as `{ ciphertext, iv, tag }` (already the convention in `sales-email-config.service.ts`).
- Sensitive fields surface `<key>IsSet: boolean` in the API response, never the decrypted value. The admin form shows `••••••••` placeholder.
- Audit log integration: when writing to a key with `encrypted: true`, the `newValue` is replaced with `{ value: '[redacted]' }` before audit-log write — fixes audit finding **AU-02** (encrypted ciphertext in audit log) as part of this work.
## Env catalog
Every env var, classified:
### A. Stays in env (boot-time / build-time / chicken-and-egg)
| Var | Reason |
| --------------------------- | ----------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Need DB connection before reading from DB |
| `REDIS_URL` | Same — Redis pre-init |
| `BETTER_AUTH_SECRET` | Cookie/session signing key, read at auth init |
| `BETTER_AUTH_URL` | Auth callback base URL, read at auth init |
| `CSRF_SECRET` | CSRF token signing, read pre-DB |
| `EMAIL_CREDENTIAL_KEY` | The AES key used to encrypt other DB-stored credentials (chicken-and-egg) |
| `NODE_ENV` | Read pre-init by Next.js, logger, etc. |
| `LOG_LEVEL` | Read at logger init pre-DB |
| `PORT` | Listen port, read at server start |
| `NEXT_PUBLIC_APP_URL` | Inlined into client JS bundle at build time |
| `NEXT_PUBLIC_SENTRY_DSN` | Same — client-side Sentry init |
| `MULTI_NODE_DEPLOYMENT` | Used at boot to gate filesystem backend |
| `SKIP_ENV_VALIDATION` | Internal bypass flag |
| `WEBSITE_INTAKE_SECRET` | Boot-time shared secret with marketing site (could go DB but operator-shared, not user-tunable) |
| `EMAIL_REDIRECT_TO` | Dev-only safety net; operator convenience |
| `SENTRY_ENVIRONMENT` | Read at Sentry SDK init pre-DB |
| `SENTRY_TRACES_SAMPLE_RATE` | Same |
### B. Migrates to admin (per-port, encrypted where credential)
| Var | Registry key | Encrypted | Already in admin? |
| ---------------------------------- | ---------------------------------- | ----------------------------- | ----------------------------- |
| `DOCUMENSO_API_URL` | `documenso_api_url` | no | yes (override) |
| `DOCUMENSO_API_KEY` | `documenso_api_key` | **yes** (was plaintext) | yes (override, plaintext bug) |
| `DOCUMENSO_API_VERSION` | `documenso_api_version` | no | yes |
| `DOCUMENSO_WEBHOOK_SECRET` | `documenso_webhook_secret` | **yes** | **no — gap** |
| `DOCUMENSO_TEMPLATE_ID_EOI` | `documenso_eoi_template_id` | no | yes |
| `DOCUMENSO_CLIENT_RECIPIENT_ID` | `documenso_client_recipient_id` | no | yes |
| `DOCUMENSO_DEVELOPER_RECIPIENT_ID` | `documenso_developer_recipient_id` | no | yes |
| `DOCUMENSO_APPROVAL_RECIPIENT_ID` | `documenso_approval_recipient_id` | no | yes |
| `MINIO_ENDPOINT` | `storage_s3_endpoint` | no | yes (storage admin) |
| `MINIO_PORT` | (combined into endpoint URL) | — | yes |
| `MINIO_ACCESS_KEY` | `storage_s3_access_key` | **yes** (was plaintext, S-23) | yes (plaintext bug) |
| `MINIO_SECRET_KEY` | `storage_s3_secret_key` | yes (already) | yes |
| `MINIO_BUCKET` | `storage_s3_bucket` | no | yes |
| `MINIO_USE_SSL` | (combined into endpoint URL) | — | yes |
| `MINIO_AUTO_CREATE_BUCKET` | `storage_s3_auto_create_bucket` | no | new |
| `SMTP_HOST` | `smtp_host_override` | no | yes |
| `SMTP_PORT` | `smtp_port_override` | no | yes |
| `SMTP_USER` | `smtp_user_override` | no | yes |
| `SMTP_PASS` | `smtp_pass_override` | yes (already) | yes |
| `SMTP_FROM` | `email_from_address` | no | yes |
| `OPENAI_API_KEY` | `openai_api_key` | yes (already) | yes |
| `APP_URL` | `app_url` | no | **new** |
| `PUBLIC_SITE_URL` | `public_site_url` | no | **new** |
### C. Skipped (YAGNI)
| Var | Reason |
| ------------------------------------------ | --------------------------------- |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | OAuth not used and not on roadmap |
## Migration of existing code
1. **Replace `getPortDocumensoConfig` body** to call the new `getSetting` per field (see Architecture section).
2. **Replace `getSalesEmailConfig` body** the same way.
3. **Replace direct `process.env.X` reads** in: `receipt-scanner.ts:4` (OpenAI client), `documents.service.ts` (any direct env reads), `webhook-event-map.ts` (webhook URL builder), all `src/lib/storage/` backend reads.
4. **Migrate the 5 admin pages** (Documenso, AI, OCR, Email, Storage) to use `RegistryDrivenForm`. Keep page-specific extras (test buttons, status cards, AI budget card, sends log).
5. **Add migrations:**
- One-time data migration: copy any plaintext `documenso_api_key_override` and `storage_s3_access_key` rows into encrypted columns, drop plaintext columns. Reuse `encrypt()`.
- Schema: add `documenso_webhook_secret` row on first registry-resolver init, and any new keys (`app_url`, `public_site_url`).
6. **Update `.env.example`:** comment out everything in category B, add an explanation header pointing operators to `/admin/<integration>` after first super-admin login. Generate `dev.env.example` and `prod.env.example` templates with category-A vars only (the boot-time minimum).
7. **Update `src/lib/env.ts`:** mark all category-B vars as `optional()` (env is fallback, not required for boot). Category-A stays required.
## Error handling
- **Resolver:** unknown key → throws (programming error). Decryption failure → throws + audit-logged with `action: 'decryption_failed'`. Missing required value → returns `null`, caller decides (e.g. Documenso send fails with a clear error toast).
- **Admin write:** Zod validation failure → 400 with field-level errors via `parseBody`. Encryption failure → 500 + audit `action: 'encryption_failed'`. Permission check at route handler (`admin.manage_settings` or domain-specific permission).
- **Form:** "Copy from env" when env var is empty → toast "no env value to copy". Save with empty cleartext on a sensitive field → DELETE the row (reverts to env/default), don't write empty ciphertext.
## Testing
Unit tests:
- `getSetting` — port → global → env → default precedence (per-port hits, global hits, env fallback, default fallback)
- `getSetting` — encrypted entry round-trips
- `getSetting` — sensitive entry surfaces `*IsSet` boolean only
- Registry validators reject malformed values
- Migration script: plaintext → encrypted round-trips correctly
Integration tests:
- `PUT /api/v1/admin/settings/:key` with valid + invalid payloads
- `POST /api/v1/admin/settings/:key/copy-from-env` with present + absent env
- Audit log row written with masked secret value
E2E (Playwright smoke):
- Super-admin opens `/admin/documenso`, sees "Using env fallback" badges on inherited fields, types a value, saves, badge disappears
- Click "Copy from env" → field auto-fills, badge changes to "Set in port"
- Per-port override actually applied: switch port → see different value resolved
## Rollout
Single PR, single migration. Backward compat via env-as-fallback means existing deployments keep working unchanged after deploy (admin DB rows are absent, so resolver falls through to env). Operator opts in to admin-canonical configuration field-by-field.
## Out of scope (separate work)
- Building admin form for OCR / berth-PDF parser tunables (feature settings, not env migration)
- Refactoring all _other_ per-port settings (vocabularies, qualification criteria, custom fields, etc.) into the registry — those already have working bespoke forms; no drift bug there.
- Adding settings versioning / rollback (not requested)
- Multi-tenant settings export/import (not requested)

View File

@@ -0,0 +1,189 @@
# Umami v2 / v3 API capabilities — reference for flesh-out planning
**Verified against:** analytics.portnimara.com (Umami v3.1.0), 2026-05-19.
**Auth:** username/password → JWT via `POST /api/auth/login`, Bearer on every request, 1h TTL (we cache 55min).
**Companion code:** `src/lib/services/umami.service.ts` (currently wraps stats/pageviews/metrics/active).
Endpoints below are listed by topic area, with what we currently use, what's available but unused, and where it could plug into the CRM.
---
## 1. Stats & traffic snapshots — `/api/websites/:id/stats`
**Currently used.** Returns the flat aggregate over the requested window plus a `comparison` block for the prior window of equal length.
```json
{
"pageviews": 2081, "visitors": 726, "visits": 872,
"bounces": 457, "totaltime": 109519,
"comparison": { "pageviews": 1935, "visitors": 642, ... }
}
```
**Unused fields we could surface:**
- `totaltime` — total seconds on site → derive avg session time (`totaltime / visits`).
- `bounces / visits` → bounce-rate KPI.
- Period-over-period deltas (already wired for trend arrows, but the _full_ comparison object has more we could use for a "what changed since last period" panel).
**Filters supported** (per Umami docs, mostly untested by us): `url`, `referrer`, `title`, `query`, `event`, `host`, `os`, `browser`, `device`, `country`, `region`, `city` — meaning every stats call can be sliced. **Big unlock:** show stats for a specific landing-page URL on the berth detail (e.g. `/berths/A12` stats), or filter by referrer to see which channels drove signed EOIs.
---
## 2. Time-series — `/api/websites/:id/pageviews`
**Currently used** for the trend chart. Returns `{pageviews: [{x, y}], sessions?: [{x, y}]}` (sessions only when `compare` is requested).
**Parameters:** `startAt`, `endAt`, `unit` (`year|month|day|hour`), `timezone`, `compare` (untapped), `filters` (untapped).
**Unused:** `compare=prev` gives the same series for the previous period — could power a dual-line "vs last period" overlay on the chart.
---
## 3. Top-N metrics — `/api/websites/:id/metrics`
**Currently used** for Top Pages / Referrers / Countries (limit 10). Returns `[{x, y}]`.
**Available `type` values** (we surface 4, Umami offers 17):
| Type | What it returns | CRM use case |
| --------------------------- | -------------------------- | --------------------------------------------------------- |
| `path` | Top URLs | ✅ Already shown (we mis-typed as `url`, now fixed) |
| `referrer` | Top referring sites | ✅ Already shown |
| `country` | Visitors by country | ✅ Already shown |
| `browser` / `os` / `device` | Tech breakdown | Not surfaced — useful for "is mobile traffic converting?" |
| `region` / `city` | Geographic drill-down | Strong fit for marina marketing |
| `language` | Visitor browser language | Could feed i18n decisions |
| `screen` | Resolution | Low value |
| `event` | Top custom events | Big unlock — see §6 below |
| `tag` | Event tags | Same |
| `query` | Top URL query strings | UTM-debug surface |
| `entry` / `exit` | First/last page in session | Funnel analysis |
| `title` | Top page titles (vs paths) | Better labels for non-slug URLs |
| `hostname` | Multi-domain sites | Probably N/A |
| `distinctId` | Custom user identifiers | If we ever pipe CRM user IDs into Umami |
---
## 4. Live visitors — `/api/websites/:id/active`
**Currently used** for the green-dot "N active right now" indicator. Returns `{visitors: number}` (last-5-min count).
**Alternative for richer realtime:** `/api/realtime/:websiteId` (live realtime feed) returns far more — current top URLs being viewed, current top countries, recent event stream, a 30-minute time-series, totals, plus a `timestamp` you can poll against. We could surface a "live" panel on the dashboard showing the most-viewed pages right now.
---
## 5. Sessions API — `/api/websites/:id/sessions/*`
**Not currently used.** Multiple endpoints worth integrating:
- `GET /sessions` — list every session in a range with full device/geo/visits/views columns. Pageable. Could power a "recent visitors" surface — see who's browsing the berth detail pages right now.
- `GET /sessions/stats` — summary aggregate (pageviews, visitors, visits, countries, events) keyed by session.
- `GET /sessions/:sessionId` — drill into a single session: device, OS, browser, country, subdivision, city, screen, language, firstAt, lastAt, visits, views, events, totaltime.
- `GET /sessions/:sessionId/activity` — full event timeline for one session (urlPath, eventName, referrerDomain, timestamps).
- `GET /sessions/:sessionId/properties` — custom session properties (email, name, etc. — if Umami's `identify()` is called from the marketing site).
- `GET /session-data/properties` + `/session-data/values` — aggregate custom session properties.
- `GET /sessions/weekly` — heatmap of session count by hour-of-week. Direct fit for an "engagement heatmap" widget.
**Big unlock:** if marketing site calls `umami.identify({email})` after EOI form submit, sessions can be linked back to a specific client. We could then show "this client's website journey" on their CRM detail page.
---
## 6. Events API — `/api/websites/:id/events/*`
**Not currently used.** Umami auto-tracks pageviews; custom events are fired explicitly (e.g. button clicks, form submits, video plays). Endpoints:
- `GET /events` — list custom events in a range.
- `GET /events/stats` — totals.
- `GET /events/series` — time-series per event.
- `GET /event-data/*` — aggregate over event payload properties.
**High-leverage CRM use cases:**
- Fire an event on the marketing site when someone clicks "Inquire about berth A12" → CRM Activity feed shows it in real-time on the inquiry record.
- Fire an event when someone downloads a brochure → see which brochures convert.
- Fire an event on EOI form-step completions → drop-off funnel analysis.
We'd need to add `umami.track('event-name', {payload})` calls on the marketing site (~1-2h work there) and a new admin surface to define/view these events.
---
## 7. Reports API — `/api/reports/*`
**Not currently used.** Umami's "saved reports" system. Endpoints:
- `GET /reports` + `GET /reports/:id` — list / retrieve saved reports.
- `POST /reports/insights` — slice-and-dice with arbitrary filters/dimensions.
- `POST /reports/funnel` — multi-step conversion analysis.
- `POST /reports/retention` — cohort retention over time.
- `POST /reports/utm` — UTM-tagged campaign performance.
- `POST /reports/journey` — most common navigation paths.
- `POST /reports/goals` — pageview/event-goal completion tracking.
- `POST /reports/revenue` — revenue attribution (if we fire `purchase` events with amount).
- `POST /reports/attribution` — first/last-click attribution modelling.
**Best fits for the CRM:**
- **Funnel report** for the EOI flow: `/berths → /berths/A12 → /inquire?berth=A12 → form submit → CRM EOI signed`. Surface drop-off percentages on the Pulse-style dashboard.
- **Journey report** to see "what paths do visitors take before signing an EOI?" — informs marketing-site IA.
- **UTM report** to plumb campaign attribution into the lead-source breakdown (currently CRM-side; could be cross-validated against marketing's UTM-tagged traffic).
- **Attribution report** to give Pipeline-by-Source a "first-click vs last-click" toggle.
---
## 8. Send events from CRM → Umami — `/api/send`
**Not currently used.** The collect endpoint accepts page hits + custom events from any client. CRM doesn't currently push events, but we could:
- Fire `umami.track('signed-eoi', {berth: 'A12', deal_value: 50000})` from the CRM after EOI completion — closes the loop between marketing-site funnel and CRM outcome.
- Fire `umami.track('contract-signed')`, `umami.track('deposit-received')` — full funnel visible in Umami without leaving it.
---
## 9. Multi-website + team admin — `/api/websites`, `/api/teams`, `/api/users`
**Not currently used.** We hard-code a single `umami_website_id` per port. Useful if a port runs multiple sites (e.g. main marina + residential subdomain): admin UI could list-and-pick from the configured Umami instance's websites instead of requiring manual ID copy-paste. Same for team membership.
---
## Prioritized opportunity list
Ranked by leverage-vs-effort, assuming the v3.1.0 fix in this commit is the baseline:
1. **Avg session time + bounce rate KPI tiles** (~20 min) — already in the `/stats` response, just need new tiles.
2. **`compare=prev` overlay on the pageviews trend chart** (~30 min) — dual-line "vs last period" surface.
3. **Country choropleth heatmap** (~4-6h) — already queued in Bucket 3 of the UAT findings doc as "World-map heatmap of Umami visitor origins."
4. **Surface top browsers / OS / devices** (~30 min) — additional `TopList` columns; pure UI work.
5. **Fire CRM-side events back into Umami** (~2-3h marketing-site + CRM hook) — closes the funnel between marketing and outcomes.
6. **EOI funnel via `/api/reports/funnel`** (~3-4h) — drop-off analysis from berth view → inquiry → signed EOI.
7. **Identify visitors → link sessions to clients** (~4-6h spread across marketing site + CRM detail surfaces) — biggest unlock but needs marketing-site changes.
8. **Sessions-list "recent visitors" panel** (~2-3h) — see who's browsing right now, drill into individual sessions.
9. **Saved-reports admin surface** (~6-10h) — let admins create + share Umami reports without leaving the CRM. Bigger product surface; defer until #1-#5 land.
---
## Service-layer additions needed to support the above
`src/lib/services/umami.service.ts` currently exports: `getStats`, `getPageviewsSeries`, `getMetric`, `getActiveVisitors`, `testConnection`. To unlock the opportunities above, add:
- `getSessions(portId, range, opts)``/sessions` (paged)
- `getSession(portId, sessionId)` → single-session drill-in
- `getSessionActivity(portId, sessionId, range)` → event timeline
- `getSessionsWeekly(portId, range)` → heatmap source
- `getEvents(portId, range)` + `getEventsStats(portId, range)` + `getEventsSeries(portId, range, eventName, unit)` → custom events
- `getRealtime(portId, range)``/api/realtime/:id` for the live panel
- `getReport(portId, reportType, body)` → POST wrappers for funnel/retention/journey/utm/goals/revenue/attribution
- `trackEvent(portId, name, payload)` → POST to `/api/send` for CRM → Umami event emission
Each is a thin wrapper around the existing `umamiFetch` (or a new `umamiPost` variant for the reports endpoints). The auth + JWT cache + retry logic already in place handles them all.
---
## Known gotchas (verified against v3.1.0)
- Metric `type=url` returns 400 — use `type=path` (handled in our code via back-compat alias).
- `/api/websites/:id/pageviews` returns `sessions` only when `compare` is in the query string — keep `.sessions` optional in TS types.
- Stats response is **flat** (`pageviews: number`), not nested (`pageviews: {value, prev}`). The v1 nested shape isn't in v2/v3.
- `/api/auth/login` returns a JWT with no `expires_in` field — we assume 1h and refresh proactively at 55min.
- Visiting `/api` in a browser returns nothing — base path has no GET handler. Use `/api/heartbeat` to check liveness.
- Filters are passed as query params (e.g. `&country=DE`), NOT as a JSON `filters` body, per actual API behaviour (docs occasionally show JSON which doesn't work for GET endpoints).

View File

@@ -0,0 +1,428 @@
# Website Analytics — flesh-out plan
**Goal:** rebuild `/{portSlug}/website-analytics` so it feels like a polished native CRM panel that _mirrors_ Umami's idiom rather than reading as a stripped-down embed. Keep a "View in Umami →" deep-link in the header for power users; render most data in-app via the API. Also extend usage into adjacent CRM surfaces (dashboard tiles, inquiry detail, email open-tracking) so Umami stops being "the analytics page" and becomes a cross-cutting data layer.
**Inputs to this plan:**
1. Live API capabilities reference — `docs/umami-api-capabilities.md` (verified empirically against v3.1.0 on analytics.portnimara.com).
2. Live UI tour via Playwright — screenshots `umami-tour-1-overview.png` through `umami-tour-9-compare.png` (10 surfaces captured).
3. Pixel-tracking probe — confirmed the `/p/<slug>` and `/q/<slug>` endpoints + their UI creation forms.
---
## 1. What Umami's UI actually does — design patterns to mirror
Tour findings (from 17 sub-pages + 4 team pages):
| Surface | Visual idiom | Adopt for CRM? |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| **Overview** | 5-tile KPI row (Visitors / Visits / Views / Bounce rate / Visit duration) — each tile shows headline number + colored arrow chip (green ↑ 58% / red ↓ 39%) + percentage delta. Single stacked bar chart below for traffic time-series (visitors stacked over visits, dual-shade blue). Filter pill + date-range nav top-right. | **Yes** — already mostly there, missing the bounce-rate + visit-duration tiles. |
| **Events** | List of custom event names with per-event count + time-series spark. | **Yes** — needs marketing-site event firing first (Phase 4). |
| **Sessions** | Dense table: avatar + per-session row showing Visits / Views / Events / Location (flag + city, country) / Browser icon / OS icon / Device icon / Last seen. Tabs for Activity vs Properties (custom session props). | **Yes** — high-leverage; lets reps see _who_ is browsing right now. |
| **Realtime** | 4 stat tiles (Views/Visitors/Events/Countries) + auto-refreshing line chart of last 30 min. | **Yes** — already partial via the glance tile. |
| **Performance** | Likely page-speed / Core Web Vitals. | Skip — not relevant to marina sales. |
| **Compare** | Pick two date ranges side-by-side. | **Partial** — single `compare=prev` overlay on the existing trend chart suffices. |
| **Breakdown** | Pivot table view across dimensions. | Skip in v1; expose via Reports later. |
| **Goals** | Define event/page-view goals, see completion rate over time. | **Yes** — defer to Phase 5. |
| **Funnels** | Multi-step conversion funnel (e.g. /berths → /berths/A12 → /inquire → submit). | **Yes** — Phase 5; high-value for inquiry conversion. |
| **Journeys** | Most common navigation paths (Sankey-like). | **Maybe** — defer; nice-to-have. |
| **Retention** | Cohort retention grid. | Skip — wrong fit for one-and-done marina inquiry traffic. |
| **Replays** | Session replay (likely paid). | Skip — unavailable on our tier. |
| **Segments / Cohorts** | Saved filters / user groups. | Skip in v1. |
| **UTM** | Campaign attribution by UTM params. | **Yes** — Phase 5 for paid-campaign tracking. |
| **Revenue** | Revenue attribution. | Skip — would require firing `purchase` events from CRM after EOI close (consider Phase 6 if leadership wants funnel→revenue). |
| **Attribution** | First/last-click attribution model. | **Maybe** — defer. |
| **Team-Boards / Websites / Links / Pixels** | Account admin surfaces. | **Pixels + Links: YES — see Phase 4.** Boards/Websites stay in Umami. |
### Visual specifics worth copying
- **KPI tile design**: large bold number, label above in muted-grey, arrow + percentage delta below in a colored chip (green-bg for positive, red-bg for negative, fixed-width for alignment). Our `KPITile` already does the right shape — we just need to add the missing two metrics.
- **Stacked bar chart for traffic**: dual-shade single bar (visitors as light-blue base, views stacked dark-blue on top). Reads cleaner than two overlapping lines.
- **Location rendering**: flag emoji + "City, Country" inline. Use `getCountryName()` + a flag library (twemoji or unicode regional indicators).
- **Browser/OS/Device icons**: small colored brand glyphs inline. Use `simple-icons` or `lucide` equivalents.
- **Filter chip + date nav**: `<` `>` arrows step through the date range; dropdown opens to preset list. Adopt the same pattern on our shell — currently we only have presets, no step-arrows.
---
## 2. Phased build plan
### Phase 1 — Fill out the Overview tiles & chart (~3-4h)
Quick wins that close visual parity with Umami's Overview:
| Task | File | Effort |
| -------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| Add **Bounce rate** KPI tile | `website-analytics-shell.tsx` | derive `bounces / visits * 100`; service field already there |
| Add **Avg visit duration** KPI tile | `website-analytics-shell.tsx` | derive `totaltime / visits` formatted as `Xm Ys`; service field already there |
| Add **`<` `>` date-step arrows** on the date-range chip | `date-range-picker.tsx` | step the current preset by one window (today→yesterday, 7d→prior-7d, etc.) |
| Convert pageviews trend to **stacked bar** (visitors vs views) | `pageviews-chart.tsx` | recharts `BarChart` stacked, light/dark blue |
| Add **`compare=prev` overlay toggle** on the trend chart | `pageviews-chart.tsx` + service `getPageviewsSeries` | optional "vs prior period" series rendered as dashed line |
| Add **Top browsers / OS / devices** ranked-list cards | new `<TopList>` consumers; service already exposes via `getMetric(type)` | mirror Top Pages/Referrers/Countries layout |
| **World choropleth heatmap** card (already queued separately) | new `visitor-world-map.tsx` (Natural Earth topojson + react-simple-maps) | ~4-6h on its own |
**Cumulative result:** Overview surface reads at ~80% parity with Umami's Overview.
---
### Phase 2 — Sessions surface (~4-5h)
New `/website-analytics/sessions` tab + supporting service wrappers:
| Task | File | Effort |
| ------------------------------------------------------------------------------------------------------------------------ | ------------------ | ------- |
| Service: `getSessions(portId, range, opts)``/api/websites/:id/sessions` (paged) | `umami.service.ts` | ~30 min |
| Service: `getSession(portId, sessionId)` → single-session detail | `umami.service.ts` | ~15 min |
| Service: `getSessionActivity(portId, sessionId, range)` → event timeline | `umami.service.ts` | ~15 min |
| Service: `getSessionsWeekly(portId, range)` → hour-of-week heatmap | `umami.service.ts` | ~15 min |
| API route: `/api/v1/website-analytics?metric=sessions[&sessionId=...]` | route.ts | ~30 min |
| UI: `sessions-table.tsx` — dense rows mirroring Umami (avatar + location flag + browser/OS/device icons + Last seen) | new component | ~2h |
| UI: `session-detail-sheet.tsx` — right-side Sheet drawer showing the session's full event timeline when a row is clicked | new component | ~1h |
| UI: `weekly-heatmap-card.tsx` — 7×24 grid colour-scaled by session count, hover for tooltip | new component | ~1h |
**Unlock:** rep can see "who is currently browsing right now, where from, on what device, what they're looking at" — directly actionable for sales follow-up.
---
### Phase 3 — Events surface (~3-4h, BLOCKED on Phase 4a)
| Task | File | Effort |
| -------------------------------------------------------------------------------------------- | ------------------ | ------- |
| Service: `getEvents(portId, range, opts)``/events` paged list | `umami.service.ts` | ~30 min |
| Service: `getEventsStats(portId, range)` → totals | `umami.service.ts` | ~15 min |
| Service: `getEventsSeries(portId, range, eventName, unit)` → per-event time-series | `umami.service.ts` | ~15 min |
| API route addition | route.ts | ~30 min |
| UI: `events-tab.tsx` — list of event names with per-event count + spark + drill-in | new component | ~1.5h |
| UI: `event-detail-sheet.tsx` — single event's time-series chart + filter by payload property | new component | ~1h |
**Dependency:** the marketing site must fire `umami.track(name, payload)` calls (Phase 4a). Without this, Events tab is empty.
---
### Phase 4 — Pixel tracking + link tracking + marketing-site event push
**Phase 4a — Marketing-site event tracking (~2-3h on marketing repo)**
Add `umami.track()` calls in the marketing site:
- `inquiry-submitted` with `{berth, source}` payload — fires on EOI form submit
- `brochure-download` with `{brochureId}` — fires on brochure download
- `berth-detail-viewed` with `{berthId, mooring}` — fires on `/berths/[mooring]` page view
- `phone-revealed` / `email-revealed` — fires when contact details are exposed
These light up the Events tab + enable funnel analysis in Phase 5.
**Phase 4b — Pixel-based email open tracking (~3-4h CRM-side)**
Probe finding: Umami exposes pixel URLs at `https://analytics.portnimara.com/p/<slug>` — fetching the URL records an event. Use case: embed in HTML emails as a 1x1 image.
**Two architecture options:**
**Option A — One Umami pixel per email type** (simple, low fidelity)
- Create a pixel manually in Umami for each templated email type (`portal-invite`, `eoi-sent`, `reservation-reminder`, etc.)
- Embed the static pixel URL in each template
- Pro: zero CRM-side code beyond template HTML. Open rates roll up in Umami by pixel.
- Con: can't tell _which recipient_ opened — only aggregate counts per template.
**Option B — One Umami pixel + CRM-side per-send tracking endpoint** (richer, recommended)
- Build `GET /api/public/email-pixel/:sendId.gif` in our CRM that:
1. Returns a 1×1 transparent GIF
2. Records the open in `document_sends.opened_at` (already a table; per CLAUDE.md "send-from accounts" section)
3. Optionally proxies the hit to Umami via `POST /api/send` with the email type + send id as event properties for cross-correlation
- Embed `<img src="https://crm.portnimara.com/api/public/email-pixel/{sendId}.gif" width="1" height="1" />` in every templated email
- Pro: per-recipient open tracking + open-time + CRM-attached. Funnels by email type via Umami too.
- Con: needs the public endpoint + a schema column (or reuse `document_sends.opened_at`).
**Recommendation: ship Option B.** The CRM-side hook gives us per-deal attribution ("client X opened the EOI reminder twice but hasn't signed"), and Umami still gets the aggregate.
| Task | File | Effort |
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------- |
| New endpoint `/api/public/email-pixel/[sendId]/route.ts` returning a 1×1 GIF + recording open | new route | ~1h |
| Migration: add `opened_at`, `open_count`, `last_opened_user_agent` to `document_sends` if not present | drizzle migration | ~30 min |
| Email template helper: inject the pixel HTML into every transactional template | `src/lib/email/render.ts` | ~30 min |
| UI surface: on each `document_sends` row in the activity feed, show "Opened N times, last at X" badge | `email-activity-row.tsx` | ~1h |
| Cross-post to Umami via `trackEvent('email-opened', {emailType, sendId})` so Umami funnel data includes opens | new `trackEvent` wrapper in `umami.service.ts` | ~30 min |
| Privacy: respect `EMAIL_REDIRECT_TO` dev gate; don't fire pixels for redirected dev emails | ditto | ~15 min |
**Phase 4c — Tracked redirect links (~1.5h)**
Umami's `/q/<slug>` endpoint is a tracked redirect — records a click then 302s to the destination URL. Use for outbound CTAs:
- "View brochure" links in emails → wrap via Umami link → records click → opens brochure
- "Schedule a viewing" buttons → wrap via Umami link → click attribution
- Marketing-site CTAs → wrap → measure engagement
| Task | File | Effort |
| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------- |
| Service: `createTrackedLink(name, destinationUrl)` → POST to Umami's links endpoint via authenticated API | `umami.service.ts` | ~45 min |
| Email template helper: `<trackedLink href="..." name="...">` JSX wrapper that auto-creates the Umami link on first render + caches the slug | `src/lib/email/components/` | ~45 min |
---
### Phase 5 — Reports surfaces (Funnels, UTM, Journeys) (~6-8h)
| Task | File | Effort |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------ |
| Service: `getReport(reportType, body)` POST wrapper covering `/funnel`, `/journey`, `/utm`, `/goals`, `/retention`, `/revenue`, `/attribution` | `umami.service.ts` | ~1h |
| UI: `/website-analytics/funnels` page — admin-configurable funnel definitions (steps as event names or URL paths), per-step drop-off chart | new page | ~3h |
| UI: `/website-analytics/utm` page — UTM source/medium/campaign breakdown with click-through to attributed sessions | new page | ~2h |
| UI: `/website-analytics/journeys` page — top navigation paths rendered as ranked list (skip Sankey for v1) | new page | ~1.5h |
| Defer: Goals / Retention / Revenue / Attribution to v2 (low signal for marina sales) | | |
**High-leverage funnels to wire as defaults:**
- **Inquiry funnel**: `/``/berths``/berths/[mooring]``inquiry-submitted` event → CRM `eoi-signed` (cross-system!) → CRM `reservation-paid` (cross-system!)
- **Email funnel**: `email-sent``email-opened` (pixel) → tracked-link click → CRM action
The cross-system funnels require Phase 4 to be live first.
---
### Phase 6 — CRM → Umami event push for outcome attribution (~2-3h)
Close the funnel from "marketing site click" → "CRM closed deal" by firing CRM-side events back into Umami via `POST /api/send`:
| Event | Fired by | Payload |
| ---------------------- | -------------------------------------------- | --------------------------------------- |
| `crm-inquiry-created` | `createInterest()` in `interests.service.ts` | `{interestId, source, leadCategory}` |
| `crm-eoi-sent` | `generateAndSign()` after EOI dispatch | `{interestId, berth, pathway}` |
| `crm-eoi-signed` | Documenso `DOCUMENT_COMPLETED` webhook | `{interestId, berth}` |
| `crm-reservation-paid` | manual stage advance to `deposit_paid` | `{interestId, berth, amount, currency}` |
| `crm-contract-signed` | manual stage advance to `contract` | `{interestId, berth, amount, currency}` |
| Task | File | Effort |
| ----------------------------------------------------------------------------------------- | ------------------- | -------- |
| Service: `trackEvent(name, payload, sessionId?)``POST /api/send` on the Umami instance | `umami.service.ts` | ~45 min |
| Hook into the 5 service entry points above (one event per outcome milestone) | each service file | ~1.5h |
| Audit log entry per event sent so we can verify Umami received it | `audit_logs` insert | included |
**Unlock:** Umami's Revenue + Attribution reports start showing CRM outcomes attributed to marketing-site channels — closes the leadership question "which traffic sources actually generate signed deals, not just leads?"
---
### Phase 7 — Cross-cutting CRM placements (~3-4h)
Beyond the dedicated `/website-analytics` page, surface Umami data inside CRM context:
| Placement | What | Effort |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------ |
| **Dashboard rail tile** (already shipped) — Pageviews + active now | already done in this session | — |
| **Inquiry detail page** — "Source attribution" card showing the inquiry's UTM params, landing page, time-on-site, pages-viewed-before-submit. Pulls from `getSession(sessionId)` if the inquiry's create payload includes a session ID (requires marketing-site change to pass it). | new `inquiry-attribution-card.tsx` | ~1.5h + marketing-site change |
| **Client detail page** — "Website activity" card: total sessions, pageviews, last-seen, top pages visited. Requires `umami.identify({email})` on marketing site to link sessions back to clients. | new `client-web-activity-card.tsx` | ~1.5h + marketing-site identify call |
| **Berth detail page** — "Marketing demand" card: pageviews to `/berths/{mooring}` over time + referrer breakdown. Drives "this berth is being viewed but not inquired-about — flag for outreach." | new `berth-demand-card.tsx` | ~1h |
| **Document send activity** — pixel opens per recipient (from Phase 4b) | inline on existing `document_sends` rows | included in 4b |
---
## 2b. Library adoptions (changes the plan materially)
Context7 lookup surfaced three official libraries that reshape the plan. **Adopt all three.**
### `@umami/api-client` — official read-side client
Covers every read endpoint we need including all the report types. Built-in filter support, login/JWT auth handled internally, `{ok, data}` discriminated union for clean error handling.
**Replaces:** ~60-70% of our current `umami.service.ts` (drop `umamiFetch`, JWT cache, decrypt boilerplate; keep thin wrappers with existing signatures so consumers don't change).
**One-time refactor (~2h):**
```ts
const clientByPort = new Map<string, UmamiApiClient>();
async function getClient(portId: string): Promise<UmamiApiClient | null> {
if (clientByPort.has(portId)) return clientByPort.get(portId)!;
const cfg = await loadUmamiConfig(portId);
if (!cfg) return null;
const client = new UmamiApiClient({
apiEndpoint: `${cfg.apiUrl}/api`,
apiKey: cfg.apiToken ?? undefined,
});
if (!cfg.apiToken && cfg.username && cfg.password) await client.login(cfg.username, cfg.password);
clientByPort.set(portId, client);
return client;
}
export async function getStats(portId: string, range: DateRange) {
const client = await getClient(portId);
if (!client) return null;
const { from, to } = rangeToBounds(range);
const result = await client.getWebsiteStats(WEBSITE_ID, {
startAt: from.getTime(),
endAt: to.getTime(),
});
return result.ok ? result.data : null;
}
```
Same pattern for `getPageviewsSeries`, `getMetric`, `getActiveVisitors`, plus new ones from the SDK: `getRealtime`, `getWebsiteSessionStats`, `runFunnelReport`, `runJourneyReport`, etc.
### `@umami/node` — official write-side SDK
For Phase 6 (CRM → Umami push) and Phase 4b cross-post:
```ts
const umami = new Umami({ websiteId, hostUrl });
await umami.track({
url: '/crm/eoi-signed',
name: 'crm-eoi-signed',
data: { interestId, berth, dealValue },
});
await umami.identify({ sessionId, email, interestId });
```
**Replaces:** the planned hand-rolled `trackEvent()` wrapper. Single line per outcome milestone.
### `react-simple-maps` — for the world heatmap (Phase 1b)
Declarative SVG choropleth on d3-geo + topojson-client. SSR-safe. Use `topojson/world-atlas` (110m resolution ~30KB) cached in `public/`. Bundle ~30-50KB + topojson 30-100KB.
```jsx
<ComposableMap projection="geoMercator">
<Geographies geography="/world-110m.json">
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill={scaleByVisitorCount(visitorsByCountry[geo.properties.iso_a2] ?? 0)}
onClick={() => onCountryClick(geo.properties.iso_a2)}
/>
))
}
</Geographies>
</ComposableMap>
```
**Chose this over visx/Nivo/Chart.js Geo:** visx is overkill for one map; Nivo + Chart.js force a different charting idiom (we use recharts everywhere); react-simple-maps' compose-primitives shape matches our recharts pattern.
### Net effect on phase efforts
| Phase | Original estimate | Revised after library adoption |
| ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
| Service refactor (one-time) | — | **+2h** (one-time foundation; pays back across all phases) |
| Phase 1 — Overview parity | 3-4h | 3-4h (unchanged; api-client makes the filter additions trivial) |
| Phase 1b — World heatmap | 4-6h | 3-4h (library choice locked in) |
| Phase 2 — Sessions | 4-5h | 3-4h (api-client has session methods built-in) |
| Phase 3 — Events | 3-4h | 2-3h (api-client provides) |
| Phase 4b — Pixel hybrid | 3-4h | 2.5-3h (cross-post is one line) |
| Phase 5 — Reports (funnels/UTM/journeys) | 6-8h | 3-4h (every report method pre-wrapped) |
| Phase 6 — CRM → Umami push | 2-3h | 1.5h (`@umami/node` handles transport) |
**Total scope drops from ~30-40h to ~22-28h** with these adoptions.
---
## 3. Service-layer additions consolidated
Add to `src/lib/services/umami.service.ts` (each is a thin wrapper around existing `umamiFetch` / new `umamiPost`):
```ts
// Sessions (Phase 2)
getSessions(portId, range, { page?, pageSize?, query? }) /sessions
getSession(portId, sessionId) /sessions/:id
getSessionActivity(portId, sessionId, range) /sessions/:id/activity
getSessionProperties(portId, sessionId) /sessions/:id/properties
getSessionsWeekly(portId, range, timezone) /sessions/weekly
// Events (Phase 3)
getEvents(portId, range, opts) /events
getEventsStats(portId, range) /events/stats
getEventsSeries(portId, range, eventName, unit) /events/series
getEventDataProperties(portId, range) /event-data/properties
// Realtime (Phase 1)
getRealtime(portId, range) /api/realtime/:id (richer than /active)
// Reports (Phase 5)
getReport(portId, reportType, body) POST /api/reports/:type (funnel/journey/utm/goals/retention/revenue/attribution)
// CRM → Umami (Phase 6)
trackEvent(portId, name, payload, sessionId?) POST /api/send
// Links + Pixels admin (Phase 4)
createTrackedLink(portId, name, destinationUrl) POST team-level /links
createTrackingPixel(portId, name) POST team-level /pixels
```
Plus a new `umamiPost(config, path, body)` helper alongside the existing `umamiFetch` since GET-only doesn't cover reports + send.
---
## 4. Pixel-tracking answer (the user's specific question)
**Q: Can we use Umami's pixel tracking for email opens?**
**A: Yes — and recommended in hybrid form.** Direct verification on the live instance:
- Pixel UI at `/teams/[teamId]/pixels` lets an admin create named pixels. Each gets an auto-generated slug.
- The pixel URL is `https://analytics.portnimara.com/p/<slug>` — fetching it records an event (no auth required from the email client side; the slug is the credential).
- Embedded as `<img src="..." width="1" height="1" />` in HTML emails, it fires when the email is rendered (Outlook/Apple Mail/etc.).
- Standard caveats: Apple Mail privacy protection pre-fetches images server-side → opens may be over-counted for iOS users. Some recipients block images entirely → opens under-counted. Same caveats as every email tracking pixel ever.
**Recommended hybrid (Phase 4b above):** build a CRM-side pixel endpoint `/api/public/email-pixel/[sendId].gif` that:
- Returns the 1×1 GIF
- Records `opened_at` in `document_sends`
- Cross-posts the hit to Umami via `POST /api/send` so the Umami Events tab + funnels include opens
This way: per-recipient attribution in the CRM, aggregate roll-ups in Umami, single source of truth for both.
---
## 5. Effort summary + prioritization
| Phase | Scope | Effort | Priority |
| ----- | ------------------------------------------------------------------------------------ | ---------------------- | ------------------------------------------------------------ |
| 1 | Overview parity (KPI tiles, stacked-bar chart, date arrows, browser/OS/device cards) | ~3-4h | **High** — most visible polish, no dependencies |
| 1b | World choropleth heatmap (already queued separately) | ~4-6h | **High** if leadership wants the visual |
| 2 | Sessions surface (table + detail sheet + weekly heatmap) | ~4-5h | **High** — biggest "wow" + actionable |
| 3 | Events surface | ~3-4h | **Medium** — blocked on 4a |
| 4a | Marketing-site event tracking | ~2-3h (marketing repo) | **High** — unblocks 3 + 5 |
| 4b | Pixel-based email open tracking (hybrid) | ~3-4h | **High** — direct ask + immediate value |
| 4c | Tracked redirect links | ~1.5h | **Medium** |
| 5 | Reports (Funnels, UTM, Journeys) | ~6-8h | **Medium** — depends on 4a being live |
| 6 | CRM → Umami event push for outcome attribution | ~2-3h | **Medium-high** — needed to close marketing→outcome loop |
| 7 | Cross-cutting placements (inquiry / client / berth detail cards) | ~3-4h | **Medium** — depends on `umami.identify()` on marketing site |
**Recommended build order (updated 2026-05-19 per user):**
1.**Service refactor** — Kept hand-rolled `umamiFetch` (the official `@umami/api-client` transitively pulls `next-basics` which requires React at module-import time, breaking SSR + tsx scripts). Adopted `@umami/node` for the write side.
2.**Phase 1** — Overview parity (KPI tiles + browser/OS/device cards + date arrows + stacked-bar chart + `compare=prev` overlay)
3.**Phase 1b** — World heatmap. **Switched from `react-simple-maps` to ECharts + `public/world-map/echarts-world.json`** — the `world-atlas/110m` topojson has antimeridian-crossing polygons (Russia/Fiji/Antarctica) that render a horizontal line through the equator regardless of projection. ECharts' world.json is pre-cleaned.
4.**Phase 4b** — Pixel-based email open tracking. `document_send_opens` table + `/api/public/email-pixel/[sendId]` endpoint + `injectTrackingPixel` helper wired into `performSend`. Per-port kill switch `email_open_tracking_enabled` (admin UI on `/admin/website-analytics`). Cross-posts to Umami as `email-opened`.
5.**Phase 2** — Sessions surface. `SessionsList` (paginated, click-through to detail), `SessionDetailSheet` (full activity stream), `WeeklyHeatmap` (7×24 grid). API endpoints `sessions`, `session`, `session-activity`, `sessions-weekly`.
6.**Phase 6** — CRM → Umami event push. `trackEvent` calls wired into `createInterest` (`interest-created`), `updateInterestStage` (`interest-stage-changed`), `setInterestOutcome` (`interest-outcome-set`).
7.**Phase 7** — Cross-cutting placements. `email-sent` (in `performSend`), `eoi-signed` (in `handleDocumentCompleted`). Remaining placements (inquiry / berth detail attribution cards) defer until UI surfaces them.
8.**Phase 4c** — Tracked redirect links. `tracked_links` + `tracked_link_clicks` tables + `/q/[slug]` redirect endpoint + `createTrackedLink` / `buildTrackedUrl` service helpers. Email-composer integration deferred to UI follow-up.
9. **Phase 3 + Phase 5 — DEFERRED to the end.** Events tab is empty until marketing-site `umami.track()` calls land (Phase 4a, deferred). Funnels save for the end per user direction — pageview-only marketing funnel is the v1; richer event-based funnels come later.
10. **Phase 4a + cross-system funnels** — when there's appetite for marketing-site repo changes, unlock Events tab + cross-system funnels.
**Total scope: ~22-28h** with library adoptions, of which ~13-15h is the high-priority Phases 1 + 1b + 4b + 2 + 6 that ship first.
Total scope: ~30-40h end-to-end for the full flesh-out.
---
## 6. What stays in Umami vs. mirrored in CRM
| In CRM (mirror) | In Umami only (deep-link) |
| ----------------------------------------------------------- | ----------------------------------------------------------------- |
| Overview / KPI tiles / trend chart | Replays (paid) |
| Sessions list + detail | Retention (low signal) |
| Top pages / referrers / countries / browsers / OS / devices | Saved Boards (admin power-user) |
| Events + per-event drill | Pixels/Links admin CRUD (use Umami for setup; render data in CRM) |
| Funnels + UTM + Journeys | Performance / Web Vitals |
| World heatmap | Cohorts / Segments (defer until use case emerges) |
| Email open tracking | Multi-website CRUD |
Every page header in the CRM analytics surface gets a small "View in Umami →" outbound link in the corner for power users who want the full feature surface.
---
## 7. Open questions for the user before implementation
1. **Marketing site repo access**: Phase 4a (umami.track calls), Phase 4 (umami.identify for client linkage), and Phase 7 (passing sessionId to inquiry intake) all require changes there. Confirm whoever owns the marketing site is in the loop.
2. **Pixel hybrid vs Umami-only**: do you want per-recipient open tracking (hybrid) or just aggregate (Umami-only)? Recommended hybrid above; switch to Umami-only if the engineering cost isn't worth it.
3. **Funnel definitions**: who defines the canonical funnels? Suggest admins set them up via a CRM-side admin page that POSTs to Umami's `/api/reports/funnel`, with the most important funnels (inquiry, email-conversion) seeded as defaults at install time.
4. **Privacy / GDPR**: email pixel tracking + `umami.identify({email})` linkage both touch PII. Confirm consent model — likely already handled by the marketing-site cookie banner, but the email pixel needs explicit opt-out handling (e.g. don't fire pixel if the recipient is in a do-not-track list).

View File

@@ -35,6 +35,27 @@ const eslintConfig = [
'react-hooks/incompatible-library': 'off',
},
},
{
// User-facing copy in src/components and src/app should never use
// em-dashes (—) in JSX text. The user reads em-dashes as a
// tell-tale "AI-generated" marker; we prefer periods, commas, or
// simple hyphens. Code comments / audit-log strings / templates
// outside these directories are exempt.
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
rules: {
'no-restricted-syntax': [
// Bumped from warn → error after the 2026-05-21 sweep cleared
// the existing 108 instances. New code reintroducing em-dashes
// now fails the lint gate.
'error',
{
selector: "JSXText[value=/\\u2014/]",
message:
'No em-dash in user-facing JSX text. Use period, comma, or hyphen instead.',
},
],
},
},
{
// Tests assert response shape via expect() — narrowing every
// `res.json()` to a structural type adds boilerplate without catching

View File

@@ -118,6 +118,10 @@ const nextConfig: NextConfig = {
remotePatterns: [{ protocol: 'https', hostname: '*.portnimara.com' }],
},
typedRoutes: true,
// ECharts ships ES modules that older Next/webpack versions can't parse
// without a transpile-pass. Listing here is the official recommendation
// from echarts-for-react when used inside Next.
transpilePackages: ['echarts', 'zrender', 'echarts-for-react'],
outputFileTracingIncludes: {
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
// runtime in the standalone build. Reading via fs.readFile from

View File

@@ -67,6 +67,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@types/pdfkit": "^0.17.6",
"@umami/node": "^0.4.0",
"@use-gesture/react": "^10.3.1",
"archiver": "^7.0.1",
"better-auth": "^1.6.11",
@@ -78,6 +79,8 @@
"cron-parser": "^5.5.0",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"embla-carousel-react": "^8.6.0",
"imapflow": "^1.3.3",
"ioredis": "^5.10.1",
@@ -141,6 +144,7 @@
"@tailwindcss/postcss": "^4.3.0",
"@total-typescript/ts-reset": "^0.6.1",
"@types/archiver": "^7.0.0",
"@types/geojson": "^7946.0.16",
"@types/iso-3166-2": "^1.0.4",
"@types/mailparser": "^3.4.6",
"@types/node": "^20.19.0",
@@ -148,6 +152,7 @@
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6",
"dotenv": "^17.4.2",

78
pnpm-lock.yaml generated
View File

@@ -118,6 +118,9 @@ importers:
'@types/pdfkit':
specifier: ^0.17.6
version: 0.17.6
'@umami/node':
specifier: ^0.4.0
version: 0.4.0
'@use-gesture/react':
specifier: ^10.3.1
version: 10.3.1(react@19.2.6)
@@ -151,6 +154,12 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
echarts:
specifier: ^6.0.0
version: 6.0.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.0.0)(react@19.2.6)
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.6)
@@ -335,6 +344,9 @@ importers:
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
'@types/geojson':
specifier: ^7946.0.16
version: 7946.0.16
'@types/iso-3166-2':
specifier: ^1.0.4
version: 1.0.4
@@ -356,6 +368,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/topojson-client':
specifier: ^3.1.5
version: 3.1.5
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
@@ -3240,6 +3255,9 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/iso-3166-2@1.0.4':
resolution: {integrity: sha512-tXaeT4FDobC8rAy6LoFvbGA4vhOQQNIdSRC5DAoYfT3D9ohnKHkDFxHzSln6WqTKVeKLrnMiMQubM8m3fqNp/w==}
@@ -3293,6 +3311,12 @@ packages:
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
'@types/topojson-client@3.1.5':
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
'@types/topojson-specification@1.0.5':
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -3367,6 +3391,9 @@ packages:
resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/node@0.4.0':
resolution: {integrity: sha512-pyphprbiF7KiDSc+SWZ4/rVM8B5vU27zIiFfEPj2lEqczpI4xAKSp+dM3tlzyRAWJL32fcbCfAaLGhJZQV13Rg==}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
cpu: [arm]
@@ -4467,6 +4494,15 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.353:
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
@@ -6572,6 +6608,9 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slice-ansi@7.1.2:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
@@ -6954,6 +6993,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -7431,6 +7473,9 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'}
@@ -10079,6 +10124,8 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
'@types/iso-3166-2@1.0.4': {}
'@types/json-schema@7.0.15': {}
@@ -10140,6 +10187,15 @@ snapshots:
dependencies:
'@types/node': 20.19.41
'@types/topojson-client@3.1.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/topojson-specification': 1.0.5
'@types/topojson-specification@1.0.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/trusted-types@2.0.7':
optional: true
@@ -10248,6 +10304,8 @@ snapshots:
'@typescript-eslint/types': 8.59.3
eslint-visitor-keys: 5.0.1
'@umami/node@0.4.0': {}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
optional: true
@@ -11225,6 +11283,18 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.6):
dependencies:
echarts: 6.0.0
fast-deep-equal: 3.1.3
react: 19.2.6
size-sensor: 1.0.3
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.353: {}
embla-carousel-react@8.6.0(react@19.2.6):
@@ -13604,6 +13674,8 @@ snapshots:
sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slice-ansi@7.1.2:
dependencies:
ansi-styles: 6.2.3
@@ -14006,6 +14078,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsx@4.21.0:
@@ -14511,6 +14585,10 @@ snapshots:
zod@4.4.3: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0
zustand@5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)):
optionalDependencies:
'@types/react': 19.2.14

BIN
public/Overhead_1_blur.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Print the current Cloudflare quick-tunnel URL, or a clear status line
# if the launchd job isn't running.
#
# Usage:
# ./scripts/tunnel-url.sh # print URL or status
# ./scripts/tunnel-url.sh --copy # print URL and copy to clipboard
#
# Paired with the launchd plist at:
# ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist
#
# Quick ops:
# launchctl load ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # start
# launchctl unload ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # stop
# launchctl kickstart -k gui/$(id -u)/solutions.letsbe.pn-crm-tunnel # restart (NEW URL)
set -euo pipefail
LOG_FILE="$HOME/Library/Logs/pn-crm-tunnel.err.log"
LABEL="solutions.letsbe.pn-crm-tunnel"
if ! launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then
echo "Tunnel is not loaded. Start with:"
echo " launchctl load ~/Library/LaunchAgents/$LABEL.plist"
exit 1
fi
if [[ ! -f "$LOG_FILE" ]]; then
echo "Tunnel job is loaded but hasn't produced a log yet. Try again in a few seconds."
exit 1
fi
# cloudflared prints the public URL once on startup, like:
# https://<words>.trycloudflare.com
# Take the most recent occurrence so a restart-then-rerun picks the
# current one rather than a stale earlier line.
URL=$(grep -Eo 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" | tail -1 || true)
if [[ -z "$URL" ]]; then
echo "Tunnel is running but no URL has appeared in the log yet."
echo "Tail it: tail -f $LOG_FILE"
exit 1
fi
echo "$URL"
echo "$URL/api/webhooks/documenso ← paste this into Documenso webhook settings"
if [[ "${1:-}" == "--copy" ]]; then
printf "%s/api/webhooks/documenso" "$URL" | pbcopy
echo "(webhook URL copied to clipboard)"
fi

View File

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

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
// `identifier` accepts either an email address or a username (330 lowercase
// letters / digits / dot / underscore / hyphen). The server endpoint
@@ -25,8 +26,27 @@ const loginSchema = z.object({
type LoginFormData = z.infer<typeof loginSchema>;
/**
* H-02: Validate a redirect target before pushing the user to it. The
* middleware appends `?redirect=<path>` when a session check fails on a
* protected route; an unsanitized router.push of that value would let a
* crafted URL bounce the user to an external host or protocol-relative
* `//evil.com` after a successful sign-in. Only same-origin, single-leading-
* slash paths pass.
*/
function safeRedirectTarget(raw: string | null): string {
if (!raw) return '/dashboard';
// Allow only paths starting with a single `/` (rules out `//evil.com`
// protocol-relative URLs and `https://…` absolute ones).
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}
export default function LoginPage() {
const router = useRouter();
const branding = useAuthBranding();
const appName = branding?.appName?.trim() || 'CRM';
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
@@ -76,7 +96,8 @@ export default function LoginPage() {
return;
}
router.push('/dashboard');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(safeRedirectTarget(searchParams.get('redirect')) as any);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
@@ -87,7 +108,7 @@ export default function LoginPage() {
return (
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
<h1 className="text-xl font-semibold text-gray-900">{appName}</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
</div>
@@ -112,7 +133,10 @@ export default function LoginPage() {
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
<Link
href="/reset-password"
className="text-xs text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Forgot password?
</Link>
</div>

View File

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

View File

@@ -1,8 +1,8 @@
'use client';
import { Suspense, useState } from 'react';
import { Suspense, useState, useSyncExternalStore } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -27,10 +27,35 @@ const passwordSchema = z
type SetPasswordFormData = z.infer<typeof passwordSchema>;
/**
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
* carry `?token=…` and stay functional until every outstanding invite
* expires — drop the `?token=` fallback after that grace period.
*/
function readTokenFromUrl(): string {
if (typeof window === 'undefined') return '';
const hash = window.location.hash.replace(/^#/, '');
if (hash) {
const params = new URLSearchParams(hash);
const fromFragment = params.get('token');
if (fromFragment) return fromFragment;
}
const search = new URLSearchParams(window.location.search);
return search.get('token') ?? '';
}
const subscribeNoop = () => () => undefined;
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
// useSyncExternalStore so the fragment-only token is read post-hydration
// (server snapshot returns null; client returns the actual value).
const token = useSyncExternalStore<string | null>(
subscribeNoop,
() => readTokenFromUrl(),
() => null,
);
const [isLoading, setIsLoading] = useState(false);
const {
@@ -73,6 +98,19 @@ function SetPasswordInner() {
}
}
// Pre-hydration: token is null. Show a loading placeholder so the user
// doesn't see a flash of "Link is missing" while the fragment is being
// read on the client.
if (token === null) {
return (
<BrandedAuthShell>
<div role="status" aria-live="polite" className="text-center text-sm text-gray-500">
Loading
</div>
</BrandedAuthShell>
);
}
if (!token) {
return (
<BrandedAuthShell>
@@ -82,7 +120,10 @@ function SetPasswordInner() {
Please use the link from the email we sent you. If the link is broken, ask your
administrator for a new one.
</p>
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
<Link
href="/login"
className="inline-block text-sm text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Back to sign in
</Link>
</div>
@@ -105,10 +146,13 @@ function SetPasswordInner() {
type="password"
autoComplete="new-password"
disabled={isLoading}
aria-describedby="password-hint"
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
{...register('password')}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
<p id="password-hint" className="text-xs text-gray-500">
At least {MIN_LENGTH} characters.
</p>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
@@ -36,6 +37,8 @@ interface StatusResp {
*/
export default function SetupPage() {
const router = useRouter();
const branding = useAuthBranding();
const appName = branding?.appName?.trim() || 'this CRM';
const [checking, setChecking] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -88,7 +91,7 @@ export default function SetupPage() {
password: data.password,
},
});
toast.success('Administrator account created sign in to continue.');
toast.success('Administrator account created - sign in to continue.');
router.replace('/login');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create administrator account');
@@ -109,9 +112,9 @@ export default function SetupPage() {
<BrandedAuthShell>
<div className="space-y-6">
<div className="text-center space-y-1">
<h1 className="text-xl font-semibold">Welcome to Port Nimara CRM</h1>
<h1 className="text-xl font-semibold">Welcome to {appName}</h1>
<p className="text-sm text-muted-foreground">
No administrator account exists yet. Create one to get started you&rsquo;ll be the
No administrator account exists yet. Create one to get started - you&rsquo;ll be the
super-administrator for this installation.
</p>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ export default function ErrorCodeReferencePage() {
</h1>
<p className="text-muted-foreground text-sm mt-1">
Every error code the platform can return, with its HTTP status and the plain-language
message a user sees. Codes are stable identifiers once shipped, they never get
message a user sees. Codes are stable identifiers - once shipped, they never get
renamed.
</p>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { ResidentialStagesAdmin } from '@/components/admin/residential-stages-admin';
import { PageHeader } from '@/components/shared/page-header';
@@ -10,6 +11,16 @@ export default function ResidentialStagesPage() {
description="Configure the stages residential interests flow through. Removing a stage that still has interests prompts you to reassign them before saving."
/>
<ResidentialStagesAdmin />
{/* Partner forwarding — sits on the same admin page so all
residential-only port settings live in one place. Reps still
see every inquiry in the CRM; this is an outbound courtesy
notification for the partner who handles residential leads. */}
<RegistryDrivenForm
sections={['residential.partner']}
title="Partner forwarding"
description="Email address(es) that receive a copy of every new residential inquiry the moment it lands. Comma-separated. Leave blank to disable."
/>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { TemplateEditor } from '@/components/admin/templates/template-editor';
/**
* Phase 7.1 — PDF template editor (read + place markers).
*
* Renders the source PDF for the selected template and lets the admin
* drop merge-field markers by clicking on the page. Persists the marker
* coordinates to `document_templates.overlay_positions` via PATCH so
* the existing `pdf_overlay` fill path can use them at generate time.
*
* Phase 7.2 (drag/resize/preview/multi-page) is queued separately.
*/
export default function TemplateEditorPage({ params }: { params: { id: string } }) {
return <TemplateEditor templateId={params.id} />;
}

View File

@@ -1,5 +1,34 @@
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
import { UserList } from '@/components/admin/users/user-list';
import { PageHeader } from '@/components/shared/page-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
/**
* "People with access" surface — covers BOTH currently-active CRM users
* and pending invitations. Previously these lived on separate routes
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
* on one page and tab between states. The standalone /admin/invitations
* route now redirects here for back-compat with bookmarks.
*/
export default function UserManagementPage() {
return <UserList />;
return (
<div className="space-y-6">
<PageHeader
title="Users"
description="Active CRM users and pending invitations. Switch tabs to manage invitations."
/>
<Tabs defaultValue="active" className="space-y-4">
<TabsList>
<TabsTrigger value="active">Active users</TabsTrigger>
<TabsTrigger value="invitations">Invitations</TabsTrigger>
</TabsList>
<TabsContent value="active">
<UserList />
</TabsContent>
<TabsContent value="invitations">
<InvitationsManager />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -6,33 +6,27 @@ import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test
import { PageHeader } from '@/components/shared/page-header';
/**
* Per-port Umami credentials. We deliberately keep all three values
* port-scoped (per the operator decision) so different ports can point at
* different Umami instances if needed. The /website-analytics dashboard
* page reads these settings via the umami.service layer at request time.
* Per-port Umami credentials. Self-hosted Umami uses username + password →
* JWT bearer token (https://docs.umami.is/docs/api/authentication); the
* service POSTs to /api/auth/login and caches the JWT in-memory. Umami
* Cloud installations use a long-lived API key instead; the optional field
* below covers that case. All credentials are port-scoped so different
* ports can point at different Umami instances.
*/
const FIELDS: SettingFieldDef[] = [
{
key: 'umami_api_url',
label: 'Umami API URL',
label: 'Umami URL',
description:
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
type: 'string',
placeholder: 'https://analytics.portnimara.com',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API token',
description:
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_username',
label: 'Username',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
description: 'Umami login username (self-hosted).',
type: 'string',
placeholder: 'admin',
defaultValue: '',
@@ -40,7 +34,8 @@ const FIELDS: SettingFieldDef[] = [
{
key: 'umami_password',
label: 'Password',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
description:
'Umami login password (self-hosted). Exchanged for a JWT via /api/auth/login on each port; the JWT is cached for 55 minutes. Stored AES-256-GCM at rest.',
type: 'password',
defaultValue: '',
},
@@ -53,6 +48,28 @@ const FIELDS: SettingFieldDef[] = [
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API key (Umami Cloud only — optional)',
description:
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
type: 'password',
defaultValue: '',
},
];
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
// records opens to `document_send_opens` and cross-posts to Umami.
const TRACKING_FIELDS: SettingFieldDef[] = [
{
key: 'email_open_tracking_enabled',
label: 'Track email opens',
description:
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
type: 'boolean',
defaultValue: false,
},
];
export default function WebsiteAnalyticsSettingsPage() {
@@ -65,10 +82,16 @@ export default function WebsiteAnalyticsSettingsPage() {
<SettingsFormCard
title="Umami connection"
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
description="Self-hosted Umami: enter URL + username + password + website ID. Umami Cloud: enter URL + API key (Cloud field at the bottom) + website ID. Each port can point at its own Umami instance, or share one instance with different website IDs."
fields={FIELDS}
extra={<UmamiTestButton />}
/>
<SettingsFormCard
title="Email open tracking"
description="Opt-in tracking for outbound sales emails. Disabled by default."
fields={TRACKING_FIELDS}
/>
</div>
);
}

View File

@@ -27,6 +27,7 @@ import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { triggerBlobDownload } from '@/lib/utils/download';
export default function ExpensesPage() {
const params = useParams<{ portSlug: string }>();
@@ -91,12 +92,7 @@ export default function ExpensesPage() {
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `expenses.${type}`;
a.click();
URL.revokeObjectURL(url);
triggerBlobDownload(blob, `expenses.${type}`);
}
const columns = getExpenseColumns({
@@ -109,7 +105,7 @@ export default function ExpensesPage() {
<div className="space-y-4">
<PageHeader
title="Expenses"
description="Track and manage port expenses"
description="Track and manage business expenses"
actions={
<div className="flex items-center gap-2">
<PermissionGate resource="expenses" action="view">

View File

@@ -206,14 +206,14 @@ export default function ScanReceiptPage() {
)}
{uploadMutation.isError && (
<span className="text-destructive">
Receipt upload failed save will still create the expense without an image.
Receipt upload failed - save will still create the expense without an image.
</span>
)}
</div>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{/* Camera button available on mobile devices that surface the
{/* Camera button - available on mobile devices that surface the
built-in capture flow when an `image/*` input has the
`capture` attribute. Hidden on desktop where it's a no-op. */}
<Button
@@ -225,7 +225,7 @@ export default function ScanReceiptPage() {
<Camera className="mr-2 h-5 w-5" />
Take photo
</Button>
{/* File picker works on every platform. Phrased so the copy
{/* File picker - works on every platform. Phrased so the copy
fits both mobile (library/files) and desktop (drag and drop). */}
<Button
type="button"
@@ -243,7 +243,7 @@ export default function ScanReceiptPage() {
</p>
</div>
)}
{/* `image/*` is the broadest accept includes HEIC on iOS,
{/* `image/*` is the broadest accept - includes HEIC on iOS,
JPEG/PNG/WebP everywhere. The capture attribute on the second
input invokes the native camera flow on mobile. */}
<input
@@ -272,7 +272,7 @@ export default function ScanReceiptPage() {
{scanMutation.isError && (
<div className="mt-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<span className="font-medium">Couldn&apos;t read this receipt automatically.</span>{' '}
You can still fill in the details manually below the receipt image will save with
You can still fill in the details manually below - the receipt image will save with
the expense.
</div>
)}

View File

@@ -6,6 +6,16 @@ import { Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
@@ -163,33 +173,39 @@ export default function InvoicesPage() {
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 flex items-center justify-center">
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
<h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground">
{/* M-U09: was a hand-rolled overlay; standardized on AlertDialog so
the focus-trap, Escape-to-close, and a11y semantics match every
other destructive-action surface in the app. */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete invoice?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
<span className="font-mono font-medium">{deleteTarget?.invoiceNumber}</span>. This
action cannot be undone.
</p>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={deleteMutation.isPending}
onClick={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden />
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { redirect } from 'next/navigation';
/**
* /<port>/residential is a namespace segment — the actual landing is
* /residential/clients. Without a page.tsx here, the breadcrumb's
* "Residential" link 404s. Server-redirect to the Clients sub-page so
* the link works as a useful shortcut.
*/
export default async function ResidentialIndexPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
redirect(`/${portSlug}/residential/clients`);
}

View File

@@ -0,0 +1,33 @@
import { notFound } from 'next/navigation';
import { MetricDetailShell } from '@/components/website-analytics/metric-detail-shell';
/**
* Full ranked-list view for one analytics metric (pages / referrers /
* countries / browsers / os / devices). Reached via the "View all" link
* on each top-N card. Honours the `range` (and optional `from`/`to`)
* query params so the detail page mirrors the time window the operator
* had selected on the parent page.
*/
const VALID_METRICS = ['pages', 'referrers', 'countries', 'browsers', 'os', 'devices'] as const;
type ValidMetric = (typeof VALID_METRICS)[number];
interface PageProps {
params: Promise<{ portSlug: string; metric: string }>;
searchParams: Promise<{ range?: string; from?: string; to?: string }>;
}
export default async function Page({ params, searchParams }: PageProps) {
const { metric } = await params;
const { range, from, to } = await searchParams;
if (!VALID_METRICS.includes(metric as ValidMetric)) notFound();
return (
<MetricDetailShell
metric={metric as ValidMetric}
initialRange={range ?? '30d'}
initialFrom={from}
initialTo={to}
/>
);
}

View File

@@ -11,9 +11,11 @@ import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { AppShell } from '@/components/layout/app-shell';
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
import { getPortBrandingConfig } from '@/lib/services/port-config';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
@@ -41,6 +43,22 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email,
};
// Per-port logo map for the sidebar. Resolved server-side so the
// sidebar can swap brand on port switch without an extra round-trip.
// Falls back to null per port when no logo is configured — the
// sidebar surfaces nothing rather than leaking a generic placeholder.
const portBrandingEntries = await Promise.all(
ports.map(async (p) => {
try {
const cfg = await getPortBrandingConfig(p.id);
return [p.id, cfg.logoUrl] as const;
} catch {
return [p.id, null] as const;
}
}),
);
const portLogoUrls: Record<string, string | null> = Object.fromEntries(portBrandingEntries);
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
@@ -48,6 +66,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
<SocketProvider>
<RealtimeToasts />
<WebVitalsReporter />
{/* Sticky banner across the app whenever EMAIL_REDIRECT_TO is
set so reps + admins always know outbound mail is being
rerouted. Production hides itself (env.ts forbids the
flag in prod) so the banner is dev/staging-only. */}
<DevModeBanner />
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render — never both — so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}
@@ -56,6 +79,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
isSuperAdmin={profile?.isSuperAdmin ?? false}
user={user}
ports={ports}
portLogoUrls={portLogoUrls}
initialFormFactor={initialFormFactor}
>
{children}

View File

@@ -5,6 +5,9 @@ import { getPortalDashboard } from '@/lib/services/portal.service';
import { isPortalDisabledGlobally } from '@/lib/services/portal-auth.service';
import { PortalHeader } from '@/components/portal/portal-header';
import { PortalNav } from '@/components/portal/portal-nav';
import { AuthBrandingProvider } from '@/components/shared/auth-branding-provider';
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
import { getPortBrandingConfig } from '@/lib/services/port-config';
export const metadata: Metadata = {
title: {
@@ -54,15 +57,31 @@ export default async function PortalLayout({ children }: { children: React.React
}
}
// Branding for the auth-shell pages (login, forgot-password, reset).
// When the visitor has a session, use that port's branding so they
// stay inside one tenant's look. Otherwise pick up the first-port
// default — the same path the CRM auth pages take.
const branding = session
? await getPortBrandingConfig(session.portId)
.then((cfg) => ({
logoUrl: cfg.logoUrl,
backgroundUrl: cfg.emailBackgroundUrl,
appName: cfg.appName,
}))
.catch(() => null)
: await resolveAuthShellBranding();
return (
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
</div>
<AuthBrandingProvider branding={branding}>
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
</div>
</AuthBrandingProvider>
);
}

View File

@@ -6,7 +6,11 @@ export default function PortalActivatePage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
<div
role="status"
aria-live="polite"
className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500"
>
Loading
</div>
}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import { FileText } from 'lucide-react';
import { FileText, CheckCircle2, XCircle, Circle } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
@@ -71,29 +71,38 @@ export default async function PortalDocumentsPage() {
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
Signers
</p>
{doc.signers.map((signer, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span
className={
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500'
}
>
{signer.status === 'signed'
? '✓'
: signer.status === 'declined'
? '✗'
: '○'}
</span>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
))}
{doc.signers.map((signer, idx) => {
const StatusIcon =
signer.status === 'signed'
? CheckCircle2
: signer.status === 'declined'
? XCircle
: Circle;
const statusLabel =
signer.status === 'signed'
? 'Signed'
: signer.status === 'declined'
? 'Declined'
: 'Pending';
const statusColor =
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500';
return (
<div key={idx} className="flex items-center gap-2 text-sm">
<StatusIcon
className={`h-4 w-4 ${statusColor}`}
aria-label={statusLabel}
/>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
);
})}
</div>
)}

View File

@@ -44,7 +44,7 @@ export default function PortalForgotPasswordPage() {
</p>
<Link
href="/portal/login"
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
className="mt-6 inline-block text-sm text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Back to sign in
</Link>
@@ -95,7 +95,10 @@ export default function PortalForgotPasswordPage() {
<p className="text-center text-sm text-gray-500">
Remember your password?{' '}
<Link href="/portal/login" className="text-[#007bff] hover:underline">
<Link
href="/portal/login"
className="text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Sign in
</Link>
</p>

View File

@@ -91,7 +91,10 @@ export default function PortalLoginPage() {
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/portal/forgot-password" className="text-xs text-[#007bff] hover:underline">
<Link
href="/portal/forgot-password"
className="text-xs text-[#0058b3] underline-offset-2 underline hover:no-underline"
>
Forgot password?
</Link>
</div>

View File

@@ -25,7 +25,7 @@ export default async function PortalProfilePage() {
<span className="font-medium">{session.email}</span>
</div>
<p className="text-xs text-gray-400 pt-1">
To update name, phone, or address, please contact your port team they keep the records
To update name, phone, or address, please contact your port team - they keep the records
authoritative.
</p>
</div>

View File

@@ -12,7 +12,7 @@ import { ports } from '@/lib/db/schema/ports';
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const portName = port?.name ?? 'Port Nimara';
const portName = port?.name ?? 'CRM';
const manifest = {
name: `${portName} - Scanner`,

View File

@@ -1,11 +1,18 @@
import type { Metadata } from 'next';
import { eq } from 'drizzle-orm';
import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config';
export const metadata: Metadata = {
title: 'Scan receipt - Port Nimara',
title: 'Scan receipt',
};
export default function ScanPage() {
return <ScanShell />;
export default async function ScanPage({ params }: { params: Promise<{ portSlug: string }> }) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
}

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { auth } from '@/lib/auth';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
import { enforcePublicRateLimit, parseBody } from '@/lib/api/route-helpers';
@@ -11,14 +12,46 @@ const bodySchema = z.object({
});
export async function POST(req: NextRequest): Promise<NextResponse> {
// 10/hour/IP — bounds brute-force against the CRM invite token.
// 10/hour/IP — bounds brute-force against either token store.
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
try {
const { token, password } = await parseBody(req, bodySchema);
const result = await consumeCrmInvite({ token, password });
return NextResponse.json({ data: { email: result.email } });
// Two distinct token issuers can land users on /set-password:
// 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).
// Try the CRM-invite path first. If the token isn't in that table
// (NotFoundError), fall through to better-auth — these are mutually
// exclusive token spaces, so at most one will accept it.
try {
const result = await consumeCrmInvite({ token, password });
return NextResponse.json({ data: { email: result.email } });
} catch (err) {
if (!(err instanceof NotFoundError)) throw err;
}
try {
await auth.api.resetPassword({
body: { newPassword: password, token },
});
return NextResponse.json({ data: { email: null } });
} catch {
// Both stores rejected the token; surface a clean unified error
// (matches the `{ error: string }` shape the page consumes via
// `body.error`).
return NextResponse.json(
{
error: 'This link is invalid or has expired. Request a new one.',
code: 'INVITE_OR_RESET_INVALID',
},
{ status: 400 },
);
}
} catch (err) {
return errorResponse(err);
}

View File

@@ -45,9 +45,18 @@ export async function POST(req: NextRequest) {
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
// H-04: RFC 6585 §4 requires Retry-After on 429 so automated clients
// can back off correctly. rateLimitHeaders only emits the X-RateLimit-*
// triplet; checkRateLimit's helper enforcePublicRateLimit adds this
// header, but this route uses checkRateLimit directly so the header
// has to be added explicitly.
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: { message: 'Too many attempts. Try again later.' } },
{ status: 429, headers: rateLimitHeaders(rl) },
{
status: 429,
headers: { ...rateLimitHeaders(rl), 'Retry-After': String(retryAfter) },
},
);
}

View File

@@ -0,0 +1,106 @@
import { NextResponse, type NextRequest } from 'next/server';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentSendOpens, documentSends } from '@/lib/db/schema/brochures';
import { logger } from '@/lib/logger';
import { trackEvent } from '@/lib/services/umami.service';
/**
* GET /api/public/email-pixel/[sendId]
*
* Returns a 1×1 transparent GIF and records an open event in
* `document_send_opens` + bumps the cached aggregates on `document_sends`.
*
* Lookups are gated by `track_opens=true` on the send row, so a leaked
* sendId for an untracked email is a no-op (the pixel still returns
* 200/GIF so email clients don't surface a broken-image icon).
*
* Privacy: we deliberately don't store IP addresses or any data beyond
* user-agent + referer. Apple Mail privacy proxy pre-fetches images, so
* opens from iOS users are over-counted; image-blocking clients
* (Outlook with images disabled) under-count. Standard email-tracking
* caveats apply.
*/
// 1×1 transparent GIF, base64-encoded. Generated once at module-load so
// every request returns the same buffer without re-allocating.
const TRANSPARENT_GIF = Buffer.from(
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
'base64',
);
function gifResponse(): NextResponse {
return new NextResponse(TRANSPARENT_GIF as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': 'image/gif',
'Content-Length': String(TRANSPARENT_GIF.length),
// Tell every upstream cache to keep its hands off — we count opens
// on the FETCH itself, so any cached response is a missed open.
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
Pragma: 'no-cache',
Expires: '0',
},
});
}
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ sendId: string }> },
): Promise<NextResponse> {
const { sendId } = await ctx.params;
try {
// Look up the send row; ignore unknown / un-tracked sends silently.
const sendRow = await db.query.documentSends.findFirst({
where: and(eq(documentSends.id, sendId), eq(documentSends.trackOpens, true)),
columns: { id: true, portId: true, recipientEmail: true, documentKind: true },
});
if (!sendRow) return gifResponse();
const userAgent = req.headers.get('user-agent');
const referer = req.headers.get('referer');
// Best-effort write — never block the pixel response on a slow DB.
// The pixel must return promptly so email clients render normally.
db.insert(documentSendOpens)
.values({
portId: sendRow.portId,
sendId: sendRow.id,
userAgent: userAgent ?? null,
referer: referer ?? null,
})
.then(() =>
db
.update(documentSends)
.set({
openCount: sql`${documentSends.openCount} + 1`,
firstOpenedAt: sql`COALESCE(${documentSends.firstOpenedAt}, NOW())`,
})
.where(eq(documentSends.id, sendRow.id)),
)
.catch((err) => {
logger.warn({ err, sendId: sendRow.id }, 'email-pixel: failed to record open');
});
// Cross-post to Umami so the marketing funnel includes opens. Don't
// await — fire-and-forget so the pixel response stays fast.
trackEvent(
sendRow.portId,
'email-opened',
{
sendId: sendRow.id,
documentKind: sendRow.documentKind,
},
'email://pixel',
).catch((err) => {
logger.debug({ err, sendId: sendRow.id }, 'email-pixel: umami cross-post failed');
});
return gifResponse();
} catch (err) {
logger.warn({ err, sendId }, 'email-pixel: unexpected error');
return gifResponse();
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse, type NextRequest } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files } from '@/lib/db/schema/documents';
import { getStorageBackend } from '@/lib/storage';
import { errorResponse, NotFoundError } from '@/lib/errors';
/**
* Public, unauthenticated stream-by-id for branding assets only. Used by
* outbound email templates and the branded auth shell — surfaces where
* the consumer can't authenticate (an inbox image fetch has no session
* cookie). The `category = 'branding'` gate ensures only assets the
* admin explicitly uploaded as port branding leak through this surface;
* every other category (eoi, contract, receipt, …) keeps its
* authenticated `/api/v1/files/[id]/preview` path.
*
* Cached for a day at the edge. Admins replacing a logo write a new
* file id (the system_settings URL changes), so a stale CDN entry for
* the old id is harmless.
*/
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const { id } = await ctx.params;
const row = await db.query.files.findFirst({ where: eq(files.id, id) });
if (!row || row.category !== 'branding') {
throw new NotFoundError('File');
}
const backend = await getStorageBackend();
const stream = await backend.get(row.storagePath);
// Convert the Node Readable into a Web ReadableStream so NextResponse
// can hand it back without buffering the whole blob in memory.
const webStream = new ReadableStream<Uint8Array>({
start(controller) {
stream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(webStream, {
headers: {
'Content-Type': row.mimeType ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=86400, immutable',
},
});
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -1,23 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { withTransaction } from '@/lib/db/utils';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { createAuditLog } from '@/lib/audit';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { publicInterestSchema } from '@/lib/validators/interests';
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
import { parsePhone } from '@/lib/i18n/phone';
import type { CountryCode } from '@/lib/i18n/countries';
import { createPublicInterest } from '@/lib/services/public-interest.service';
/**
* Throws RateLimitError if the IP has exceeded the public-form quota.
@@ -32,14 +24,11 @@ async function gateRateLimit(ip: string): Promise<void> {
}
}
type PublicInterestData = z.infer<typeof publicInterestSchema>;
// `withTransaction` exposes its tx argument as `typeof db` (see lib/db/utils.ts).
// Keep the helper aligned with that.
type Tx = typeof db;
// POST /api/public/interests - unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction.
// POST /api/public/interests — unauthenticated public interest registration.
// The transactional trio creation (client + yacht + interest, plus optional
// company + membership) lives in `createPublicInterest()` so it's testable
// without an HTTP fixture. This handler is the thin HTTP shell: rate-limit,
// port resolution, body parsing, then post-commit audit log + email fan-out.
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
@@ -51,213 +40,12 @@ export async function POST(req: NextRequest) {
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
if (!portId) throw new ValidationError('Port context required');
// Server-side phone normalization for older website builds that post raw
// international/national strings. Newer builds may pre-fill phoneE164/Country.
let phoneE164 = data.phoneE164 ?? null;
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
if (!phoneE164) {
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
phoneE164 = parsed.e164;
phoneCountry = parsed.country ?? phoneCountry;
}
const fullName =
data.firstName && data.lastName
? `${data.firstName} ${data.lastName}`
: (data.fullName ?? 'Unknown');
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided). Read-only lookup - safe
// to do outside the transaction.
let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
if (data.mooringNumber) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
});
if (berth) {
berthId = berth.id;
resolvedMooringNumber = berth.mooringNumber;
}
}
// ─── Transactional trio creation ────────────────────────────────────────
const result = await withTransaction(async (tx) => {
// 1. Find or create client by email. The inquiry-funnel audit
// flagged that the previous exact match was case-sensitive —
// capital-letter resubmissions spawned duplicate client+yacht+
// interest rows. Match LOWER(value) instead so foo@x.com and
// Foo@X.COM dedupe to the same client.
let clientId: string;
const normalizedEmail = data.email.trim().toLowerCase();
const existingContact = await tx.query.clientContacts.findFirst({
where: and(
eq(clientContacts.channel, 'email'),
sql`LOWER(${clientContacts.value}) = ${normalizedEmail}`,
),
});
if (existingContact) {
const existingClient = await tx.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId),
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
const updates: Partial<typeof clients.$inferInsert> = {};
if (data.preferredContactMethod) {
updates.preferredContactMethod = data.preferredContactMethod;
}
if (data.nationalityIso && !existingClient.nationalityIso) {
updates.nationalityIso = data.nationalityIso;
}
if (Object.keys(updates).length > 0) {
await tx.update(clients).set(updates).where(eq(clients.id, clientId));
}
} else {
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
}
} else {
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
}
// 2. Optional: upsert company + add membership
let companyId: string | null = null;
if (data.company) {
const existingCompany = await tx.query.companies.findFirst({
where: and(
eq(companies.portId, portId),
sql`lower(${companies.name}) = lower(${data.company.name})`,
),
});
if (existingCompany) {
companyId = existingCompany.id;
} else {
const [newCompany] = await tx
.insert(companies)
.values({
portId,
name: data.company.name,
legalName: data.company.legalName ?? null,
taxId: data.company.taxId ?? null,
incorporationCountryIso: data.company.incorporationCountryIso ?? null,
incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null,
status: 'active',
})
.returning();
companyId = newCompany!.id;
}
// Add active membership only if one doesn't already exist (open row).
const existingMembership = await tx.query.companyMemberships.findFirst({
where: and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.clientId, clientId),
isNull(companyMemberships.endDate),
),
});
if (!existingMembership) {
await tx.insert(companyMemberships).values({
companyId,
clientId,
role: data.company.role ?? 'representative',
startDate: new Date(),
isPrimary: false,
});
}
}
// 3. Create yacht. Owner is the company when provided, else the client.
const ownerType: 'client' | 'company' = companyId ? 'company' : 'client';
const ownerId = companyId ?? clientId;
const [newYacht] = await tx
.insert(yachts)
.values({
portId,
name: data.yacht.name,
hullNumber: data.yacht.hullNumber ?? null,
registration: data.yacht.registration ?? null,
flag: data.yacht.flag ?? null,
yearBuilt: data.yacht.yearBuilt ?? null,
lengthFt: data.yacht.lengthFt != null ? String(data.yacht.lengthFt) : null,
widthFt: data.yacht.widthFt != null ? String(data.yacht.widthFt) : null,
draftFt: data.yacht.draftFt != null ? String(data.yacht.draftFt) : null,
currentOwnerType: ownerType,
currentOwnerId: ownerId,
status: 'active',
})
.returning();
const yachtId = newYacht!.id;
// 3a. Open ownership_history row for the new yacht.
await tx.insert(yachtOwnershipHistory).values({
yachtId,
ownerType,
ownerId,
startDate: new Date(),
endDate: null,
createdBy: 'public-submission',
});
// 4. Store address if provided AND no primary address exists yet.
if (data.address && Object.values(data.address).some(Boolean)) {
const existingAddr = await tx.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)),
});
if (!existingAddr) {
await tx.insert(clientAddresses).values({
clientId,
portId,
label: 'Primary',
streetAddress: data.address.street ?? null,
city: data.address.city ?? null,
subdivisionIso: data.address.subdivisionIso ?? null,
postalCode: data.address.postalCode ?? null,
countryIso: data.address.countryIso ?? null,
isPrimary: true,
});
}
}
// 5. Create interest with yachtId wired up. The legacy
// interests.berth_id column has been replaced by the
// interest_berths junction (plan §3.4); when the public form
// resolves to a known berth we materialise it as a primary,
// specific-interest junction row in the same transaction so it
// rolls back together with the parent interest insert.
const [newInterest] = await tx
.insert(interests)
.values({
portId,
clientId,
yachtId,
source: 'website',
pipelineStage: 'open',
})
.returning();
if (berthId) {
await tx.insert(interestBerths).values({
interestId: newInterest!.id,
berthId,
isPrimary: true,
isSpecificInterest: true,
isInEoiBundle: false,
});
}
return {
interestId: newInterest!.id,
clientId,
yachtId,
companyId,
};
});
const result = await createPublicInterest({ portId, data });
// ─── Post-commit side-effects (fire-and-forget) ─────────────────────────
// `AuditLogParams.userId` is `string | null`; null is the documented
// "system-generated" sentinel and matches `audit_logs.user_id` being
// nullable in the schema. The earlier `null as unknown as string`
// cast was a relic from before the type was widened.
// nullable in the schema.
void createAuditLog({
userId: null,
portId,
@@ -270,7 +58,7 @@ export async function POST(req: NextRequest) {
companyId: result.companyId,
source: 'website',
pipelineStage: 'open',
berthId,
berthId: result.berthId,
},
metadata: { type: 'public_registration', ip },
ipAddress: ip,
@@ -286,11 +74,11 @@ export async function POST(req: NextRequest) {
portId,
portSlug: port?.slug ?? portId,
interestId: result.interestId,
clientFullName: fullName,
clientFullName: result.fullName,
clientEmail: data.email,
clientPhone: data.phone,
mooringNumber: resolvedMooringNumber,
firstName,
mooringNumber: result.resolvedMooringNumber,
firstName: result.firstName,
});
return NextResponse.json(
@@ -301,46 +89,3 @@ export async function POST(req: NextRequest) {
return errorResponse(error);
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function createClientInTx(
tx: Tx,
portId: string,
fullName: string,
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod' | 'nationalityIso'>,
phoneE164: string | null,
phoneCountry: CountryCode | null,
): Promise<string> {
const [newClient] = await tx
.insert(clients)
.values({
portId,
fullName,
preferredContactMethod: data.preferredContactMethod,
nationalityIso: data.nationalityIso ?? null,
source: 'website',
})
.returning();
const clientId = newClient!.id;
await tx.insert(clientContacts).values({
clientId,
channel: 'email',
// Store lowercased so the case-insensitive dedup match above always
// hits on subsequent submissions.
value: data.email.trim().toLowerCase(),
isPrimary: true,
});
await tx.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
valueE164: phoneE164,
valueCountry: phoneCountry,
isPrimary: false,
});
return clientId;
}

View File

@@ -13,6 +13,7 @@ import {
} from '@/lib/email/templates/residential-inquiry';
import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
import { env } from '@/lib/env';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
@@ -142,12 +143,22 @@ async function sendResidentialNotifications(args: {
}): Promise<void> {
const { portId, data, crmDeepLink } = args;
const branding = await getBrandingShell(portId);
const [branding, portBrand, emailCfg] = await Promise.all([
getBrandingShell(portId),
getPortBrandingConfig(portId).catch(() => null),
getPortEmailConfig(portId).catch(() => null),
]);
// Use the port's configured From address (or branding app name) for
// the "contact us" line on the confirmation email so other ports don't
// direct replies to sales@portnimara.com.
const contactEmail = emailCfg?.fromAddress ?? '';
const portName = portBrand?.appName ?? 'our team';
// Client confirmation
const confirmation = await residentialClientConfirmation(
{
firstName: data.firstName,
contactEmail: 'sales@portnimara.com',
contactEmail,
},
{ branding },
);
@@ -155,7 +166,7 @@ async function sendResidentialNotifications(args: {
key: 'residential_inquiry_client_confirmation',
portId,
fallback: confirmation.subject,
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
tokens: { portName, recipientName: data.firstName },
});
await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
@@ -204,7 +215,7 @@ async function sendResidentialNotifications(args: {
portId,
fallback: alert.subject,
tokens: {
portName: 'Port Nimara',
portName,
clientName: `${data.firstName} ${data.lastName}`.trim(),
email: data.email,
phone: data.phone,

View File

@@ -0,0 +1,128 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { searchAuditLogs } from '@/lib/services/audit-search.service';
/**
* M-AU03 — CSV export of audit log search results.
*
* Accepts the same query-string filters as `GET /api/v1/admin/audit`
* (q, userId, action, entityType, entityId, severity, source, from, to)
* and streams up to 10 000 rows back as a CSV download. The 10k cap
* keeps the response under a couple of megabytes; reps wanting deeper
* history should narrow the filter or run multiple exports.
*
* Permission gate matches the read endpoint: `admin.view_audit_log`.
*/
export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req, ctx) => {
try {
const url = new URL(req.url);
const params = url.searchParams;
const parseDate = (v: string | null): Date | undefined => {
if (!v) return undefined;
const d = new Date(v);
return Number.isFinite(d.getTime()) ? d : undefined;
};
// Cap the export at 10 000 rows. Anyone needing deeper history
// can scroll through the paginated UI or narrow the date range.
const HARD_CAP = 10_000;
let collected: Awaited<ReturnType<typeof searchAuditLogs>>['rows'] = [];
let cursor: { createdAt: Date; id: string } | undefined;
// Run a small loop so we paginate through the cursor-based search
// service to fill up to HARD_CAP rows.
while (collected.length < HARD_CAP) {
const remaining = HARD_CAP - collected.length;
const page = await searchAuditLogs({
portId: ctx.portId,
q: params.get('q') ?? undefined,
userId: params.get('userId') ?? undefined,
action: params.get('action') ?? undefined,
entityType: params.get('entityType') ?? undefined,
entityId: params.get('entityId') ?? undefined,
severity: params.get('severity') ?? undefined,
source: params.get('source') ?? undefined,
from: parseDate(params.get('from')),
to: parseDate(params.get('to')),
limit: Math.min(remaining, 500),
cursor,
});
collected = collected.concat(page.rows);
if (!page.nextCursor) break;
cursor = page.nextCursor;
}
const csv = buildCsv(collected);
const filename = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* RFC 4180 CSV serializer. Escapes embedded quotes by doubling them and
* wraps any field containing comma / quote / newline in double-quotes.
* Trailing CRLF terminator per spec.
*/
function buildCsv(rows: Awaited<ReturnType<typeof searchAuditLogs>>['rows']): string {
const headers = [
'createdAt',
'id',
'portId',
'userId',
'action',
'entityType',
'entityId',
'severity',
'source',
'ipAddress',
'userAgent',
'metadata',
'oldValue',
'newValue',
];
const escape = (v: unknown): string => {
if (v === null || v === undefined) return '';
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
};
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(
[
r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
r.id,
r.portId,
r.userId,
r.action,
r.entityType,
r.entityId,
r.severity,
r.source,
r.ipAddress,
r.userAgent,
r.metadata,
r.oldValue,
r.newValue,
]
.map(escape)
.join(','),
);
}
return lines.join('\r\n') + '\r\n';
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { renderShell } from '@/lib/email/shell';
import { sendEmail } from '@/lib/email';
const SAMPLE_SUBJECT_SUFFIX = ' — branding preview';
function buildSampleEmail(branding: {
logoUrl: string | null;
emailBackgroundUrl: string | null;
primaryColor: string;
appName: string;
emailHeaderHtml: string | null;
emailFooterHtml: string | null;
}): { subject: string; html: string } {
const subject = `${branding.appName}${SAMPLE_SUBJECT_SUFFIX}`;
const body = `
<h1 style="font-size:20px;margin:0 0 12px;color:${branding.primaryColor};">A sample notification</h1>
<p style="margin:0 0 12px;color:#334155;">Hi there,</p>
<p style="margin:0 0 12px;color:#334155;">
This is a preview of how transactional emails from <strong>${branding.appName}</strong>
will look using the current branding settings (logo, background image, primary color,
and any custom header/footer HTML you've added).
</p>
<p style="margin:0 0 12px;">
<a href="https://example.com" style="display:inline-block;padding:10px 18px;background-color:${branding.primaryColor};color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;">
Primary action button
</a>
</p>
<p style="margin:0;color:#64748b;font-size:13px;">
Adjust the values in the Identity and Email branding cards above, save, then refresh the
preview to see your changes.
</p>
`;
const html = renderShell({
title: subject,
body,
branding: {
logoUrl: branding.logoUrl,
backgroundUrl: branding.emailBackgroundUrl,
primaryColor: branding.primaryColor,
emailHeaderHtml: branding.emailHeaderHtml,
emailFooterHtml: branding.emailFooterHtml,
},
});
return { subject, html };
}
// GET — return the sample email rendered with the current port's branding.
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const branding = await getPortBrandingConfig(ctx.portId);
const { subject, html } = buildSampleEmail(branding);
return NextResponse.json({ data: { subject, html } });
} catch (error) {
return errorResponse(error);
}
}),
);
const sendTestSchema = z.object({
recipient: z.string().email('Enter a valid email address'),
});
// POST — actually send the sample email to a single recipient.
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const { recipient } = await parseBody(req, sendTestSchema);
const branding = await getPortBrandingConfig(ctx.portId);
const { subject, html } = buildSampleEmail(branding);
await sendEmail(recipient, subject, html);
return NextResponse.json({ data: { sent: true, recipient } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -51,10 +51,13 @@ export const GET = withAuth(
return NextResponse.json({ data: null });
}
const baseUrl = env.APP_URL.replace(/\/+$/, '');
// Stream from the public-by-id surface (gated on `category='branding'`)
// so the URL works as a direct `<img src>` — the authenticated
// `/api/v1/files/<id>/preview` returns JSON, not image bytes.
return NextResponse.json({
data: {
fileId: file.id,
previewUrl: `${baseUrl}/api/v1/files/${file.id}/preview`,
previewUrl: `${baseUrl}/api/public/files/${file.id}`,
sizeBytes: file.sizeBytes,
mimeType: file.mimeType,
},
@@ -96,7 +99,7 @@ export const POST = withAuth(
return NextResponse.json({
data: {
fileId: result.fileId,
previewUrl: `${baseUrl}/api/v1/files/${result.fileId}/preview`,
previewUrl: `${baseUrl}/api/public/files/${result.fileId}`,
warnings: result.warnings,
finalDimensions: processed.finalDimensions,
finalBytes: processed.finalBytes,

View File

@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { findTemplateIdByEnvelopeId } from '@/lib/services/documenso-client';
import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.service';
/**
* POST /api/v1/admin/documenso/sync-template/:templateId
*
* Calls Documenso's GET /template/{id} via the configured per-port creds,
* pre-fills the matching documenso_*_recipient_id settings, and caches the
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
*
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
* ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows,
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
* before the sync runs.
*
* Admin-only via `admin.manage_settings`. Audit-logged through the per-field
* writeSetting calls inside the service.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const raw = params.templateId ?? '';
let templateId: number;
if (/^envelope_/.test(raw)) {
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
if (!resolved) {
throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`);
}
templateId = resolved;
} else {
templateId = Number(raw);
if (!Number.isInteger(templateId) || templateId <= 0) {
throw new ValidationError(
'templateId must be a positive integer or a Documenso envelopeId (envelope_…)',
);
}
}
const result = await syncDocumensoTemplate(templateId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET /api/v1/admin/documenso/sync-template/report
*
* Returns the cached sync result from the most recent successful Sync run,
* so the admin panel's status box survives a page reload without re-hitting
* Documenso. Returns `{ data: null }` when no sync has run for this port.
*
* Admin-only via `admin.manage_settings` — same gate as the sync write
* endpoint, since the report contains template recipient identities and
* AcroForm field names that aren't OK to leak outside the admin surface.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const report = await getEoiTemplateSyncReport(ctx.portId);
return NextResponse.json({ data: report });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { listTemplates } from '@/lib/services/documenso-client';
/**
* GET /api/v1/admin/documenso/templates
*
* Lists every Documenso template visible to the configured API key
* for the calling port. Drives the "Documenso-first templates" admin
* picker (R62) — reps see real template names instead of having to
* type numeric IDs.
*
* Gated on `admin.manage_settings` since the data exposed is essentially
* the same surface area as the Documenso settings page itself.
*
* Response shape: `{ data: Array<{ id, name }> }`. Cached client-side
* by the picker for ~5 minutes.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const templates = await listTemplates(ctx.portId);
return NextResponse.json({ data: templates });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
const bodySchema = z.object({
to: z.string().email().optional(),
});
/**
* Fire a test email through the per-port sales SMTP credentials. Used by
* the admin "Test SMTP" button on the Sales email config card to verify
* connectivity / auth without waiting for the next real send to fail.
*
* Sends a small text/HTML message to either the body-supplied `to` or
* (default) the admin's own email so they get the verification in their
* inbox. Returns { ok: true } on success or { ok: false, error } on
* failure — the admin UI rates accordingly.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const recipient = body.to ?? ctx.user.email;
if (!recipient) {
return NextResponse.json(
{ data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } },
{ status: 200 },
);
}
try {
const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`;
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
return NextResponse.json({ data: { ok: true, to: recipient } });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn({ portId: ctx.portId, err: message }, 'Sales SMTP test send failed');
return NextResponse.json({ data: { ok: false, error: message } });
}
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,61 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
const testSendSchema = z.object({
recipient: z.string().email('Enter a valid email address'),
});
/**
* SMTP connectivity test. Sends a minimal plaintext-ish message so the
* admin can verify the configured SMTP credentials (env or per-port DB)
* reach the inbox WITHOUT depending on branding being uploaded.
*
* Separate from the branding-preview send (`/admin/branding/email-preview`):
* - This one isolates the SMTP-host/port/user/pass surface.
* - The branding one exercises the rendering pipeline + logo bytes.
*
* Surface SMTP errors to the caller directly (auth failure, ENOTFOUND,
* connection refused) — the whole point of the test is to see them
* inline in the admin UI.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const { recipient } = await parseBody(req, testSendSchema);
const subject = 'CRM SMTP test — connection verified';
const html = `
<div style="font-family:system-ui,-apple-system,sans-serif;font-size:14px;color:#1e293b;padding:24px;line-height:1.5;">
<h1 style="font-size:18px;margin:0 0 12px;">SMTP test</h1>
<p style="margin:0 0 12px;">
If you're reading this, the SMTP credentials configured for this port
are reaching ${recipient}.
</p>
<p style="margin:0;color:#64748b;font-size:13px;">
Sent from /admin/email — Port Nimara CRM
</p>
</div>
`;
const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email — Port Nimara CRM`;
const info = await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
logger.info(
{ portId: ctx.portId, recipient, messageId: info.messageId },
'Admin SMTP test send succeeded',
);
return NextResponse.json({
data: { sent: true, recipient, messageId: info.messageId ?? null },
});
} catch (error) {
logger.warn({ err: error, portId: ctx.portId }, 'Admin SMTP test send failed');
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { readSetting, SETTING_KEYS } from '@/lib/services/port-config';
import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout';
import { logger } from '@/lib/logger';
/**
* POST /api/v1/admin/embedded-signing/test
*
* Verifies that the configured `embedded_signing_host` (the marketing
* site that hosts the branded embedded-signing wrapper) is reachable
* and returns a 2xx for the test path. Used by the admin "Test
* connection" button on the Documenso settings page so an admin can
* tell whether their marketing-site cutover is ready BEFORE signers
* get sent there from outbound emails.
*
* Two checks:
* 1. Bare host returns 2xx — the site is up.
* 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not
* every marketing site exposes /sign/health, so we degrade to a
* root probe when the dedicated path 404s.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const host = await readSetting<string>(SETTING_KEYS.embeddedSigningHost, ctx.portId);
if (!host) {
return NextResponse.json({
data: {
ok: false,
error: 'No embedded_signing_host configured. Set the URL in Documenso settings first.',
},
});
}
const checked: Array<{ path: string; status?: number; ok: boolean; error?: string }> = [];
const probe = async (path: string) => {
try {
const res = await fetchWithTimeout(`${host.replace(/\/$/, '')}${path}`, {
method: 'GET',
redirect: 'manual',
});
checked.push({
path,
status: res.status,
ok: res.ok || (res.status >= 300 && res.status < 400),
});
return res.status;
} catch (err) {
const msg =
err instanceof FetchTimeoutError
? `timed out after ${err.timeoutMs}ms`
: err instanceof Error
? err.message
: String(err);
checked.push({ path, ok: false, error: msg });
return null;
}
};
// Try root first — it's the most universal signal of "the site is
// up." Then probe /sign/success which the post-signing redirect
// typically points to, so admins can also catch a stale path.
await probe('/');
await probe('/sign/success');
const anyOk = checked.some((c) => c.ok);
if (!anyOk) {
logger.warn({ portId: ctx.portId, host, checked }, 'Embedded signing host probe failed');
}
return NextResponse.json({
data: {
ok: anyOk,
host,
checks: checked,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { copyFromEnv } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/copy-from-env
*
* One-click migration helper used by the admin form's "Copy from env"
* button. Reads the env var named in the registry entry's `envFallback`
* field and writes it as the current scope's row. Returns `{ copied: false }`
* if the env var is unset / empty.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const result = await copyFromEnv(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { registryFor } from '@/lib/settings/registry';
import { getSetting } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/reveal
*
* Returns the decrypted cleartext for an encrypted / sensitive setting.
* Used by the eye-toggle on encrypted fields in the registry-driven admin
* form so the operator can verify what they saved earlier.
*
* Gated on `admin.manage_settings` (the same permission required to write
* the value — so this never widens an existing trust boundary). Every
* reveal is audit-logged with the request id so a super-admin can trace
* who looked at what and when.
*
* Refuses to reveal values resolved from `env` or `default` — those would
* leak server-process secrets via the API.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const key = params.key!;
const entry = registryFor(key);
if (!entry) throw new NotFoundError(`Unknown setting: ${key}`);
if (!entry.encrypted && !entry.sensitive) {
// Non-sensitive values are already returned in the resolved-list
// endpoint, so a dedicated reveal isn't needed (and could be
// misused to bypass observability).
return NextResponse.json({ data: { revealed: false, value: null } }, { status: 200 });
}
// Resolve through the standard chain so the user sees exactly what
// the runtime would. The resolver decrypts on the way out.
const value = await getSetting<string>(key, ctx.portId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'view',
entityType: 'setting',
entityId: key,
metadata: { settingKey: key, op: 'reveal' },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { revealed: true, value: value ?? null } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { writeSetting, deleteSetting } from '@/lib/settings/resolver';
const putSchema = z.object({
value: z.unknown(),
});
/**
* PUT /api/v1/admin/settings/:key
*
* Writes a registry-known setting. The resolver validates against the
* entry's Zod schema, encrypts at rest if registered as such, and writes
* an audit log with secrets masked.
*
* Body: { value: <whatever the entry's type accepts> }
*
* Empty / null `value` on a non-sensitive field DELETEs the row (reverts
* to global → env → default). On a sensitive/encrypted field, empty is a
* no-op so an unchanged save through the ••• placeholder doesn't wipe
* the stored ciphertext. Use the DELETE endpoint to explicitly revert.
*/
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const { value } = await parseBody(req, putSchema);
await writeSetting(params.key!, value, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* DELETE /api/v1/admin/settings/:key
*
* Removes the row, reverting the resolver to global → env → default.
* 404 if no row exists at the appropriate scope.
*/
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
await deleteSetting(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -40,17 +40,31 @@ export const POST = withAuth(
if (!port) throw new ValidationError('No active port');
const buffer = Buffer.from(await fileEntry.arrayBuffer());
// Pick the storage filename's extension from the upload's MIME so
// PNG uploads aren't silently relabelled `.jpg` (the previous
// hardcoded extension flattened alpha-transparent logos).
const mimeType = fileEntry.type || 'image/jpeg';
const ext =
mimeType === 'image/png'
? 'png'
: mimeType === 'image/webp'
? 'webp'
: mimeType === 'image/gif'
? 'gif'
: mimeType === 'image/avif'
? 'avif'
: 'jpg';
const record = await uploadFile(
port.id,
port.slug,
{
buffer,
originalName: fileEntry.name || 'branding.jpg',
mimeType: fileEntry.type || 'image/jpeg',
originalName: fileEntry.name || `branding.${ext}`,
mimeType,
size: fileEntry.size,
},
{
filename: `branding-${Date.now()}.jpg`,
filename: `branding-${Date.now()}.${ext}`,
category: 'branding',
entityType: 'port',
entityId: port.id,
@@ -64,7 +78,10 @@ export const POST = withAuth(
);
const baseUrl = env.APP_URL.replace(/\/+$/, '');
const url = `${baseUrl}/api/v1/files/${record.id}/preview`;
// Branding assets must survive in email-inbox land where no session
// cookie travels — route through the public-by-id surface gated on
// `category='branding'` rather than the authenticated preview path.
const url = `${baseUrl}/api/public/files/${record.id}`;
return NextResponse.json({ data: { fileId: record.id, url } });
} catch (error) {

View File

@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { entriesForSections, registryFor } from '@/lib/settings/registry';
import { resolveForAdminAPI } from '@/lib/settings/resolver';
/**
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
* GET /api/v1/admin/settings/resolved?keys=branding_logo_url,smtp_host_override
*
* Returns the resolved value + source (port/global/env/default) for every
* requested registry entry. Drives both the registry-driven admin form
* (sections param) and the onboarding-checklist auto-detection (keys
* param) — both need port→global→env→default resolution rather than the
* raw `/admin/settings` rows (which only show DB writes).
*
* Either parameter is supported; if both are present the sets union.
* Sensitive fields surface `isSet` only — never the decrypted value.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const url = new URL(req.url);
const sectionsParam = url.searchParams.get('sections');
const keysParam = url.searchParams.get('keys');
if (!sectionsParam && !keysParam) {
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
}
const sections = sectionsParam
? sectionsParam
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
const extraKeys = keysParam
? keysParam
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
const sectionEntries = entriesForSections(sections);
const keyEntries = extraKeys
.map((k) => registryFor(k))
.filter((e): e is NonNullable<typeof e> => Boolean(e));
// Dedupe by `key` so section + key overlap doesn't double-resolve.
const seen = new Set<string>();
const entries = [...sectionEntries, ...keyEntries].filter((e) => {
if (seen.has(e.key)) return false;
seen.add(e.key);
return true;
});
const keys = entries.map((e) => e.key);
const resolved = await resolveForAdminAPI(keys, ctx.portId);
// Return the entry metadata so the client can render labels/types
// without bundling the registry into the client JS. Strip the
// `validator` + `transform` function references — they're not
// JSON-serializable.
const entriesForClient = entries.map((e) => ({
key: e.key,
section: e.section,
label: e.label,
description: e.description,
type: e.type,
options: e.options,
encrypted: !!e.encrypted,
sensitive: !!(e.sensitive || e.encrypted),
scope: e.scope,
envFallback: e.envFallback,
placeholder: e.placeholder,
defaultValue: e.defaultValue,
}));
const values: Record<string, unknown> = {};
for (const [k, r] of resolved.entries()) {
values[k] = r;
}
return NextResponse.json({ data: { entries: entriesForClient, values } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -46,7 +46,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
'generate_eoi',
'export',
]),
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list']),
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list', 'update_prices']),
documents: new Set([
'view',
'create',

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { user, userPortRoles, userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/admin/users/picker
*
* Lightweight list of users in the active port, used by admin form
* user-select dropdowns (e.g. linking a CRM user to a Documenso recipient
* slot). Returns only the fields needed to render an option: id, email,
* name. Excludes deactivated users.
*
* Gated on `admin.manage_settings` — anyone editing per-port admin
* settings can already see all the configured Documenso recipient
* email/name values, so revealing the user roster to them doesn't
* widen the trust boundary. Tighter than the full `admin/users` GET
* (which is `admin.manage_users`-gated).
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const rows = await db
.select({
id: user.id,
email: user.email,
name: user.name,
isActive: userProfiles.isActive,
})
.from(user)
.innerJoin(userPortRoles, eq(userPortRoles.userId, user.id))
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
.where(and(eq(userPortRoles.portId, ctx.portId)));
// Dedupe by id (a user with multiple role rows in this port would
// otherwise repeat) and drop deactivated profiles.
const seen = new Set<string>();
const data = rows
.filter((r) => r.isActive !== false)
.filter((r) => {
if (seen.has(r.id)) return false;
seen.add(r.id);
return true;
})
.map(({ id, email, name }) => ({ id, email, name }));
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -7,7 +7,6 @@ import {
getLeadSourceAttribution,
getOccupancyTimeline,
getPipelineFunnel,
getRevenueBreakdown,
type DateRange,
type MetricBase,
type PresetDateRange,
@@ -16,7 +15,6 @@ import {
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
pipeline_funnel: getPipelineFunnel,
occupancy_timeline: getOccupancyTimeline,
revenue_breakdown: getRevenueBreakdown,
lead_source_attribution: getLeadSourceAttribution,
};

View File

@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { and, eq, isNull, desc } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { interestBerths, interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
/**
* GET /api/v1/berths/[id]/active-interests
*
* Lightweight read for the berth-list popover: every non-archived
* non-terminal interest currently linked to this berth, plus the link's
* flags (primary, in-EOI-bundle). Sorted most-recently-updated first so
* the popover surfaces the hottest deals at the top.
*
* Tenancy: the berth row must belong to the caller's port; the inner
* join to interests carries an implicit port filter via the interest.
* Throws NotFoundError when the berth doesn't exist or is cross-port
* (same enumeration-prevention as the other berth routes).
*/
export const GET = withAuth(
withPermission('berths', 'view', async (_req, ctx, params) => {
try {
const berthId = params.id!;
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, ctx.portId)),
columns: { id: true },
});
if (!berth) throw new NotFoundError('Berth');
const rows = await db
.select({
interestId: interests.id,
clientName: clients.fullName,
pipelineStage: interests.pipelineStage,
isPrimary: interestBerths.isPrimary,
isInEoiBundle: interestBerths.isInEoiBundle,
updatedAt: interests.updatedAt,
})
.from(interestBerths)
.innerJoin(interests, eq(interests.id, interestBerths.interestId))
.innerJoin(clients, eq(clients.id, interests.clientId))
.where(
and(
eq(interestBerths.berthId, berthId),
eq(interests.portId, ctx.portId),
isNull(interests.archivedAt),
isNull(interests.outcome),
),
)
.orderBy(desc(interests.updatedAt))
.limit(20);
return NextResponse.json({
data: rows.map((r) => ({
interestId: r.interestId,
clientName: r.clientName,
pipelineStage: r.pipelineStage,
isPrimary: r.isPrimary,
isInEoiBundle: r.isInEoiBundle,
})),
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthPriceSchema } from '@/lib/validators/berths';
import { updateBerthPrice } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
/**
* PATCH /api/v1/berths/[id]/price
*
* Focused price-update endpoint gated by the dedicated
* `berths.update_prices` permission. Lets a role mutate berth pricing
* without granting the full `berths.edit` surface.
*
* Always audited (one `audit_log` row per call with
* `field_changed='price'` and the before/after values).
*/
export const PATCH = withAuth(
withPermission('berths', 'update_prices', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateBerthPriceSchema);
const updated = await updateBerthPrice(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { bulkUpdateBerthPricesSchema } from '@/lib/validators/berths';
import { bulkUpdateBerthPrices } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
/**
* POST /api/v1/berths/bulk-update-prices
*
* Bulk update berth prices in a single transaction (up to 500 per call).
* Gated by `berths.update_prices`. Returns counts so the UI can present
* "Updated N · Unchanged M · Missing K" feedback.
*
* Audit: one `audit_log` row per actually-updated berth (idempotent —
* berths whose new price matches the existing value are skipped and
* counted as `unchanged`).
*/
export const POST = withAuth(
withPermission('berths', 'update_prices', async (req, ctx) => {
try {
const body = await parseBody(req, bulkUpdateBerthPricesSchema);
const result = await bulkUpdateBerthPrices(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

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