Address the two CRITICAL items from auth-flow-auditor plus the
high-impact M10 open-redirect.
**C1 — Password reset doesn't revoke existing sessions**
CRM side: Better Auth has a built-in
`emailAndPassword.revokeSessionsOnPasswordReset` flag — flip it on.
Verified by reading password.mjs in node_modules/better-auth: this
calls `internalAdapter.deleteSessions(userId)` after the password
update commits. One-line fix, closes the canonical session-bumping
gap on the CRM forgot-password flow.
Portal side: the portal uses JWT sessions (not DB-side rows) so
there's no `deleteSessions` to call. Add a per-user
`password_changed_at` watermark column on `portal_users` and have
`verifyPortalToken` reject any token whose `iat` predates the
watermark. Updated on `resetPassword`, `changePortalPassword`, and
`activateAccount` so every password mutation revokes outstanding
cookies. Token shape gains a required `portalUserId` claim so the
verify step can do the watermark lookup without an email-based join;
legacy tokens (pre-Wave-11) lack it and are rejected → forces one
re-login per portal user post-deploy (24h max delay since portal
tokens already self-expire at 24h).
Migration `0058_portal_password_revocation.sql` stamps existing
rows to `now()` so no current session is invalidated by the schema
change itself.
**M10 — Portal login `?next=` open redirect**
`portal/login/page.tsx` did `router.replace(next as never)` against
unvalidated `searchParams.get('next')`. An attacker could send a
victim to `/portal/login?next=https://evil.example` and the post-sign-in
redirect would navigate cross-site. Add `safeNextPath()` that requires
`/portal/...` prefix and rejects protocol-relative URLs; everything
else falls back to `/portal/dashboard`.
**Other auth-flow items confirmed resolved by earlier waves:**
- H6 resolve-identifier enumeration: endpoint deleted in Wave 1
(replaced with sign-in-by-identifier which keeps the synthetic
email behind a server-side proxy)
Tests updated: portal-auth integration test mocks `db` so the new
DB-watermark lookup in `verifyPortalToken` stays unit-pure.
Tests 1315/1315 after `psql ALTER TABLE` to apply migration locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Close the CRITICAL + HIGH-tractable race conditions the
concurrency-auditor flagged. The wide-impact items (BullMQ jobId
plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too
many call sites for a single contained wave and stay deferred.
**C-1 — handleDocumentCompleted concurrent-retry orphan-blob**
Wave 1 fixed the compensating-delete on single-process failure but the
idempotency gate at line 1110 reads `doc.status` outside any row lock.
Two webhook deliveries arriving in parallel both pass the gate, both
storage.put + db.insert(files), and the losing files row orphans its
blob since documents.signed_file_id only points at one. Now the
transaction at line 1176 SELECTs the document `FOR UPDATE` and
re-checks the gate; if a concurrent worker already completed, throws a
sentinel `DocumentAlreadyCompletedError` which the outer catch
recognizes and runs the compensating storage.delete at info level
(not error). Net effect: at-most-once signed-PDF persistence even
under Documenso 5xx-then-retry storms.
**H-1 — moveFolder cycle check race**
Two concurrent folder moves (A → B and B → A) in READ COMMITTED can
each pass the cycle check against pre-state and both commit, leaving
A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of
the move transaction so the walk-and-write is atomic per port.
Lock auto-releases on tx end; no impact on cross-port folder ops.
**H-3 — upsertInterestBerth 23505 → generic 500**
Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary`
and the loser surfaced as a generic 500. Catch the 23505 + constraint
name and remap to ConflictError so the UI gets a "Another rep changed
the primary berth at the same time. Refresh and try again." toast.
**M-2 — username uniqueness 23505 → generic 500**
Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the
UPDATE then fails at the partial unique index. Catch 23505 +
`idx_user_profiles_username_unique` and remap to ConflictError.
Tests 1315/1315.
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 pdf-auditor findings that survived the 2026-05-12 PDF stack
overhaul (pdfme → react-pdf). Items C-2/C-3 (tiptap-to-pdfme bugs) were
resolved when that 571-LOC bridge was deleted; remaining items:
- **M-7 wrong-port brand fallback** — replace `'Port Nimara'` defaults
in PDF-rendering services. `reports.service` and `expense-export`
throw when the port row is missing (the job is FK-keyed on a real
port, so absence = broken state, must not stamp a competitor brand).
`record-export` uses `'(port)'` as the visible placeholder.
- **M-2 silent field drift in fill-eoi-form** — promote the
always-silent catch in `setText` / `setCheckbox` to log a structured
warning per missing field (mirroring the existing `setBerthRange`
pattern). A re-cut template with drifted AcroForm field names now
surfaces in ops logs instead of shipping with empty values.
- **M-3 form not flattened** — `fillEoiFormFields` now flattens the
AcroForm before save. Documenso pathway flattens server-side; this
brings the in-app pathway to parity, so the signer can't edit
pre-filled yacht dimensions / address / berth number after the fact.
- **M-1 PDF metadata** — set Title / Author / Subject / Lang / Producer
/ Creator on the generated EOI PDF for downstream readers and a11y
tooling.
- **M-4 noisy berth-range warnings** — downgrade per-mooring warn to
debug; emit a single summary warn per call when any passthrough
occurred. Multi-berth EOIs with archived/legacy moorings no longer
spam the log on every render.
- **M-6 source PDF sha pinning** — pin
`assets/eoi-template.pdf` sha256 via `EXPECTED_EOI_SHA256` (exported
for tests); `loadEoiTemplatePdf` warns once per process when the
bytes drift without an explicit hash bump. Documented the
intentional-update workflow in `assets/README.md`.
Tests updated in `tests/unit/pdf/fill-eoi-form.test.ts` to reflect
flatten + metadata (form fields are gone after flatten; pdf-lib has no
getLanguage so we assert the other setters round-trip).
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>
Closes Wave 1.3 (CRITICAL). The previous storage.put → files.insert
→ documents.update sequence had two real failure modes:
1. **Orphan blob.** If storage.put succeeded but the files.insert or
documents.update failed, the blob lived forever in MinIO with no
DB pointer. Re-runs re-uploaded a new blob without cleaning up
the previous one.
2. **Zombie completed state.** The catch block at the end ran
`documents.update({status: 'completed'})` with NO signedFileId
on any failure path. The idempotency early-return at the top
requires BOTH status='completed' AND signedFileId, so retries
*did* still re-attempt — but reps saw a "completed" document
with no signed file, hiding the failure.
Fix:
- Track `putStoragePath` outside the try. After storage.put lands,
the variable holds the path; cleared once the DB commit succeeds.
- files.insert + documents.update + reservation contract mirror all
run in a single `db.transaction(...)`. Atomic commit-or-rollback.
- Catch block: compensating `storage.delete(putStoragePath)` if the
DB commit didn't land. Logs at error level on compensating-delete
failure so a human can clean up.
- Catch block no longer sets `status='completed'`. The doc stays
in its prior state; Documenso's retry (or our poll-worker) re-
attempts the full sequence safely thanks to the unchanged
idempotency gate.
Verified: tsc clean, documents-completion-auto-deposit tests all
pass (5/5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
p-retry wraps every Documenso API call with 3 attempts (1 + 2 retries),
exponential backoff (1s → 4s with jitter). AbortError short-circuits
on:
- 401/403 — auth failures won't fix themselves on retry
- 4xx other than 429 — Documenso rejected the payload; retrying
hurts more than it helps
5xx + 429 (rate-limit) go through the retry path with backoff so we
politely re-attempt after delay. Recovers the single-connection-blip
scenario the audit's services pass flagged.
p-queue installed too (audit §36.A.1 companion to p-limit). No
concrete land site today — we don't bulk-fan-out to Documenso, and
existing pLimit covers our internal mass-op fan-outs. Available for
future rate-per-second scenarios.
Verified: tsc clean, vitest 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled `[fields].map(v => \`"\${v}"\`).join(',')`
pattern in expense-export.tsx with papaparse's Papa.unparse.
The previous version didn't handle:
- commas inside fields (would split rows mid-record)
- newlines inside fields (would terminate rows early)
- BOM for Excel-friendly encoding
- numeric/null normalization
Papa.unparse handles all of those + accepts a keyed-object row shape
that lets us define column order and get matching headers for free.
Verified: tsc clean, vitest 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat
(no new dep — built into Node 18+ + every supported browser). Replaces 96
ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the
codebase.
src/lib/utils/format-date.ts (new):
formatDate(value, preset?, options?) — primary helper
formatDateRange(start, end, options?) — collapsed range strings
formatRelative(value, options?) — "3 hours ago" / "in 2 days"
Presets (named so callers don't memorize Intl options shape):
date.short 12 May
date.medium 12 May 2026
date.long Monday, 12 May 2026
date.iso 2026-05-12 (TZ-aware ISO date, no time)
datetime.short 12 May 14:30
datetime.medium 12 May 2026 14:30
datetime.long Monday, 12 May 2026 at 14:30 UTC
datetime.iso 2026-05-12T14:30:00.000Z
time 14:30
Defensive defaults:
- null/undefined/Invalid Date → '—' (overridable via { fallback })
- locale defaults to en-GB (settles audit-flagged en-US/en-GB drift)
- tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name)
Sample sweep (3 sites — proves the pattern; remaining 93 sites can be
migrated opportunistically when files are touched):
src/lib/services/expense-pdf.service.ts:608 default subheader
src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}}
src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}}
The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule:
"replace as you touch the file" — gives compounding cleanup without
a single risky 90-file commit.
tests/unit/format-date.test.ts (new) — 17 tests:
- fallback handling (null/undefined/invalid/explicit)
- date.iso correctness in UTC + non-UTC timezones
- datetime.iso = full ISO string
- en-GB locale-formatted output
- timezone respect across NY/UTC
- time-only preset
- Date/string/epoch ms inputs all accepted
- formatDateRange same-year collapse, different-year keep, missing ends
- formatRelative: just-now / minutes / hours / days / future / invalid
1315/1315 vitest green (+17 new from format-date.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 — bounds three remaining unbounded Promise.all fan-outs that the
audit flagged as potential prod-incident vectors. Same pattern proven by
email-compose (4 concurrent S3 reads) and document-signing-emails (3
concurrent SMTP sends) in earlier commits.
berth-pdf.service.ts:574 — presignDownload S3 round-trips
bound: pLimit(8). A 20-version berth used to issue 20 simultaneous
presigns. ~1× round-trip latency preserved on typical 5-15-version
berths; pathological 100-version case no longer saturates the keep-alive
pool.
custom-fields.service.ts:327 — pg upserts on bulk field-value writes
bound: pLimit(8). Port admin stacking 50+ field definitions on one
client would have burst 50 concurrent upserts at the pg pool.
notifications.service.ts:344 — createNotification fan-out across watchers
bound: pLimit(8). Hot pipeline items can accumulate many watchers; a
document event used to fan out N notification inserts + N socket emits
in one burst.
Audit also flagged brochures.service.ts and backup.service.ts as
candidates — verified neither actually has an unbounded fan-out, just
sequential queries. No change needed; speculative entries removed from
BACKLOG implicitly.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 — converts the two switches in search.service.ts from `switch`
to ts-pattern's `match().with().exhaustive()`. The conversion exposed
a real bug: the single-bucket dispatch handled 15 of 16 SearchResults
buckets and silently dropped `type=notes` to the default empty-results
fall-through. `searchNotes()` has existed since the federated-notes
audit but was never wired into the runSingleBucket() dispatch. Calling
/api/v1/search?type=notes returned empty even with seeded note data.
The .exhaustive() switch now requires every SearchResults bucket. New
buckets fail the build until they get a dispatch case — same guarantee
the Documenso webhook conversion gives.
Notes:
- labelForSource (4 trivial label cases) — converted to ts-pattern
for visual consistency with the larger switch in the same file.
- The 3 other switches the audit flagged (client-restore.service.ts,
recently-viewed/route.ts, custom-fields/[entityId]/route.ts) operate
on tagged-union internal types where TypeScript already enforces
exhaustiveness via control-flow narrowing — converting them adds
noise without changing safety. Documented in docs/BACKLOG.md as
"TS-narrowing already exhaustive; deferred indefinitely."
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 (single commit) — applies the portal-auth.tsx pattern to every
hand-strung transactional email template. JSX components rendered via
@react-email/components' render() replace inline-style string templates
+ hand-rolled escapeHtml().
Ported (.ts → .tsx, public function signatures become async):
crm-invite.tsx — admin/super-admin CRM invite
admin-email-change.tsx — sign-in email changed notification
inquiry-client-confirmation.tsx — public berth inquiry receipt
inquiry-sales-notification.tsx — internal sales alert for inquiries
residential-inquiry.tsx — pair: client confirmation + sales alert
notification-digest.tsx — daily/hourly unread-notification digest
document-signing.tsx — triplet: invitation + completed + reminder
Each template now defines its body as a typed React component, drops
escapeHtml() entirely (react-email auto-escapes string interpolation
in JSX text + attributes), and passes the rendered HTML to the existing
renderShell() for shell wrapping. The shell + branding flow is unchanged.
Caller migration (all sync → async):
src/app/api/public/residential-inquiries/route.ts
src/lib/queue/workers/email.ts
src/lib/services/notification-digest.service.ts
src/lib/services/users.service.ts
src/lib/services/document-signing-emails.service.ts
src/lib/services/crm-invite.service.ts
All call sites already lived inside async functions; only the await was
needed. No public API shape changes other than return type (now Promise).
The pattern now applies uniformly across all 8 email templates (portal-
auth.tsx + the 7 in this commit). Email template directory is fully
react-email-based.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 13 of 14 — replaces a quietly-broken tesseract.js
pathway with unpdf for tier-2 of the berth-PDF parser.
The previous code did:
const tesseract = await import('tesseract.js');
await tesseract.recognize(buffer, 'eng'); // ← buffer is a PDF
tesseract.recognize() expects an image, not a PDF. The PDFs we get from
the AcroForm-stripped berth-spec sheets would have failed at runtime
(either an "unsupported format" error or silently empty text). Tier-2
was dark code.
unpdf (serverless-friendly pdfjs wrapper) extracts text directly from
the PDF stream. Works on text-PDFs (real text streams), returns empty
on scanned/raster PDFs — those legitimately fall through to the AI
tier where they belong.
The OcrAdapter interface shape is preserved so:
- Existing unit tests that stub the adapter still work
- parseAnyBerthPdf(buffer, { adapter }) override still works
- The 30-second timeout race + warning collection still works
tesseract.js stays as a dep — scan-shell.tsx (receipt scanner) still
uses it for on-device image OCR, which is its intended use case.
1298/1298 vitest green.
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 11 of 14 — invoices are client-facing documents, and
per the new "no CRM-generated client-facing PDFs" rule (see the design
spec), the in-app pdfme rendering is removed entirely.
Future invoice rendering will use the deferred AcroForm-fill admin-
template feature: admin uploads a PDF template with named form fields,
CRM fills them with invoice data via pdf-lib. Same pattern as the
in-app EOI pathway. Tracked in BACKLOG.md.
Deleted:
- src/lib/services/invoices.ts:generateInvoicePdf (60 LOC)
- src/lib/pdf/templates/invoice-template.ts (entire pdfme template)
- src/app/api/v1/invoices/[id]/generate-pdf/route.ts
- src/components/invoices/invoice-pdf-preview.tsx (regenerate UI)
- "PDF Preview" tab on invoice detail page
- 5 now-unused imports in invoices.ts (files, ports, buildStoragePath,
getStorageBackend, env)
sendInvoice() retained: still queues the send-invoice email job, still
flips status to "sent", still emits the socket event. The PDF-attach
step is gone — downstream consumers either render externally or wait
for the AcroForm-fill feature. The `pdfFileId` column on invoices stays
so existing rows don't break, just never gets written by this code path.
1319/1319 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commit 10 of 14 — migrates the pdfme-based parent-company
expense export to react-pdf and adds a shared brand header to the
pdfkit-based streaming expense PDF so both surfaces match the rest of
the internal-only PDF family.
parent-company-expense.tsx:
Summary KV grid (entry count, subtotal, fee, total) + entries table
with right-aligned EUR amounts and a totals row. Footnote rendered
when the EUR rate lookup falls through to the 1:1 USD:EUR fallback.
expense-export.tsx (renamed .ts -> .tsx):
- exportParentCompany now renders the react-pdf template via
resolvePortLogo() + renderPdf()
- dropped the inline pdfme template object (was the last pdfme caller
in this file)
- return type widened from Uint8Array to Buffer; caller already wraps
in Buffer.from() so no API change downstream
expense-pdf.service.ts (the pdfkit streaming engine — unchanged):
- addHeader() now draws a dark slate band matching the brand-kit
header band, with the port logo letterboxed on the left and the
document title right-aligned. Falls back to text port-name if the
logo image is missing or can't be decoded by pdfkit
- port + logo resolved once per export via Promise.all
- subheader stays beneath the band in muted grey, same as before
- streaming behavior + receipt embedding + sharp compression
untouched — the only change is the visual treatment of the header
Old pdfme inline template deleted along with the generatePdf import.
After this commit, the only remaining pdfme imports are in:
invoice-template.ts, tiptap-to-pdfme.ts, eoi-standard-inapp.ts, and
document-templates.ts (lines 516-522). All four are removed in
commits 11-12.
1319/1319 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commits 7-9 of 14 — bundled because all three record exports
share the same conversion pattern and call sites.
Templates:
client-summary.tsx header + KV grid for client, contacts table
with primary badge, yacht table, interests
table with stage/category, recent activity
table
berth-spec.tsx header + status badge, overview KV grid,
dimensions KV grid (with min markers), pricing
& tenure KV grid, infrastructure KV grid,
waiting list table with priority badges,
maintenance log table
interest-summary.tsx header + stage badge, status KV grid, client
KV, optional yacht/berth sections, milestones
KV grid, recent timeline table
record-export.tsx (renamed .ts -> .tsx for JSX):
- swap generatePdf(...) calls for renderPdf(<…Pdf … />) calls
- inject port logo via resolvePortLogo()
- shape data into typed template props (Drizzle returns are passed
through deliberately so the template controls its own type surface)
Drops two latent bugs the old templates carried:
- client.nationality was read as a property but the schema field is
nationalityIso — old PDFs always showed "—" for nationality
- interest.notes was read but the interests table doesn't have a
notes column (interest_berths does) — old PDFs always showed "No
notes"
Both fields are now sourced correctly (or omitted) in the new templates.
Old pdfme files deleted (3 templates). API routes that import
exportClientPdf/exportBerthPdf/exportInterestPdf unchanged.
Tests:
tests/unit/record-export-templates.test.tsx (4 tests): each template
renders to valid PDF bytes with representative data, plus a minimal-
input path for the berth spec.
1317/1317 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / commits 3-6 of 14 — bundled because every report follows the
same conversion pattern (coordinate-stuffed pdfme template -> JSX brand
kit). Each report now has a real header (logo + port name), structured
KeyValueGrid for summary stats, a chart (BarChart / FunnelChart / PieChart
/ LineChart-ready), and a DataTable for detail rows.
Templates:
activity-report.tsx bar chart of events-per-day, summary KPIs, top
actions table, recent-events table (50 rows)
revenue-report.tsx bar chart of revenue per stage, breakdown table
with totals row, currency-aware formatting
pipeline-report.tsx funnel chart of interests per stage, top interests
table, win rate / cycle KPIs
occupancy-report.tsx donut pie of berth status mix, status breakdown
table with percentages, occupancy rate KPI
reports.service.tsx (renamed .ts -> .tsx for JSX):
- swap REPORT_TYPE_MAP `template`/`buildInputs` for a single `render`
function returning a typed react-pdf element
- inject port logo via resolvePortLogo() and pass through to every
template through a ReportContext object
- keep the existing job queue / storage / file-row / socket-emit
flow intact — only the inner PDF-bytes generation changed
Old pdfme files deleted (4 templates). buildStoragePath / files-table
insert / notifications / status updates all unchanged.
Tests:
tests/unit/report-templates.test.tsx (5 tests): each report renders
to valid PDF bytes given a representative seed-style fixture; empty
data path doesn't throw.
1313/1313 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>
Migrates the activation + reset email templates from hand-strung HTML
strings to React components rendered via @react-email/components.
Concrete wins this lands:
- React auto-escapes interpolation — drops the hand-rolled escapeHtml()
helper. Eliminates the entire class of "I forgot to escape" XSS bugs.
- @react-email primitives (Button, Hr, Link, Text) render to
Outlook/Gmail/AppleMail-safe inline-styled HTML.
- JSX over template strings makes the templates editable / reviewable.
- Sets the pattern for the remaining 7 templates (crm-invite,
document-signing, inquiry-*, notification-digest, admin-email-change,
residential-inquiry). Migrate opportunistically when those files are
next touched.
The shell (logo, blurred background, table-based wrapper) stays via
renderShell so this is a strictly inner-body migration — visual parity
preserved.
Vitest config: added @vitejs/plugin-react so .tsx files imported by
tests (transitively via the service that uses the template) transform
correctly under Next's tsconfig `jsx: 'preserve'` setting.
Verified: tsc clean, vitest 1293/1293 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cap concurrency on two services that were fanning out unbounded
requests to external systems:
1. email-compose.service.ts — attachment resolution. User attaches
20 files → 20 simultaneous S3/MinIO GETs + 20 buffers in heap.
Now capped at 4 concurrent reads; peak memory bounded by
4 × max-attachment-size regardless of attachment count.
2. document-signing-emails.service.ts — sendSigningCompleted fanned
out one SMTP send per recipient simultaneously. A Sales Contract
with 10 recipients (client + 5 sellers + 4 witnesses) hit SMTP
provider connection limits (Mailgun/SES/Postmark all cap concurrent
connections in the single digits) and dropped overflow silently.
Now capped at 3 concurrent sends.
Both use `pLimit(N)` from the Sindre Sorhus suite — well-tested at
scale, ~1kb gzip per service. Pattern is established for the
remaining audit-flagged mass-op services (brochures, backup, GDPR
export) to adopt as those files are touched.
Verified: tsc clean, vitest 1293/1293 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolved 65 type errors across the codebase via these v4 migration
patterns:
- `ZodError.errors` renamed to `ZodError.issues` (4 call sites in auth
routes + central error handler).
- `z.record(value)` now requires explicit key type: `z.record(z.string(),
value)`. Updated 7 sites across templates / forms / saved-views /
website-inquiries.
- `.refine(check, msgFn)` second-arg shape changed — now requires an
`{ error: (issue) => ... }` object form. Updated
`mergeFieldsSchema` in document-templates validator.
- `.transform(...).default(...)` chains: v4 enforces default value type
matches transform OUTPUT. Reordered to `.default(...).transform(...)`
in list-query / company-memberships handlers.
- `z.coerce.*()` INPUT type widened to `unknown` in v4. Service signatures
using `z.input<typeof schema>` (kept for caller flexibility around
defaults) now re-parse via `schema.parse(data)` to recover the
post-coercion shape Drizzle needs. Done in berth-reservations service.
Invoice service narrows `lineItems` locally with a typed cast since
re-parsing would double-validate.
- `.optional().transform(...)` no longer propagates the optional marker
through v4's new ZodPipe. Moved `.optional()` to the END of chain in
`optionalDesiredDimSchema` (interests) and documents list query
(folderId, signatureOnly).
- ZodIssue subtype shapes simplified: `received` removed from
invalid_type, `type` renamed to `origin` on too_small. Test fixtures
updated.
- @hookform/resolvers v5 splits Resolver into 3-generic form (Input,
Context, Output). useForm calls in 6 forms (client, yacht, berth,
interest, expense, invoices-new-page) now pass explicit generics:
`useForm<z.input<typeof schema>, unknown, z.infer<typeof schema>>`.
Verified: tsc clean (0 errors), vitest 1293/1293 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Concurrency-auditor HIGH: the cycle walk + UPDATE used to run as
separate statements. Two concurrent moves (A→B and B→A) could each
pass the walk against the pre-move tree and both write, leaving an
A↔B cycle. Whole sequence now runs inside one db.transaction().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier 1.4: error_events.request_body_excerpt sanitizer now redacts
GDPR-relevant fields (email, phone, dob, address, fullName, firstName,
lastName, postcode, nationalId, etc.) on top of the existing
credential list. A 5xx in /api/v1/clients no longer lands full client
PII in the super-admin inspector.
Tier 3.10: ScanShell <main> now adds pb-[max(1.5rem, env(safe-area-
inset-bottom))]. Mobile-pwa audit caught the Save expense button sitting
flush against the iPhone 14/15 home indicator in standalone PWA mode.
Tier 6.2: dashboard widget-registry now dynamic-imports every
recharts-backed chart widget (berth status, lead source, occupancy
timeline, pipeline funnel, revenue breakdown, source conversion).
~80-150KB initial-bundle savings when reps have charts disabled.
ssr:false because recharts needs window.
Tier 6.3: DataTable wraps the assembled columns in useMemo keyed on
(columns, hasBulkActions). TanStack docs explicitly warn that
rebuilding columns every render resets the table's internal state.
Tier 7.1: Added .dockerignore (was missing — 7.6 GB context with
.env reachable via COPY . .). Excludes git, env files, node_modules,
build artefacts, IDE config, test artefacts, audit docs.
Tier 7.4: Dockerfile.dev now runs as the node user (uid 1000) — was
root. Working dir moves to /home/node/app.
Tier 7.5: docker-compose.prod.yml adds memory limits (2g postgres,
512m redis, 1g crm-app, 1g crm-worker) and json-file log rotation
(max-size, max-file) to every service.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier 2.2: revenue PDF totalCompleted now filters on outcome='won' —
setInterestOutcome forces stage='completed' for every outcome (incl.
lost + cancelled), so the stage-only filter was including those toward
"TOTAL COMPLETED REVENUE".
Tier 2.3: fetchPipelineData stageCounts adds the missing .groupBy() —
without it Postgres rejects the SELECT (per-stage breakdown was broken
or coercing to ELSE-stage row).
Tier 2.4: hot-deals widget rank ladder fixed two stage-name typos —
'in_comms' → 'in_communication', 'deposit_10' → 'deposit_10pct'. Both
stages were collapsing to the ELSE 0 branch server-side AND rendering
raw enum to the user in hot-deals-card.tsx.
Tier 3.2: portal /portal/interests no longer renders raw enum to
clients. New PORTAL_SIGNING_LABELS table maps every EOI/contract
status to plain English (e.g. "waiting_for_signatures" → "Waiting for
signatures").
Tier 4.1 (CRITICAL): permission-overrides PUT now requires caller-
superset on every `true` write. Admins with only `admin.manage_users`
could previously grant other users leaves they don't hold themselves
(permanently_delete_clients, system_backup). Super-admins bypass.
Tier 4.4: search graph-expansion re-gates every merged bucket by the
destination's view permission. A user with berths.view but no
interests.view searching "A12" no longer sees interest rows surfaced
via expansion.
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>
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>
The dashboard's occupancy-timeline metric was firing N separate queries
(one per day, 30 for .30d / 90 for .90d) that saturated the postgres pool
and stalled every other request in the app. Replace with a single query
using generate_series for the date range + LEFT JOIN onto active
reservations + COUNT(DISTINCT berth_id) GROUP BY day.
Same data, ~30× fewer queries on .30d, ~90× fewer on .90d. The snapshot
cache layer still applies, so cached reads are still zero-DB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds isPortalDisabledGlobally() helper that returns true when every
configured per-port client_portal_enabled row is false. The (portal)
layout calls it and renders a "Portal not available" notice instead of
the login/activate/reset pages when the kill switch is flipped.
Closes the gap where flipping the admin System Settings toggle would
leave /portal/login publicly reachable as a form that rejects every
submit with a ConflictError. Now a clean notice page appears instead.
Single-port deployments get a global toggle out of this — the existing
per-port admin UI in System Settings effectively becomes the master
switch. Multi-port future will need URL-level port discrimination
(subdomain or path prefix) before the all-ports-off heuristic should
be replaced with a per-port resolution.
API routes (/api/portal/*) stay on the existing service-layer gate
(every portal-auth function checks isPortalEnabledForPort). Direct
curl gets a per-call ConflictError, which is acceptable for non-human
clients; the UI gate is what matters for accidental discovery.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- A1: idempotency gate in handleDocumentCompleted (prevents duplicate files on Documenso retry)
- A3: LEFT JOIN port_id move to outer WHERE (uses idx_docs_signed_file_id)
- G-C5: contract_sent / contract_signed auto-advance triggers in sendDocument + handleDocumentCompleted
- 0-byte signed PDF guard before storage.put
- portId in outer catch + poll worker
- Sanitize storagePath/storageBucket in aggregated files API
- Audit log for handleDocumentCompleted file insert
- Replace em-dashes in aggregated group labels with colons
- G-I6: delete orphaned hub-counts route + getHubTabCounts service fn
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- G-C4: deposit_received in invoices.ts
- G-C4 + G-I2: interest_archived + notifyNextInLine in archiveInterest
- G-C4: interest_completed in setInterestOutcome
- G-C4: berth_unlinked in removeInterestBerth
- G-I5: portal invoices include billingEntityType='company' when client is the director
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>
Three follow-ups from Task 15 code review:
1. (Important) The aggregated files API now LEFT JOINs against
documents to surface signedFromDocumentId per file row. The
"view signing details" button on EntityFolderView's Files
section now passes the workflow id to SigningDetailsDialog
instead of the file id. Previously the button always 404'd
and the dialog hung in the loading state. Drops the v1
filename-prefix heuristic.
2. (Minor) Drop dead initialTab prop + DocumentsHubTab import —
leftover from the pre-refactor tab strip.
3. (Minor) FlatFolderListing remounts on folder switch via a key
prop, restoring the pre-refactor typeFilter reset behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When listDocuments is called with folderId set (including folderId=null
for root-only), exclude status='completed' rows. The signed-PDF file
appears in the Files section with a "view signing details" link; the
workflow row would just be noise alongside the file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four follow-ups from Task 8 code review:
1. Aggregation now filters companyMemberships to active rows only
(isNull(endDate)) on both client→companies and company→clients
joins. Previously a rep who left a company 2y ago would still
see that company's files in their aggregated view. Brings this
service in line with the 8 other call sites in the codebase that
already filter on endDate.
2. Move collectRelatedEntities import to the top of
documents.service.ts — was wedged mid-file.
3. listInflightWorkflowsAggregatedByEntity now calls
assertEntityInPort for symmetry with the files version. Cross-
port reads short-circuit early instead of executing N empty
port-scoped queries.
4. Add a cross-port leakage regression test for the workflow
projection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
listFilesAggregatedByEntity walks the relationship graph (symmetric
reach: clients <-> companies via memberships, <-> yachts via current
ownership) and groups results by source: DIRECTLY ATTACHED + FROM
COMPANY/YACHT/CLIENT. File-FK snapshot is the source of truth so
historical files survive yacht-ownership transfer. Each group caps at
20 rows + a total for "Show all (N)" drill-through. Defense-in-depth
port_id filter at every join.
listInflightWorkflowsAggregatedByEntity reuses the same graph walk
for in-flight signing workflows (draft/sent/partially_signed only).
Completed workflows are hidden — they surface via their signed-PDF
file row instead.
applyEntityFkFromFolder auto-sets the matching entity FK on the file
row when the upload target is a system-managed entity subfolder (E8).
Wired into uploadFile; validator extended with folderId field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups from Task 7 code review:
1. Drop the dead interest.yachtId fallback branch. interests.clientId
is NOT NULL so the yacht branch was unreachable. Comment explains
the schema constraint so the branch can be re-added if that
constraint is ever relaxed.
2. Add defense-in-depth port_id filter to the interests lookup
inside resolveDocumentOwner (matches CLAUDE.md convention and
every other interests query in this file).
3. Add two integration test cases for direct-company and direct-yacht
owner resolution — closes the coverage gap where the signed-file
row's companyId/yachtId columns are populated for the first time
in this commit chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleDocumentCompleted resolves the workflow owner via the Owner-wins
chain (document.clientId → companyId → yachtId, then interest.clientId
→ yachtId), ensures the matching entity subfolder, and sets
files.folder_id + the matching entity FK on the signed file row.
Falls back to root (folder_id=null) when no owner is resolvable.
ensureEntityFolder failures are logged at warn level — the signed
PDF always lands; the backfill script heals missing folders.
The interest fallback omits the company branch because interests
table has no companyId column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups from Task 6 code review:
1. applyEntityArchivedSuffix short-circuits when the folder is already
archived — prevents archivedAt drift on backfill replay.
2. applyEntityRestoredSuffix short-circuits when the folder was never
archived — matches the docstring's "no-op" claim.
3. Inline comment on archiveClient's fire-and-forget hook documents
why Task 6 uses void (archive UI doesn't depend on folder sync)
while Task 5 uses await (rename should be visible to the next
read).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
applyEntityArchivedSuffix stamps " (archived)" + archived_at on the
entity subfolder so the UI mutes it and auto-deposit halts. Restore
is the inverse. demoteSystemFolderOnEntityDelete flips
system_managed=false, appends " (deleted)", and clears the entity FK
so the partial unique index releases the slot — orphaned files
retain their entity FK snapshots and surface in the rep's clean-up
view.
All three helpers are best-effort from the entity-side hooks; folder
errors are logged at warn level but do not fail the entity-update
operation. UPDATE WHERE clauses include port_id (defense-in-depth).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups from code review:
1. The UPDATE in the retry loop now scopes by both id and port_id so
it matches every other mutation in document-folders.service.ts and
honours the CLAUDE.md defense-in-depth pattern.
2. The three entity-rename hooks now log at warn level (not error) —
a missed folder rename is best-effort cosmetic drift, not a paging
incident. Matches the existing convention used elsewhere in the
codebase for non-fatal background work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-entity subfolder names mirror the entity's current display string.
Wired into updateClient / updateCompany / updateYacht; runs only when
the name field changes. Best-effort (logged + swallowed) so a folder-
sync error never fails an entity update. Preserves the (archived)
suffix when present; skips entirely when the folder has been demoted
to (deleted) — the rep owns the name at that point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>