Files
pn-new-crm/docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md
Matt 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

31 KiB
Raw Blame History

Documents Hub Split + Auto-Filed Client Folders

Status: Draft — awaiting final review Date: 2026-05-10 Builds on: Wave 11.B feat/documents-folders (per-port nestable document_folders tree, soft-rescue delete, sibling-name uniqueness)

Overview

Today the CRM has two parallel document surfaces that confuse reps:

  1. /[port]/documents — Documenso signature workflows only (rows in documents). Hub tabs are signing-status (in_progress / awaiting_them / awaiting_me / completed / expired). Carries the new document_folders tree (Wave 11.B).
  2. /[port]/documents/files — bare uploaded files only (rows in files). Has its own "folder" mechanism driven by storagePath prefix matching, completely disconnected from document_folders.

The signed PDF that Documenso produces lives in the files table (documents.signed_file_id points at it), but it has no folder home and no entity-driven grouping — reps can't find a client's signed contracts without going through the signing workflow row first.

This spec unifies both surfaces under a single hub with a stacked Signing in progress / Files layout, anchored by a per-port nestable folder tree that gains three system-managed roots (Clients/, Companies/, Yachts/). Each entity gets one auto-created subfolder on first need; signed PDFs from completed workflows auto-deposit into the owner's folder. The folder view is owner-aggregated: opening Clients/Smith, John/ surfaces files attached to John, plus files of his linked companies and yachts, each rendered as a labelled subsection.

Conceptual model

Three first-class concepts after this spec ships:

  • File (files row) — a stored binary artifact (PDF/image/etc.) with one folder_id and entity FKs (client_id / company_id / yacht_id). The canonical "document" reps file and find. Produced by either direct upload or as the output of a completed signing workflow.
  • Signing workflow (documents row) — the process of getting a PDF signed via Documenso. Lifecycle draftsentpartially_signedcompleted. Surfaces in the hub's Signing section while in-flight. On completion, produces a signed-PDF file; the workflow row itself becomes audit history accessed via a "view signing details" link on the resulting file. Stops appearing in user-facing folder views.
  • Folder (document_folders row) — per-port nestable tree (existing). Extended to hold both files and in-flight workflows. Gains three system-managed roots and per-entity auto-subfolders.

documents.folder_id stays meaningful for in-flight workflows (rep can file by deal/project). Becomes irrelevant on completion — the rendering layer hides completed workflows from folder views entirely.

files.folder_id is new (not in current schema) — added by this spec.

Scope boundaries

In scope

  • New files.folder_id column + index, FK to document_folders.id
  • document_folders schema additions: system_managed, entity_type, entity_id, archived_at
  • Three system roots (Clients/, Companies/, Yachts/) auto-created on port init
  • Lazy per-entity subfolder creation on first auto-deposit or first manual upload
  • Auto-deposit logic in handleDocumentCompleted (set files.folder_id + entity FKs on signed PDF)
  • Owner-resolution chain (Owner-wins: client_id ?? company_id ?? yacht_id on workflow, falling back to interest)
  • Owner-aggregation projection in the files & documents listing endpoints
  • Symmetric relationship walking (Client ↔ Company ↔ Yacht via memberships and ownership)
  • Hub UI rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder 🔒 markers
  • "View signing details" dialog on signed-PDF file rows
  • System-folder protection: rename/move/delete blocked at API + UI
  • Entity rename auto-syncs system folder name (transactional)
  • Entity archive applies (archived) suffix; entity hard-delete demotes to user folder with (deleted) suffix
  • Search box scope: current folder + descendants, results across both Signing and Files
  • Hub root view (no folder selected): port-wide Signing + recent Files
  • One-time backfill script: ensure system folders exist, set files.folder_id from entity FKs, copy entity FKs from completed workflows onto signed files
  • Removal of /[port]/documents/files route (301 redirect to /[port]/documents)
  • Removal of the legacy storagePath-prefix folder rendering

