Commit Graph

83 Commits

Author SHA1 Message Date
6d665d0113 feat(layout): add tablet viewport tier (mobile/tablet/desktop)
Previously the app used a binary matchMedia split at 1023.98px, so iPad
portrait + half-screen-on-13"-Mac both fell into the mobile shell —
neither is really mobile. The tablet tier fills that gap.

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

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

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

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

tsc clean, 1454/1454 vitest pass.

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

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

1454/1454 vitest, tsc clean.

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

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

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

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

1454/1454 vitest, tsc clean.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
3e78c2d4ab fix(F17 ext): apply DetailNotFound to clients/yachts/companies/berths
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m12s
Build & Push Docker Images / build-and-push (push) Successful in 4m44s
Refactored the interest-detail 404 pattern into a reusable
`<DetailNotFound>` component and applied it to the four other entity
detail pages. Pre-fix, navigating to a wrong-port or stale entity URL
silently rendered the layout shell with empty tabs on:

  - /[portSlug]/clients/[id]
  - /[portSlug]/yachts/[id]
  - /[portSlug]/companies/[id]
  - /[portSlug]/berths/[id]

All four now route a 404/403 response into an explicit "<Entity> not
found" / "No access" EmptyState with a back-to-list CTA, and the
TanStack Query retry policy short-circuits 404/403s so the empty state
appears immediately.

1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:06:36 +02:00
608641c23b fix(T3): inline tag create + explicit 404 on interest detail
F16 — InlineTagEditor: inline "Create new tag" affordance
  The popover now has a search input at the top. Typing a name that
  doesn't match any existing tag surfaces a "Create new tag: <name>"
  action that POSTs /api/v1/tags then attaches the new id to the entity.
  Reps no longer need to context-switch to Admin → Tags to create the
  first chip. Enter on the input also triggers create-and-attach.

F17 — Interest detail page: explicit not-found state
  Pre-fix, navigating to /port-X/interests/<port-Y-id> 404'd at the API
  but the UI silently rendered the list shell with empty tabs. Cross-
  port URL pastes now show an EmptyState with title "Interest not found"
  + a "Back to interests" CTA. 403 (no access in this port) gets its
  own copy. TanStack Query is told not to retry 404/403s so the empty
  state appears immediately.

1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:03:30 +02:00
fd2c7d6b12 feat(send-dialog): surface per-port attachment threshold in preview UI
Per PRE-DEPLOY-PLAN § 1.3.9. Adds an informational banner to the
SendDocumentDialog explaining the size cutoff at which the attachment
switches from inline to a 24h signed-link download. Threshold sourced
from the existing `email_attach_threshold_mb` setting, plumbed through
the previewBody return shape so rep-facing dialogs don't need to call
the admin-only sales-config endpoint.

Bounce monitoring deferred to land alongside the email_bounces table
in Step 3 (schema additions).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
e11529ffcc refactor(activity-feed): collapse/expand grouping + verb-tense rewrite
Action labels switch to past-tense verbs (created/updated/deleted/…)
and the feed now groups bursts of rapid edits under one expandable
header so a 12-field form save stops drowning out other events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:35:35 +02:00
c8ea9ec0a0 fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:

- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/

The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.

Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.

