Commit Graph

734 Commits

Author SHA1 Message Date
76a57b1d6f feat(portal): route-level gate when client_portal_enabled is off
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>
2026-05-11 14:47:46 +02:00
d597e158fe feat(documenso): full v2 endpoint coverage + sequential signing + redirectUrl
Wire up the remaining version-aware paths so a port pointed at Documenso 2.x
takes the v2 endpoint on every CRUD operation, with two new v2-only settings
exposed in admin UI.

documenso-client.ts:
- createDocument: v2 multipart /envelope/create + getDocument follow-up to
  return the full doc shape (v1 path unchanged)
- sendDocument: v2 /envelope/distribute (returns per-recipient signingUrl in
  the same response — eliminates the v1 separate-GET round-trip)
- sendReminder: v2 /envelope/redistribute with recipientIds filter
- downloadSignedPdf: v2 /envelope/{id}/download
- CreateDocumentMeta type: { subject, message, redirectUrl, signingOrder }
  threaded through v1 + v2 paths (v1 ignores signingOrder)

port-config.ts:
- New settings: documenso_signing_order (PARALLEL/SEQUENTIAL, v2-only),
  documenso_redirect_url (both versions honour)
- PortDocumensoConfig gains signingOrder + redirectUrl

documenso-payload.ts:
- DocumensoTemplatePayload.meta gains signingOrder
- buildDocumensoPayload reads from options.signingOrder, omits when null

document-templates.ts (EOI template flow):
- Pass docCfg.signingOrder + docCfg.redirectUrl into buildDocumensoPayload

documents.service.ts (sendForSigning uploaded-doc flow):
- Pass portId to documensoCreate + documensoSend (was missing)
- Thread signingOrder + redirectUrl via the new meta param

Admin Documenso settings page:
- v2 benefits card updated: now lists envelope CRUD, one-call send,
  sequential enforcement, post-sign redirect as wired (was roadmap)
- Roadmap callout pruned to the three remaining deferred items:
  template/use migration, /envelope/update, non-SIGNER recipient roles
- New "v2 signing behaviour" SettingsFormCard with the two new settings