Explicitly out of scope

  • Permission/role changes beyond what documents.view and documents.manage_folders already gate
  • Bulk file actions (multi-select move, multi-select download zip) — separate work
  • Tagging or labels on files — separate work
  • Trash / restore for hard-deleted files (current behavior preserved)
  • Search across file content (full-text PDF search) — current behavior preserved (search is title/filename only)
  • Per-port admin override for aggregation symmetry (rejected as needless setting at E11)
  • Per-user feature flag rollout — hard cutover (E rollout decision)
  • Native PDF preview rebuild — existing FilePreviewDialog reused

Folder tree structure & governance

System-managed roots and subfolders

Three reserved root folders are auto-created when a port is initialised:

Clients/
Companies/
Yachts/

Per-entity subfolders are created lazily on first need — when a workflow completes for that entity, when a rep manually uploads a file scoped to that entity, or when a rep clicks "Open folder" on the entity's detail page. Empty entities don't appear in the tree.

Subfolder naming:

  • Default name = entity display name (client firstName lastName / company name / yacht name).
  • Numeric collision suffix: Smith, John (2), Smith, John (3), etc. Suffix appended to the new (later-created) folder; existing folder names never change due to collision.
  • Auto-rename on entity rename — runs in the same DB transaction as the entity update.
  • Entity archive: (archived) suffix appended, folder shown muted in tree, auto-deposit blocked until restored.
  • Entity hard-delete: (deleted) suffix appended, system_managed flipped to false (folder demoted to a regular user folder; rep can rename/move/delete normally).

System-folder protection

When system_managed = true:

  • Rename API rejects with ConflictError("System folders can't be renamed").
  • Move API rejects with ConflictError("System folders can't be moved").
  • Delete API rejects with ConflictError("System folders can't be deleted").
  • UI hides rename/move/delete actions in FolderActionsMenu for these rows.
  • UI displays a 🔒 marker next to the folder name.

The three roots themselves (Clients/ / Companies/ / Yachts/) are also system_managed = true and protected identically.

User folders

User-created folders sit alongside the three system roots and inside any other folder (subject to existing depth/cycle rules from Wave 11.B). Standard CRUD via documents.manage_folders permission. Examples reps will create: Templates/, Compliance/, Marketing PDFs/.

Routing on workflow completion

handleDocumentCompleted (in src/app/api/webhooks/documenso/route.ts) currently:

  1. Verifies the Documenso secret.
  2. Downloads the fully signed PDF.
  3. Creates a files row for the signed PDF.
  4. Sets documents.signed_file_id to the new file id.
  5. Updates documents.status = 'completed'.

This spec extends the handler with steps 3a, 3b, 3c — inserted between (3) and (4):

3a. resolveOwner(workflow):
      candidates = [
        workflow.client_id,
        workflow.company_id,
        workflow.yacht_id,
        workflow.interest?.primary_client_id,
        workflow.interest?.primary_company_id,
        workflow.interest?.primary_yacht_id,
      ]
      return first non-null candidate (with its entity_type) OR null

3b. if owner != null:
      folder = ensureEntityFolder(port_id, owner.entity_type, owner.entity_id)
      // INSERT … ON CONFLICT (port_id, entity_type, entity_id) DO NOTHING RETURNING id
      // re-SELECT on conflict to get the existing folder's id
      file.folder_id = folder.id
      // copy entity FK to file row if not already set (so aggregation reads file FKs as source of truth)
      file[`${owner.entity_type}_id`] ??= owner.entity_id

3c. if owner == null:
      file.folder_id remains null
      // file lives at root, surfaced in the root-view Files section

Owner resolution happens at completion time, not creation time — if the rep edited the workflow's owner mid-signing (rare), the signed PDF lands in the most recent owner's folder.

The workflow's own folder_id is not touched. After status = 'completed', the rendering layer hides the workflow from folder views; only the resulting signed file is visible (with a "view signing details" link to the workflow + signers + events timeline).

Owner-aggregation projection