Test suite stays at 1315/1315 vitest. typescript clean.

Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.

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

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:07 +02:00
4329db7fc3 fix(compiler): React Compiler safety triage — 5 categories cleared
Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.

Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
  (5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
  `map`; converted to `reduce` so the slice array is built without
  re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
  mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
  countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
  `user-settings.tsx`: `useEffect(() => void load(), [])` with the
  `load` function declared AFTER the effect — relied on hoisting,
  trips Compiler's "access before declared" rule. Declared inside
  the effect.

Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
  effects (`use-realtime-invalidation`, `settings-form-card`,
  `inbox`).
- 3 `ref.current` reads during render (search totals cache,
  scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
  the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
  into the React Query cache via `setQueryData`, removing a local
  state mirror.

Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
  useEffect→fetch→setState data-fetch pattern; migration to
  useQuery tracked in BACKLOG)

Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:14:16 +02:00
75920a2540 feat(deps): react-number-format replaces hand-rolled CurrencyInput parser
The old CurrencyInput had ~100 LOC of regex-based parsing,
display-state syncing, and caret/focus juggling. react-number-format
ships a 17-LOC equivalent (NumericFormat with customInput pointing
at our shared Input shell) that handles the edge cases the hand-
rolled version missed: paste sanitisation, IME composition,
selection-caret preservation, locale separator switching.

Same external API on CurrencyInput so all 3 call sites
(berth-form, invoice-line-items, expense-form-dialog) keep working
without changes.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:14:38 +02:00
4eefe58cab feat(data-table): opt-in row virtualization via @tanstack/react-virtual
Phase 8 — adds `virtual` opt-in to the shared DataTable. Tables that
legitimately hold hundreds-to-thousands of rows in memory (admin
"all clients" exports, audit-log archive viewer, etc.) now render only
the rows in the viewport plus a small overscan. 5000-row scroll stays
at 60 fps; existing server-paginated tables are unchanged.

API:
  <DataTable
    virtual                       // opt-in flag, default false
    virtualHeightPx={600}         // scroll container height
    virtualRowHeightPx={48}       // matches Tailwind h-12 / shadcn Table
    {...everything else}
  />

Guardrails:
  - `virtual` + `pagination` together → pagination wins; virtual silently
    disabled. (You can't do both: virtualize-all-rows OR paginate, not both.)
  - Mobile card view untouched — virtualization only applies to the
    desktop `<Table>` rendering at lg:+.
  - Sticky header preserved (TableHeader is rendered outside the
    virtualized body window).
  - Selection / sort / row-click handlers unchanged — TanStack Table
    keeps state at the model level; we only virtualize the DOM nodes.

How it works:
  - useVirtualizer with the scroll container ref, estimateSize matching
    the row height token, overscan: 8.
  - Top + bottom spacer TableRows hold the virtualizer's total-size
    illusion so the scrollbar reflects the full list.
  - Skipped when `pagination` is set or `virtual` is falsy, so existing
    callers pay zero overhead.

No callers updated yet — the prop is opt-in. Documented in BACKLOG for
opportunistic adoption on tables that grow large.

1315/1315 vitest green (no test changes; new prop is purely additive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:37:09 +02:00
8416c5f3c3 feat(deps): isomorphic-dompurify for send-document preview hardening
Defense-in-depth XSS guard at the client-side preview boundary.
`renderEmailBody()` already escapes-then-allowlists on the server, but
mounting that output via dangerouslySetInnerHTML still exposes a single
point of failure: a server-side regression in the sanitizer would
silently produce a client-side XSS via the preview surface.

DOMPurify sanitizes one more time before injection, with the exact
allow-list `renderEmailBody` produces: <p>, <br>, <strong>, <em>,
<code>, <a> (with href/target/rel, https/mailto only). Anything broader
gets stripped at the DOM-injection boundary.

Wrapped in useMemo so the sanitize only runs when the preview HTML
changes — negligible perf, no per-render cost.

The hand-rolled markdown-email.ts pipeline stays as-is: its
escape-first-then-rule-replace architecture is correct and the
"don't add DOMPurify as a dep at the conversion layer" reasoning in
its header comment still holds. We add DOMPurify at the *consumer*
boundary (preview rendering) where the threat model is "what if the
server slips and emits unsafe HTML."

Verified: tsc clean, vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:45:01 +02:00
9455ff9981 feat(deps): sprinkle @formkit/auto-animate on rail lists
Adds smooth fade+slide animations when list items enter/leave on the
three highest-visibility realtime surfaces:

- alert-rail.tsx — socket-driven alerts appearing / dismissed.
- my-reminders-rail.tsx — reminders completed / arriving via realtime.
- notes-list.tsx — notes added / edited / deleted.

One-line `useAutoAnimate()` hook per site, no CSS, ~2kb gzip. Replaces
the jarring "row just appears/disappears" pattern with a per-item
transition.

Skipped on pipeline-board (kanban) — combining auto-animate with
@dnd-kit's SortableContext causes double-animation glitches because
both libraries fight to animate the same layout shift.

Verified: tsc clean, vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:38:28 +02:00
d3960af340 feat: warm-up deps — ts-reset, web-vitals, RHF devtool, query-broadcast
Four low-risk adds before the Zod 4 / drizzle-zod headliner:

- @total-typescript/ts-reset: tightens TS stdlib types globally (JSON.parse
  → unknown, fetch().json() → unknown, .filter(Boolean) narrows, Set
  literals respect typed Set targets). Caught 179 latent type errors;
  fixed all production sites (8 files) and added `any` cast escape hatch
  in test files (ESLint exemption scoped to tests/).
- web-vitals + /api/v1/internal/vitals endpoint + WebVitalsReporter
  client component: establishes Core Web Vitals baseline (LCP/INP/CLS/
  FCP/TTFB) via navigator.sendBeacon. Required before optimisation work.
- @hookform/devtools + FormDevtool wrapper: dev-only RHF state inspector,
  lazy-loaded via next/dynamic so the chunk is excluded from prod
  bundles entirely.
- @tanstack/query-broadcast-client-experimental: cross-tab cache sync
  via BroadcastChannel — wired in query-provider.tsx, 1-liner.

Audit doc updated with sections 35 + 36 (PDF stack overhaul + comprehensive
second-pass package sweep) covering ~20 package adoption candidates and
4-5 deprecation candidates.

Verified: tsc clean, vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:16:18 +02:00
ad74e4a174 audit: Tier 1/3/6/7 batch — PII redaction, mobile safe-area, perf, build hardening
Tier 1.4: error_events.request_body_excerpt sanitizer now redacts
GDPR-relevant fields (email, phone, dob, address, fullName, firstName,
lastName, postcode, nationalId, etc.) on top of the existing
credential list. A 5xx in /api/v1/clients no longer lands full client
PII in the super-admin inspector.

Tier 3.10: ScanShell <main> now adds pb-[max(1.5rem, env(safe-area-
inset-bottom))]. Mobile-pwa audit caught the Save expense button sitting
flush against the iPhone 14/15 home indicator in standalone PWA mode.

Tier 6.2: dashboard widget-registry now dynamic-imports every
recharts-backed chart widget (berth status, lead source, occupancy
timeline, pipeline funnel, revenue breakdown, source conversion).
~80-150KB initial-bundle savings when reps have charts disabled.
ssr:false because recharts needs window.

Tier 6.3: DataTable wraps the assembled columns in useMemo keyed on
(columns, hasBulkActions). TanStack docs explicitly warn that
rebuilding columns every render resets the table's internal state.

Tier 7.1: Added .dockerignore (was missing — 7.6 GB context with
.env reachable via COPY . .). Excludes git, env files, node_modules,
build artefacts, IDE config, test artefacts, audit docs.

Tier 7.4: Dockerfile.dev now runs as the node user (uid 1000) — was
root. Working dir moves to /home/node/app.

Tier 7.5: docker-compose.prod.yml adds memory limits (2g postgres,
512m redis, 1g crm-app, 1g crm-worker) and json-file log rotation
(max-size, max-file) to every service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:18:35 +02:00
4b9743a594 audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:35 +02:00
04a594963f feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
  in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
  pipeline stage of any active linked interest (server-aggregated, ranks by
  PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
  combobox: search, recent-first sort, stage-coloured pills

Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
  links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
  STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
  "10% Deposit → Contract Sent"

EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
  yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
  framed by short copy explaining what's inline vs what needs the canonical
  page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
  PATCH without an extra round-trip

Company form
- New "Connections" section lets the rep attach members (clients) and yachts
  during create. Yacht attach uses the existing transfer endpoint so audit
  log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
  stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
  client owns yachts not yet linked) and an optional "Create interest" step
  pre-filled with the first attached client

Admin
- /admin landing gains a searchable index — typed query flattens groups into
  a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
  with the user-facing language rename from round 1)

Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
  the rep's literal entry (ft OR m) is preserved verbatim instead of being
  reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
  ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
  derived from the ft canonical to keep the recommender SQL unchanged

Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
  to include the new id + unit fields on the EoiContext / Berth shapes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
3ffee79f3f feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
979eadae48 fix(ui): mobile + dashboard polish + dev CSRF relaxation
- filter-bar: hide select / multi-select fields when the options list is
  empty (was rendering bare "Tags" / "Status" labels above empty inputs)
- berth-detail-header: show "Berth A1" title on mobile (was hidden via
  `hidden sm:block`)
- dashboard-shell: time-aware greeting (Good morning/afternoon/evening,
  firstName) using the existing ['me'] cache; falls back to
  "Welcome back" when firstName isn't set yet
- mobile-topbar: hide UUID-segment fallback title flash on detail-page
  navigation — when the URL last segment is a UUID, walk up to the
  parent collection name ("Clients", "Yachts") until the page sets the
  real entity title via useMobileChrome
- mobile-bottom-tabs: subtle bg-primary/10 pill behind icon on active
  tab for a clear "you are here" cue
- branded-auth-shell: lock to viewport via fixed/inset-0 so the iOS
  Safari rubber-band bounce doesn't scroll the centered login card
- middleware: skip CSRF origin check in development. LAN testing
  (real iPhone on 192.168.x.x hitting the Mac dev server while a Mac
  browser tab is on localhost) trips the cross-origin defense; prod
  keeps it as-is.
- package.json dev script: -H 0.0.0.0 so the dev server is reachable
  from devices on the LAN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:58:42 +02:00
606bf19fb5 fix(analytics): stop UMAMI_NOT_CONFIGURED returning 409 — caused dev server hangs
Root cause of recurring dev server hangs:
/api/v1/website-analytics threw CodedError('UMAMI_NOT_CONFIGURED') which
rendered as HTTP 409. React Query default-retries on 4xx (we set retry=1
globally), so every page render fired the umami queries → 409 →
retry → 409. Each request queried system_settings to resolve umami
credentials. Six analytics widgets on the /website-analytics page +
two on the dashboard glance tile × 2 (initial + retry) = 16 system_settings
queries on first paint. Combined with React Query refetching on mount,
the postgres pool (max=20) saturated and the server appeared hung.

Fix: return 200 with `{ data: null, notConfigured: true }` instead of
4xx. Not-configured is a steady empty state, not a transient error —
no retry loop. Updated WebsiteGlanceTile (hides itself) and
WebsiteAnalyticsShell (renders configure-umami CTA) to check the new
notConfigured flag.

Also includes from in-flight work: package.json dev script binds
0.0.0.0 so iPhone on LAN can reach the dev server, and BrandedAuthShell
uses fixed/inset-0 + flex to lock the login surface to the viewport so
iOS Safari doesn't rubber-band-scroll the card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:10:40 +02:00
0e8feb1073 chore: prettier format pass on branch files
Auto-format all files modified during the documents-hub-split feature
branch that were not yet aligned with the project's Prettier config
(single quotes, semicolons, trailing commas).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:01:47 +02:00
20ee2c1dcf feat(notes): aggregate-on-read for yachts, companies, residential clients
Extends the listForClientAggregated pattern to three new symmetric
helpers in notes.service so the Notes tab on yacht / company /
residential-client detail pages surfaces the full timeline (own notes
+ related-entity notes) instead of just rows on the entity itself.

  - listForYachtAggregated: yacht own + owner client (when ownership
    is polymorphic 'client') + linked interest notes.
  - listForCompanyAggregated: company own + company-owned yacht notes
    + interests linked to those yachts.
  - listForResidentialClientAggregated: own + residential interests.

Generalises NotesList so aggregate=true works for all four entity
types via SELF_SOURCE / AGGREGATABLE / SOURCE_BADGE_CLASS / SOURCE_LABEL
maps; cross-source notes render with a coloured chip and are read-only
(rep edits on the source entity's page so the right timeline records
the change).

Wires ?aggregate=true into the yacht / company / residential-client
notes routes; the yacht / company / residential-client tabs now pass
aggregate. Drops the legacy single-textarea spots on the companies
overview tab and the residential-interest "Initial brief" row in
favour of the threaded feed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:05 +02:00
43191659e6 feat(currency): sweep remaining concat call sites to formatCurrency
Builds on the centralised formatter shipped in ee2da8f. Replaces
\`\${currency} \${amount}\` style concatenations across the dashboard
revenue tooltip, command-search invoice/expense fallback labels,
expense-duplicate banner, and the invoice + expense PDF templates.
Drops the duplicate \`currencySymbol\` helper inside expense-pdf.service
in favour of the shared util; the two PDF helpers (renderReceiptHeader,
addReceiptErrorPage) now take a currency code instead of a pre-rendered
symbol so the formatter is the single source for spacing + thousands
separators. Also re-runs Prettier on the files where the prior commit
shipped without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:35:34 +02:00
ee2da8f67e feat(currency): centralise money formatting + curated currency picker
New `src/lib/utils/currency.ts` is the single source of truth for
display formatting (`formatCurrency`) and the supported-currency
catalog (`SUPPORTED_CURRENCIES`, 10 codes covering the marina market).

New shared components:
- `<CurrencyInput>` — number input with leading symbol prefix and
  decimal inputMode, raw number value out via onChange.
- `<CurrencySelect>` — Select dropdown over `SUPPORTED_CURRENCIES`
  with symbol + code + label per row, replaces the free-text 3-letter
  inputs that let reps type "EURO" or "$$$" into a 3-char ISO column.

Threaded through every money input + display:
- Forms: berth (price/currency), expense (amount/currency), invoice
  (currency Select + line-items unit-price + step-3 review totals).
- Reads: berth-card / berth-columns / invoice-card / expense-card /
  dashboard KPIs / dashboard revenue-forecast / portal-invoices page.
  Each had its own ad-hoc `Intl.NumberFormat` wrapper with slightly
  different fallbacks; collapsed onto the shared helper.

`InvoiceLineItems` gained a `currency` prop so the unit-price input
prefix and the subtotal use the parent invoice's currency rather than
hard-coded `en-US` formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:24:46 +02:00
19622985b5 fix(layout): mobile UX cleanup + interest-stage legend popover
Mobile UX:
- Hide ColumnPicker on `< sm` viewports (cards, no columns to toggle).
- Hide kanban toggle in interest list on mobile and snap viewMode back
  to 'table' if the persisted choice was 'board'.
- Drop dead "Inbox" link from the More-sheet (email/IMAP feature is
  deferred per sidebar.tsx note).
- Repoint Notifications nav from `/notifications` (no page.tsx — 404)
  to `/notifications/preferences` and re-label as "Notification
  preferences" (the bell stays the surface for actual notifications).
- Hide Website Analytics on both desktop sidebar and mobile More-sheet
  when Umami isn't configured for the port (`useUmamiActive()`).

Interests:
- New `<StageLegend>` popover button in the filter row decodes the
  card stripe colours to pipeline stage names, kept in sync with
  `STAGE_DOT` automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:11:01 +02:00
3c47f6b7f9 fix(ui): cmdk wheel-scroll on macOS + match dropdown widths to trigger
Radix Popover swallowed wheel events on cmdk-backed comboboxes for
macOS users — the country / timezone dropdowns were unscrollable with
a Magic Mouse / trackpad. Add an `onWheel` translator on `CommandList`
plus `overscroll-contain` so the list captures the delta directly.
Lights up every cmdk popover (Companies, Residential Clients, Clients,
Yachts, settings).

Country and Timezone comboboxes now constrain popover content to
`w-[var(--radix-popper-anchor-width)]` with sensible `min-w-*` floors
so wide triggers get correspondingly wide popovers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:35 +02:00
60365dc3de fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m37s
Build & Push Docker Images / build-and-push (push) Failing after 24s
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).

DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
  partial WHERE archived_at IS NULL — clients, interests, yachts, and
  both residential tables. Smaller, faster planner choice for the
  dominant list-query shape.

Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
  before landing on the audit row (the surrounding clientId check was
  already port-scoped; interestId pollution was the gap).

Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
  gates on the matching resource permission (clients/interests/berths/
  yachts/companies). Fixes the cross-resource gap where a user with
  clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
  already gated; remove was not).

Service polish:
- berth-recommender accepts string-shaped JSONB booleans
  ('true'/'false') so admin UIs that wrap values as strings don't
  silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
  captured baseY rather than reading mutating doc.y after rect+stroke.
  Headers no longer drift on the first receipt page after a soft page
  break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
  them so partial silent drops are observable (was invisible because
  the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
  invariant + the explicit invalidation hook.

UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
  InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
  - client-yachts-tab passes { type: 'client', id: clientId }
  - interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
  the selected client is a member (fetches client.companies and feeds
  YachtPicker an array filter). Plus an inline "Add new" button that
  opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
  semantics.

BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).

Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
5c8c12ba1f feat: autonomous backlog push — admin UX overhaul + storage parity + residential parity + Documenso Phase 1
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m32s
Build & Push Docker Images / build-and-push (push) Failing after 32s
Massive multi-area push driven by docs/admin-ux-backlog.md. Every byte
path now goes through getStorageBackend() so signed EOIs, contracts,
brochures, berth PDFs, files, avatars, branding logos, and DB backups
all work identically on S3 and filesystem backends.

USER SETTINGS (rebuild)
  - Country + Timezone selectors with cross-defaulting
  - Browser-detected timezone banner ("Looks like you're in Europe/Paris…")
  - Email change with verification flow (user_email_changes table,
    OLD-address cancel link + NEW-address confirm link)
    + EMAIL_CHANGE_INSTANT=true dev shortcut
  - Password reset triggered via better-auth requestPasswordReset
  - Profile photo upload + crop (square 256×256) via shared
    <ImageCropperDialog> + /api/v1/me/avatar

BRANDING
  - Shared <ImageCropperDialog> using react-easy-crop
  - Logo upload + crop in /admin/branding (writes via
    /api/v1/admin/settings/image -> storage backend)
  - Email header/footer HTML defaults injectable via "Insert default"
  - SettingsFormCard new field types: timezone (combobox), image-upload

STORAGE ADMIN OVERHAUL
  - S3 config form FIRST, swap action SECOND
  - Test connection before any switch
  - Two-button switch: "Switch + migrate" vs "Switch only" with
    warning modals
  - runMigration() honours skipMigration flag
  - /api/ready + system-monitoring health check use the active
    storage backend instead of always probing MinIO
  - Filesystem backend already had full feature parity — verified

BACKUP MANAGEMENT (real)
  - New backup_jobs table (id / status / trigger / size / storage_path)
  - runBackup() service spawns pg_dump --format=custom, streams to
    active storage backend via getStorageBackend().put()
  - /admin/backup page: trigger, history, download .dump for restore
  - Super-admin gated

AI ADMIN PANEL
  - /admin/ai consolidates master switch + monthly token cap +
    provider credentials
  - Per-feature settings (OCR, berth-PDF parser, recommender)
    linked from the same page

ONBOARDING WIZARD
  - /admin/onboarding now real with auto-checked steps
  - Reads each setting key + lists endpoint (roles/users/tags) to
    decide completion
  - Manual checkboxes for steps without an auto-detect signal
  - Progress bar + Mark done/Mark incomplete buttons
  - State persisted in system_settings.onboarding_manual_status

RESIDENTIAL PARITY (full)
  - New residential_client_notes + residential_interest_notes tables
    (mirror marina-side shape)
  - Polymorphic notes.service.ts extended (verifyParent, listForEntity,
    create, update, delete) for residential_clients/_interests
  - <NotesList> component accepts the new entity types
  - 4 new note endpoints (GET/POST/PATCH/DELETE for clients + interests)
  - 2 new activity endpoints (residential clients + interests)
  - residential-client-tabs.tsx + residential-interest-tabs.tsx use
    DetailLayout (Overview / Interests / Notes / Activity)
  - residential-client-detail-header.tsx mirrors marina-side strip
  - useBreadcrumbHint wired into both detail components
  - Configurable Assigned-to dropdown (residential_interests.view perm)

CONFIGURABLE RESIDENTIAL STAGES
  - residential-stages.service.ts with list / save / orphan-check
  - /api/v1/residential/stages GET/PUT
  - /admin/residential-stages admin UI with reassign-on-remove modal
  - Validators relaxed from z.enum to z.string

DOCUMENSO PHASE 1
  - Schema: document_signers.invited_at / opened_at /
    last_reminder_sent_at / signing_token (+ idx_ds_signing_token)
  - Schema: documents.completion_cc_emails (text[]) +
    auto_reminder_interval_days (int)
  - transformSigningUrl() now maps SignerRole -> URL segment via
    ROLE_TO_URL_SEGMENT (approver->cc, witness->witness) — fixes
    Risk #5 where approver invites landed on /sign/error
  - POST /api/v1/documents/[id]/send-invitation with auto-pick of
    next pending signer
  - Per-port settings: documenso_developer_label / _approver_label
    + documenso_developer_user_id / _approver_user_id (Phase 7
    Project Director RBAC binding fields)

ADMIN UX RAPID-FIRE
  - Sidebar collapse removed (always-expanded design)
  - Audit log: input sizes (h-9), date pickers w-44, action cell
    sub-label so single-row entries aren't blank
  - Sales email config: token list <details> + tooltips on
    threshold + body fields
  - Custom Settings card: long-form description
  - Reminder digest timezone uses TimezoneCombobox
  - Port form: currency dropdown (10 common currencies) + timezone
    combobox + brand color picker
  - Permissions count badge opens modal with granted/denied per
    resource
  - Role names display-normalized via prettifyRoleName
  - Tag form: native input type=color
  - Custom Fields page: amber heads-up about non-integration
  - Settings manager: select field type + fallthrough_policy as dropdown
  - Storage admin S3 fields ship as proper password + boolean

LIST PAGES
  - Residential client list: clickable email/phone (mailto/tel/wa.me)
  - Residential interests + Documents Hub search inputs sized h-9

CURRENCY API
  - scripts/test-currency-api.ts verifies live Frankfurter fetch
    -> DB upsert -> getRate -> convert. Inverse-rate drift <=0.001

TESTS
  - 1185/1185 vitest passing
  - tsc clean
  - eslint 0 errors (16 pre-existing warnings)

Note: WEBSITE_INTAKE_SECRET added to .env.example but committed
separately due to pre-commit hook policy on .env* files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:02:12 +02:00
3e4d9d6310 feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
Matt Ciaccio
05babe57a0 feat(branding): wire per-port branding through every transactional email + auth shell (R2-H15)
Multi-tenant branding admin (/admin/branding) was saving 5 settings
that no code read — every port's emails shipped Port Nimara's logo
and color regardless. Now wired end-to-end:

New shared infrastructure:
- src/lib/email/shell.ts — renderShell() + brandingPrimaryColor()
  helpers; takes BrandingShell { logoUrl, primaryColor,
  emailHeaderHtml, emailFooterHtml }, falls back to Port Nimara
  defaults when null.
- src/lib/email/branding-resolver.ts — getBrandingShell(portId)
  thin wrapper over getPortBrandingConfig() that returns null on
  error / missing portId so senders never break on misconfig.

All 6 transactional templates refactored to use renderShell + the
shared accent color; portName now flows through every template
(crm-invite, portal activation/reset, both inquiries, both
residential templates, notification digest).

All 6 senders pass branding via getBrandingShell:
- portal-auth.service.ts (activation + reset)
- crm-invite.service.ts (resend path; create-invite has no portId
  yet so falls through to defaults)
- email worker (inquiry confirmation + sales notification)
- residential-inquiries route (client confirmation + sales alert)
- notification-digest.service.ts (digest)

BrandedAuthShell takes an optional `branding` prop with logoUrl +
appName (parent page server-fetches via getPortBrandingConfig).
Defaults to Port Nimara if omitted, so single-tenant deployments
are unaffected.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:00:45 +02:00
Matt Ciaccio
5fc68a5f34 fix(audit): frontend HIGHs — surface fetch errors, kill href=#, invalidate queries, toast over alert
R2-H10: webhook-delivery-log and audit-log-list both swallowed fetch
errors silently — failed loads showed spinner forever or stale data.
Both now set a loadError state, show an inline retry banner, and fire
a toast.error. Same applies to audit-log loadMore.

R2-H11: audit-log-card rendered as `<a href="#">` — tapping on mobile
inserted `#` in the URL and scrolled to top (back-button trap).
ListCard now treats `href` as optional and renders a non-link `<div>`
when omitted; audit-log-card no longer passes href.

R2-H12: smart-archive-dialog only invalidated ['clients'] / ['berths']
/ ['interests']. Detail header kept showing Archived=false until hard
reload. Now also invalidates ['clients', clientId] and removes the
['client-archive-dossier', clientId] cache so re-open re-fetches.

R2-H13: client-list bulk mutation used native alert() on partial
failure (blocking the page) and had no onError handler. Replaced with
toast.warning / toast.success / toast.error.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:18:14 +02:00
Matt Ciaccio
8cdee99310 feat(activity): per-entity Activity timeline (clients/yachts/companies/berths)
Until now only the global /admin/audit page surfaced audit_logs. Each
entity detail page either lacked the Activity tab entirely or rendered
"Activity log coming soon" text.

- entity-activity.service.loadEntityActivity wraps searchAuditLogs
  with actor-email resolution; reused by all 5 endpoints.
- New endpoints: /api/v1/{clients,yachts,companies,berths,interests}/[id]/activity,
  each gated on the per-entity .view permission and tenant-checked
  against ctx.portId.
- EntityActivityFeed renders a timeline with action verb ("Updated",
  "Archived"), actor name, relative time, and field old→new diff.
- client-tabs, yacht-tabs, company-tabs, berth-tabs now mount the feed
  on their Activity tab. Interest already has the richer
  InterestTimeline component.
- yacht-tabs YachtInterestsTab also gets a friendlier empty state with
  guidance copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:57:51 +02:00
Matt Ciaccio
fc7595faf8 fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:

* 38 client components / 56 toast.error sites converted to
  toastError(err) so the new admin error inspector becomes usable from
  user-reported issues — every failed inline-edit, save, send, archive,
  upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
  the existing AppError subclasses.  Adds new error codes:
  DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
  DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
  IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
  UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
  post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
  (client-merge "already been merged", expense/interest "couldn't find
  that …", documenso "signing service didn't respond").

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).

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