Template flow stays on /api/v1/templates/{id}/generate-document by design —
Documenso 2.x accepts v1 endpoints via backward compat; full migration to
v2 /template/use requires per-template field-ID capture (admin schema work,
deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:38:45 +02:00
ad312df8a4 feat(documenso): v2 coverage on getDocument/health + reminder webhook + admin UI benefits panel
- documenso-client.ts: getDocument now routes to /api/v2/envelope/{id} when port apiVersion=v2; checkDocumensoHealth surfaces resolved apiVersion for the admin Test button
- webhook route: handle DOCUMENT_REMINDER_SENT (structured log only, no audit-table noise) + DOCUMENT_CREATED / DOCUMENT_SENT (informational log)
- Admin Documenso page: prominent v1-vs-v2 explainer card listing v2-only capabilities the CRM already exploits (bulk fields, percent coords, richer fieldMeta, v2 webhook aliases, envelope endpoints) + amber roadmap callout for sequential signing / redirectUrl / template/use / envelope/update / non-SIGNER roles
- CLAUDE.md: idempotency + v2 webhook event list, berth-rules engine section, DOCUMENSO_API_URL gotcha, storage backend listByPrefix + timeout

Still v1-only (call out in admin UI roadmap): createDocument, generateDocumentFromTemplate, sendDocument, sendReminder, downloadSignedPdf. Migrating template/use to v2 requires per-template field-ID mapping in template config; deferred to a follow-up plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:24:40 +02:00
9a5ba87d6c fix(integration): webhook v2 events, storage migrate, test theatre
- 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>
2026-05-11 14:02:26 +02:00
955911302b fix(folders): logging, files-rescue, hard-delete wiring, audit logs
- A6: logger import + warn calls in document-folders.service.ts
- G-C1: re-parent files (not just documents) in deleteFolderSoftRescue
- A4: importer sets files.folder_id (was only setting documents.folder_id)
- A7 + G-C3: demote system folder + nullify scratchpadNotes in client-hard-delete
- Defense-in-depth portId on folder-move UPDATE
- Audit logs for createFolder, syncEntityFolderName, archive/restore suffix
- portId in companies/yachts archive log context
- Row-count telemetry in backfill CLI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:57:42 +02:00
b5ebed9c36 fix(documents-ui): a11y, mobile, realtime lift, type-safety, UI polish
- A2: lift useRealtimeInvalidation to DocumentsHub (covers all 3 render modes)
- B1-B4: aria-label, aria-pressed, aria-expanded, Lock SVG aria-hidden
- C1-C7: Sheet wrap for mobile sidebar, border axis fix, 44x44 tap targets
- Mobile Important: useMobileChrome title, FolderActionsMenu icon size, breadcrumb tap targets, signer email truncate
- Type-safety: ENTITY_TYPES guard, AggregatedSection discriminated union
- UI/UX: em-dash to colon in SigningDetailsDialog, Loading state normalize, StatusPill on HubRootView, view signing details as Button

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:56:54 +02:00
c761b4b911 fix(documents): idempotency, perf, contract pipeline, observability
- 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>
2026-05-11 13:56:46 +02:00
c0e5af8b92 fix(sales): wire missing berth-rule triggers + portal company-billed invoices
- 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>
2026-05-11 13:53:10 +02:00
1b00c8a7a2 feat(db): tighten chk_system_folder_shape, add recommender FK + composite indexes
- Fix A5: chk_system_folder_shape NULL escape
- Fix Audit 17 G-I4: berthRecommendations.interestId FK with cascade
- Add (port_id, client_id) / (port_id, company_id) / (port_id, yacht_id) composite indexes on files + documents for aggregated-projection performance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:47:52 +02:00
0804944647 fix(documents): folderId=empty-string normalises to null at validator
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>
2026-05-11 13:10:19 +02:00
0e8feb1073 chore: prettier format pass on branch files
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>
2026-05-11 13:01:47 +02:00
b6f55636ab chore(documents): remove legacy /documents/files route + folder tree
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>
2026-05-11 12:47:11 +02:00
a4c49f5e5a fix(documents): surface signedFromDocumentId + hub cleanup
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>
2026-05-11 12:44:48 +02:00
631b5d7ed5 feat(documents): rebuild hub — root view + entity-folder view
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>
2026-05-11 12:39:03 +02:00
7f85128dc2 feat(documents): folder tree + actions UI for system-managed folders
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>
2026-05-11 12:34:10 +02:00
13fe3841d1 fix(documents): SigningDetailsDialog — partially_signed key match
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>
2026-05-11 12:32:21 +02:00
2129fbdf15 feat(documents): SigningDetailsDialog
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>
2026-05-11 12:29:25 +02:00
03738bfa9a feat(documents): AggregatedSection + useAggregatedListing
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>
2026-05-11 12:26:57 +02:00
ae3f483cb6 feat(documents): hide completed workflows from folder views
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>
2026-05-11 12:14:51 +02:00
c9f0bdc687 fix(documents): tighten cross-port test + refine paths + signing-details coverage
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>
2026-05-11 12:13:27 +02:00
dec54806cb feat(documents): entity-aggregated query params + signing-details API
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>
2026-05-11 12:06:49 +02:00
d2b0d42e84 fix(documents): tighten aggregation — filter ended memberships + symmetry
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>
2026-05-11 12:02:33 +02:00
3037d832c6 feat(documents): owner-aggregated projection (files + workflows)
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>
2026-05-11 11:54:23 +02:00
8e2e2ea113 fix(documents): tighten owner resolution + cover company/yacht paths
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>
2026-05-11 11:48:44 +02:00
ee6e3f3f3f feat(documents): auto-deposit signed PDFs into entity folders
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>
2026-05-11 11:41:47 +02:00
0412107d86 fix(documents): tighten archive/restore idempotency + document fire-and-forget
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>
2026-05-11 11:38:18 +02:00
4c5dc7ec17 feat(documents): entity-folder archive / restore / demote helpers
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>
2026-05-11 11:34:02 +02:00
3b34b41989 fix(documents): syncEntityFolderName defense-in-depth + log level
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>
2026-05-11 11:30:19 +02:00
86a6944d1c feat(documents): syncEntityFolderName + entity-rename hooks
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>
2026-05-11 11:25:16 +02:00
64d0ae540b feat(documents): block rename/move/delete on system folders
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>
2026-05-11 11:20:21 +02:00
2f3200764a feat(documents): ensureEntityFolder (concurrent-safe + suffix on collision)
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>
2026-05-11 11:14:11 +02:00
a23a9862cc docs(documents): clarify ensureSystemRoots safety invariants
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>
2026-05-11 11:10:47 +02:00
b0831a6872 feat(documents): ensureSystemRoots + wire into createPort
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>
2026-05-11 11:06:41 +02:00
eee4f06737 fix(documents): correct 0051 migration header — backfill ships separately
Header comment said the migration backfills the structure; it doesn't.
Backfill is in scripts/backfill-document-folders.ts (Task 11) so the
schema change can deploy first and the data work runs idempotently
after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:03:53 +02:00
48f6fb94a7 feat(documents): schema for hub split + entity-folder lifecycle
Adds system_managed / entity_type / entity_id / archived_at to
document_folders for the three system roots (Clients/Companies/
Yachts) + per-entity auto-subfolders. Adds files.folder_id so a
file's home is a first-class field (not derived from storagePath
prefix). Partial unique index uniq_document_folders_entity dedupes
entity subfolders per port; chk_system_folder_shape pins the shape
of system rows. Migration is idempotent and ships without backfill —
the backfill script runs as a separate deploy step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:00:40 +02:00
5422f11747 chore: prettier formatter drift across recent commits
Prettier reformatting on files touched in the wave 11.B sequence —
markdown italics _underscore-style_, single-line conditionals, minor
whitespace fixes. No semantic changes. .env.example reformatting left
unstaged (blocked by pre-commit hook).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:57:37 +02:00
ef63e86fde feat(documents): importer for organized S3/filesystem buckets
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>
2026-05-10 16:53:51 +02:00
e790ff708b feat(documents): path-style download URLs for rep-facing readability
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>
2026-05-10 16:50:16 +02:00
cf8bbf3018 fix(documents): defense-in-depth port_id scope + invisible chevron a11y
- 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>
2026-05-10 16:50:02 +02:00
8e06d4549d fix(documents): keep feature-flags query out of realtime invalidation
The feature-flags query previously sat at ['documents', 'feature-flags'],
which the hub's useRealtimeInvalidation([['documents']]) registration
matched via TanStack's default prefix matching. Every document socket
event refetched the flag, silently defeating the 5-minute staleTime.
Move the key to ['documents-feature-flags'] so it sits outside the
prefix; document events no longer trigger a flag refetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:34:51 +02:00
f8fcb8d8ad feat(documents): admin-configurable Expired tab visibility
New documents_show_expired_tab system setting (default true). Public
read via GET /api/v1/documents/feature-flags (gated on documents.view
so reps can read it without holding manage_settings). When off, the
Expired tab is hidden from the documents hub — useful when expired
EOIs are noise that distracts reps from active deals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:30:56 +02:00
c8e6371793 fix(documents): reset type filter on tab/folder switch + label chips
Switching tab or folder while a type filter was active left the
filter applied silently — the chip cloud regenerated from the new
result set so no chip lit up, but the documentType= query param
kept narrowing the list. Reset typeFilter to undefined whenever tab
or selected folder changes.