The killer feature. When a rep opens an entity folder (Clients/Smith, John/), the listing query is not a simple WHERE folder_id = … — it's a projection that walks the relationship graph and groups results by owner-source.

Aggregation graph

Aggregation is symmetric (E aggregation reach decision). Walking from any entity, surface files attached to:

  • the entity itself (DIRECTLY ATTACHED)
  • linked clients via company_memberships
  • linked companies via company_memberships and via yacht ownership
  • linked yachts via current ownership (yachts.current_owner_type + current_owner_id)
    • any second-degree links (e.g., Clients/Smith shows files of Smith Marine LLC's yachts via the chain Smith → Smith Marine LLC → owned yachts)

Each result group is rendered with a labelled header: DIRECTLY ATTACHED · 3, FROM COMPANY — SMITH MARINE LLC · 1, FROM YACHT — MV SERENITY · 2, etc. Files lived where they were physically filed (e.g., Yachts/MV Serenity/); the aggregation only borrows them for display, with a lives in <path> caption per row.

Source-of-truth: file FKs

Aggregation reads each file's own client_id / company_id / yacht_id (snapshotted at upload/creation time), not the linked entity's current relationships. This makes yacht ownership transfer a no-op for historical files: a file uploaded for John when he owned MV Serenity stays under John's view forever, even after the yacht is sold to Mary. Mary's view shows files uploaded after the transfer (which carry client_id = Mary). Both clients' folders coexist with their respective historical artifacts.

Per-group pagination

Each owner-source group renders its top 20 rows by created_at desc. When a group has more, a Show all (148) link drills into a flat paginated list scoped to that source. Keeps page render bounded for large portfolios (200+ yacht leasing clients).

Defense-in-depth port_id

Every join in the aggregation SQL filters port_id = $port — at the entity table, at the membership table, at the yacht table, at the file table. Project pattern (per CLAUDE.md "defense-in-depth port_id scope" / berth recommender precedent). Single-place port_id check at the entry point alone is rejected — it bit the recommender exactly once and we fixed it the same way.

UI layout

Layout A: stacked sections, owner-labelled groups inside each

Confirmed in mockup review.

┌─────────────────────────────────────────────────────────────────────┐
│ /port-nimara/documents → Clients / Smith, John 🔒                   │
├──────────────┬──────────────────────────────────────────────────────┤
│  FOLDERS     │  Clients  Smith, John 🔒          [Upload] [+ Sign] │
│              │                                                       │
│  📁 Clients  │  ⏳ SIGNING IN PROGRESS · 2                          │
│   📁 Smith…🔒│   FROM CLIENT                                         │
│   📁 …       │   ▢ EOI · Berth A12 · sent 2d ago     Awaiting them  │
│  📁 Companies│   FROM YACHT — MV SERENITY                            │
│  📁 Yachts   │   ▢ NDA · sent yesterday              Awaiting them  │
│              │                                                       │
│  📁 Templates│  📎 FILES                                             │
│  📁 Complian.│   DIRECTLY ATTACHED · 3                               │
│              │   ▢ Signed EOI · A11.pdf  signed Apr 14 · view sig…  │
│  + New folder│   ▢ Passport scan.pdf    uploaded Mar 2              │
│              │                                                       │
│              │   FROM COMPANY — SMITH MARINE LLC · 1                 │
│              │   ▢ Articles of inc.pdf  · lives in Companies/…       │
│              │                                                       │
│              │   FROM YACHT — MV SERENITY · 2                        │
│              │   ▢ Signed NDA.pdf       · lives in Yachts/…          │
│              │   ▢ Survey report.pdf    · lives in Yachts/…          │
└──────────────┴──────────────────────────────────────────────────────┘

Layout primitives:

  • Left panel: existing FolderTree extended for 🔒 markers and system_managed-aware action suppression (rename/move/delete hidden in FolderActionsMenu).
  • Main panel: breadcrumb + actions row, then stacked Signing/Files sections. Each section has its in-section grouped headers.
  • Signing section: hidden entirely when no in-flight workflows match the entity scope. When present, renders above Files.
  • Files section: always present (may be empty with placeholder).
  • "View signing details" link: appears on rows for signed-PDF files (those whose source can be traced via documents.signed_file_id). Click opens <SigningDetailsDialog> — modal showing signers, events, timeline, signed-at timestamps.

Hub root view (no folder selected)

Default landing when rep clicks Documents in the sidebar:

  • Signing section: all in-flight workflows port-wide (effectively today's /[port]/documents hub behavior, minus the signing-status sub-tabs which collapse).
  • Files section: recently uploaded/modified files port-wide, paginated by updated_at desc.

The folder tree on the left is the primary navigation; root view is the "I just opened the hub, show me what's recent" landing.

Old /[port]/documents/files route

Removed. Server-side 301 redirect to /[port]/documents. The <Files…> components and the legacy storagePath-prefix folder code are deleted.

Hub-tab simplification

Today's signing-status tabs (in_progress / eoi_queue / awaiting_them / awaiting_me / completed / expired) collapse into one Signing section — the rep will filter by signer-status via in-section chips if needed, but the dominant navigation is folders, not signing-status. The documentsHubTabs enum + tab query param are removed; hub-counts API endpoint is reduced to "in-flight count" only (used for the Signing section's counter badge).

Edge cases — decisions

ID Edge case Decision
E1 Entity renamed System folder name auto-syncs in the same transaction.
E2 Two entities collide on folder name (e.g., both "Smith, John") Append numeric suffix (2), (3) to the new colliding folder. Existing folders never change.
E3 Entity archived Folder stays with (archived) suffix, muted style. Auto-deposit halts.
E4 Entity hard-deleted Folder gets (deleted) suffix, system_managed flips to false (rep can clean up). Files retain orphaned data.
E5 Yacht ownership transferred Files snapshot their entity FKs at upload time. Old client folder retains historical files; new client gets a new folder for files created after transfer. Both coexist.
E6 Workflow's owner FK changes mid-signing Resolve owner at completion time. Signed PDF lands in current owner's folder.
E7 Rep moves a file out of a system folder Allowed. folder_id changes; entity FK is unchanged so aggregation still surfaces it via FK. The "lives in …" caption updates.
E8 Rep manually uploads into an entity folder Auto-set the file's matching entity FK from the destination folder's entity_type + entity_id. Custom folders → no auto-mapping.
E9 Workflow has no entity at all Signed PDF lands at root with folder_id = null. Surfaces in root-view Files section only.
E10 File/workflow attached to interest only, interest has no resolved owner Same as E9 — root, null folder. Manual move or future backfill resolves later.
E11 Aggregated view returns 1000+ files Top 20 per owner-source group, Show all (N) drilldown into flat paginated list per source.
E12 Hub root view (no folder selected) Port-wide Signing + recent Files, both paginated.
E13 Concurrent completions race for the same entity folder INSERT … ON CONFLICT DO NOTHING RETURNING id, then re-SELECT if needed. Uses the new partial unique index uniq_document_folders_entity.
E14 Cross-port aggregation leak port_id = $p filter at every join in aggregation SQL. Defense-in-depth.
Lazy folder creation When are system root + per-entity folders created? Roots: on port init. Subfolders: lazy on first need (auto-deposit, manual upload, or "Open folder" button on entity page).
Aggregation reach Symmetric or owner-down only? Symmetric — walk relationships in both directions. Clients/Smith/, Companies/Smith Marine LLC/, Yachts/MV Serenity/ all show the full graph from their vantage point.
Search scope Where does the search box look? Current folder + descendants. Empty/root selection → port-wide. Includes both Signing and Files results.
Rollout Feature flag or hard cutover? Hard cutover. Migration backfills data; new hub replaces old hub on merge.

Schema deltas

files table

ALTER TABLE files
  ADD COLUMN folder_id text REFERENCES document_folders(id) ON DELETE SET NULL;

CREATE INDEX idx_files_folder ON files(folder_id);
CREATE INDEX idx_files_port_folder ON files(port_id, folder_id);

document_folders table

ALTER TABLE document_folders
  ADD COLUMN system_managed boolean NOT NULL DEFAULT false,
  ADD COLUMN entity_type text,           -- null | 'root' | 'client' | 'company' | 'yacht'
  ADD COLUMN entity_id text,             -- null when entity_type is null or 'root'
  ADD COLUMN archived_at timestamptz;    -- mirrors entity archive state

-- Per-port uniqueness on (entity_type, entity_id) for entity subfolders.
-- Excludes 'root' folders (handled by name uniqueness already in place).
CREATE UNIQUE INDEX uniq_document_folders_entity
  ON document_folders(port_id, entity_type, entity_id)
  WHERE entity_id IS NOT NULL;

-- Enforce: system_managed=true requires either entity_type='root' OR (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL).
ALTER TABLE document_folders
  ADD CONSTRAINT chk_system_folder_shape CHECK (
    NOT system_managed OR
    entity_type = 'root' OR
    (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL)
  );

Backfill migration (one-time data migration script)

Runs as part of the deploy. Idempotent — safe to re-run.

  1. For every port: ensure Clients/, Companies/, Yachts/ exist with system_managed=true, entity_type='root'.
  2. For every (client | company | yacht) entity that has at least one file or completed workflow attached: ensure its subfolder exists.
  3. For every file with a non-null client_id / company_id / yacht_id: set folder_id to the matching subfolder via owner-resolution (Owner-wins).
  4. For every completed workflow with signed_file_id: ensure the signed file's entity FKs are populated by copying from the workflow row (handles legacy completions where the signed file row was created without entity FKs).
  5. Files with no entity FKs → folder_id left null.

Script: pnpm tsx scripts/backfill-document-folders.ts. Wraps in pg_advisory_xact_lock(<port_id_hash>) per port to serialize concurrent runs.

Implementation surface (preview, full breakdown in the plan)

Service layer

  • src/lib/services/document-folders.service.ts
    • ensureEntityFolder(portId, entityType, entityId) — INSERT-ON-CONFLICT + re-SELECT
    • ensureSystemRoots(portId) — idempotent root creation
    • syncEntityFolderName(portId, entityType, entityId, newName) — called from entity update services
    • applyEntityArchivedSuffix(portId, entityType, entityId) / applyEntityRestoredSuffix(...) — toggle (archived) suffix
    • demoteSystemFolderOnEntityDelete(portId, entityType, entityId) — flip system_managed=false, append (deleted) suffix
  • src/lib/services/files.service.ts
    • listFilesInFolder(portId, folderId, opts) — direct listing (folder_id match)
    • listFilesAggregatedByEntity(portId, entityType, entityId, opts) — owner-grouped projection
    • applyEntityFkFromFolder(portId, folderId, fileInsert) — used by upload endpoints (E8)
  • src/lib/services/documents.service.ts
    • listInflightWorkflowsAggregatedByEntity(...) — same projection for in-flight workflows
  • src/lib/services/clients.service.ts / companies.service.ts / yachts.service.ts
    • Add hooks to call syncEntityFolderName on rename, applyEntityArchivedSuffix on archive/restore, demoteSystemFolderOnEntityDelete on hard delete

API routes

  • src/app/api/v1/files/route.ts — accept folderId (direct) or entityType + entityId (aggregated) query params
  • src/app/api/v1/documents/route.ts — same; collapse tab enum to a signingState filter (in-flight only by default)
  • src/app/api/v1/documents/hub-counts/route.ts — reduce to in-flight count
  • src/app/api/v1/documents/[id]/signing-details/route.tsnew — returns workflow + signers + events for the dialog
  • src/app/api/webhooks/documenso/route.ts (handleDocumentCompleted) — extend with owner-resolve + ensure-folder + set-FK steps

UI components

  • src/components/documents/documents-hub.tsx — major rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder integration. Drop the signing-status tabs.
  • src/components/documents/folder-tree.tsx — render 🔒 marker for system_managed; suppress rename/move/delete in FolderActionsMenu for system rows
  • src/components/documents/aggregated-section.tsxnew — renders a Signing or Files section grouped by owner-source with per-group pagination
  • src/components/documents/signing-details-dialog.tsxnew — modal for "view signing details"
  • src/app/(dashboard)/[portSlug]/documents/files/page.tsxdeleted, replaced by 301 redirect in next.config.mjs
  • src/components/files/folder-tree.tsx and the legacy storagePath-prefix logic — deleted

Stores / hooks

  • src/stores/file-browser-store.ts — repurposed to drive the unified hub state (currentFolder, viewMode); the legacy storagePath-keyed currentFolder semantics are replaced with document_folders.id references

Testing strategy

Unit (vitest)

  • document-folders.service.test.ts: extend with system-folder tests — ensureEntityFolder idempotency, syncEntityFolderName collision (numeric suffix), applyEntityArchivedSuffix round-trip, demoteSystemFolderOnEntityDelete flips system_managed.
  • files.service.aggregated.test.ts: aggregation projection — symmetric walk, defense-in-depth port_id, per-group pagination, file-FK-as-source-of-truth (yacht transfer scenario).
  • documents-completion.handler.test.ts: handleDocumentCompleted with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner).

