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>
L-001 hunt landed these:
- src/lib/services/clients.service.ts — stageRank used pre-refactor
9-stage names exclusively (`contract_signed`, `deposit_10pct`, …).
Every modern 7-stage interest fell to rank 0, making client-list
"most-progressed deal" sort effectively random. Modern values now
own the canonical ranks; legacy aliases map to their 7-stage
equivalents so historical audit data still sorts.
- src/lib/services/berth-recommender.service.ts — STAGE_ORDER had
the same 9-stage shape. LATE_STAGE_THRESHOLD pointed at the (now
nonexistent) `deposit_10pct` slot. Reworked to the 7-stage scale;
threshold now at `deposit_paid` (5).
- Stale comments referencing `deposit_10pct` in schema (clients,
financial) and client-archive services updated to current copy.
- Smart-archive dialog rendered `i.pipelineStage` as raw enum; now
routes through `stageLabelFor` (the new helper added with A2).
Test fixture updates: berth-recommender.test.ts numeric inputs
re-mapped to the new 7-stage scale (eoi_signed=5 → eoi=3, etc.).
1373/1373 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Pre-fix the dashboard layout mounted BOTH the desktop and mobile shells
to the DOM on every page, hidden via CSS data-shell rules. Two Tabs
providers had data-state="active" concurrently, every fetch fired twice,
every component piece of state lived in two trees, a11y landmarks
duplicated, and half the click attempts hit the wrong layer.
New <AppShell> client wrapper mounts exactly ONE tree based on the
server-classified User-Agent (no hydration mismatch, no first-paint
flash on real mobile devices) plus a runtime matchMedia subscription
that swaps shells when the viewport crosses 1024px (e.g. desktop
browser resized).
Knock-on changes:
- Dashboard layout fetches once and hands the data to AppShell;
AppShell picks Desktop (Sidebar + Topbar + main) or MobileLayout
- Stripped the now-orphan data-shell CSS rules from globals.css —
nothing emits the attribute any more
- MobileLayout drops its data-shell="mobile" attribute (was the lever
the dead CSS rules pulled)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the long-dormant berths.status_override_mode column into a closed
loop so reps can reconcile berths flipped to under_offer/sold without a
backing interest.
Phase 1 — Status source tracking:
- updateBerthStatus() stamps 'manual' on every user-facing write
- berth-rules-engine.ts stamps 'automated' on auto-rule writes
- new clearBerthOverride() helper nulls the field and stamps the
reason "Reconciled via interest <id>" — only the wizard calls it
Phase 2 — Visual indicator:
- Amber "Manual" chip on berth-list rows where statusOverrideMode='manual'
AND no active linked interest (the candidates for catch-up)
Phase 3 — Reconciliation queue:
- new service listManualReconcileBerths() with cross-port-safe
NOT-EXISTS against activeInterestsWhere
- GET /api/v1/berths/reconcile-queue
- new page /[portSlug]/admin/berths/reconcile listing the queue,
each row linking to the catch-up wizard
Phase 4 — Catch-up wizard:
- POST /api/v1/berths/[id]/reconcile orchestrates create-client
(optional quick-create), create-interest with primary berth link,
and clearBerthOverride — composed via existing service helpers
- <CatchUpWizard> dialog: existing-client or quick-create, optional
yacht link, stage picker scoped to the current berth status, with
contract auto-setting outcome=won
Phase 5 — Entry points:
- sidebar Admin > "Reconcile berths" link
- berth-list row action menu shows "Catch up…" on flagged rows
Doc upload + payment recording (spec phases 4.4 / 4.5) are deferred —
once the interest exists, the rep uses the standard interest detail
page surfaces for those follow-ups. The wizard's MVP responsibility is
to take a manual berth to "interest exists, override cleared" in one
round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F23: when the rep tries to leave the Enquiry stage on an interest with no yacht linked, the stage popover now switches into an inline yacht-picker view (filtered to the client's own yachts when known). On submit it PATCHes interest.yachtId then chains the stage move, so the prereq fix and the advance happen in one flow instead of the rep bouncing to the validation error toast.
F24: Country moved out of the Basic Information section (next to Full Name *) into Source & Preferences alongside Timezone — country is timezone-hint material, not first-line identity data. Quick-path for a new client is now just name + contact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F19: client form drops empty-value contacts on submit; auto-promotes first remaining row to primary if none flagged.
F20: new-interest dialog redirects to the detail page on create instead of bouncing back to the list.
F21: stage-transition validation errors render with STAGE_LABELS — "Yacht is required before leaving the Enquiry stage." (was "yachtId is required before leaving stage=enquiry").
F22: blocked-stage marker swapped from the ⚑ unicode glyph to a Lucide AlertTriangle with aria-label.
F25: documents-hub folder selection moves to ?folder=<id> querystring so deep-link / browser-back / refresh round-trip the current folder.
F26: reopen-outcome action now toasts "Outcome cleared — interest is open again."
F27: stage PATCH where target === current short-circuits to a no-op return; downstream callers don't see a phantom stage_change audit row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Batch of small fixes from the post-audit plan:
F11 — "Mark as won" dialog copy
Was: "This will move the interest to Completed and stamp the outcome."
Completed was retired in the 7-stage refactor; copy now reads
"marks Won; stage stays where it is" with a parallel Lost variant.
F13 — Bulk-add berths wizard had no UI entry point
Page existed at /[portSlug]/admin/berths/bulk-add but nothing linked
to it. Added a "Bulk add" button on the Berths list toolbar, gated
on `berths.import`. Also fixed the API route's permission key
(was `berths.create`, a phantom — switched to `berths.import` to
match seed-permissions).
F14 — Audit Log nav entry
Sidebar Admin section now lists "Audit Log" → /admin/audit, gated
by the adminRequired group rule.
F18 — Recommender `limit` param ignored
POST /interests/[id]/recommend-berths now accepts `limit` as an
alias for `topN`. Audit sent `{limit:3}` and silently got 8 rows
back; both names now resolve.
Tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 8 per PRE-DEPLOY-PLAN § 1.2.5.
Activation + password-reset links now carry the token in the URL
fragment (`#token=…`) instead of the query string (`?token=…`). URL
fragments are client-side only — the token never hits the server,
never lands in proxy logs, never sits in the Referer header, and is
invisible to upstream CDN/cache layers. The form still POSTs the
token in the request body to authenticate.
Changes:
- portal-auth.service.ts URL builders for activation + reset switch
to `#token=`. Inline comments cite the security rationale.
- password-set-form.tsx reads the token via useSyncExternalStore so
the SSR snapshot returns `null` and the client snapshot reads
window.location.hash post-hydration (no set-state-in-effect
Compiler violation). Helper prefers the fragment but falls back to
the legacy `?token=` search param for the back-compat TTL window —
so links sent before the switchover still work for their remaining
lifetime. Component renders a "Loading…" placeholder during the
pre-hydration null state.
No DB changes; tokens themselves unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 7 per PRE-DEPLOY-PLAN § 1.7. The canonical noun for an in-flight
sales record is "interest" everywhere in the codebase — entity name,
schema, kanban label, URL, etc. Customer-visible "deal" remnants are
either a holdover from pre-refactor copy or hand-written admin
descriptions that drifted.
Sweeps applied:
- /admin/qualification-criteria description: "before a deal moves out
of the Enquiry stage" → "before an interest moves out…"
- /admin/documenso descriptions (×3): "per-deal upload-and-place…" →
"per-interest upload-and-place…"; "upload per deal" → "upload per
interest"; "drafted per deal" → "drafted per interest".
- bulk-archive-wizard.tsx placeholder: "late-stage deal" → "late-stage
interest".
- smart-archive-dialog.tsx title: "Late-stage deal" → "Late-stage
interest".
- /api/v1/berths/[id]/deal-documents → /api/v1/berths/[id]/interest-documents
(route directory renamed; the single in-tree caller in
berth-deal-documents-tab.tsx updated to match; React Query key also
switched to "berth-interest-documents" for cache hygiene).
The `BerthDealDocumentsTab` component name + `berth-deal-documents-tab.tsx`
file path are intentionally left as-is — pure aliases, internal to the
codebase, churn cost > readability win. Rename when next touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 minimal-but-functional per PRE-DEPLOY-PLAN § 1.6.
Berth Heat — new widget showing top 15 berths by active interest
count via the interest_berths junction (non-primary links included so
multi-berth deals warm every berth in their bundle). Investor-friendly
demand-pressure view; the ranked-table shape exports cleanly to PDF/
CSV. Future heatmap viz reads the same shape via /api/v1/dashboard/
berth-heat.
Defaults flipped for investor-friendliness:
- kpi_pipeline_value → defaultVisible (currency-aware headline number).
- source_conversion → defaultVisible (conversion funnel by source;
reads the inquiry → client linkage from Step 3).
- berth_heat → defaultVisible.
Pipeline-velocity-over-time + true heatmap viz deferred. pipeline_funnel
covers snapshot stage breakdowns; over-time velocity warrants its own
design pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 5 per PRE-DEPLOY-PLAN § 1.4.13.
Service: bulkAddBerths(portId, inputs, meta) — input-level dedup
catches in-batch duplicates, then a single SELECT against existing
port rows rejects with ConflictError on first collision. All inserts
in one round-trip; audit log + realtime alert.
Validator: bulkAddBerthsSchema with min(1) max(500) per call.
Route: POST /api/v1/berths/bulk-add gated on berths.create.
Wizard UI (/[portSlug]/admin/berths/bulk-add):
Step 1 — dock letter A-E, range start+end mooring numbers, tenure
default. Generates N empty rows.
Step 2 — editable table with per-row dimensions / pontoon / pricing.
"Apply to all" inputs in the header row copy a value down every
row at once (covers the "every row is 40ft × 15ft at €125k" case
in two clicks). Per-row remove button.
Drag-fill deferred. Server-side mooring uniqueness check is canonical;
client-side dedup is a pre-flight courtesy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4 second slice. Adds the "Mark as signed without file" action to
contract + reservation tabs per PRE-DEPLOY-PLAN § 1.5.14.
Service: `markExternallySigned(interestId, portId, docType, reason)`
flips the relevant doc-status column ('contract_doc_status' /
'reservation_doc_status' / 'eoi_doc_status') to 'signed', writes an
audit log entry with `metadata.type='externally_signed'` capturing
the optional reason, and fires the appropriate berth-rule trigger
(eoi_signed / contract_signed) so downstream automation (berth
status flips, notifications) treats it identically to a Documenso-
signed completion.
Route: POST /api/v1/interests/[id]/mark-externally-signed gated on
interests.edit. Validates docType against the canonical 3-value enum.
UI: <MarkExternallySignedDialog> AlertDialog with optional reason
textarea + per-docType copy. Wired into EmptyContractState and
EmptyReservationState empty-state buttons. The action sits alongside
"Upload draft for signing" and "Upload paper-signed copy" as a third
option for reps whose canonical paper lives elsewhere.
EOI not yet wired into a UI surface — the eoi flow already has a
full upload pipeline. Service supports it for completeness.
Followup: quick brochure/PDF download buttons + per-user reminder
digest schedule still pending in Step 4 backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4 (in progress) — first slice of UX features.
P-4.5: inquiry → client linkage now survives the triage conversion.
- inquiry-inbox.tsx adds `?create=1` to the redirect so the new-client
sheet auto-opens (the existing prefill_* params were already being
written but the form never opened).
- client-list.tsx reads prefill_name / prefill_email / prefill_phone /
prefill_source / prefill_inquiry_id from useSearchParams and passes
them to ClientForm via a typed `prefill` prop.
- ClientForm hydrates the create-flow initial values from the prefill
AND threads `sourceInquiryId` through to the createClient mutation.
- createClientSchema accepts `sourceInquiryId`; the existing service
spread already passes it to drizzle's insert.
Net effect: a website inquiry that gets converted now lands as a
client row with `clients.source_inquiry_id` populated. The conversion
funnel-by-source chart (Step 6) can attribute the win back to the
originating inquiry.
Documents tab N+1: `listInflightWorkflowsAggregatedByEntity` previously
walked direct + every company + every yacht + every related client
sequentially. On a busy client (~25 related entities) this was ~50
sequential round-trips with cumulative latency. Replaced with a single
`Promise.all` over the four lookup groups + nested Promise.all over
the per-entity queries within each group. Same query count, but wall-
clock collapses from "sum of every query" to "max single round-trip"
(typically <100ms now vs >1s before).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Per PRE-DEPLOY-PLAN § 1.3.7. Lays the foundation for admin-configurable
routing of every outbound email category to either the noreply or
sales sender account.
Pieces shipped:
- `src/lib/services/email-routing.ts` — EmailCategory enum (17
categories covering every shipped surface), DEFAULT_CATEGORY_ROUTING
map (auth/notifications/EOI-invite → noreply; brochure/PDF/sales
send-outs → sales), `resolveSenderForCategory()` + a graceful
fallback to noreply when the resolved sender is sales but creds
aren't configured.
- `GET / PATCH /api/v1/admin/email/routing` endpoints — gated on
`admin.manage_settings`. Returns the routing + sales-availability
flag + canonical category list.
- `EmailRoutingCard` — matrix UI dropped into /admin/email below the
sales-email-config card. Per-category dropdown auto-disables the
`sales` option when the port has no sales SMTP creds; explains the
state in an amber callout. Save-on-change with toast + "Reset to
defaults" button.
Setting persisted as `system_settings.email_routing` (JSONB blob).
Followup: opportunistic migration of existing dispatchers (sendEmail,
createSalesTransporter callers) to use `resolveSenderForCategory()` —
the defaults preserve current behavior so this is non-blocking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single coherent commit completing § 1.1 (hot-path correctness) plus
§ 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now
self-consistent across dashboard / kanban / hot deals / PDF reports.
## Active-interest sweep (canonical predicate everywhere)
Routed every "active interest" filter through `activeInterestsWhere`
(commit b966d81 helper). The helper enforces port-scoping + archivedAt
IS NULL + outcome IS NULL — strict definition, won is closed.
Touched sites:
- src/lib/services/reminders.service.ts:digestPort — no longer fires
reminders for won/lost/cancelled deals
- src/lib/services/berths.service.ts:getLatestInterestStageByBerth
- src/lib/services/client-archive-dossier.service.ts (next-in-line
others lookup)
- src/lib/services/client-archive.service.ts (remaining-under-offer
recount before flipping berth back to available)
- src/lib/services/client-restore.service.ts (yacht-usage check)
- src/lib/services/interests.service.ts:listInterestsForBoard +
getInterestStageCounts + the "others on same berth" lookup —
kanban / board now exclude terminal deals
- src/lib/services/report-generators.ts: fetchPipelineData,
fetchRevenueData stage breakdowns, top-N interests
## Pipeline-value currency conversion
`getKpis()` now fetches the port's defaultCurrency from `ports` and
converts each berth's `priceCurrency`→port-default via
`currency.service`. Returns `pipelineValue` + `pipelineValueCurrency`
instead of the lying `pipelineValueUsd`. Missing rates fall through to
raw amount summing (so the tile still shows an approximate number) —
behind a follow-up to surface a "rates incomplete" indicator.
3 consumers updated: KpiCards, PipelineValueTile, ActiveDealsTile.
## Occupancy = sold only
Both the dashboard KPI tile and the revenue-report PDF occupancy data
now count only `berth.status='sold'`. `under_offer` is a hold, not
occupation. The analytics timeline switches from
`berth_reservations`-derived to a cumulative-won-deals derivation via
`interests.outcome='won' AND outcome_at::date <= day` — same source of
truth, historical shape preserved.
## Revenue PDF two-card layout
Added `totalForecast` + `pipelineWeights` to `RevenueData`. Summary
section now renders both:
- "Completed revenue (won)" — money in the bank
- "Forecast revenue (pipeline-weighted)" — expected pipeline value
Pipeline weights resolve from `system_settings.pipeline_weights`
(per-port admin override) and fall back to STAGE_WEIGHTS defaults. PDF
and dashboard forecast tiles reconcile.
## Multi-berth EOI mooring (4.5)
Documenso `Berth Number` form field now carries the formatBerthRange
output for BOTH single- and multi-berth EOIs. Single-berth output is
byte-identical to the legacy primary-only path
(`formatBerthRange(['A1']) === 'A1'`). Multi-berth EOIs now render
the full range ("A1-A3, B5") in the existing field instead of being
silently dropped against a nonexistent `Berth Range` field.
Dropped:
- `'Berth Range'` from the Documenso formValues payload + TS type
- `setBerthRange()` helper from fill-eoi-form.ts (now redundant)
- The "missing Berth Range AcroForm field" warning log
Updated CLAUDE.md to reflect — no Documenso admin template change
needed.
## Tests
- Updated `documenso-payload.test.ts` — new fixture asserts
formatBerthRange output flows into Berth Number; multi-berth case
added.
- Updated `analytics-service.test.ts:computeOccupancyTimeline` —
fixture creates a won interest instead of a reservation.
- Updated `alerts-engine.test.ts:interest.stale` — fixture stage
switched from dead `'in_communication'` to canonical `'qualified'`.
- Updated `report-templates.test.tsx:revenue` — fixture carries
`totalForecast` + `pipelineWeights` to match new RevenueData.
1373/1373 vitest pass. tsc + eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`outcome` is the canonical terminal-state signal. Pre-2026-05-14
`setInterestOutcome` also forced `pipelineStage='completed'` (a value
outside the 7-stage canon) which:
- broke `safeStage()` (silently coerced to 'enquiry' downstream)
- prevented analytics from answering "what stage was the deal at when
it closed?" because every closed deal looked identical
- forced belt-and-suspenders filters everywhere ('outcome=won' AND
'pipeline_stage=completed') that became redundant after migration 0062
Changes:
- `setInterestOutcome` no longer touches pipelineStage. Deal stays at
whatever stage it was on when the outcome was recorded; outcome is
the terminal signal. Audit log + websocket emit now carry
`stageAtOutcome` instead of the stale `oldStage`.
- `clearInterestOutcome` smarter reopen-stage logic: if current stage
is the legacy 'completed' sentinel (pre-existing rows from before
this commit), default to 'qualified'. Otherwise preserve the stage
the deal was at, so reopening drops the rep back where they were.
Explicit data.reopenStage still wins.
- `/api/v1/admin/dashboard-stats` route reworked: per-stage breakdown
now filters `outcome IS NULL` (only active rows count per stage);
`closedTotal` derives from a new `outcome IS NOT NULL` count query;
`completed30d` switches from `pipelineStage='completed' AND updatedAt`
to `outcome IS NOT NULL AND outcomeAt` (avoids long-closed deals
leaking into the window on unrelated edits).
- `berth-interests-tab.tsx` "active" filter switches from
`pipelineStage !== 'completed'` to `!outcome && !archivedAt` — the
legacy check stopped matching post-refactor.
- Socket event type `interest:outcomeSet` renames `oldStage` →
`stageAtOutcome` with a doc-comment explaining the semantics shift.
PIPELINE_STAGES canon is now the only valid pipeline_stage value range
for newly-set outcomes. Legacy rows still carry 'completed' until they
naturally churn through reopen + re-close, at which point they enter
the new convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit log entries accumulate via cursor pagination — the user can
load many pages into the same client-side array. With virtual=true
the table only renders the visible viewport rows (plus overscan), so
a 10k-row session stays at 60fps instead of choking on a full DOM
write per "Load more" click.
The other two BACKLOG candidates (super-admin port switcher, client
export modal preview) aren't present in the current codebase — the
super-admin route group hasn't been built and the export modal is
download-only. Skip until those surfaces exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chevron up/down buttons rewrote a single row's display_order,
which didn't actually swap positions since the neighbouring rows kept
their original orders. Replaced with a proper drag-handle (dnd-kit
sortable, matching the waiting-list-manager pattern) backed by a new
POST /admin/qualification-criteria/reorder endpoint that rewrites
display_order = index for every row in a transaction. The service
rejects partial / extraneous id lists so a stale UI can't silently
drop a criterion. Optimistic local-cache update keeps the row in
position during the round-trip; rollback on error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Payments (deposit / balance / refund records on an interest) used to
share `invoices.record_payment`, which forces a port that doesn't
issue invoices at all to still navigate the invoicing permission
group to grant its sales reps payment-recording rights. Splitting
the resource lets admins gate the two surfaces independently.
The new resource has three actions:
- view — gates the UI affordance (API reads still go through
`interests.view`)
- record — POST / PATCH a payment
- delete — DELETE a payment record
Seed maps updated for all six system roles; existing role rows +
per-user permission overrides are backfilled by migration 0064 so
upgrades don't silently lose access. Two call sites (POST /interests/
[id]/payments, PATCH /payments/[id]) → payments.record; one
(DELETE /payments/[id]) → payments.delete. The PermissionGates on the
payments-section UI swap to the new keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Drop the standalone /settings/profile route + user-profile component;
folding the same fields into user-settings means one place to update
and one menu item. UserMenu loses the Profile dropdown entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related cleanups while QA-testing on iPad:
1. Origin-forwarding bug on /api/auth/sign-in-by-identifier
- The custom identifier-sign-in route forwarded to better-auth's
/sign-in/email handler but did NOT preserve the inbound Origin +
Referer headers. Better-auth's CSRF check then 403'd every login
with MISSING_OR_NULL_ORIGIN — and the UI showed a generic
"Invalid credentials" toast even when the password was right.
- Fix: pass through req.headers.get('origin') and
req.headers.get('referer') when constructing forwardReq.
- Affects: every login attempt from any device (this isn't dev-
only); discovered testing from 192.168.1.17 → app on the same
LAN IP. Production users hit the same path.
2. Dark mode disabled
- Drop the Sun/Moon toggle from user-menu, the documentElement
class flip, darkMode from ui-store, darkMode from the user-
preferences validator. Hardcode sonner theme="light" (was
reading next-themes which isn't actually wired anywhere else).
- The 10 stray `dark:` Tailwind utilities are left alone — they're
inactive without the `dark` class on <html> so they don't ship
anything that renders, just dead CSS.
3. Center dialog animation
- Dialog content was sliding in from the top-right corner (slide-
in-from-left-1/2 + slide-in-from-top-[48%]) which felt jarring.
Drop the slide directions, keep just zoom-in-95 + the base
fade-in/out so dialogs appear in place with a subtle scale-up.
4. Login placeholder
- Removed the "you@example.com or yourname" placeholder so the
field reads as a clean empty input below the "Email or username"
label.
No tests added (the 1340 vitest suite passes); changes are surface-
level UI tweaks + the origin-header fix where a unit-test of the
custom route would mostly be testing better-auth's behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two of the six Phase 6 polish items shipped in one commit because they
share the data + plumbing path (per-doc message uses the signing-
progress UI's existing layout).
1) Signing-progress activity badges
- Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all
populated by Phase 1+2 webhook handlers) per signer in the
existing progress widget. Each badge renders as
"Invited 2 hours ago / Opened yesterday / Reminded 3 days ago"
via Intl.RelativeTimeFormat.
- Resend button: was silent on success/failure; now uses
useMutation + toast so the rep sees whether the reminder fired
or fell into a cadence cooldown. Honours the existing
sendReminderIfAllowed return shape (`{sent, reason}`).
- Title-tooltips on each badge show the exact ISO timestamp.
2) Per-document custom invitation message
- New `documents.invitation_message` column (migration 0060;
applied via psql per the dev-flow note in CLAUDE.md).
- Textarea in UploadForSigningDialog step 2 (recipient configurator),
1000-char cap, placeholder text shows the expected tone.
- custom-document-upload.service accepts `invitationMessage`,
trims + stores on the documents row.
- sendCascadingInviteForNextSigner now reads
doc.invitationMessage and passes as customMessage so every
cascaded recipient (developer / approver / witness) sees the
same note — not just the first signer.
- send-invitation route (manual resend path) reads the same
column → customMessage so manual reminders match.
- The email template's existing customMessage rendering does
the XSS escape; no other plumbing needed.
Phase 6 items still deferred (each ~2-3h, mostly independent):
- Auto-send delay (`eoi_send_delay_minutes` setting + scheduled
BullMQ job — needs a scheduler hook).
- Document expiration (`documents.expires_at` + Documenso
`expiresAt` passthrough — needs Documenso v2 endpoint shape
verification).
- Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs
an admin page with Replay button).
Tests: 1340 → 1350 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 lands the visual half of the Documenso build — the upload-
for-signing dialog the Contract + Reservation tabs hand off to. Four
files of new code; the existing tab placeholders point at it.
Files added:
- lib/services/document-field-detector.ts — Phase 4c auto-detect
scanner. Uses pdfjs-dist to extract per-page text + positions, then
matches anchor patterns (Signature, Date, Initials, Email, Name,
underscore-runs) and produces percent-coordinate DetectedField
rows. Recipient label inference walks ±100pt of each match for
Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the
PDF is image-only; UI falls back to manual placement without an
error. 6 unit tests pin the matching + coordinate math.
- app/api/v1/documents/auto-detect-fields/route.ts — multipart POST
endpoint that delegates to detectFields(). Permission-gated by
documents.send_for_signing.
- app/api/v1/documents/signing-defaults/route.ts — GET endpoint that
surfaces just the per-port developer + approver display name/email
+ sendMode flag. No secrets exposed; lets the dialog prefill the
recipient configurator without an admin-scoped settings read.
- components/documents/upload-for-signing-dialog.tsx — the Phase 4
UI. Three-step state machine inside a single Dialog:
1. select-file: drop/click PDF picker + title input
2. configure-recipients: client + developer + approver prefilled,
rep can add/remove/reorder + change role (SIGNER/APPROVER/CC)
3. place-fields: react-pdf renders the source PDF; auto-detect
runs in the background on file load and seeds the overlay;
rep places, drags, resizes, deletes, reassigns fields via the
palette + side panel. Native DOM drag (no dnd-kit dependency
added — the coordinate math stays obvious).
Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3
service); success toast reflects port sendMode (auto fires the
invite immediately, manual leaves it for the rep).
Files modified:
- components/interests/interest-contract-tab.tsx + reservation-tab.tsx:
swap the ComingSoonDialog placeholder for the real
UploadForSigningDialog with the matching documentType prop. The
placeholder ComingSoonDialog helper is deleted from both.
- scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp
staged-only tsconfig so side-effect CSS imports (e.g.
react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing
declare-module shim. Without this fix the staged compile reports
TS2882 even though the full tsc --noEmit pass passes.
Design choices noted in code comments:
- Native drag over dnd-kit: the field overlay's percent-based
coordinate math is short enough that adding a drag library adds
complexity without saving lines.
- Auto-detect on file-load (not on demand): runs immediately so the
rep doesn't have to click a second button — empty result drops
back to manual placement silently.
- Per-recipient color swatches indexed by signingOrder.
- Recipient seed via useMemo + user-event handler instead of
useEffect → setRecipients (Wave 3 set-state-in-effect avoidance).
Server-side, Phase 3 plumbing handles the rest: tenant guard, magic-
byte verify, Documenso round-trip with per-port v1/v2 routing,
recipient signingToken capture for Phase 2 webhook cascade, auto-
send when port.sendMode === 'auto'.
Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean.
Deferred polish (Phase 6):
- Per-field metadata side panel for DROPDOWN/RADIO option lists
- Pinch-zoom + zoom-out controls on the field-placement canvas
- Recipient drag-reorder via dnd-kit
- Required toggle per field
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the
tractable Critical/High items from each:
error-ux-auditor (5 items)
- C2: 17 toast.error(err.message) sites swept to toastError(err, …) so
every user-visible failure carries a copy-paste Reference ID
- C3: apiFetch synthesizes a client-side correlation id when a 5xx
comes back with a non-JSON body (reverse-proxy HTML pages); message
becomes "The server is unreachable. Please try again." with code
UPSTREAM_UNREACHABLE
- C4: checkRateLimit fails OPEN when Redis is unavailable so an outage
no longer 500s login + portal sign-in; logged at warn so monitoring
catches it
- H2: StorageTimeoutError (name='TimeoutError') replaces the plain
Error throw in s3.ts withTimeout — error-classifier hints fire now
- H5: errorResponse() adopted across /api/storage/[token],
/api/public/website-inquiries, and the Documenso webhook body (drops
the "Invalid secret" reconnaissance string)
outbound-webhook-auditor (5 items)
- C1: signature is now HMAC(secret, `${ts}.${body}`) with the
timestamp surfaced as X-Webhook-Timestamp so receivers can reject
replays outside a freshness window
- C3: dead-letter with reason missing_signing_secret when secret is
null (defence-in-depth against DB tampering / future migration
mistakes)
- H2: webhooks queue bumped to maxAttempts=8 with 30 s base
exponential backoff so a 30 s receiver blip during a deploy no
longer dead-letters every in-flight event; per-queue
backoffDelayMs added to QUEUE_CONFIGS
- M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192
- M2: dispatch-time https:// assertion before fetch, so a bad DB edit
can't slip plaintext through
storage-pathing-auditor (2 items)
- H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…`
with portSlug threaded into backend.presignUpload — engages the
filesystem-proxy port-binding `p` token verifier
- H2: presignDownloadUrl auto-derives portSlug from the key's first
segment when callers don't pass it, so all 8 download sites engage
the `p`-token guard without per-site plumbing
search-auditor (1 item)
- H3: removed dead void wantEmail; void wantPhone; pair plus the
unused looksLikeEmail helper — the bucket-reorder it was scaffolded
for was never wired
maintainability-auditor (1 item)
- M2: swept seven abandoned `void <symbol>` markers and their dead
imports across clients/bulk, interests/bulk, admin/email-templates,
admin/website-submissions, alert-rules, and notes.service
Deferred to future work (substantial refactors, schema migrations, or
multi-file UI work):
- error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage,
ErrorBanner component, /api/ready route, worker DLQ admin surface)
- maintainability C1-C4 (documents/search/notes service splits,
interest-tabs split — multi-hour refactors)
- currency C1-H5 (mixed-currency dashboard aggregation, FX history
table, rounding policy) — wait for second non-USD port
- outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU
with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy)
- storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type
binding)
Tests: 1315/1315 vitest ✅ ; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**mobile-pwa-auditor H4 — mobile shell uses min-h-screen**
`min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE
viewport height (URL bar collapsed). On first paint the page renders
~75–100px taller than visible, and reps see a blank strip past the
bottom tab bar until the URL bar collapses on first scroll. Swap
`min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner
layout already does this correctly.
**multi-port-auditor C1 — port-switcher race / cross-port bleed**
`apiFetch` previously preferred Zustand for the X-Port-Id header and
only consulted the URL slug as a fallback. Zustand lags by one render
behind `PortProvider`'s reconcile effect; clicking from /port-A to
/port-B fired the first round of queries with X-Port-Id = port-A
while the page chrome rendered port-B → silent cross-port data bleed
in the UI.
Make the URL slug authoritative: read it first via
`window.location.pathname` + `resolvePortIdFromSlug`, fall back to
Zustand only on global routes (/dashboard) without a port slug.
**multi-port-auditor C3 — defaultPortId silently stripped**
`withAuth` reads `preferences.defaultPortId` as the X-Port-Id
fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS
allow-list silently dropped the key on every write. The fallback was
therefore dead — super-admins always landed alphabetically-first.
Add `defaultPortId: z.string().uuid().optional()` to the strict
schema and include it in ALLOWED_PREF_KEYS so super-admins can
persist their last-picked port.
Tests 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**asset-auditor C1+C2+H1+H3 — image normalization**
Add `src/lib/services/image-normalize.ts` and wire it into
`uploadFile()` so every accepted image is re-encoded via sharp before
hitting storage:
- Strips EXIF (GPS coords, device serial, photographer) so uploaded
photos don't leak per-pixel PII to anyone with a download URL (C1).
- Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})`
so a 30000×30000 palette PNG can't decompression-bomb a downstream
sharp decode (C2).
- Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat
the prefix-only magic-byte check) (H1).
- Freezes animated GIFs to first frame (H3).
Avatar route already funnels through uploadFile so it's covered by
the single change.
**asset-auditor M2 — sanitizeFilename strips RTL/zero-width**
Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069)
+ zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`.
Closes the classic Windows-icon-spoof vector
(`invoice_fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing
collision spoofs.
**datetime-auditor C1 — reminder dueAt drift on every save**
The `<input type="datetime-local">` round-trip in reminder-form.tsx
used `iso.slice(0,16)` (load) and `new Date(value).toISOString()`
(submit). The slice drops the `Z` so a UTC instant is mis-interpreted
as local on load, then converted back to UTC on save — every save
of an existing Warsaw reminder drifted backwards by 2h (CEST). After
two saves the reminder appears at 06:00 instead of 10:00.
Add `toLocalDatetimeLocal(d: Date)` helper that builds the local
YYYY-MM-DDTHH:MM string from getter methods so the round-trip is
TZ-safe. snooze-dialog already did this correctly; the contact-log
dialog also uses the correct localIsoString pattern.
**datetime-auditor C2 — BullMQ cron in UTC, not port-local**
`upsertJobScheduler` defaulted `tz` to UTC. Patterns like
`0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter
/ 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`.
Sub-hourly / hourly patterns are TZ-invariant and stay UTC.
**datetime-auditor C3 — report-scheduler never advanced next_run_at**
The minutely scheduler selected `nextRunAt <= now()` and enqueued
generate-report — but never bumped nextRunAt. For weekly/monthly
reports this meant the job re-fired every single minute until a
human zeroed the row out, flooding recipients with dupes.
Now uses `cron-parser` (added as a dep) to compute the next fire
from `report.schedule` and UPDATEs the row BEFORE the enqueue.
Malformed cron expressions disable the row instead of re-attempting
every minute.
Tests 1315/1315. Migration 0058 applied via psql.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Address the CRITICAL + high-leverage HIGH items from the types-auditor:
**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.
**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.
**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.
**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each
document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the CRITICAL and high-leverage HIGH items from the
onboarding-auditor report:
**C1 — checklist auto-checks were reading the wrong setting keys**
A port that had actually been configured still showed three steps as
incomplete, permanently capping the checklist at < 70 %.
- email step: `sales_email_smtp_host` → `smtp_host_override` (the key
the email admin page actually persists).
- documenso step: `documenso_api_url` → compound gate
`documenso_api_url_override` + `documenso_developer_email` +
`documenso_approver_email` + `documenso_eoi_template_id`. All four
are required for `buildDocumensoPayload` not to error out; checking
only the URL falsely greenlit the step until a rep tried to send an
EOI and Documenso 404'd.
- settings step: `recommender_top_n_default` → `heat_weight_recency`.
The defaults are layered (port > global > built-in), so a port using
the built-ins never writes the `top_n_default` row — old key was an
unreachable green. heat_weight_recency genuinely means "admin tuned
the recommender".
**C2 — forms step href was broken**
`STEPS[8].href = '../'` resolved through the Link template to the
dashboard, not `/admin/forms`. Fixed to `'forms'`.
**C3 — EOI signer-identity gate**
Folded into the new compound-gate logic on the documenso step
(see C1). Now matches what the EOI pipeline actually requires before
it can send.
**C4 — ensureSystemRoots failure mode poisoned port creation**
`ports.service.createPort` awaited `ensureSystemRoots` after the port
row had committed, so a throw bubbled out as a 500 even though the
inline comment said "non-fatal if this throws". Wrap in try/catch +
logger.warn — the row stays live, the next admin action self-heals
via `ensureEntityFolder`, and the operator doesn't retry into a 409.
**H5 — berth-list empty-state copy misleads fresh ports**
"Berths are imported from external sources. Adjust your filters..."
implied data existed but was hidden. Branch on whether any filter is
active: with none, suggest running `import-berths-from-nocodb.ts`;
with filters, the original "adjust filters" message.
**M4 — admin-sections-browser description was wrong**
"Setup checklist for fresh ports (read-only references)" implied the
page was read-only when it has working manual-completion checkboxes
and discouraged clicking in. Reworded.
Additionally, the OnboardingStep type gains an optional
`autoCheckSettingKeysAll` field for compound gates (used by the
documenso step), and the auto-detected hint shows all keys when the
gate is compound.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the highest-impact items from the copy-auditor's CRITICAL +
HIGH + MEDIUM bands:
**C2 portal raw-status leak**
- Drop the staff-only `leadCategory` chip from the portal interests
page entirely. Privacy + optics: clients should never see "hot lead"
in their own portal. `eoiStatus` was already wrapped in
`portalSigningLabel`; only the categorical chip remained.
**C3 signing-status label drift**
- Add `src/lib/labels/document-status.ts` as the single source of
truth for the {draft, sent, partially_signed, completed, expired,
cancelled} lifecycle: labels (CRM + portal variants), StatusPill
variant, and the "active / in-flight" set.
- Wire it into interest-eoi-tab, interest-contract-tab,
interest-reservation-tab — they previously redefined identical
STATUS_LABELS / ACTIVE_STATUSES blocks per-file.
**H1 + M3 verbiage codemod**
- `Save Changes` → `Save changes` (sentence case, matches the
surrounding admin/CRM pattern).
- `Saving...` (ASCII three dots) → `Saving…` (Unicode ellipsis).
Matches the project's UTF-8-elsewhere convention and reads
correctly via screen-readers.
**M1 envelope jargon → signing request**
- smart-archive-dialog: "Leave envelope pending" → "Leave signing
request pending"; "Void the signing envelope" → "Cancel the signing
request"; section header updated to match.
- document-detail: "voids the signing envelope" → "cancels the signing
request".
- bulk-archive-wizard: "leave invoices/signing envelopes alone" →
"leave invoices/signing requests alone".
- Documenso admin page intentionally keeps `envelope` (dev/integration
vocabulary).
**M5 Hot Lead casing**
- Normalize `Hot Lead` / `General Interest` / `Specific Qualified` to
sentence case in `constants.ts` LABEL_OVERRIDES and all per-file
lead-category maps so the CRM trend (sentence case) is consistent.
**C1 surface-level rename**
- "Linked prospect (optional)" → "Linked interest (optional)" on the
berth status-change dialog.
- "Deal Documents" tab → "Interest Documents" (URL/route kept as
`/deal-documents` to avoid breaking deep links; rename deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five DataTable consumers were rendering as horizontally-scrolling
desktop tables on mobile because they had no cardRender prop. Now they
collapse to a vertical card list below the lg: breakpoint with the
same actions inline:
- admin/tags/tag-list
- admin/roles/role-list
- admin/ports/port-list (also: Active/Inactive badge -> StatusPill)
- admin/document-templates/template-list (also: Active/Inactive badge
-> StatusPill)
- admin/custom-fields/custom-fields-manager
All five now share the user-list / berth-list pattern: row-card with
title, secondary meta, and trailing action buttons; same TanStack
table instance powers both the desktop table and the mobile cards.
Closes ui/ux H2 + extends M2 (status-pill coverage).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build a shared <TemplateTokenPicker> that renders the canonical
MERGE_FIELDS catalog grouped by scope, plus a dynamically-fetched
"Custom (port-specific)" group surfaced from /api/v1/admin/custom-fields.
The custom group is filtered to entity types the resolver actually
expands at send time (client/interest/berth - see
mergeCustomFieldValues in document-sends.service).
Wire it into both consumers:
- admin/document-templates/template-form.tsx (replaces TEMPLATE_VARIABLES
list which had drifted from the canonical catalog)
- admin/sales-email-config-card.tsx (replaces flat alphabetical dump)
Closes custom-fields §B "UI surfacing of {{custom.…}} tokens".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extend StatusPill with berth (available/under_offer/sold) and user
(enabled/disabled) variants so every "this thing is in state X" pill
shares one primitive and palette.
- Swap berth-card, berth-detail-header, berth-columns from ad-hoc
bg-green-100 / bg-yellow-100 / bg-red-100 Tailwind tuples to
<StatusPill status="...">.
- Swap UserList Active/Disabled <Badge> and user-card Inactive pill to
StatusPill; Super-Admin chip kept as a domain-specific accent (violet).
Closes ui/ux M1+M2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Replaces the `if (open) { setStage(...); setCode(''); ... }` reset
useEffect with a key-based remount of the dialog body. The body now
mounts fresh each time the dialog opens; useState initialisers
run naturally instead of being chased by an effect.
Pattern (apply to remaining dialogs in the same shape):
```tsx
export function MyDialog(props) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
{props.open && <MyDialogBody key={props.id} {...props} />}
</DialogContent>
</Dialog>
);
}
```
Applied to:
- hard-delete-dialog (keyed on clientId)
- bulk-hard-delete-dialog (keyed on joined clientIds)
set-state-in-effect: 43 → 41.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
set-state-in-effect: 44 → 43.
Eight admin list/load sites migrated total this session; the
remaining ~43 hits are predominantly the dialog/form open→reset
pattern (intentional setState-in-effect when a dialog opens to
populate fields from props). Cleanest fix is key-based remount
of the dialog body; tracked in BACKLOG as a focused refactor pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the useState + useEffect + apiFetch pattern with TanStack
Query in six admin list pages — same pattern, mechanical refactor:
- admin/tags/tag-list
- admin/ports/port-list
- admin/roles/role-list
- admin/users/user-list
- admin/document-templates/template-list
- admin/webhooks/page
- dashboard/timezone-drift-banner (also: detected-tz reads via
useSyncExternalStore so render stays pure)
Side benefits: list refetches now share a query cache across tabs
(via @tanstack/query-broadcast-client-experimental that was wired
up earlier this branch), so when admin A edits a role in one tab,
admin B's tab sees the updated row without a manual reload.
set-state-in-effect warnings: 51 → 45.
Verified: tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installs all five Tier 2 polish deps the audit flagged. Each integrates
where it adds concrete value today:
- **embla-carousel-react** — shadcn-style `<Carousel>` primitive in
`src/components/ui/carousel.tsx`. Available for future berth/yacht
photo galleries; no current call site beyond the primitive.
- **yet-another-react-lightbox** — wired into the image branch of
`file-preview-dialog.tsx`. Clicking the preview image now opens a
fullscreen lightbox with zoom/pan/keyboard nav. Lazy-loaded so the
~50kb only ships when a user actually previews an image.
- **@use-gesture/react** — `usePinch` on the PdfViewer's content
pane for native pinch-zoom on tablets/phones. Clamped to the
same [50%, 300%] range as the +/- buttons; desktop wheel still
scrolls.
- **react-virtuoso** — installed but NOT wired. Inbox is naturally
bounded by recent-notifications filter at ~10-20 items; ScrollArea
handles it fine. Reserve for actual scale issues (admin audit log
archive, etc.).
- **motion** — installed but NOT wired. Pipeline kanban uses
dnd-kit's own transforms and conflicts with motion's layout
animation. @formkit/auto-animate already handles list-mutation
animations elsewhere. Available for opportunistic adoption when
a polish surface emerges that the existing libraries don't cover.
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Replaces the `<iframe src={presignedUrl}>` preview path which
delegated rendering to the browser's built-in PDF viewer. The iframe
worked on desktop but failed on mobile (older Android Chrome
refuses inline PDFs; iOS Safari opens a new tab).
`<PdfViewer>` renders via pdfjs-dist + react-pdf so the experience
is identical across all browsers + form factors. Adds page nav,
zoom controls, and per-page accessibility labels.
Lazy-loaded via next/dynamic with ssr:false — pdfjs is ~150kb gzip,
no route ships it unless a PDF is actually previewed.
pdfjs worker + CMaps + fonts loaded from unpkg CDN pinned to the
matched pdfjs-dist version (first-load cost paid once per user, no
bundle-size impact on routes that never preview a PDF).
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>