Also use TYPE_LABELS for chip text so the filter chips match the
human-readable labels already shown in the row's Type column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:27:49 +02:00
433ab3bf75 feat(documents): dynamic type-filter chips + move-to-folder row action
Type-filter chip cloud sourced from the documentTypes seen in the
current result set, replacing the static dropdown over the whole
DOCUMENT_TYPES enum. New "Move to folder…" entry on the per-row
action menu (gated on documents.manage_folders) opens the
MoveToFolderDialog Combobox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:21:14 +02:00
4556a03b8b feat(documents): wire folder sidebar + breadcrumb + In-progress tab
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>
2026-05-10 12:12:53 +02:00
4dd1fa4b24 fix(documents): MoveToFolderDialog — Root search + reset on reopen
cmdk filters by the CommandItem value prop, so the sentinel
"__root__" silently failed to match natural search terms like "no
folder". Use the human label instead. Also reset pickedId when the
dialog re-opens so a cancelled pick doesn't carry a stale highlight
into the next open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:07:48 +02:00
e6103a4473 feat(documents): MoveToFolderDialog single-doc move picker
cmdk Combobox dialog showing all folder paths flat (' / '-separated),
plus a "Root (no folder)" pseudo-option. Move button disabled when the
picked folder matches the document's current folder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:04:24 +02:00
ebede74ca0 fix(documents): FolderActionsMenu — disable on pending + skip no-op rename
Pass loading={deleteMutation.isPending} to ConfirmationDialog so a
second tap on Delete doesn't dispatch a concurrent DELETE. Also
disable the rename Save button when the name hasn't changed, so an
accidental click doesn't fire a no-op PATCH and a misleading
"Folder renamed" toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:02:51 +02:00
bd8bb2e032 feat(documents): FolderActionsMenu (create / rename / delete dialogs)
DropdownMenu trigger with three actions: New folder (works at root or
inside the selected folder), Rename, Delete (confirm-then-soft-rescue).
Delete copy explicitly tells reps the contents move to the parent so
nothing dies silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:59:19 +02:00
d904122498 fix(documents): FolderBreadcrumb a11y — aria-hidden separators + aria-current
Match the existing src/components/ui/breadcrumb.tsx pattern: separator
chevrons are aria-hidden so screen readers don't announce them, and
the terminal segment (Root or current folder name) carries
aria-current="page" so SR users know which crumb is the current page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:57:30 +02:00
dd481e0c7d feat(documents): FolderBreadcrumb header crumb trail
Renders the current folder's path as a clickable breadcrumb with a
Home affordance back to "All documents". Each ancestor is clickable
to navigate up; the last segment is the current folder (non-clickable,
foreground colour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:54:37 +02:00