Integration (vitest + real Postgres)

  • documents-hub-system-folders.integration.test.ts: API-level — listing aggregated, system folder protection (rename/move/delete return 4xx), entity rename round-trips, archive/delete lifecycle.
  • backfill-document-folders.integration.test.ts: backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows.

E2E (Playwright)

  • documents-hub-aggregated.smoke.spec.ts: open client folder → see grouped Signing + Files → open signing-details dialog → close.
  • documents-hub-upload-into-entity-folder.smoke.spec.ts: upload PDF into Clients/Smith/ → verify client_id auto-set → verify file appears in entity folder.
  • documents-hub-completion-auto-deposit.realapi.spec.ts: round-trip Documenso completion → verify signed PDF lands in owner's entity folder. (Joins the existing realapi project.)

Visual

  • Regenerate baselines for /[port]/documents (root view) and /[port]/documents with a folder selected. Snapshot key: hub-root, hub-entity-folder.

Risks and mitigations

Risk Mitigation
Aggregation queries slow on large portfolios (5k+ files per client) Per-group pagination caps render cost; supporting indexes on files(port_id, client_id), files(port_id, company_id), files(port_id, yacht_id) already exist; new files(folder_id) and files(port_id, folder_id) cover folder filtering
Backfill migration locks production for too long Per-port advisory lock; backfill batched in chunks of 1000 file rows per transaction; safe to re-run if interrupted
System-folder protection bypass via direct DB write Application-level enforcement; we accept that direct DB writes can bypass (no DB constraint enforces "you can't update system_managed=true rows"). Audit log entries on folder ops surface anomalies
Hard cutover means broken hub if backfill fails Backfill is idempotent and runs before code rollout; if backfill fails, the new hub still renders (just with sparse folders); rollback = revert the migration + redeploy old hub binary
Rep confused by "view signing details" link disappearing for non-Documenso signed files (e.g., manually uploaded "already signed" PDFs via /upload-signed) The link shows only when signed_file_id traces to a documents row; manually-uploaded "signed" PDFs that bypass the workflow won't have the link, which is correct — there's nothing to show

Open questions deferred to plan

  • Whether to add a "Signing status" filter chip strip inside the Signing section (the deferred replacement for awaiting_them/awaiting_me tabs). Default: defer; add if rep feedback asks for it.
  • Whether Signing section in entity folders should also surface workflows whose interest_id resolves to the entity (not just direct entity FK match). Default: yes, via the same Owner-wins resolution chain — codify in the projection helper.