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>
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>
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 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>
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>
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>
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>
Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme
serializer and every code path that depended on it. TipTap document
templates remain as Documenso-template seed bodies; the CRM no longer
renders them to PDF in-app.
Deleted:
src/lib/pdf/tiptap-to-pdfme.ts (571 LOC)
src/lib/pdf/templates/eoi-standard-inapp.ts (337 LOC)
src/app/api/v1/admin/templates/preview/route.ts
src/app/api/v1/document-templates/[id]/generate/route.ts
src/app/api/v1/document-templates/[id]/generate-and-send/route.ts
src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC)
src/lib/services/document-templates.ts:generateAndSend (~40 LOC)
src/lib/validators/document-templates.ts:generateAndSendSchema
src/lib/validators/document-templates.ts:previewAdminTemplateSchema
tests/unit/tiptap-serializer.test.ts (old bridge tests)
Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC):
- validateTipTapDocument() — still used to reject unsupported nodes
on save in the admin template editor
- TEMPLATE_VARIABLES — drives the merge-token picker in the
admin template form + preview UI
generateAndSign() now throws a clear ValidationError when a non-EOI
template tries the in-app pathway. Use a Documenso template, or wait
for the deferred AcroForm-fill admin-upload feature.
seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub
bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never
actually rendered (in-app EOI is pdf-lib AcroForm fill on the source
PDF — generateEoiPdfFromTemplate, unchanged).
After this commit, pdfme has zero callers left. Commit 14 drops the
deps and the generate.ts shim.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.
Server pipeline (src/lib/services/logo.service.ts):
- magic-byte format check via sharp metadata
- rejects animated/multi-frame inputs
- SVGs sanitized via svgo preset-default + post-pass regex check
(rejects <script>, on*=, javascript:, external href, <foreignObject>),
then rasterized to PNG at 300 DPI
- HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
- optional crop coords applied server-side (bounds-checked first)
- auto-trim near-white borders
- resize so longest edge <= 1200px, sRGB, palette-PNG
- rejects undersized output (< 200px any side) or > 1MB
- atomic system_settings upsert; soft-archives prior file row + storage object
API:
GET /api/v1/admin/branding/logo current logo metadata
POST /api/v1/admin/branding/logo multipart upload + crop
DELETE /api/v1/admin/branding/logo clear; future PDFs fall back
to port-name text header
GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
with the current logo so
admins can spot-check
letterboxing in real shell
UI:
src/components/admin/branding/pdf-logo-uploader.tsx
- react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
- file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
- dark-band preview swatch shows how the logo lands in the header
- post-upload warnings panel surfaces every server-side normalization
(resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
- "Test with sample PDF" button streams a real PDF for spot-check
- "Remove" tears down the file + storage object + setting
Wired into the existing /admin/branding settings page beneath the
Identity and Email-branding cards.
Audit:
Two new AuditAction enum values added: branding.logo.uploaded and
branding.logo.archived. Captured per upload + per archived prior logo.
Tests:
tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
undersized rejection, empty/oversized rejection, non-image rejection,
out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
with embedded script rejection, SVG with external href rejection,
JPEG-with-no-alpha warning collection.
1308/1308 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier 0.2: src/lib/env.ts now refuses boot when NODE_ENV=production AND
EMAIL_REDIRECT_TO is set. Sendmail logs the rewrite at warn (was debug)
so dev/staging windows where someone forgets to unset are immediately
visible.
Tier 0.6: backup_jobs.storage_path added to TABLES_WITH_STORAGE_KEYS in
src/lib/storage/migrate.ts. Flipping the storage backend used to
silently orphan every pg_dump artefact — last-resort recovery path is
now actually portable.
Tier 1.7: createAuditLog now runs metadata through maskSensitiveFields
(was only applied to old/new value diffs). Portal-auth, crm-invite,
hard-delete and email-accounts services were writing raw emails into
this column unbounded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admin search now matches against per-card keyword lists so typing
"client portal", "smtp", "tier ladder" lands on the System Settings card
(which hosts those flags). The same keyword list extends the topbar
global search (NAV_CATALOG) so any setting key resolves from the cmd-K
input — settings results sort to the bottom of the dropdown beneath
entity hits.
User management:
- Third action button (Power/PowerOff) enables/disables sign-in from the
desktop list; mobile card dropdown gains the same item. Backed by the
existing userProfiles.isActive flag — withAuth already refuses
disabled sessions with 403.
- UserForm collects first + last name (canonical) alongside displayName,
with admin email-change behind a confirmation modal. On confirm we
send the OLD address an automated "your admin changed your sign-in
email" notice (new template at admin-email-change.ts) and rewrite
the Better Auth user row.
- Phone field swaps the bare tel input for the shared PhoneInput
(country combobox + AsYouType formatting + E.164 storage).
- "Manage permissions" link points to /admin/roles?focusUser=… as
a stepping stone for the future fine-tuned-permissions UI.
Role names normalize through a new ROLE_LABELS + formatRole() helper
in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the
prettifyRoleName in role-list; user-list and user-card now render
"Sales Agent" instead of "sales_agent". Custom roles pass through
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greeting
- The "Good morning / afternoon / evening, Matt" line now derives from the
browser's local time, computed inside a useEffect so the rendered HTML
can't lock to the server's clock during hydration. Until the effect
fires, the header reads "Welcome" — a neutral phrase that's correct at
every hour and never produces a hydration warning. The phrase re-evaluates
hourly so a rep leaving the dashboard open across a boundary (5am, noon,
6pm) doesn't keep stale text on screen.
Timezone-drift banner
- New <TimezoneDriftBanner> on the dashboard surfaces when the browser's
resolved timezone (Intl.DateTimeFormat().resolvedOptions().timeZone, which
follows the OS — and the OS usually follows physical location) doesn't
match the user's stored CRM preference. The rep gets a one-tap "Update to
Tokyo" button and a dismiss × that's sticky per browser via localStorage.
- Why a banner rather than auto-update: the stored timezone drives reminder
firing time, daily-digest delivery, and due-date rendering. Silently
pinning it to a transient travel location would shift their reminder
schedule underfoot. The banner gives them control.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
New documents_show_expired_tab system setting (default true). Public
read via GET /api/v1/documents/feature-flags (gated on documents.view
so reps can read it without holding manage_settings). When off, the
Expired tab is hidden from the documents hub — useful when expired
EOIs are noise that distracts reps from active deals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors files.manage_folders. Gates create / rename / move / delete
of document folders, plus moving documents between folders. Reps with
documents.edit but not manage_folders can rename docs in place but
can't reorganise the tree. Admin + sales_manager get the perm by
default; sales_rep + viewer don't.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New /admin/vocabularies route + VocabulariesManager component. Catalog
at src/lib/vocabularies.ts defines 11 vocabularies grouped into
Interests / Berths / Expenses / Documents domains, each shipping with
the canonical defaults from src/lib/constants.ts (interest temps,
status-change reasons, tenure types, expense categories, document
types, plus the 5 berth-spec dropdowns).
Editor supports add / remove / reorder / inline-rename / reset-to-
defaults; only dirty cards save. Uses the existing
/api/v1/admin/settings PUT endpoint (already gated on
admin.manage_settings) so storage piggybacks on system_settings
(port_id, key) per the established pattern.
Reps need read access without holding manage_settings — added a
public-read /api/v1/vocabularies endpoint plus useVocabulary() hook
(5-minute staleTime). The admin manager invalidates the vocabularies
query on save so consumers (status-change dialog, expense form, etc.)
pick up new lists immediately.
Adds a Vocabularies card to the admin landing page.
Follow-up sweep owed: actual consumers (interest-card temperature pill,
berth-tabs select dropdowns, expense form category list, etc.) still
read from the hardcoded constants.ts arrays. Wire them through
useVocabulary in a separate pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wave through the remaining audit-final-deferred items that aren't blocked
on the back-burnered Documenso work.
Multi-tenant isolation:
- Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim;
verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a
buggy issuer in some future code path that mixes port scopes — every
storage key generated by generateStorageKey() already prefixes the
slug. document-sends opts in for 24h emailed download links; other
callers continue working unchanged via the optional field.
DB schema reconciliation:
- Migration 0047 rebuilds system_settings unique index with NULLS NOT
DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are
uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate
(storage_backend, NULL) rows that had accumulated from race-prone
delete-then-insert patterns in ocr-config / settings / residential-
stages / ai-budget services. All four services converted to true
onConflictDoUpdate upserts so the race window is closed.
API uniformity:
- Response shape standardization: 16 routes converted from
`{ success: true }` to 204 No Content. CLAUDE.md documents the
convention (`{ data: <T> }` for content, 204 for empty mutations,
portal-auth retains `{ success: true }` for the frontend's auth chain).
- req.json() → parseBody() migration across 9 admin/CRM routes
(custom-fields, expenses/export ×3, currency convert,
search/recently-viewed, admin/duplicates, berths/pdf-{upload-url,
versions, parse-results}). Uniform 400 error shapes for
ZodError-flagged bodies.
Custom-fields merge tokens (shipped end-to-end):
- merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the
`{{custom.<fieldName>}}` shape.
- document-templates validator accepts the dynamic shape alongside
the static catalog tokens.
- document-sends.service mergeCustomFieldValues resolver fetches
per-port custom_field_definitions for client/interest/berth contexts
and substitutes stored values keyed by `{{custom.fieldName}}`.
- custom-fields-manager amber banner updated to reflect that merge
tokens now expand (search index + entity-diff remain documented
design limitations).
/api/v1/files cross-entity filtering:
- Validator + listFiles + uploadFile accept companyId AND yachtId
alongside clientId. file-upload-zone propagates both.
- New CompanyFilesTab component mirrors ClientFilesTab; restored as a
visible Documents tab in company-tabs.tsx (was a hidden stub).
Inline TODOs:
- Reviewed remaining two TODOs (per-user reminder schedule, import
worker handlers). Both are placeholders for future feature surfaces,
not bugs — per-port digest works for every customer; nothing
currently enqueues import jobs (verified). Annotated in BACKLOG.
BACKLOG.md updated to reflect what landed and what's still pending
(Documenso-related items still bundled with the back-burnered phases).
Tests: 1185/1185 vitest, tsc clean.
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.
The inquiry inbox was read-only — every inquiry stayed there forever
with no way to mark "I handled this" or "this is spam." Now:
- Migration 0045 adds triage_state ('open' | 'assigned' | 'converted'
| 'dismissed' default 'open') + triaged_at + triaged_by columns to
website_submissions, plus a (port_id, triage_state, received_at)
index for the inbox query.
- New PATCH /api/v1/admin/website-submissions/[id]/triage flips the
state with audit log entry.
- List endpoint takes a `state` filter (default 'inbox' = open +
assigned, hides converted + dismissed).
- UI: per-row Convert / Assign / Dismiss / Reopen actions; second
filter row for state; triage badge per card. "Convert" jumps to
/clients with prefill_name / prefill_email / prefill_phone /
prefill_source / prefill_inquiry_id query params + marks the row
converted (the client-create form will read those — same prefill
pattern other entry points use).
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R2-M11: mobile More-sheet missing 4 destinations. Added Reservations,
Notifications, Residential, Website analytics — anyone using mobile
chrome to triage on the go can now reach those domains.
R2-M12: portal had no profile / change-password surface. New
/portal/profile page with read-only contact details + a
ChangePasswordForm component, backed by a new POST
/api/portal/auth/change-password endpoint and
changePortalPassword() service function. Audits both ok and failure
cases at warning severity. Added Profile to PortalNav.
R2-M1: portal dashboard "My Memberships" tile had no href and no
/portal/memberships route — dead-end on tap. Hidden until a
memberships page ships; the count remains in the underlying data.
R2-M7: InlineStagePicker never sent override:true so users with
interests.override_stage couldn't actually use the perm from the
inline chip — they had to fall back to the modal picker. Now the
picker auto-detects when a transition isn't legal AND the user has
override_stage, sets override:true, and supplies a default reason.
Frontend M2: hard-delete-dialog confirm stage now has a "Send a new
code" link in case the original expired before the user could enter
it. Avoids forcing a full Cancel + reopen.
Frontend M4: audit-log-list date-range validation. From > To now
shows an inline error and skips the request rather than firing an
empty-range query that surfaces "no entries found".
R2-M6: external-EOI route now requires interests.edit AND
documents.upload_signed (defense-in-depth) — uploading a signed EOI
mutates interest state, so the upload-signed perm alone shouldn't
let a custom role flip an interest.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
R2-H6: webhook-delivery-log Replay column was rendered for any user
who could load the page; the route gates on admin.manage_webhooks.
Now the entire Replay column is hidden when the user lacks the perm.
R2-H7: Bulk Archive action was visible to sales_agent + viewer
(clients.delete:false). Now wrapped in canBulkArchive (clients.delete).
R2-H8: Bulk Add tag / Remove tag were visible to viewer (clients.edit:
false). Now wrapped in canBulkTag (clients.edit).
R2-H9: bulk-hard-delete silently dropped clients that became
unarchived between preflight and execute. The service now returns
{deletedCount, skipped[]} and the dialog stays open on partial
success showing the per-row reason table — operators can see exactly
which IDs were skipped and why.
R2-M9: bulk-archive-preflight catch block was leaking dossier-loader
error messages, letting an attacker enumerate "not found" vs "exists
in another port". Replaced with a generic 'Could not load dossier —
client may have been removed' blocker.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit log was previously silent on authentication and on background
work. This wires:
- Login (success + failed) and logout via a wrapper around better-auth's
[...all] handler. Failed logins are severity 'warning' and carry the
attempted email so brute-force attempts surface in the inspector.
- New severity (info|warning|error|critical) and source (user|auth|
system|webhook|cron|job) columns on audit_logs. permission_denied
defaults to 'warning', hard_delete to 'critical'.
- Webhook delivery success/failure/DLQ/retry now write audit rows
alongside the webhook_deliveries detail table.
- IP address is now visible as a column in the inspector (was already
captured at the helper level).
- Audit UI: severity badges per row, severity + source dropdowns, IP
column, expanded action filter covering hard-delete, webhook events,
job/cron events.
Migration 0044 adds the two columns + their port-scoped indexes.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Outbound webhook deliveries already retry with backoff, dead-letter
after maxAttempts, and notify super admins. This adds operator-level
replay: a per-row button on the deliveries log spawns a fresh pending
delivery + queues a new BullMQ job. The original failed row stays
intact so the response body remains for audit; the replay payload
carries retried_from/retried_at markers so receivers can deduplicate.
Inbound idempotency was already handled via the documentEvents
signatureHash unique index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Permanent client deletion is now reachable from:
- archived single-client detail page (icon button, gated by new
admin.permanently_delete_clients perm)
- archived clients list bulk action
Both flows are 2-stage: request a 4-digit code (sent to operator's
account email, 10min Redis TTL), then enter both code AND a typed
confirmation (client name single, "DELETE N CLIENTS" bulk). Cascade
strategy preserves audit trails: signed documents, email threads,
files and reminders are detached but retained; addresses, contacts,
notes, portal user, GDPR records, interests and reservations are
deleted via FK cascade or explicit tx delete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manual stage override
Sales reps need to skip canTransitionStage rules when the data was
entered out of order — e.g. recording a contract_signed deal whose
earlier stages were never tracked in the system.
- New permission flag interests.override_stage in RolePermissions.
Plumbed through the schema TS type, the role-editor UI, the seed
file's pre-built roles (super_admin/director/sales_manager get it,
sales_agent + viewer don't), and the test factories.
- changeStageSchema gains an optional `override` boolean and the
service checks it before evaluating canTransitionStage. When
override=true the reason field becomes required (min 5 chars) and
is recorded in the audit log.
- The route handler gates `override` on the new permission so a
sales_agent without it can't pass override=true and bypass.
- InterestStagePicker auto-detects when the requested transition is
blocked by the table and switches into "override mode" — shows an
amber warning, requires the reason, button label flips to
"Override stage". When the operator lacks the permission, the
warning is red and the button is disabled.
Residential Partner role
Per the smart-archive scoping conversation: external partners who
handle residential inquiries shouldn't see marina clients, yachts,
berths, or financials. The two residential_* permission groups
already exist; this commit just seeds a pre-built system role
("residential_partner") with those flags + minimal own-reminders, so
admins can invite a partner today via /admin/users without manually
building the permission set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the bulk of audit-pass-#1 admin gaps in one batch.
New admin pages:
- /admin/inquiries reads website_submissions with filter chips for
berth/residence/contact + payload viewer per row.
- /admin/sends reads document_sends with sent/failed filter chips and
expandable body markdown; failures surface errorReason and any
fallback-to-link reason from the SMTP retry.
- /admin/email-templates lets per-port admins override the subject of
each transactional template (8 templates catalogued in
template-catalog.ts). Body editing is a follow-on; portal_activation
+ portal_reset are wired to honor the override via loadSubjectOverride.
- /admin/reports replaces the "Coming in Layer 3" placeholder with a
KPI dashboard: 4 KPI tiles, pipeline funnel bars, berth occupancy
donut-bars, conversion %, refresh every 60s.
- backup/import/onboarding admin pages replace placeholders with
actionable guidance: backup posture + planned features, available CLI
imports + planned UI, ordered onboarding checklist linking to admin
pages.
Existing pages widened:
- settings-manager exposes the 9 berth-recommender tunables that were
previously code-only (recommender_*, heat_weight_*, fallthrough_*,
tier_ladder_hide_late_stage).
- role-form covers all 19 RolePermissions schema groups; previously
missing yachts/companies/memberships/reservations + missing
documents.edit + files.edit checkboxes. snake_case residential
labels replaced with friendly text.
portal-auth.service.ts now also writes audit_log rows for portal
invite, resend, activate, password-reset request, and reset (closes one
more audit-pass-#2 gap while we were touching the file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:
* 50 route files swept (61 sites): manual NextResponse.json({error,
status: 4xx|5xx}) early-returns replaced by typed throws +
errorResponse(err) at the catch.
- Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
helper from src/lib/api/helpers.ts so denials hit the audit log.
- Path-param + body validation 400s become ValidationError throws.
- 404s become NotFoundError or CodedError('NOT_FOUND') for AI
feature-flag paths.
- 11 manual 5xx returns now re-throw so error_events captures the
request-id (the admin error inspector becomes usable from real
incidents).
- website-analytics 200-with-error anti-pattern flipped to 409 +
UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
- 11 sites intentionally preserved: storage/[token] anti-enumeration
token-failure paths, webhook-secret 401, "Unknown port" 400 in
public intake.
* 7 admin forms (roles, users, ports, webhooks, custom-fields,
document-templates, tags) gain a formatErrorBanner() helper from
src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
banner — the rep can copy the request id when reporting a failed
save. Banners get whitespace-pre-line so newlines render.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Working through the audit-v2 deferred backlog. Each round was tested
(typecheck + 1168/1168 vitest) before moving on.
Round 1 — DB performance + AI cost visibility:
- Add missing FK indexes Postgres doesn't auto-create on
berth_reservations.{interest_id, contract_file_id},
documents.{file_id, signed_file_id}, document_events.signer_id,
document_templates.source_file_id, form_submissions.{form_template_id,
client_id}, document_sends.{brochure_id, brochure_version_id,
sent_by_user_id}. Without these, RESTRICT-checks on parent delete +
reverse-lookups walk the child tables fully. Migration 0037.
- AI worker now writes one ai_usage_ledger row per OpenAI call so admins
can audit spend per port/user/feature and future per-port budgets have
history to read from. Failure to write is logged-not-thrown so the
user-facing email draft is unaffected.
Round 2 — Boot-time + transport hardening:
- S3 backend verifies the bucket exists at startup (or auto-creates
when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now
surfaces with a clear boot error instead of a vague Minio error
inside the first user-facing request.
- Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx
+ network errors, fail-fast on 4xx. Stops one transient flake from
leaving a document with a partial field set.
- FilesystemBackend logs a structured warn-once at boot when the dev
HMAC fallback is in effect, so two processes started with different
BETTER_AUTH_SECRET values are observable (random 401s on file
downloads otherwise).
- Logger redact paths extended to cover *.headers.{authorization,
cookie}, *.config.headers.authorization, encrypted-credential blobs
(secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso
X-Documenso-Secret header, and 2-level nested forms.
Round 3 — UI feedback + permission gates:
- Storage admin migrate dialog: success toast with row count + error
toast on both dryRun and migrate mutations.
- Invoice detail Send + Record-payment buttons wrapped in
PermissionGate (invoices.send / invoices.record_payment); both
mutations now toast on success/error.
- Admin user list Edit button wrapped in PermissionGate(admin.manage_users).
- Scan-receipt page surfaces an amber warning when OCR fails so reps
know they can fill the form manually instead of staring at a stalled
spinner; the editable form now also opens on scanMutation.isError
/ uploadedFile, not only on success.
- Email threads list now renders skeleton rows during load + shared
EmptyState for the empty case (was a single "Loading…" line).
Round 4 — Service / route correctness:
- documentSends.sent_by_user_id was a free-text NOT NULL column with no
FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row
survives a user being hard-deleted. Migration 0038 with a defensive
null-out for any orphan ids before attaching the constraint.
- Saved-views route: documented why withAuth alone is correct (the
service strictly filters by (portId, userId) — owner-only by design).
- Public-interests audit log: replaced "userId: null as unknown as
string" cast with userId: null; AuditLogParams already accepts null
for system-generated events.
- EOI in-app PDF fill: extracted setBerthRange() that, when the
AcroForm field is missing AND the context has a non-empty range
string, logs a structured warn so the deployment gap (live Documenso
template needs the field) is observable instead of silently dropping
the multi-berth range.
Test status: 1168/1168 vitest. tsc clean. Two new migrations
(0037/0038) need pnpm db:push (or migration apply) on the dev DB.
Deferred-doc updated with the remaining open items (bigger refactors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-domain audit (security, routes, DB, integrations, UI/UX) ran after
the cf37d09 merge. Critical + high-impact items landed here; deferred
medium/low items indexed in docs/audit-final-deferred.md (now organised
into a "Audit-final v2" section).
Security:
- Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived
download URL minted by `presignDownload` for an emailed brochure can no
longer be replayed against the proxy PUT to overwrite the original
storage object. `verifyProxyToken` requires `expectedOp` and rejects
mismatches; legacy tokens missing `op` fail-closed. Regression tests
added.
- Markdown email merge values are now markdown-escaped (`[`, `]`, `(`,
`)`, `*`, `_`, `\`, backticks, braces) before substitution into the
rep-authored body. A malicious value like `[click here](https://evil)`
stored in `client.fullName` no longer survives `escapeHtml` to render
as a real `<a href>` in the outbound email. Phishing-via-merge-field
closed; regression tests added.
- Middleware now performs an Origin/Referer check on
POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of
better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes
exempt as they don't carry the session cookie.
Routes:
- Template management routes were calling `withPermission('documents',
'manage', ...)` — but `documents` doesn't have a `manage` action. The
registry has `document_templates.manage`. Every non-superadmin was
getting 403'd on the seven template endpoints. Fixed across the
/admin/templates surface.
- Custom-fields permission resource is hardcoded to `clients` regardless
of which entity (yacht/company/etc.) the values belong to. Documented
as deferred (requires per-entity routes).
DB:
- documentSends: every parent FK (client_id, interest_id, berth_id,
brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the
audit trail outlasts hard-deletes. The denormalized columns
(recipient_email, document_kind, body_markdown, from_address) were
added precisely for this. Migration 0035.
- Polymorphic discriminators on yachts.current_owner_type and
invoices.billing_entity_type now have CHECK constraints — typos like
`'clients'` vs `'client'` were silently inserting unreachable rows
before. Migration 0036.
Integrations:
- Email attachment resolution (`src/lib/email/index.ts`) was importing
MinIO directly instead of `getStorageBackend()`. Filesystem-backend
deployments would have broken every email-with-attachment send. Now
routes through the pluggable abstraction per CLAUDE.md.
- Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit
`readStatus` or send lowercase, so an event that was the SIGNAL of an
open was being silently dropped. Now treats any recipient on a
DOCUMENT_OPENED event as opened.
UI/UX:
- Expense detail used to render `receiptFileIds` as opaque UUID badges —
reps couldn't view the receipt they uploaded. Now renders an image
thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for
PDFs. Closed the "where's my receipt?" loop in the expense flow.
- Expense detail Edit + Archive buttons now `<PermissionGate>` and the
archive mutation surfaces success/error toasts instead of silent 403s.
- Brochures admin: setDefault/archive/create mutations now have onError
toasts (only onSuccess existed before).
- Removed broken bulk-upload link in scan/page (route doesn't exist;
used a raw `<a>` triggering a full reload to a 404).
Test status: 1168/1168 vitest passing. tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7,
§5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for
per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP
config + body templates, and the supporting admin UI.
Migration: 0031_brochures_and_document_sends.sql
Schema additions:
- brochures (port-wide, with isDefault marker + archive)
- brochure_versions (versioned uploads, storageKey per §4.7a)
- document_sends (audit log of every rep-initiated send; failures
captured with failedAt + errorReason). berthPdfVersionId is a plain
text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions
so the two phases stay independent.
§14.7 critical mitigations:
- Body XSS: rep-authored markdown goes through renderEmailBody()
(HTML-escape first, then a tight allowlist of bold/italic/code/link
rules). https:// + mailto: only — javascript:/data: URLs stripped.
Tested against script/img/iframe/svg/onerror polyglots.
- Recipient typo: strict email regex + two-step confirm modal that
shows the exact recipient before send.
- Unresolved merge fields: pre-send dry-run /preview endpoint blocks
submission until findUnresolvedTokens() returns empty.
- SMTP failure: every transport rejection writes a document_sends row
with failedAt + errorReason; UI surfaces the message.
- Hourly per-user rate limit: 50 sends/user/hour via existing
checkRateLimit().
- Size threshold fallback (§11.1): files above
email_attach_threshold_mb (default 15) ship as a 24h signed-URL
download link in the body instead of an attachment. Storage stream
flows directly to nodemailer to avoid buffering 20MB+.
§14.10 critical mitigation:
- SMTP/IMAP passwords encrypted at rest via the existing
EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/
sales-config GET endpoint never returns the decrypted value — only
a *PassIsSet boolean. PATCH treats empty string as "leave unchanged"
and explicit null as "clear", so the masked-placeholder UI round-
trips without forcing re-entry on every save.
system_settings keys (per-port unless noted):
- sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted}
- sales_imap_{host,port,user,pass_encrypted}
- sales_auth_method (default app_password)
- noreply_from_address
- email_template_send_berth_pdf_body, email_template_send_brochure_body
- brochure_max_upload_mb (default 50)
- email_attach_threshold_mb (default 15)
UI surfaces (per §5.7, §5.8, §5.9):
- <SendDocumentDialog> shared 2-step compose+confirm flow.
- <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton>
wrappers per detail page.
- /[portSlug]/admin/brochures: list, upload (direct-to-storage
presigned PUT for the 20MB+ files per §11.1), default toggle,
archive.
- /[portSlug]/admin/email extended with <SalesEmailConfigCard>:
SMTP + IMAP creds, body templates, threshold/max settings.
Storage: every upload + download goes through getStorageBackend() —
no direct minio imports, per Phase 6a contract.
Tests: 1145 vitest passing (+ 50 new in
markdown-email-sanitization.test.ts, document-sends-validators.test.ts,
sales-email-config-validators.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6a from docs/berth-recommender-and-pdf-plan.md §4.7a + §14.9a. Lays
the storage groundwork for Phase 6b/7 file-bearing schemas (per-berth PDFs,
brochures) without touching those domains yet.
New files:
- src/lib/storage/index.ts StorageBackend interface + per-process
factory keyed on system_settings.
- src/lib/storage/s3.ts S3-compatible backend (MinIO/AWS/B2/R2/
Wasabi/Tigris) wrapping the existing minio
JS client. Includes a healthCheck() used
by the admin "Test connection" button.
- src/lib/storage/filesystem.ts Local filesystem backend with all §14.9a
mitigations baked in.
- src/lib/storage/migrate.ts Shared migration core — pg_advisory_lock,
per-row resumable progress markers,
sha256 round-trip verification, atomic
storage_backend flip on success.
- scripts/migrate-storage.ts Thin CLI shim around runMigration().
- src/app/api/storage/[token]/route.ts
Filesystem proxy GET. Verifies HMAC,
enforces single-use replay protection
via Redis SET NX, streams via NextResponse
ReadableStream with explicit Content-Type
+ Content-Disposition. Node runtime only.
- src/app/api/v1/admin/storage/route.ts
GET status + POST connection test.
- src/app/api/v1/admin/storage/migrate/route.ts
Super-admin-only POST that runs the
exact same runMigration() as the CLI.
- src/app/(dashboard)/[portSlug]/admin/storage/page.tsx
Super-admin admin UI (current backend,
capacity stats, switch button with
dry-run, test connection, backup hint).
- src/components/admin/storage-admin-panel.tsx
Client component for the page above.
§14.9a critical mitigations implemented:
- Path-traversal: storage keys validated against ^[a-zA-Z0-9/_.-]+$;
`..`, `.`, `//`, leading `/`, and overlength keys rejected.
- Realpath: storage root realpath'd at create time, every per-key
resolution checked against the realpath'd prefix.
- Storage root created (or chmod'd) to 0o700.
- Multi-node refusal: FilesystemBackend.create() throws when
MULTI_NODE_DEPLOYMENT=true.
- HMAC token: sha256-HMAC over the (key, expiry, nonce, filename,
content-type) payload. Verified with timingSafeEqual; bad sig,
expired, or invalid-key payloads all return 403.
- Single-use replay: token body cached in Redis SET NX EX 1800s.
- sha256 round-trip: copyAndVerify() re-fetches from the target after
put() and aborts the migration on any mismatch.
- Free-disk pre-flight: when migrating to filesystem, sums byte counts
via source.head() and aborts if free space < total * 1.2.
- pg_advisory_lock(0xc7000a01) prevents concurrent migrations.
- Resumable: per-row progress markers in _storage_migration_progress.
system_settings keys read by the factory (jsonb, no schema change):
storage_backend, storage_s3_endpoint, storage_s3_region,
storage_s3_bucket, storage_s3_access_key,
storage_s3_secret_key_encrypted, storage_s3_force_path_style,
storage_filesystem_root, storage_proxy_hmac_secret_encrypted.
Defaults: storage_backend=`s3`, storage_filesystem_root=`./storage`
(./storage added to .gitignore).
Tests added (34 tests, all green):
- tests/unit/storage/filesystem-backend.test.ts — key validation
allow/reject matrix, realpath escape, 0o700 perms, multi-node
refusal, HMAC token sign/verify/tamper/expire/invalid-key.
- tests/unit/storage/copy-and-verify.test.ts — sha256 mismatch on
round-trip aborts the migration.
- tests/integration/storage/proxy-route.test.ts — happy path, wrong
HMAC secret, expired token, replay rejection.
Phase 6a ships zero file-bearing tables — TABLES_WITH_STORAGE_KEYS is
intentionally empty. berth_pdf_versions and brochure_versions land in
Phase 6b and join the list there. Existing s3_key columns: only
gdpr_export_jobs.storage_key, already named correctly — no rename needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).
Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
port switcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inquiry Settings + Business Rules cards used a flex-row layout that
crushed the label column into a narrow vertical stripe at 390px ("Inquiry
/ Contact / Email" wrapping one word per line) while the input took the
right side.
Stack label + helper text above the input on phone widths; restore the
side-by-side row from sm up. Same pattern as the other detail-edit rows
that were fixed in pass-2/pass-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>