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>
Lint-staged reformat after the previous commit added the file. Same
content, prettier's preferred line wrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the bare "+ New document" Button on the documents hub with a
NewDocumentMenu dropdown so reps explicitly pick between:
- "Upload file" → opens a Dialog with FileUploadZone scoped to the
current folder + entity context. No signing flow attached.
- "Generate document for signing" → navigates to /documents/new wizard.
Avoids the prior ambiguity where reps clicked "+ New document" intending
to attach a file and were dropped into the Documenso signer wizard.
Also adds FolderDropZone wrapping FlatFolderListing and EntityFolderView.
Dragging files from the OS over the current folder shows a drop overlay;
drop fires N parallel uploads carrying the folder + entity context.
Mirrors the per-entity Files tab UX but works in-place on the hub.
Both surfaces hit /api/v1/files/upload with folderId + entityType/Id +
the legacy clientId/companyId/yachtId FKs so files land on the right
entity AND inside the correct folder.
Also includes the in-flight prettier reformat from lint-staged on a
few previously-touched files (create-document-wizard, file-upload-zone,
admin/documenso/page) and adds the standalone prod-readiness audit
report to docs/superpowers/audits/ for permanent reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- filter-bar: hide select / multi-select fields when the options list is
empty (was rendering bare "Tags" / "Status" labels above empty inputs)
- berth-detail-header: show "Berth A1" title on mobile (was hidden via
`hidden sm:block`)
- dashboard-shell: time-aware greeting (Good morning/afternoon/evening,
firstName) using the existing ['me'] cache; falls back to
"Welcome back" when firstName isn't set yet
- mobile-topbar: hide UUID-segment fallback title flash on detail-page
navigation — when the URL last segment is a UUID, walk up to the
parent collection name ("Clients", "Yachts") until the page sets the
real entity title via useMobileChrome
- mobile-bottom-tabs: subtle bg-primary/10 pill behind icon on active
tab for a clear "you are here" cue
- branded-auth-shell: lock to viewport via fixed/inset-0 so the iOS
Safari rubber-band bounce doesn't scroll the centered login card
- middleware: skip CSRF origin check in development. LAN testing
(real iPhone on 192.168.x.x hitting the Mac dev server while a Mac
browser tab is on localhost) trips the cross-origin defense; prod
keeps it as-is.
- package.json dev script: -H 0.0.0.0 so the dev server is reachable
from devices on the LAN
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes consolidated as the root-cause fix for the recurring dev
server hangs:
1) DEV pool max 60 → 30. 60 caused 60 simultaneous query log lines
written via process.stderr per page-load on heavy admin pages.
stderr write backpressure stalled the Node event loop, manifesting
as full HTTP request hangs (TCP accept worked, server never wrote
the response). 30 is enough headroom for the clients-page aggregate
fanout (≈12 queries) + sidebar widgets without the log-storm.
2) DRIZZLE_LOG opt-in. Drizzle's `logger: true` setting writes every
query (full SQL + params) to stderr. With 30 concurrent queries the
stderr buffer fills faster than the terminal can drain. Default is
now off in dev; set DRIZZLE_LOG=1 explicitly when you need it.
Stress-tested with rapid navigation across /dashboard /clients
/documents /yachts /companies /interests /berths /website-analytics —
all 200, no hangs, no timeouts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of recurring dev server hangs:
/api/v1/website-analytics threw CodedError('UMAMI_NOT_CONFIGURED') which
rendered as HTTP 409. React Query default-retries on 4xx (we set retry=1
globally), so every page render fired the umami queries → 409 →
retry → 409. Each request queried system_settings to resolve umami
credentials. Six analytics widgets on the /website-analytics page +
two on the dashboard glance tile × 2 (initial + retry) = 16 system_settings
queries on first paint. Combined with React Query refetching on mount,
the postgres pool (max=20) saturated and the server appeared hung.
Fix: return 200 with `{ data: null, notConfigured: true }` instead of
4xx. Not-configured is a steady empty state, not a transient error —
no retry loop. Updated WebsiteGlanceTile (hides itself) and
WebsiteAnalyticsShell (renders configure-umami CTA) to check the new
notConfigured flag.
Also includes from in-flight work: package.json dev script binds
0.0.0.0 so iPhone on LAN can reach the dev server, and BrandedAuthShell
uses fixed/inset-0 + flex to lock the login surface to the viewport so
iOS Safari doesn't rubber-band-scroll the card.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default max=20 was saturating during normal admin clickthrough — the
clients list page does aggregate-per-client queries (yachts, memberships,
interests, contacts) that fan out 5-6 connections per row, plus
dashboard analytics, plus React Query refetch-on-focus. With 20 slots,
the server appeared to hang for 30s (statement_timeout) until queries
released their slots. Production keeps the conservative max=20 since
multi-replica deployments share the postgres max_connections budget.
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>
Reps no longer have to copy/paste UUIDs into the New-document wizard.
Three UUID inputs replaced:
- Template id Input → DocumentTemplatePicker (queries /api/v1/document-templates
with name search; filters to isActive=true)
- Uploaded file id Input → inline FileUploadZone (drop or browse PDF; surfaces
the uploaded file id directly to the wizard via the new onUploadComplete
signature)
- Subject id Input → conditional picker: ClientPicker / CompanyPicker /
YachtPicker / InterestPicker depending on the subject-type dropdown.
Reservation falls back to Input for now (no ReservationPicker yet).
Other polish in the wizard:
- SIGNER_ROLES labels capitalized in the role select (client → Client, etc.)
via a formatSignerRole() helper. Internal values stay lowercase.
- Pinned h-9 on Select triggers so the type/subject row + signer-role select
vertically align with their adjacent inputs.
- Subject-type change now resets subjectId — picker options are type-specific
and a stale id from a different entity table would be invalid.
Infrastructure for hub uploads (will be consumed in a follow-up dropdown +
drag-drop pass):
- /api/v1/files/upload route now parses folderId from FormData (schema
already supported it).
- FileUploadZone accepts a folderId prop and forwards it, plus a new
onUploadComplete(file) callback shape that surfaces { id, filename } on
each successful upload. Existing per-entity callers (Files tab on clients,
companies, yachts, interests) ignore the arg, no behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The gradient "Documents — Track signing status..." banner was rendering on
all three render modes (HubRootView / EntityFolderView / FlatFolderListing),
duplicating the in-empty-state CTA on folder views. Keep it only on the
root landing page; for folder views, surface a compact "+ New document"
button in the upper-right aligned with the FolderBreadcrumb row.
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>
- .env.example: strip /api/v1 from DOCUMENSO_API_URL (was producing double-pathed 404s), add DOCUMENSO_API_VERSION docs (v1 vs v2 support), add MINIO_AUTO_CREATE_BUCKET, document DOCUMENSO_TEMPLATE_ID_EOI + recipient role IDs
- Add listByPrefix to InMemoryBackend test stub (was 3 pre-existing tsc errors)
Pre-commit hook bypassed on explicit user request (CLAUDE.md policy blocks .env* by default; user authorized this update as part of audit-fixes cutover prep).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- F1: DOCUMENT_DECLINED handler (v2 Decline vs Reject) — routes to same
handler as DOCUMENT_REJECTED until product refines downstream UX
- Add RECIPIENT_VIEWED / RECIPIENT_SIGNED v2-alias cases with telemetry
logging so we see when v2 deployments emit them
- D1: populate TABLES_WITH_STORAGE_KEYS (files, berth_pdf_versions,
brochure_versions, gdpr_exports) — was an empty list, migrated 0 files
- MinIO putObject/getObject/statObject/removeObject socket timeout wrapper
to prevent worker hangs on TCP blackhole (30s deadline)
- E1: convert test.skip on smoke-setup infra failure to throw new Error
so green-skipped silence becomes a real test failure (Playwright
doesn't expose vitest's expect.fail)
- Regression tests: folderId='' → null transform, applyEntityRestoredSuffix
no-op (never-archived), syncEntityFolderName collision loop past (2)
Note: matching .env.example documentation (D2 — bare DOCUMENSO_API_URL,
DOCUMENSO_API_VERSION, MINIO_AUTO_CREATE_BUCKET, DOCUMENSO_TEMPLATE_ID_EOI,
recipient role id vars) prepared but not committed — pre-commit hook
blocks .env*. Apply manually via the separate .env workflow.
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>
The hub UI sends folderId='' when the user picks "root-only" in the
folder sidebar. The Zod validator was accepting it as a string and
the service then ran eq(folderId, '') instead of isNull(folderId),
returning zero results.
Adding a .transform on folderId converts empty string to null at the
boundary so the service receives the expected nullable shape. Pre-
existing bug from Wave 11.B that the hub rebuild made more visible.
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>
The bare `request` fixture is an isolated API context that does not
share the browser session cookie established by login(). Result: every
API call hit withAuth and 401'd, and the tests silently skipped
themselves through the existing graceful-skip guards — the assertions
never ran. Switching to page.request shares the browser context cookie
jar, so the API calls now reach the handler and the assertions execute.
Also adds a conditional "view signing details" trigger assertion
behind a feature-flag-style check so future signed-file seed fixtures
exercise the SigningDetailsDialog path automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two smoke specs cover the headline flows:
- 04-documents-hub-aggregated: asserts system roots (Clients/Companies/
Yachts) appear in FolderTreeSidebar with lock icons, breadcrumb updates
on selection, and EntityFolderView renders Signing + Files sections.
- 04-documents-hub-upload-into-entity: API-fixture approach (Option B) —
creates a client, uploads via /api/v1/files/upload with clientId, then
asserts the file surfaces in the entity folder view.
Visual baselines: hub-root added to the PAGES table so it snapshots via the
standard loop; hub-entity-folder added as a best-effort standalone test with
explicit skip guards when no entity sub-folders exist. Baselines require a
running dev server to generate (pnpm exec playwright test --project=visual
--update-snapshots).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds db:backfill:doc-folders npm script. Run after the 0051 migration
applies. Idempotent; safe to re-run on interrupted deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /documents/files page rendered a storagePath-prefix folder tree
disconnected from document_folders. Replaced by the unified hub
(Task 15). 301 redirect catches stray bookmarks. file-browser-store
repurposed to hold the document_folders.id selection.
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>
Three rendering modes for the main panel:
- HubRootView (no folder selected): port-wide Signing + recent Files.
- EntityFolderView (system-managed entity subfolder selected):
AggregatedSection × 2 with owner-grouped subsections + per-row
"view signing details" link on signed files (heuristic: filename
starts with "signed-"; follow-up: surface signedFromDocumentId
from the aggregated API).
- FlatFolderListing (any other folder): existing search + chips + list.
Drops the signing-status tab strip (in_progress / awaiting_them / etc.)
— folders are the primary navigation now. hub-counts query removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FolderTreeSidebar shows a lock marker on system_managed rows and renders
archived entity folders muted. FolderActionsMenu disables Rename /
Move / Delete with a tooltip explanation when a system folder is
selected; the server-side guard (Task 4) is the authoritative
rejection — the UI is the friendly first line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mapWorkflowStatus had key 'partial:' but the schema status string is
'partially_signed'. The mismatch fell through to the 'pending' default
so a partially-signed workflow rendered the wrong pill colour. Aligns
the lookup key with the actual status enum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal rendering workflow + signers + events for a signed-PDF file.
Wired to GET /api/v1/documents/[id]/signing-details. The "view signing
details" link on signed-file rows in the Files section opens this
dialog (wiring in the entity-folder view task).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two TanStack Query hooks fetch the entity-aggregated payload for files
and workflows; AggregatedSection renders one labelled subsection per
owner-source group with a Show all (N) button wired via the onShowAll
callback. Dumb component — parent owns the row rendering + drill-
through navigation (Task 15 composes this into EntityFolderView).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
--port without a value (or with a --flag value) previously silently
fell back to all-ports mode because process.argv[indexOf+1] was
undefined. Now exits 1 with an explicit error. Hardens the script
before it gets wired into deploy in Task 17.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Idempotent one-time backfill that runs as part of the deploy:
1. Ensures Clients/Companies/Yachts roots per port.
2. Copies entity FKs from completed workflows onto signed file rows
(legacy completions ran before the auto-deposit handler shipped).
3. Ensures per-entity subfolders for every entity with attached
files and sets files.folder_id.
pg_advisory_xact_lock(hashtext(portId)::bigint) per port so concurrent
runs serialize. Safe to re-run; the SELECT-then-UPDATE pattern targets
only rows where folder_id IS NULL.
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>
Three follow-ups from Task 9 code review:
1. Cross-port isolation test now explicitly asserts the other-port
file's id is absent from the aggregated result (previously only
checked .length > 0, which would pass even with leakage).
2. Refine errors now carry path fields so frontend field-level error
display can target the right form input (matches createDocumentSchema
pattern in the same validators module).
3. Add a service-composition test for the signing-details route's
workflow+signers+events shape — closes the coverage gap for the
thin Promise.all combinator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/v1/files?entityType=client&entityId=… and the same params on
the documents route return the owner-aggregated projection
{ groups: [{ label, source, files|workflows, total }] }. folderId
remains for direct-folder listing; the two modes are mutually
exclusive (zod refine).
GET /api/v1/documents/[id]/signing-details returns
{ workflow, signers, events } for the "view signing details" dialog
on signed-PDF rows.
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>