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>
assertNotSystemManaged centralises the guard so the three mutation
paths surface identical ConflictError shapes. System roots and per-
entity subfolders are immutable through the rep-facing API; the only
way for system_managed to flip back to false is the entity-hard-
delete demotion path (next task).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Idempotent per-entity subfolder creation under the matching system
root. Fast-path SELECT short-circuits the common case. Inserts race
safely via uniq_document_folders_entity (partial unique on
port_id+entity_type+entity_id) — the loser re-SELECTs the winner's
row. Sibling-name collisions between two entities with the same
display name append (2), (3), … to the new folder; existing folders
never rename. Exports EntityType for use by downstream tasks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds inline comments explaining (a) why no-target onConflictDoNothing
is safe for root inserts (the only unique index that can fire on a
root row is uniq_document_folders_sibling_name; the partial entity
index excludes entity_id=NULL rows) and (b) why createPort doesn't
wrap the root bootstrap in a transaction (ensureSystemRoots is re-
runnable; the backfill script heals orphaned ports). Surfaces the
assumption that Task 3 (ensureEntityFolder) must not blindly copy
this pattern — it inserts with entity_id NOT NULL and needs an
explicit conflict target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds idempotent root-folder bootstrap (Clients/Companies/Yachts)
called on every port-init. ON CONFLICT DO NOTHING on the sibling-name
unique index prevents racing inserts; the re-SELECT returns the stable
row set in SYSTEM_ROOT_NAMES order. Same helper is invoked by the
backfill script in a later task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One-shot script that walks an existing organized bucket tree, builds
matching document_folders rows mirroring the path, then inserts
documents + files rows pointing at the existing storage keys verbatim
— no path rewrite. For migrating from a legacy MinIO bucket whose
folder structure is already the source of truth.
Idempotency:
• Folders: sibling-name unique index swallows duplicate creates;
we reuse the row on ConflictError.
• Documents: skipped when (port_id, fileStoragePath) already exists.
Adds StorageBackend.listByPrefix (recursive readdir on filesystem;
listObjectsV2 stream-drain on s3) — the first one-shot caller, not
a hot path. Pure parseImportPath helper extracted to its own module
and unit-tested for trailing slashes, empty intermediate segments,
prefix mismatch, and special-character folder names (8 tests).
Audit log per imported doc carries source='organized-bucket-importer',
storageKey, and folderSegments so the documents inspector can filter
on imports later.
CLI:
pnpm tsx scripts/import-organized-documents.ts \\
--port-slug <slug> \\
--bucket-prefix "legacy-imports/" \\
(--dry-run | --apply) [--uploaded-by <userId>]
Folds in Prettier post-hook drift on documents.service.ts +
download handler — same lint-staged formatting the earlier commits
already absorbed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Storage paths stay UUID-flat per the established CRM pattern (every
other content type — brochures, berth PDFs, invoices, reports,
templates, expense receipts — uses the same shape). The new
catch-all /api/v1/documents/[id]/download/[...slug] route serves
files keyed on doc id but rebuilds the slug from current state and
404s on mismatch — a hand-edited or stale link can't render the
wrong filename or fold a wrong-folder path into a forwarded URL.
URLs in shared links / browser tabs read like
'Deals 2026/Q1/contract.pdf' even though storage keys remain UUIDs.
listDocuments + getDocumentById now hydrate a `downloadUrl` field
per row (null when no file is attached yet) so UI consumers don't
reconstruct paths. Filename is batch-fetched via files-table join
to keep the query builder shape unchanged.
Tests: 5 integration cases — happy-path stream, wrong-folder slug,
wrong-filename slug, orphaned doc (no fileId), cross-port (tenancy
isolation). Storage backend swapped to a real FilesystemBackend in
a tempdir so the byte-streaming path is exercised end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- renameFolder/moveFolder UPDATE and deleteFolderSoftRescue DELETE now
carry an explicit port_id predicate so the write is bounded to the
same tenancy the pre-fetch verified, defending against future
refactors that drop or reorder the ownership check.
- FolderRow's collapsed-children chevron is `invisible` for layout
purposes, but it was still in the tab order with a misleading
Expand/Collapse aria-label. Add aria-hidden + tabIndex=-1 when no
children so keyboard users skip it.
Surfaced by post-implementation review (subagent code-review pass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents hub now opens with the folder tree on the left and a
breadcrumb on top. Folder selection is its own state — undefined =
"All", null = "Root only", string = specific folder. Filter pushes
through to /api/v1/documents via folderId query param.
Drops the "Signature-based only" pill — it defaulted to true and
silently hid informational documents, which confused new reps. With
folders the rep organises by location, not by signature-vs-not.
Adds an "In progress" hub tab covering status IN (draft, sent,
partially_signed) for the everyday "what's in flight" view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
listDocuments accepts folderId (string | null | undefined) and
includeDescendants. folderId=null returns only docs at root;
includeDescendants=true expands the subtree via collectDescendantIds
(in-memory walk over the cached tree -- folder trees are small).
PATCH /api/v1/documents/[id]/folder moves a single document under
documents.manage_folders, with audit-log metadata { type: 'folder_move' }.
Bumping updatedAt is correct for per-doc moves because reps deliberately
acted on that document -- different semantics from the bulk soft-rescue
in Task 4.
createDocument accepts an optional folderId for the upcoming UI's
"create in current folder" affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review followups on e9251a3:
- Move createAuditLog OUT of the deleteFolderSoftRescue transaction
callback so a rolled-back transaction can't leave a phantom audit
row. Pattern matches clients.service.ts, expense-dedup.service.ts.
- Add portId filter to the moveFolder ancestor-walk findFirst —
defense-in-depth so corrupted parentId pointing at another port
short-circuits the walk instead of silently traversing it.
- Drop updatedAt bump on rescued documents — folder rescue is an
administrative storage op, not a content change; bumping made
every rescued doc appear "recently modified" in list views.
- Add userId param + audit-log emission on renameFolder and
moveFolder for parity with createFolder + deleteFolderSoftRescue.
Tests updated to pass TEST_USER_ID as the new 4th arg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renameFolder + moveFolder enforce sibling-name uniqueness via the
shared isSiblingNameConflict helper and reject cross-port leakage at
the service boundary. moveFolder walks the destination's ancestor
chain to refuse cycles before the write.
deleteFolderSoftRescue re-parents every child folder and document up
to the deleted folder's parent (or to root) inside a transaction,
then drops the folder row. Children never disappear silently — a
wrong click moves work up the tree, never deletes it. Audit-logged
with rescuedTo metadata.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review followups on 4b31f01:
- beforeEach now scopes the documentFolders cleanup to the test port
via .where(eq(documentFolders.portId, portId)) so parallel suites
don't wipe each other's fixtures.
- Cross-port parent guard message changed from "Parent folder not
found in this port" (read like a 404) to "Invalid parent folder"
to match the ValidationError type that already maps to 400.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-memory tree build (single SELECT + JS nesting); the folder tree is
small enough that a recursive CTE buys nothing. Sibling-name conflict
maps the Postgres unique-index 23505 to a typed ConflictError so the
UI can render a clean toast. Cross-port parentId rejected at the
service boundary. Also adds document_folders to the global teardown
CTE so test ports can be cleaned up without FK violations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Berth detail page now has two tabs:
- Spec: the existing versioned berth-spec PDF surface (current panel,
version history, parser badge).
- Deal Documents: NEW. Lists EOIs / contracts / etc. attached to
interests currently linked to this berth via interest_berths.
New service helper listDealDocumentsForBerth joins documents →
interests → interest_berths with a port_id guard on both sides.
GET /api/v1/berths/[id]/deal-documents wraps it, gated on berths.view.
Read-only — title, type, status badge, and an Open link to the source
interest page. Edits / sends still happen on the interest's own page.
The Spec tab paragraph now points reps to the new Deal Documents tab
instead of telling them to navigate via Interests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When status moves to under_offer or sold, the dialog now surfaces an
interest selector below the reason textarea. Picking an interest
passes interestId on the PATCH, which the service uses to call
setPrimaryBerth — auto-creates a primary interest_berths row if not
present, demoting any prior primary in the same transaction so the
unique partial index never fires. Cross-port leakage is blocked inside
the existing interest-berths helper.
Reasons are now offered as quick-pick chips above the textarea,
sourced from the new berth_status_change_reasons vocabulary
(Wave 5). Clicking a chip fills the textarea so reps stay on the
keyboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the listForClientAggregated pattern to three new symmetric
helpers in notes.service so the Notes tab on yacht / company /
residential-client detail pages surfaces the full timeline (own notes
+ related-entity notes) instead of just rows on the entity itself.
- listForYachtAggregated: yacht own + owner client (when ownership
is polymorphic 'client') + linked interest notes.
- listForCompanyAggregated: company own + company-owned yacht notes
+ interests linked to those yachts.
- listForResidentialClientAggregated: own + residential interests.
Generalises NotesList so aggregate=true works for all four entity
types via SELF_SOURCE / AGGREGATABLE / SOURCE_BADGE_CLASS / SOURCE_LABEL
maps; cross-source notes render with a coloured chip and are read-only
(rep edits on the source entity's page so the right timeline records
the change).
Wires ?aggregate=true into the yacht / company / residential-client
notes routes; the yacht / company / residential-client tabs now pass
aggregate. Drops the legacy single-textarea spots on the companies
overview tab and the residential-interest "Initial brief" row in
favour of the threaded feed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the centralised formatter shipped in ee2da8f. Replaces
\`\${currency} \${amount}\` style concatenations across the dashboard
revenue tooltip, command-search invoice/expense fallback labels,
expense-duplicate banner, and the invoice + expense PDF templates.
Drops the duplicate \`currencySymbol\` helper inside expense-pdf.service
in favour of the shared util; the two PDF helpers (renderReceiptHeader,
addReceiptErrorPage) now take a currency code instead of a pre-rendered
symbol so the formatter is the single source for spacing + thousands
separators. Also re-runs Prettier on the files where the prior commit
shipped without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the public berth feed to verbatim NocoDB parity (all fields
except Price, which is held pending an explicit policy decision per
the audit follow-ups Q4). Adds:
- Berth Approved (boolean)
- Water Depth (number)
- Width Is Minimum / Water Depth Is Minimum (boolean)
- Length / Width / Draft / Water Depth / Nominal Boat Size (Metric)
- CreatedAt / UpdatedAt (ISO strings, useful for cache invalidation)
Booleans pass through as nullable to preserve NocoDB's tri-state
checkbox semantics (true / false / unset). Test fixtures cover the
new fields end-to-end including the null-passthrough case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
company_notes was missing updated_at — every other notes table has it,
and notes.service.ts substituted created_at into the response shape so
callers wouldn't notice. Add the column (defaulted + backfilled to
created_at for existing rows), wire the update path to set it on
edit, and drop the substitution from the read + edit handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drizzle SQL template was running `\d+$` through JS string-literal
escape rules, eating the backslash and matching every character class
\d alias instead of digits. Berths sorted lexically (A1, A10, A11,
A2, …) instead of by trailing number. Switch to `[0-9]+$` POSIX form
which survives the escape pass intact.
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.