From 40e3db237ddad34e9db76703daa1c70e8c1ebefe Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 May 2026 10:57:46 +0200 Subject: [PATCH] docs(plans): documents hub split + auto-filed client folders 19-task implementation plan layering on top of Wave 11.B. Builds three system-managed roots (Clients/Companies/Yachts), per-entity auto- subfolders, Documenso auto-deposit on completion, owner-aggregated projection (symmetric reach, file-FK source of truth, defense-in-depth port_id), and the hub UI rebuild. Hard cutover; backfill via idempotent script. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-documents-hub-split-and-client-folders.md | 4369 +++++++++++++++++ 1 file changed, 4369 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md diff --git a/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md b/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md new file mode 100644 index 00000000..ff65ace6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md @@ -0,0 +1,4369 @@ +# Documents Hub Split + Auto-Filed Client Folders Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the parallel `/[port]/documents` (Documenso signing rows) and `/[port]/documents/files` (bare uploads) surfaces with a single unified hub anchored by a per-port folder tree that has three system-managed roots (`Clients/` / `Companies/` / `Yachts/`), auto-creates per-entity subfolders, auto-deposits Documenso-signed PDFs into the owner's folder, and renders entity folders as an owner-aggregated projection (DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT groups, each paginated). + +**Architecture:** Build on top of Wave 11.B's `document_folders` table (per-port nestable tree, soft-rescue delete, sibling-name uniqueness). Add `system_managed` / `entity_type` / `entity_id` / `archived_at` columns to `document_folders`; add `folder_id` to `files`. New service helpers (`ensureSystemRoots`, `ensureEntityFolder`, `syncEntityFolderName`, `applyEntityArchivedSuffix`, `demoteSystemFolderOnEntityDelete`, `listFilesAggregatedByEntity`, `listInflightWorkflowsAggregatedByEntity`) drive the auto-deposit + projection logic. `handleDocumentCompleted` extends to resolve the owner and ensure the entity folder before assigning `signed_file_id`. Hub UI rebuilds around a stacked Signing-in-progress / Files layout; legacy `/files` route 301-redirects; storagePath-prefix folder tree is deleted. Hard cutover — backfill runs as part of the deploy migration, no feature flag. + +**Tech Stack:** Next.js 15 App Router (typedRoutes), TypeScript strict (noUncheckedIndexedAccess), Drizzle ORM on PostgreSQL, TanStack React Query, shadcn/ui (Radix Dialog, Command, Popover, Collapsible), Vitest (unit + integration), Playwright (smoke + visual). + +**Source design:** `docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md` (commit `286eb51`). Read end-to-end before starting — the spec captures every locked decision (edge cases E1–E14, aggregation reach, rollout strategy, governance). + +**Builds on:** Wave 11.B (branch `feat/documents-folders`, already merged into current branch). Tasks 1–19 in `docs/superpowers/plans/2026-05-09-documents-folders.md` are **done**; this plan continues on the same branch without altering Wave 11.B's commits. + +**Decisions locked (from the spec):** + +- **Rollout:** hard cutover, no feature flag, backfill runs in the migration. +- **Aggregation reach:** symmetric (Client ↔ Company ↔ Yacht walk in both directions). +- **Source of truth for aggregation:** snapshotted file FKs (`files.client_id` / `files.company_id` / `files.yacht_id`), not the linked entity's current relationships. +- **Per-group pagination:** top 20 by `created_at desc`, `Show all (N)` drilldown into a flat paginated list scoped to the source. +- **System folder governance:** rename/move/delete blocked at API + UI when `system_managed = true`; UI shows 🔒 marker. +- **Entity rename:** syncs system folder name in the same transaction. +- **Entity archive:** `(archived)` suffix, muted style, auto-deposit halts. +- **Entity hard-delete:** `(deleted)` suffix + `system_managed = false` (demoted to user folder). +- **Concurrency for entity folders:** `INSERT … ON CONFLICT (port_id, entity_type, entity_id) DO NOTHING RETURNING id` + re-`SELECT` on conflict, backed by partial unique index `uniq_document_folders_entity`. +- **Cross-port leakage:** defense-in-depth `port_id` filter at every join in aggregation SQL. +- **Search scope:** current folder + descendants; empty/root selection → port-wide; spans both Signing and Files. +- **Completed workflows in folder views:** hidden — only the signed-PDF file appears, with a "view signing details" link to the workflow audit trail. + +**Out of scope (explicit, from spec):** + +- Permission changes beyond existing `documents.view` + `documents.manage_folders`. +- Bulk file actions (multi-select move, zip download). +- File tagging / labels. +- Trash / restore for hard-deleted files (current behavior preserved). +- Full-text PDF content search (title/filename only, as today). +- Per-port admin override for aggregation symmetry. +- Native PDF preview rebuild (existing `FilePreviewDialog` reused). + +**Conventions to honour (from `CLAUDE.md`):** + +- Strict TypeScript, no `any`. Unused vars prefixed `_`. +- Prettier: single quotes, semicolons, trailing commas, 100-char width. +- Body parsing: ALWAYS use `parseBody(req, schema)` from `@/lib/api/route-helpers`. +- Response envelope: `{ data: }` for content; `204 No Content` for no-body mutations. Errors go through `errorResponse(error)` from `@/lib/errors`. +- Schema migrations during dev: after `db:push` or `psql -f migrations/...`, restart `next dev` to flush stale prepared-statement column lists. +- Service-tested handlers go in sibling `handlers.ts` when integration tests need to bypass middleware. +- Defense-in-depth `port_id` filter at every join (per CLAUDE.md + recommender precedent). +- Pre-commit hook blocks `.env*` files; never `--no-verify`. + +--- + +## File Structure + +**Schema (1 file modified, 1 migration created):** + +- Modify: `src/lib/db/schema/documents.ts` — add `systemManaged`, `entityType`, `entityId`, `archivedAt` columns to `documentFolders`; add `folderId` column to `files`; partial unique index `uniq_document_folders_entity`; CHECK constraint `chk_system_folder_shape`. +- Create: `src/lib/db/migrations/0051_documents_hub_split.sql` — column adds, partial unique index, CHECK constraint, supporting indexes on `files.folder_id`, backfill DML. + +**Service layer (3 files modified, 0 created):** + +- Modify: `src/lib/services/document-folders.service.ts` — add `ensureSystemRoots`, `ensureEntityFolder`, `syncEntityFolderName`, `applyEntityArchivedSuffix`, `applyEntityRestoredSuffix`, `demoteSystemFolderOnEntityDelete`. Extend `renameFolder` / `moveFolder` / `deleteFolderSoftRescue` to reject when `system_managed = true`. +- Modify: `src/lib/services/files.ts` — add `listFilesInFolder`, `listFilesAggregatedByEntity`, `applyEntityFkFromFolder` (E8 auto-mapping). Extend `uploadFile` to call `applyEntityFkFromFolder` when `folderId` is set. +- Modify: `src/lib/services/documents.service.ts` — extend `handleDocumentCompleted` with owner-resolve + ensure-folder + entity-FK-copy steps (3a–3c). Add `listInflightWorkflowsAggregatedByEntity`. Hide `status='completed'` workflows from `listDocuments` when `folderId` is set. +- Modify: `src/lib/services/ports.service.ts` — call `ensureSystemRoots(port.id, port.id /* system user */)` after `createPort` insert. +- Modify: `src/lib/services/clients.service.ts` — call `syncEntityFolderName` on rename in `updateClient`; `applyEntityArchivedSuffix` in `archiveClient`; `applyEntityRestoredSuffix` in `restoreClient`. +- Modify: `src/lib/services/companies.service.ts` — same hooks in `updateCompany` / `archiveCompany` / restore. +- Modify: `src/lib/services/yachts.service.ts` — same hooks in `updateYacht` / `archiveYacht`. + +**Validators (2 files modified):** + +- Modify: `src/lib/validators/documents.ts` — extend `listDocumentsSchema` with `entityType` + `entityId` query params (mutually exclusive with `folderId`). +- Modify: `src/lib/validators/files.ts` — extend list/upload schemas with `folderId` + `entityType` + `entityId` query params. + +**API routes (3 files modified, 1 created):** + +- Modify: `src/app/api/v1/documents/route.ts` — accept `entityType + entityId` query params; route to aggregated projection or flat list. +- Modify: `src/app/api/v1/files/route.ts` — same. +- Modify: `src/app/api/v1/document-folders/[id]/route.ts` — return `400 ConflictError` when caller tries to rename / move / delete a `system_managed = true` folder (handled in the service; route just needs to pass `userId` through). +- Create: `src/app/api/v1/documents/[id]/signing-details/route.ts` — `GET` returns `{ workflow, signers, events }` for the signing-details dialog. Wraps `getDocumentDetail` from `documents.service.ts`. + +**Webhook handler (1 file modified):** + +- Modify: `src/lib/services/documents.service.ts:handleDocumentCompleted` — extend with steps 3a (resolve owner via the Owner-wins chain), 3b (ensure entity folder), 3c (set `files.folder_id` + copy entity FKs onto the signed file). The same handler is called by `src/app/api/webhooks/documenso/route.ts:187` and `src/jobs/processors/documenso-poll.ts:59` — no changes needed at those call sites. + +**UI components (5 files created, 4 files modified, 2 files deleted):** + +- Create: `src/components/documents/aggregated-section.tsx` — renders a Signing or Files section grouped by owner-source with per-group pagination + per-row "lives in " caption. +- Create: `src/components/documents/signing-details-dialog.tsx` — modal showing workflow + signers + events for a signed-PDF file row. +- Create: `src/hooks/use-aggregated-listing.ts` — TanStack Query wrapper for the aggregated projection endpoint (Signing + Files). +- Create: `src/components/documents/hub-root-view.tsx` — port-wide root landing (no folder selected): recent Signing + recent Files sections, both paginated. +- Create: `src/components/documents/entity-folder-view.tsx` — composes `AggregatedSection × 2` (one for Signing, one for Files) when the selected folder is a system-managed entity subfolder. +- Modify: `src/components/documents/documents-hub.tsx` — major rebuild: stacked Signing/Files sections, drop signing-status tabs and `documentsHubTabs` enum, branch on `selectedFolder.entityType` to render `EntityFolderView` vs the plain folder listing vs `HubRootView`. +- Modify: `src/components/documents/folder-tree-sidebar.tsx` — render 🔒 marker for `system_managed`; show muted style for `archived_at != null`. +- Modify: `src/components/documents/folder-actions-menu.tsx` — disable rename / move / delete buttons when the selected folder is `system_managed = true`; show a tooltip explaining why. +- Modify: `src/components/documents/document-list.tsx` (or wherever the per-row Move action lives) — add the "view signing details" link on rows that represent signed-PDF files. +- Delete: `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` — replaced by a 301 redirect in `next.config.mjs`. +- Delete: `src/components/files/folder-tree.tsx` — legacy `storagePath`-prefix folder rendering, no longer used. + +**Stores (1 modified):** + +- Modify: `src/stores/file-browser-store.ts` — drop the `storagePath`-keyed `currentFolder` state (still used by `/files` page today). Repurpose as: `selectedFolderId` (the `document_folders.id` ref or `null` / `undefined`). + +**Backfill / migration support (1 created):** + +- Create: `scripts/backfill-document-folders.ts` — one-time idempotent script (also invoked from the migration). Per port: ensure 3 system roots; ensure subfolders for every entity with attached files or completed workflows; set `files.folder_id` from entity FKs; copy entity FKs from completed workflows onto signed `files` rows. Wraps in `pg_advisory_xact_lock()` per port. + +**Routing (1 modified):** + +- Modify: `next.config.mjs` — add a permanent redirect `/[portSlug]/documents/files` → `/[portSlug]/documents`. + +**Tests (8 created, 3 modified):** + +- Create: `tests/unit/document-folders-system-folders.test.ts` — `ensureEntityFolder` idempotency, `syncEntityFolderName` collision (numeric suffix), `applyEntityArchivedSuffix` round-trip, `demoteSystemFolderOnEntityDelete` flips `system_managed`, system-folder rename/move/delete rejected. +- Create: `tests/unit/aggregated-projection.test.ts` — `listFilesAggregatedByEntity` symmetric walk, per-group pagination, file-FK-as-source-of-truth (yacht-transfer scenario). +- Create: `tests/integration/documents-completion-auto-deposit.test.ts` — `handleDocumentCompleted` with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner) — verifies the signed file row gets `folder_id` set + entity FKs copied. +- Create: `tests/integration/documents-hub-system-folders.test.ts` — API-level: aggregated listing, system folder protection (rename/move/delete return 4xx), entity rename round-trips folder name, archive/restore lifecycle. +- Create: `tests/integration/files-folder-aggregation.test.ts` — `GET /api/v1/files?entityType=client&entityId=…` returns the owner-aggregated payload with correct group counts. +- Create: `tests/integration/backfill-document-folders.test.ts` — backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows. +- Create: `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts` — open Clients/Smith/, see grouped Signing + Files, click "view signing details" → dialog opens. +- Create: `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts` — upload PDF into Clients/Smith/, verify `client_id` auto-set and file appears in the entity folder. +- Modify: `tests/e2e/visual/snapshots.spec.ts` — add `hub-root` and `hub-entity-folder` snapshots; regenerate baselines after intentional UI changes. +- Modify: `tests/integration/documents-list-folder-filter.test.ts` — assert completed workflows are hidden when `folderId` is set. +- Modify: `tests/e2e/realapi/eoi-documenso-completion.spec.ts` (or whichever realapi spec covers the round-trip) — assert the signed PDF lands in the owner's entity folder. + +**Docs (1 modified):** + +- Modify: `CLAUDE.md` — extend the existing "Document folders" subsection with: system-managed roots + entity subfolders, owner-aggregation projection, file-FK-as-source-of-truth invariant for aggregation, defense-in-depth `port_id` filter in aggregation SQL. + +--- + +## Execution order + +Tasks are ordered so each one ships a self-contained increment. Hard prerequisite chains (schema → service → API → UI) are respected, but inside each layer tasks are independent and can be parallelised by `subagent-driven-development`. + +1. **Task 1** — Schema: column adds + partial unique index + CHECK constraint (migration `0051`). +2. **Task 2** — Service: `ensureSystemRoots` + port-init wiring. +3. **Task 3** — Service: `ensureEntityFolder` (concurrent-safe). +4. **Task 4** — Service: system-folder protection (extend `renameFolder` / `moveFolder` / `deleteFolderSoftRescue`). +5. **Task 5** — Service: `syncEntityFolderName` + collision suffixing + wire into clients / companies / yachts services. +6. **Task 6** — Service: archive / restore / hard-delete suffix helpers + wire into entity services. +7. **Task 7** — Webhook: extend `handleDocumentCompleted` with owner-resolve + ensure-folder + entity-FK-copy steps. +8. **Task 8** — Service: aggregated projection (`listFilesAggregatedByEntity` + `listInflightWorkflowsAggregatedByEntity`). +9. **Task 9** — API: `files` + `documents` routes accept `entityType + entityId` query params; new `signing-details` route. +10. **Task 10** — API: hide completed workflows from `listDocuments` when `folderId` is set. +11. **Task 11** — Backfill script + idempotency tests. +12. **Task 12** — UI: `AggregatedSection` component + `useAggregatedListing` hook. +13. **Task 13** — UI: `SigningDetailsDialog` + per-row "view signing details" link. +14. **Task 14** — UI: `FolderTreeSidebar` + `FolderActionsMenu` system-folder awareness (🔒 marker, archived muted, action suppression). +15. **Task 15** — UI: `HubRootView` + `EntityFolderView` + rebuild `DocumentsHub` to compose them. +16. **Task 16** — Files page removal + 301 redirect + legacy `folder-tree.tsx` deletion. +17. **Task 17** — Backfill on deploy: run the script from the migration (or as a step in deploy). +18. **Task 18** — E2E: smoke + visual snapshots. +19. **Task 19** — CLAUDE.md update + final verification (`pnpm exec tsc --noEmit` + full vitest + playwright smoke). + +--- + +## Task 1: Schema — `document_folders` system columns + `files.folder_id` + +**Files:** + +- Modify: `src/lib/db/schema/documents.ts` +- Create: `src/lib/db/migrations/0051_documents_hub_split.sql` + +- [ ] **Step 1: Extend `documentFolders` table definition** + +In `src/lib/db/schema/documents.ts`, replace the existing `documentFolders` declaration (the block beginning `export const documentFolders = pgTable(...)`) with one that adds four columns and one partial unique index: + +```typescript +export const documentFolders = pgTable( + 'document_folders', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + portId: text('port_id') + .notNull() + .references(() => ports.id), + parentId: text('parent_id'), + name: text('name').notNull(), + /** True = folder is managed by the system (one of the three roots + * Clients/Companies/Yachts, or an auto-created entity subfolder). + * System folders reject rename/move/delete at the API layer. Demoted + * to false when the owning entity is hard-deleted. */ + systemManaged: boolean('system_managed').notNull().default(false), + /** null | 'root' | 'client' | 'company' | 'yacht'. 'root' is the + * three system roots; the entity values mark per-entity subfolders. */ + entityType: text('entity_type'), + /** Null when entityType is null or 'root'; the entity's id otherwise. + * Combined with entityType to dedupe entity folders per port. */ + entityId: text('entity_id'), + /** Mirrors the entity's archive state. Non-null = folder muted in UI + * and auto-deposit halted. Cleared on entity restore. */ + archivedAt: timestamp('archived_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('idx_document_folders_port').on(table.portId), + index('idx_document_folders_parent').on(table.parentId), + uniqueIndex('uniq_document_folders_sibling_name').on( + table.portId, + sql`COALESCE(${table.parentId}, '__root__')`, + sql`LOWER(${table.name})`, + ), + // One subfolder per entity per port. Excludes 'root' folders (the + // three system roots are deduped by sibling-name uniqueness). + uniqueIndex('uniq_document_folders_entity') + .on(table.portId, table.entityType, table.entityId) + .where(sql`${table.entityId} IS NOT NULL`), + ], +); +``` + +Make sure `boolean` is in the import list at the top of the file — it should already be imported (the `files` table uses it elsewhere). If not, add `boolean` to the `drizzle-orm/pg-core` import. + +- [ ] **Step 2: Add `folderId` column to `files` table definition** + +In the same file, find the `files` table declaration (~line 21) and add `folderId` next to the existing entity-FK columns. Also add an index on `(port_id, folder_id)` for the aggregated lookup: + +```typescript +clientId: text('client_id').references(() => clients.id), +yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }), +companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }), +folderId: text('folder_id').references((): AnyPgColumn => documentFolders.id, { + onDelete: 'set null', +}), +``` + +And inside the `(table) => [...]` index list: + +```typescript +index('idx_files_folder').on(table.folderId), +index('idx_files_port_folder').on(table.portId, table.folderId), +``` + +`AnyPgColumn` is already imported at the top of the file (`documents.folderId` uses it). + +- [ ] **Step 3: Verify TypeScript compiles** + +Run: `pnpm exec tsc --noEmit` +Expected: clean exit (no output). + +- [ ] **Step 4: Write the migration SQL** + +Create `src/lib/db/migrations/0051_documents_hub_split.sql`: + +```sql +-- Wave 11.B+: documents hub split + auto-filed client folders. +-- Adds system-managed folder lifecycle columns to document_folders +-- (Clients/Companies/Yachts roots + per-entity subfolders), adds the +-- folder_id pointer to files, and backfills the structure for every +-- existing port + file. Idempotent — safe to re-run. + +-- ─── document_folders: lifecycle columns ────────────────────────────────── +ALTER TABLE "document_folders" + ADD COLUMN IF NOT EXISTS "system_managed" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "entity_type" text, + ADD COLUMN IF NOT EXISTS "entity_id" text, + ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone; + +-- Shape guard: system_managed=true implies a known shape. Either a root +-- (entity_type='root', entity_id null) or a per-entity subfolder +-- (entity_type in {client,company,yacht} AND entity_id NOT NULL). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_system_folder_shape' + ) THEN + 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) + ); + END IF; +END $$; + +-- Partial unique index: one subfolder per (port, entity_type, entity_id). +-- Excludes root folders (entity_id IS NULL) — those are deduped by the +-- existing sibling-name uniqueness index. +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_document_folders_entity" + ON "document_folders" ("port_id", "entity_type", "entity_id") + WHERE "entity_id" IS NOT NULL; + +-- ─── files: folder pointer ──────────────────────────────────────────────── +ALTER TABLE "files" + ADD COLUMN IF NOT EXISTS "folder_id" text REFERENCES "document_folders" ("id") + ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS "idx_files_folder" ON "files" ("folder_id"); +CREATE INDEX IF NOT EXISTS "idx_files_port_folder" ON "files" ("port_id", "folder_id"); +``` + +The migration intentionally does NOT include the data backfill — the backfill is in a separate script (`scripts/backfill-document-folders.ts`, Task 11) so the schema change can deploy first and the backfill can be re-run idempotently after. + +- [ ] **Step 5: Apply the migration to the dev database** + +Run: + +```bash +PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \ + -f src/lib/db/migrations/0051_documents_hub_split.sql +``` + +Expected output: `ALTER TABLE`, `DO`, `CREATE UNIQUE INDEX`, `ALTER TABLE`, `CREATE INDEX` × 2. No errors. + +If `next dev` is running, restart it (per CLAUDE.md — postgres.js prepared statement cache). + +- [ ] **Step 6: Sanity check the schema** + +Run: + +```bash +PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \ + -c "\d document_folders" \ + -c "\d files" +``` + +Expected: `document_folders` shows `system_managed`, `entity_type`, `entity_id`, `archived_at` columns plus the new partial unique index and the check constraint. `files` shows `folder_id` plus the two new indexes. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/db/schema/documents.ts src/lib/db/migrations/0051_documents_hub_split.sql +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +## Task 2: Service — `ensureSystemRoots` + wire into port creation + +**Files:** + +- Modify: `src/lib/services/document-folders.service.ts` +- Modify: `src/lib/services/ports.service.ts` +- Test: `tests/unit/document-folders-system-folders.test.ts` + +- [ ] **Step 1: Write the failing test for `ensureSystemRoots`** + +Create `tests/unit/document-folders-system-folders.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documentFolders } from '@/lib/db/schema/documents'; +import { ensureSystemRoots } from '@/lib/services/document-folders.service'; +import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; + +describe('document-folders service · ensureSystemRoots', () => { + let portId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + }); + + it('creates Clients, Companies, and Yachts root folders with system_managed=true', async () => { + await ensureSystemRoots(portId, TEST_USER_ID); + const rows = await db + .select() + .from(documentFolders) + .where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root'))); + expect(rows.map((r) => r.name).sort()).toEqual(['Clients', 'Companies', 'Yachts']); + for (const r of rows) { + expect(r.systemManaged).toBe(true); + expect(r.parentId).toBeNull(); + expect(r.entityId).toBeNull(); + } + }); + + it('is idempotent — second call does not create duplicates', async () => { + await ensureSystemRoots(portId, TEST_USER_ID); + await ensureSystemRoots(portId, TEST_USER_ID); + const rows = await db + .select() + .from(documentFolders) + .where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root'))); + expect(rows).toHaveLength(3); + }); + + it('returns the three root rows in a stable order (Clients, Companies, Yachts)', async () => { + const roots = await ensureSystemRoots(portId, TEST_USER_ID); + expect(roots.map((r) => r.name)).toEqual(['Clients', 'Companies', 'Yachts']); + }); +}); +``` + +Adjust the import path for `setupTestPort` / `TEST_USER_ID` to match whatever helper layout the integration tests use — read `tests/integration/document-folders-crud.test.ts` for the convention (Wave 11.B uses this same fixture file). + +- [ ] **Step 2: Run the test — expect import failure** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts` +Expected: `ensureSystemRoots` is not exported by `document-folders.service.ts` — module-level error. + +- [ ] **Step 3: Implement `ensureSystemRoots`** + +Append to `src/lib/services/document-folders.service.ts`: + +```typescript +const SYSTEM_ROOT_NAMES = ['Clients', 'Companies', 'Yachts'] as const; +type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number]; + +/** + * Idempotently create the three system root folders for a port + * (`Clients/`, `Companies/`, `Yachts/`). Returns the rows in stable + * order. Safe to call on every port-init and on every backfill run. + * + * Uses INSERT … ON CONFLICT … DO NOTHING via the sibling-name unique + * index (`uniq_document_folders_sibling_name`) so a concurrent caller + * can't race two inserts of the same root. Re-SELECTs on conflict so + * the return shape is always populated. + */ +export async function ensureSystemRoots( + portId: string, + userId: string, +): Promise { + // Try to insert all three; collect existing ids on conflict. + const values = SYSTEM_ROOT_NAMES.map((name) => ({ + portId, + parentId: null, + name, + systemManaged: true, + entityType: 'root' as const, + entityId: null, + createdBy: userId, + })); + + await db.insert(documentFolders).values(values).onConflictDoNothing({ + target: [ + documentFolders.portId, + sql`COALESCE(${documentFolders.parentId}, '__root__')`, + sql`LOWER(${documentFolders.name})`, + ], + }); + + // Re-SELECT — the rows that already existed are not in `.returning()` + // when ON CONFLICT DO NOTHING is used. SELECT is the authoritative + // post-write state. + const rows = await db + .select() + .from(documentFolders) + .where( + and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')), + ); + + // Preserve SYSTEM_ROOT_NAMES order for callers (stable test assertions + // and a stable UI render order). + return SYSTEM_ROOT_NAMES.map((name) => { + const row = rows.find((r) => r.name === name); + if (!row) throw new Error(`ensureSystemRoots: missing root ${name} after upsert`); + return row; + }); +} +``` + +You'll also need to add `sql` to the imports at the top of the file (it's not currently imported). Update the import line: + +```typescript +import { and, asc, eq, sql } from 'drizzle-orm'; +``` + +- [ ] **Step 4: Run the test — expect pass** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts` +Expected: 3/3 pass. + +- [ ] **Step 5: Wire `ensureSystemRoots` into `createPort`** + +Open `src/lib/services/ports.service.ts`. Find `createPort` (~line 23). After the `.returning()` insert, before the audit log call (or before the function returns), call `ensureSystemRoots`: + +```typescript +// After: const [row] = await db.insert(ports).values(...).returning(); +// Before: createAuditLog(...) / return row; + +await ensureSystemRoots(row.id, meta.userId); +``` + +Add the import at the top: + +```typescript +import { ensureSystemRoots } from '@/lib/services/document-folders.service'; +``` + +Read the existing file first to get the exact structure — line 23 is `createPort`'s signature, but the insert + audit-log block sits inside it. Place the call after the row insert succeeds and before any return. + +- [ ] **Step 6: Verify the wiring with a smoke test** + +Run: `pnpm exec tsc --noEmit && pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts` +Expected: clean tsc + 3/3 pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/services/document-folders.service.ts src/lib/services/ports.service.ts tests/unit/document-folders-system-folders.test.ts +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +## Task 3: Service — `ensureEntityFolder` (concurrent-safe) + +**Files:** + +- Modify: `src/lib/services/document-folders.service.ts` +- Test: `tests/unit/document-folders-system-folders.test.ts` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/document-folders-system-folders.test.ts`: + +```typescript +import { ensureEntityFolder } from '@/lib/services/document-folders.service'; +import { clients } from '@/lib/db/schema/clients'; + +describe('document-folders service · ensureEntityFolder', () => { + let portId: string; + let clientId: string; + let rootId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + const roots = await ensureSystemRoots(portId, TEST_USER_ID); + rootId = roots.find((r) => r.name === 'Clients')!.id; + + const [client] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); + clientId = client!.id; + }); + + it('creates a subfolder under the matching system root with system_managed=true', async () => { + const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + expect(folder.systemManaged).toBe(true); + expect(folder.entityType).toBe('client'); + expect(folder.entityId).toBe(clientId); + expect(folder.parentId).toBe(rootId); + expect(folder.name).toBe('Smith, John'); // lastName, firstName per the entity-display convention + }); + + it('is idempotent — returns the same row on second call', async () => { + const a = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + const b = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + expect(a.id).toBe(b.id); + const all = await db + .select() + .from(documentFolders) + .where(and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId))); + expect(all).toHaveLength(1); + }); + + it('appends a numeric suffix on name collision with an existing folder', async () => { + // Pre-seed a folder with the same name (e.g., a second client called John Smith) + const [collidingClient] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); + await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + const second = await ensureEntityFolder(portId, 'client', collidingClient!.id, TEST_USER_ID); + expect(second.name).toBe('Smith, John (2)'); + }); + + it('rejects unknown entity types', async () => { + await expect( + // @ts-expect-error -- runtime check + ensureEntityFolder(portId, 'boat', clientId, TEST_USER_ID), + ).rejects.toThrow(/entity type/i); + }); +}); +``` + +- [ ] **Step 2: Run the test — expect failure** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t ensureEntityFolder` +Expected: `ensureEntityFolder` is not exported. + +- [ ] **Step 3: Implement `ensureEntityFolder`** + +Append to `src/lib/services/document-folders.service.ts`: + +```typescript +import { clients } from '@/lib/db/schema/clients'; +import { companies } from '@/lib/db/schema/companies'; +import { yachts } from '@/lib/db/schema/yachts'; + +export type EntityType = 'client' | 'company' | 'yacht'; +const ENTITY_TYPES = new Set(['client', 'company', 'yacht']); + +/** + * Returns the display name for an entity, in the form used by the + * Clients/Companies/Yachts subfolders. Clients render as "LastName, + * FirstName" (matches the rep-facing list views); companies + yachts + * use their `name` column verbatim. + */ +async function resolveEntityDisplayName( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + if (entityType === 'client') { + const c = await db.query.clients.findFirst({ + where: and(eq(clients.id, entityId), eq(clients.portId, portId)), + columns: { firstName: true, lastName: true }, + }); + if (!c) throw new NotFoundError('Client'); + return `${c.lastName ?? ''}, ${c.firstName ?? ''}`.trim().replace(/^,\s*|,\s*$/, ''); + } + if (entityType === 'company') { + const co = await db.query.companies.findFirst({ + where: and(eq(companies.id, entityId), eq(companies.portId, portId)), + columns: { name: true }, + }); + if (!co) throw new NotFoundError('Company'); + return co.name; + } + // yacht + const y = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)), + columns: { name: true }, + }); + if (!y) throw new NotFoundError('Yacht'); + return y.name; +} + +/** + * Idempotently create the per-entity subfolder under the matching + * system root (`Clients/` / `Companies/` / `Yachts/`). Returns the + * folder row regardless of whether it was newly created or already + * existed. Concurrent callers race safely via the partial unique + * index `uniq_document_folders_entity` — the loser INSERT does + * nothing and the re-SELECT returns the winner's row. + * + * On sibling-name collision (two entities want the same name), appends + * a numeric suffix `(2)`, `(3)`, …, until the insert succeeds. The + * `system_managed` flag stays true on the suffixed folder. + */ +export async function ensureEntityFolder( + portId: string, + entityType: EntityType, + entityId: string, + userId: string, +): Promise { + if (!ENTITY_TYPES.has(entityType)) { + throw new ValidationError(`Unknown entity type: ${entityType}`); + } + + // Fast path: row already exists. + const existing = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, entityType), + eq(documentFolders.entityId, entityId), + ), + }); + if (existing) return existing; + + // Locate the system root for this entity type. + const rootName: SystemRootName = + entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts'; + const root = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, 'root'), + eq(documentFolders.name, rootName), + ), + }); + if (!root) { + // Self-heal: the port-init hook may have been skipped (legacy port). + await ensureSystemRoots(portId, userId); + return ensureEntityFolder(portId, entityType, entityId, userId); + } + + const baseName = await resolveEntityDisplayName(portId, entityType, entityId); + + // Try the base name first; on sibling-name collision, append (2), (3)... + for (let attempt = 0; attempt < 50; attempt += 1) { + const candidate = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`; + try { + const [row] = await db + .insert(documentFolders) + .values({ + portId, + parentId: root.id, + name: candidate, + systemManaged: true, + entityType, + entityId, + createdBy: userId, + }) + .returning(); + if (!row) throw new Error('ensureEntityFolder: insert returned no row'); + return row; + } catch (err) { + // If another caller won the entity-id race, re-SELECT and return their row. + if (isEntityFolderConflict(err)) { + const winner = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, entityType), + eq(documentFolders.entityId, entityId), + ), + }); + if (winner) return winner; + } + // Sibling-name collision (different entity, same name) → bump suffix and retry. + if (isSiblingNameConflict(err)) continue; + throw err; + } + } + throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`); +} + +function isEntityFolderConflict(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const e = err as { code?: unknown; constraint_name?: unknown; constraint?: unknown }; + if (e.code !== '23505') return false; + return (e.constraint_name ?? e.constraint) === 'uniq_document_folders_entity'; +} +``` + +The helper imports (`clients`, `companies`, `yachts` schemas) and the existing `isSiblingNameConflict` already in the file are reused. Make sure `NotFoundError` is in the imports at the top — it is. + +- [ ] **Step 4: Run the test — expect pass** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t ensureEntityFolder` +Expected: 4/4 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/services/document-folders.service.ts tests/unit/document-folders-system-folders.test.ts +git commit -m "$(cat <<'EOF' +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. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Service — system-folder protection on rename / move / delete + +**Files:** + +- Modify: `src/lib/services/document-folders.service.ts` +- Test: `tests/unit/document-folders-system-folders.test.ts` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/document-folders-system-folders.test.ts`: + +```typescript +import { + deleteFolderSoftRescue, + moveFolder, + renameFolder, +} from '@/lib/services/document-folders.service'; + +describe('document-folders service · system folder protection', () => { + let portId: string; + let rootId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + const roots = await ensureSystemRoots(portId, TEST_USER_ID); + rootId = roots.find((r) => r.name === 'Clients')!.id; + }); + + it('rejects rename of a system-managed root', async () => { + await expect(renameFolder(portId, rootId, 'Customers', TEST_USER_ID)).rejects.toThrow( + /system folder/i, + ); + }); + + it('rejects move of a system-managed root', async () => { + const other = await ensureSystemRoots(portId, TEST_USER_ID); + const companies = other.find((r) => r.name === 'Companies')!; + await expect(moveFolder(portId, rootId, companies.id, TEST_USER_ID)).rejects.toThrow( + /system folder/i, + ); + }); + + it('rejects delete of a system-managed root', async () => { + await expect(deleteFolderSoftRescue(portId, rootId, TEST_USER_ID)).rejects.toThrow( + /system folder/i, + ); + }); + + it('allows rename/delete of a user folder under a system root', async () => { + // Create a normal subfolder under Clients/ (user-managed). + const user = await db + .insert(documentFolders) + .values({ + portId, + parentId: rootId, + name: 'Templates', + systemManaged: false, + createdBy: TEST_USER_ID, + }) + .returning(); + await expect( + renameFolder(portId, user[0]!.id, 'My Templates', TEST_USER_ID), + ).resolves.toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run the tests — expect 3 failures** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'system folder protection'` +Expected: the rename/move/delete protection tests fail (they currently succeed because there's no guard). + +- [ ] **Step 3: Add the protection guard helper** + +In `src/lib/services/document-folders.service.ts`, add this internal helper near the top of the file (after `isSiblingNameConflict`): + +```typescript +/** + * Throws ConflictError if the folder is system-managed. Centralises the + * rejection so rename/move/delete all surface identical error shapes. + */ +async function assertNotSystemManaged( + portId: string, + folderId: string, + action: 'rename' | 'move' | 'delete', +): Promise { + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), + }); + if (!folder) throw new NotFoundError('Folder'); + if (folder.systemManaged) { + const verb = + action === 'rename' ? 'renamed' : action === 'move' ? 'moved' : 'deleted'; + throw new ConflictError(`System folders can't be ${verb}`); + } + return folder; +} +``` + +- [ ] **Step 4: Wire the guard into `renameFolder`, `moveFolder`, `deleteFolderSoftRescue`** + +Replace the existing existence check at the start of each function with a call to `assertNotSystemManaged`. For `renameFolder` (current line ~120), replace: + +```typescript +const existing = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), +}); +if (!existing) throw new NotFoundError('Folder'); +``` + +with: + +```typescript +const existing = await assertNotSystemManaged(portId, folderId, 'rename'); +``` + +For `moveFolder` (current line ~168), replace: + +```typescript +const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), +}); +if (!folder) throw new NotFoundError('Folder'); +``` + +with: + +```typescript +const folder = await assertNotSystemManaged(portId, folderId, 'move'); +``` + +For `deleteFolderSoftRescue` (current line ~242), replace: + +```typescript +const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), +}); +if (!folder) throw new NotFoundError('Folder'); +``` + +with: + +```typescript +const folder = await assertNotSystemManaged(portId, folderId, 'delete'); +``` + +- [ ] **Step 5: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'system folder protection'` +Expected: 4/4 pass. Also run the full file to verify the other tests still pass: + +```bash +pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 6: Run the wider folder test suite to catch regressions** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts tests/integration/document-folders-soft-delete.test.ts` +Expected: all Wave 11.B tests still pass (the new guard is fail-closed but doesn't alter the user-folder paths). + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/services/document-folders.service.ts tests/unit/document-folders-system-folders.test.ts +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +## Task 5: Service — `syncEntityFolderName` + wire into entity rename + +**Files:** + +- Modify: `src/lib/services/document-folders.service.ts` +- Modify: `src/lib/services/clients.service.ts` +- Modify: `src/lib/services/companies.service.ts` +- Modify: `src/lib/services/yachts.service.ts` +- Test: `tests/unit/document-folders-system-folders.test.ts` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/document-folders-system-folders.test.ts`: + +```typescript +import { syncEntityFolderName } from '@/lib/services/document-folders.service'; + +describe('document-folders service · syncEntityFolderName', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + const [client] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); + clientId = client!.id; + await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + }); + + it('renames the entity subfolder when the entity is renamed', async () => { + await db + .update(clients) + .set({ firstName: 'Jonathan' }) + .where(eq(clients.id, clientId)); + await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID); + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + }); + expect(folder?.name).toBe('Smith, Jonathan'); + }); + + it('is a no-op when the folder does not exist (lazy creation)', async () => { + const otherPort = await setupTestPort(); + await ensureSystemRoots(otherPort, TEST_USER_ID); + const [otherClient] = await db + .insert(clients) + .values({ + portId: otherPort, + firstName: 'Jane', + lastName: 'Doe', + email: `jane-${crypto.randomUUID()}@example.com`, + }) + .returning(); + // No folder created. Sync should not throw. + await expect( + syncEntityFolderName(otherPort, 'client', otherClient!.id, TEST_USER_ID), + ).resolves.toBeUndefined(); + }); + + it('appends numeric suffix on rename collision (target name already taken)', async () => { + // Create a second client called Smith, Jane and give them a folder. + const [collider] = await db + .insert(clients) + .values({ + portId, + firstName: 'Jane', + lastName: 'Smith', + email: `jane-${crypto.randomUUID()}@example.com`, + }) + .returning(); + await ensureEntityFolder(portId, 'client', collider!.id, TEST_USER_ID); + + // Rename John → Jane (collision with the other Smith, Jane). + await db + .update(clients) + .set({ firstName: 'Jane' }) + .where(eq(clients.id, clientId)); + await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID); + + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + }); + expect(folder?.name).toBe('Smith, Jane (2)'); + }); +}); +``` + +- [ ] **Step 2: Run the tests — expect failure** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t syncEntityFolderName` +Expected: `syncEntityFolderName` not exported. + +- [ ] **Step 3: Implement `syncEntityFolderName`** + +Append to `src/lib/services/document-folders.service.ts`: + +```typescript +/** + * Rename the per-entity subfolder to match the entity's current display + * name. Called from the entity rename services (`updateClient`, + * `updateCompany`, `updateYacht`). No-op when the folder does not exist + * (lazy creation — entities without a folder skip the sync entirely). + * + * Sibling-name collision is resolved by suffix bump (matches + * `ensureEntityFolder` semantics). + * + * Intentionally does NOT call `assertNotSystemManaged` — this helper + * is the legitimate path for renaming a system folder. + */ +export async function syncEntityFolderName( + portId: string, + entityType: EntityType, + entityId: string, + _userId: string, +): Promise { + if (!ENTITY_TYPES.has(entityType)) return; + + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, entityType), + eq(documentFolders.entityId, entityId), + ), + }); + if (!folder) return; // Lazy creation — nothing to sync yet. + + // Preserve archived suffix if present. + const isArchived = folder.name.endsWith(' (archived)'); + const isDeleted = folder.name.endsWith(' (deleted)'); + if (isDeleted) return; // Demoted; rep owns the name now. + + const baseName = await resolveEntityDisplayName(portId, entityType, entityId); + const targetSuffix = isArchived ? ' (archived)' : ''; + + for (let attempt = 0; attempt < 50; attempt += 1) { + const candidate = + attempt === 0 + ? `${baseName}${targetSuffix}` + : `${baseName} (${attempt + 1})${targetSuffix}`; + if (candidate === folder.name) return; // No-op rename. + try { + const [updated] = await db + .update(documentFolders) + .set({ name: candidate, updatedAt: new Date() }) + .where(eq(documentFolders.id, folder.id)) + .returning(); + if (updated) return; + } catch (err) { + if (isSiblingNameConflict(err)) continue; + throw err; + } + } + throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`); +} +``` + +- [ ] **Step 4: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t syncEntityFolderName` +Expected: 3/3 pass. + +- [ ] **Step 5: Wire into `clients.service.ts:updateClient`** + +Open `src/lib/services/clients.service.ts` and find `updateClient` (~line 489). The function takes a partial update object. After the `db.update(clients).set(...).returning()` succeeds, but **only** when `firstName` or `lastName` is in the update payload, call: + +```typescript +import { syncEntityFolderName } from '@/lib/services/document-folders.service'; + +// Inside updateClient, after the .returning() call: +if (data.firstName !== undefined || data.lastName !== undefined) { + // Best-effort — folder sync must not fail the entity update. + await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => { + logger.error({ err, clientId: id }, 'Failed to sync client folder name'); + }); +} +``` + +Read the actual `updateClient` signature first — `meta.userId` and `logger` are the conventions in the codebase; adjust if the file uses a different name. `logger` is imported at the top of most service files; if it isn't here, add: `import { logger } from '@/lib/logger';`. + +- [ ] **Step 6: Wire into `companies.service.ts:updateCompany`** + +Same pattern in `src/lib/services/companies.service.ts` (~line 133). The trigger condition is `data.name !== undefined`: + +```typescript +if (data.name !== undefined) { + await syncEntityFolderName(portId, 'company', id, meta.userId).catch((err) => { + logger.error({ err, companyId: id }, 'Failed to sync company folder name'); + }); +} +``` + +- [ ] **Step 7: Wire into `yachts.service.ts:updateYacht`** + +Same pattern in `src/lib/services/yachts.service.ts` (~line 113): + +```typescript +if (data.name !== undefined) { + await syncEntityFolderName(portId, 'yacht', id, meta.userId).catch((err) => { + logger.error({ err, yachtId: id }, 'Failed to sync yacht folder name'); + }); +} +``` + +- [ ] **Step 8: Verify TypeScript compiles** + +Run: `pnpm exec tsc --noEmit` +Expected: clean exit. + +- [ ] **Step 9: Run the full folder test suite + entity service tests** + +Run: + +```bash +pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts tests/integration/document-folders-crud.test.ts tests/integration/clients tests/integration/companies tests/integration/yachts +``` + +Expected: all pass. (The `.catch` swallows folder errors so failing sync paths don't break entity updates.) + +- [ ] **Step 10: Commit** + +```bash +git add src/lib/services/document-folders.service.ts src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts tests/unit/document-folders-system-folders.test.ts +git commit -m "$(cat <<'EOF' +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 fields change. 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) +EOF +)" +``` + +--- + +## Task 6: Service — archive / restore / hard-delete suffix helpers + +**Files:** + +- Modify: `src/lib/services/document-folders.service.ts` +- Modify: `src/lib/services/clients.service.ts` (archive/restore/delete hooks) +- Modify: `src/lib/services/companies.service.ts` (same) +- Modify: `src/lib/services/yachts.service.ts` (same) +- Test: `tests/unit/document-folders-system-folders.test.ts` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/document-folders-system-folders.test.ts`: + +```typescript +import { + applyEntityArchivedSuffix, + applyEntityRestoredSuffix, + demoteSystemFolderOnEntityDelete, +} from '@/lib/services/document-folders.service'; + +describe('document-folders service · archive lifecycle', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + const [client] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); + clientId = client!.id; + await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + }); + + it('appends (archived) suffix and stamps archived_at on archive', async () => { + await applyEntityArchivedSuffix(portId, 'client', clientId); + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + }); + expect(folder?.name).toBe('Smith, John (archived)'); + expect(folder?.archivedAt).toBeInstanceOf(Date); + expect(folder?.systemManaged).toBe(true); // still system-managed + }); + + it('removes (archived) suffix and clears archived_at on restore', async () => { + await applyEntityArchivedSuffix(portId, 'client', clientId); + await applyEntityRestoredSuffix(portId, 'client', clientId); + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + }); + expect(folder?.name).toBe('Smith, John'); + expect(folder?.archivedAt).toBeNull(); + }); + + it('appends (deleted) and flips system_managed=false on entity hard-delete', async () => { + await demoteSystemFolderOnEntityDelete(portId, 'client', clientId); + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + }); + expect(folder?.name).toBe('Smith, John (deleted)'); + expect(folder?.systemManaged).toBe(false); + }); + + it('is a no-op when the folder does not exist', async () => { + const otherPort = await setupTestPort(); + await ensureSystemRoots(otherPort, TEST_USER_ID); + const [other] = await db + .insert(clients) + .values({ + portId: otherPort, + firstName: 'Lone', + lastName: 'Wolf', + email: `lone-${crypto.randomUUID()}@example.com`, + }) + .returning(); + // No folder created; archive should not throw. + await expect( + applyEntityArchivedSuffix(otherPort, 'client', other!.id), + ).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run the tests — expect failure** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'archive lifecycle'` +Expected: helpers not exported. + +- [ ] **Step 3: Implement the three helpers** + +Append to `src/lib/services/document-folders.service.ts`: + +```typescript +const ARCHIVED_SUFFIX = ' (archived)'; +const DELETED_SUFFIX = ' (deleted)'; + +/** + * Stamp an entity's subfolder as archived: append " (archived)" to the + * name (idempotent — won't double-append) and set archived_at. No-op + * when the folder does not exist (lazy creation). Used by the entity + * archive paths in clients / companies / yachts services. + */ +export async function applyEntityArchivedSuffix( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + if (!ENTITY_TYPES.has(entityType)) return; + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, entityType), + eq(documentFolders.entityId, entityId), + ), + }); + if (!folder) return; + const newName = folder.name.endsWith(ARCHIVED_SUFFIX) + ? folder.name + : `${folder.name}${ARCHIVED_SUFFIX}`; + await db + .update(documentFolders) + .set({ name: newName, archivedAt: new Date(), updatedAt: new Date() }) + .where(eq(documentFolders.id, folder.id)); +} + +/** + * Inverse of `applyEntityArchivedSuffix` — strip " (archived)" from + * the name and clear archived_at. No-op when the folder does not + * exist or wasn't archived. Used by the entity restore paths. + */ +export async function applyEntityRestoredSuffix( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + if (!ENTITY_TYPES.has(entityType)) return; + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, entityType), + eq(documentFolders.entityId, entityId), + ), + }); + if (!folder) return; + const newName = folder.name.endsWith(ARCHIVED_SUFFIX) + ? folder.name.slice(0, -ARCHIVED_SUFFIX.length) + : folder.name; + await db + .update(documentFolders) + .set({ name: newName, archivedAt: null, updatedAt: new Date() }) + .where(eq(documentFolders.id, folder.id)); +} + +/** + * Entity has been hard-deleted: demote the folder to a regular user + * folder by clearing `system_managed`, appending " (deleted)" to the + * name, and dropping the entity FK so the partial unique index no + * longer constrains it. Files still inside the folder retain their + * snapshotted entity FKs (orphaned — they appear in the root-view + * Files section once the rep cleans up). + * + * Idempotent: re-demoting an already-demoted folder is a no-op. + */ +export async function demoteSystemFolderOnEntityDelete( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + if (!ENTITY_TYPES.has(entityType)) return; + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, entityType), + eq(documentFolders.entityId, entityId), + ), + }); + if (!folder) return; + const stripped = folder.name.endsWith(ARCHIVED_SUFFIX) + ? folder.name.slice(0, -ARCHIVED_SUFFIX.length) + : folder.name; + const newName = stripped.endsWith(DELETED_SUFFIX) ? stripped : `${stripped}${DELETED_SUFFIX}`; + await db + .update(documentFolders) + .set({ + name: newName, + systemManaged: false, + entityType: null, + entityId: null, + archivedAt: null, + updatedAt: new Date(), + }) + .where(eq(documentFolders.id, folder.id)); +} +``` + +- [ ] **Step 4: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'archive lifecycle'` +Expected: 4/4 pass. + +- [ ] **Step 5: Wire into `clients.service.ts:archiveClient` and `restoreClient`** + +In `src/lib/services/clients.service.ts`, add the import: + +```typescript +import { + applyEntityArchivedSuffix, + applyEntityRestoredSuffix, +} from '@/lib/services/document-folders.service'; +``` + +Inside `archiveClient` (~line 537), after the entity update succeeds: + +```typescript +await applyEntityArchivedSuffix(portId, 'client', id).catch((err) => { + logger.error({ err, clientId: id }, 'Failed to apply archived suffix to client folder'); +}); +``` + +Inside `restoreClient` (~line 565), after the entity update succeeds: + +```typescript +await applyEntityRestoredSuffix(portId, 'client', id).catch((err) => { + logger.error({ err, clientId: id }, 'Failed to clear archived suffix on client folder'); +}); +``` + +- [ ] **Step 6: Wire archive into companies + yachts services** + +In `companies.service.ts:archiveCompany` (~line 189): + +```typescript +import { + applyEntityArchivedSuffix, + applyEntityRestoredSuffix, +} from '@/lib/services/document-folders.service'; + +// After the entity update: +await applyEntityArchivedSuffix(portId, 'company', id).catch((err) => { + logger.error({ err, companyId: id }, 'Failed to apply archived suffix to company folder'); +}); +``` + +If `restoreCompany` exists, wire it too with `applyEntityRestoredSuffix`. If it doesn't exist (grep first: `grep -n 'restoreCompany\|export async function restore' src/lib/services/companies.service.ts`), skip restore for companies — note the gap in the commit message. + +Same for `yachts.service.ts:archiveYacht` (~line 167) — wire archive with `applyEntityArchivedSuffix('yacht', …)`. Grep for `restoreYacht`; if absent, skip. + +- [ ] **Step 7: Wire hard-delete demote into delete paths** + +Grep for the delete service functions: + +```bash +grep -n 'export async function delete' src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts +``` + +If a hard-delete function exists (e.g., `deleteClient` outside the archive path), add `demoteSystemFolderOnEntityDelete(portId, '', id)` before the entity row is removed. If only soft-delete exists (the codebase prefers archiveX), skip Task 6 Step 7 — the suffix helper is still ready for whenever hard-delete lands. + +- [ ] **Step 8: Verify TypeScript compiles + run the full folder suite** + +Run: + +```bash +pnpm exec tsc --noEmit && pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts tests/integration/document-folders-crud.test.ts tests/integration/document-folders-soft-delete.test.ts +``` + +Expected: clean tsc + all pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/lib/services/document-folders.service.ts src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts tests/unit/document-folders-system-folders.test.ts +git commit -m "$(cat <<'EOF' +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 but do not fail the entity-update operation. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Webhook — extend `handleDocumentCompleted` with auto-deposit + +**Files:** + +- Modify: `src/lib/services/documents.service.ts` +- Test: `tests/integration/documents-completion-auto-deposit.test.ts` + +- [ ] **Step 1: Write the failing integration tests** + +Create `tests/integration/documents-completion-auto-deposit.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documents, files, documentFolders } from '@/lib/db/schema/documents'; +import { clients } from '@/lib/db/schema/clients'; +import { interests } from '@/lib/db/schema/interests'; +import { handleDocumentCompleted } from '@/lib/services/documents.service'; +import { ensureSystemRoots } from '@/lib/services/document-folders.service'; +import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; + +// Stub the Documenso download so the handler doesn't hit the network. +vi.mock('@/lib/services/documenso-client', async (orig) => { + const real = await orig(); + return { + ...real, + downloadSignedPdf: vi.fn(async () => Buffer.from('%PDF-1.4 stub\n')), + }; +}); + +describe('handleDocumentCompleted · auto-deposit', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + + const [client] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); + clientId = client!.id; + }); + + it('client-direct: signed PDF lands in the client subfolder', async () => { + const [doc] = await db + .insert(documents) + .values({ + portId, + clientId, + documentType: 'eoi', + title: 'EOI · John Smith', + documensoId: `doc-${crypto.randomUUID()}`, + status: 'partially_signed', + createdBy: TEST_USER_ID, + }) + .returning(); + + await handleDocumentCompleted({ documentId: doc!.documensoId!, portId }); + + const updatedDoc = await db.query.documents.findFirst({ where: eq(documents.id, doc!.id) }); + expect(updatedDoc?.status).toBe('completed'); + expect(updatedDoc?.signedFileId).not.toBeNull(); + + const signedFile = await db.query.files.findFirst({ + where: eq(files.id, updatedDoc!.signedFileId!), + }); + const clientFolder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + }); + expect(clientFolder).toBeDefined(); + expect(signedFile?.folderId).toBe(clientFolder!.id); + expect(signedFile?.clientId).toBe(clientId); + }); + + it('no owner: signed PDF lands at root with folder_id=null', async () => { + const [doc] = await db + .insert(documents) + .values({ + portId, + documentType: 'other', + title: 'Untargeted contract', + documensoId: `doc-${crypto.randomUUID()}`, + status: 'partially_signed', + createdBy: TEST_USER_ID, + }) + .returning(); + + await handleDocumentCompleted({ documentId: doc!.documensoId!, portId }); + + const updatedDoc = await db.query.documents.findFirst({ where: eq(documents.id, doc!.id) }); + expect(updatedDoc?.signedFileId).not.toBeNull(); + const signedFile = await db.query.files.findFirst({ + where: eq(files.id, updatedDoc!.signedFileId!), + }); + expect(signedFile?.folderId).toBeNull(); + expect(signedFile?.clientId).toBeNull(); + }); + + it('via interest: resolves owner through interest.primaryClientId', async () => { + const [interest] = await db + .insert(interests) + .values({ + portId, + primaryClientId: clientId, + pipelineStage: 'eoi_sent', + clientReadyToSign: true, + }) + .returning(); + + const [doc] = await db + .insert(documents) + .values({ + portId, + interestId: interest!.id, + documentType: 'eoi', + title: 'EOI · via interest', + documensoId: `doc-${crypto.randomUUID()}`, + status: 'partially_signed', + createdBy: TEST_USER_ID, + }) + .returning(); + + await handleDocumentCompleted({ documentId: doc!.documensoId!, portId }); + + const updatedDoc = await db.query.documents.findFirst({ where: eq(documents.id, doc!.id) }); + const signedFile = await db.query.files.findFirst({ + where: eq(files.id, updatedDoc!.signedFileId!), + }); + expect(signedFile?.clientId).toBe(clientId); + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + }); + expect(signedFile?.folderId).toBe(folder!.id); + }); +}); +``` + +Read `tests/integration/document-folders-crud.test.ts` first to copy the exact `setupTestPort` import path. Check `tests/helpers/` for the `getStorageBackend` mock — most integration tests stub it to write to a temp filesystem. If they don't, you'll need to mock it inline. + +The `interest.primaryClientId` field name may differ — confirm by reading `src/lib/db/schema/interests.ts`. The spec calls it that; the schema may name it `primary_client_id` (snake case) which Drizzle exposes as `primaryClientId`. + +- [ ] **Step 2: Run the tests — expect failure** + +Run: `pnpm exec vitest run tests/integration/documents-completion-auto-deposit.test.ts` +Expected: tests fail because the handler doesn't set `folder_id` yet. + +- [ ] **Step 3: Add an owner-resolution helper to `documents.service.ts`** + +In `src/lib/services/documents.service.ts`, add this internal helper near the top of the file (after the imports). It encapsulates the Owner-wins chain from the spec: + +```typescript +import { ensureEntityFolder, type EntityType } from '@/lib/services/document-folders.service'; + +interface ResolvedOwner { + entityType: EntityType; + entityId: string; +} + +/** + * Owner-wins owner resolution chain — see spec §"Routing on workflow + * completion" §3a. Returns the first non-null candidate in priority + * order: direct client/company/yacht FK on the document, then the + * linked interest's primary entity. Returns null when no owner is + * resolvable (signed PDF will land at root). + */ +async function resolveDocumentOwner( + doc: { clientId: string | null; companyId: string | null; yachtId: string | null; interestId: string | null }, +): Promise { + if (doc.clientId) return { entityType: 'client', entityId: doc.clientId }; + if (doc.companyId) return { entityType: 'company', entityId: doc.companyId }; + if (doc.yachtId) return { entityType: 'yacht', entityId: doc.yachtId }; + + if (doc.interestId) { + const interest = await db.query.interests.findFirst({ + where: eq(interests.id, doc.interestId), + columns: { primaryClientId: true, primaryCompanyId: true, primaryYachtId: true }, + }); + if (interest?.primaryClientId) { + return { entityType: 'client', entityId: interest.primaryClientId }; + } + if (interest?.primaryCompanyId) { + return { entityType: 'company', entityId: interest.primaryCompanyId }; + } + if (interest?.primaryYachtId) { + return { entityType: 'yacht', entityId: interest.primaryYachtId }; + } + } + return null; +} +``` + +Verify the actual column names on `interests` first. Read `src/lib/db/schema/interests.ts`. If the columns are named differently (e.g., `clientId` instead of `primaryClientId`), adjust the projection columns and the field reads. + +- [ ] **Step 4: Modify `handleDocumentCompleted` to set folder_id + copy entity FKs** + +In the same file, find `handleDocumentCompleted` (~line 1065). Locate the block that inserts the signed file row (~line 1088): + +```typescript +const [fileRecord] = await db + .insert(files) + .values({ + portId: doc.portId, + clientId: doc.clientId ?? null, + filename: `signed-${doc.id}.pdf`, + // ... + }) + .returning(); +``` + +Replace it with a version that resolves the owner and ensures the entity folder before inserting: + +```typescript +// Resolve owner via the Owner-wins chain. The signed PDF lands in +// this owner's auto-created entity subfolder (or at root if no owner). +const owner = await resolveDocumentOwner(doc); + +let entityFolderId: string | null = null; +if (owner) { + try { + const folder = await ensureEntityFolder(doc.portId, owner.entityType, owner.entityId, 'system'); + entityFolderId = folder.id; + } catch (err) { + // Folder creation is best-effort — signed file still lands, just + // at root. Log so we can clean up post-deploy. + logger.error( + { err, documentId: doc.id, owner }, + 'ensureEntityFolder failed during document completion', + ); + } +} + +const [fileRecord] = await db + .insert(files) + .values({ + portId: doc.portId, + clientId: owner?.entityType === 'client' ? owner.entityId : (doc.clientId ?? null), + companyId: owner?.entityType === 'company' ? owner.entityId : (doc.companyId ?? null), + yachtId: owner?.entityType === 'yacht' ? owner.entityId : (doc.yachtId ?? null), + folderId: entityFolderId, + filename: `signed-${doc.id}.pdf`, + originalName: `signed-${doc.id}.pdf`, + mimeType: 'application/pdf', + sizeBytes: String(signedPdfBuffer.length), + storagePath, + storageBucket: env.MINIO_BUCKET, + category: 'eoi', + uploadedBy: 'system', + }) + .returning(); +``` + +Make sure the existing `companyId` / `yachtId` propagation is preserved — the helper writes the resolved owner's id onto the matching FK, while leaving non-resolved FKs at whatever the workflow had. The `doc` object already carries `clientId / companyId / yachtId / interestId` from `resolveWebhookDocument`. + +- [ ] **Step 5: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/integration/documents-completion-auto-deposit.test.ts` +Expected: 3/3 pass. + +- [ ] **Step 6: Run the wider documents suite to catch regressions** + +Run: `pnpm exec vitest run tests/integration/documents tests/unit/documents` +Expected: all pre-existing tests still pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/services/documents.service.ts tests/integration/documents-completion-auto-deposit.test.ts +git commit -m "$(cat <<'EOF' +feat(documents): auto-deposit signed PDFs into entity folders + +handleDocumentCompleted resolves the workflow owner via the Owner-wins +chain (client → company → yacht → interest.primaryClientId → +.primaryCompanyId → .primaryYachtId), ensures the matching entity +subfolder exists, 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 but never +fail the completion — the signed PDF always lands. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Service — aggregated projection (`listFilesAggregatedByEntity` + workflows) + +**Files:** + +- Modify: `src/lib/services/files.ts` +- Modify: `src/lib/services/documents.service.ts` +- Test: `tests/unit/aggregated-projection.test.ts` + +This is the killer-feature task. Owner-aggregated projection walks the relationship graph (symmetric reach per spec) and returns results grouped by owner-source: DIRECTLY ATTACHED, FROM COMPANY — X, FROM YACHT — Y, FROM CLIENT — Z. Each group caps at 20 rows with a `Show all (N)` drill-through. + +- [ ] **Step 1: Write failing tests for `listFilesAggregatedByEntity`** + +Create `tests/unit/aggregated-projection.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documentFolders, files } from '@/lib/db/schema/documents'; +import { clients } from '@/lib/db/schema/clients'; +import { companies, companyMemberships } from '@/lib/db/schema/companies'; +import { yachts } from '@/lib/db/schema/yachts'; +import { listFilesAggregatedByEntity } from '@/lib/services/files'; +import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service'; +import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; + +describe('files service · listFilesAggregatedByEntity', () => { + let portId: string; + let clientId: string; + let companyId: string; + let yachtId: string; + let clientFolderId: string; + let companyFolderId: string; + let yachtFolderId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + + const [c] = await db.insert(clients).values({ + portId, firstName: 'John', lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }).returning(); + clientId = c!.id; + + const [co] = await db.insert(companies).values({ + portId, name: 'Smith Marine LLC', + }).returning(); + companyId = co!.id; + + const [y] = await db.insert(yachts).values({ + portId, name: 'MV Serenity', currentOwnerType: 'client', currentOwnerId: clientId, + }).returning(); + yachtId = y!.id; + + await db.insert(companyMemberships).values({ + companyId, clientId, + }); + + clientFolderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id; + companyFolderId = (await ensureEntityFolder(portId, 'company', companyId, TEST_USER_ID)).id; + yachtFolderId = (await ensureEntityFolder(portId, 'yacht', yachtId, TEST_USER_ID)).id; + }); + + async function insertFile(opts: { + clientId?: string | null; + companyId?: string | null; + yachtId?: string | null; + folderId: string; + filename: string; + }) { + const [row] = await db.insert(files).values({ + portId, + clientId: opts.clientId ?? null, + companyId: opts.companyId ?? null, + yachtId: opts.yachtId ?? null, + folderId: opts.folderId, + filename: opts.filename, + originalName: opts.filename, + mimeType: 'application/pdf', + storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', + uploadedBy: TEST_USER_ID, + }).returning(); + return row!; + } + + it('groups DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT for a client view', async () => { + await insertFile({ clientId, folderId: clientFolderId, filename: 'Passport.pdf' }); + await insertFile({ companyId, folderId: companyFolderId, filename: 'Articles.pdf' }); + await insertFile({ yachtId, folderId: yachtFolderId, filename: 'Survey.pdf' }); + + const result = await listFilesAggregatedByEntity(portId, 'client', clientId); + const groupNames = result.groups.map((g) => g.label); + expect(groupNames).toContain('DIRECTLY ATTACHED'); + expect(groupNames.some((n) => n.startsWith('FROM COMPANY'))).toBe(true); + expect(groupNames.some((n) => n.startsWith('FROM YACHT'))).toBe(true); + + const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); + expect(direct?.files.map((f) => f.filename)).toContain('Passport.pdf'); + + const company = result.groups.find((g) => g.label.startsWith('FROM COMPANY')); + expect(company?.files.map((f) => f.filename)).toContain('Articles.pdf'); + + const yacht = result.groups.find((g) => g.label.startsWith('FROM YACHT')); + expect(yacht?.files.map((f) => f.filename)).toContain('Survey.pdf'); + }); + + it('caps each group at 20 rows and surfaces total for Show all', async () => { + for (let i = 0; i < 25; i += 1) { + await insertFile({ clientId, folderId: clientFolderId, filename: `direct-${i}.pdf` }); + } + const result = await listFilesAggregatedByEntity(portId, 'client', clientId); + const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); + expect(direct?.files).toHaveLength(20); + expect(direct?.total).toBe(25); + }); + + it('snapshots are file-FK-based — yacht transfer does not move historical files', async () => { + const file = await insertFile({ + yachtId, clientId, folderId: yachtFolderId, filename: 'Historic.pdf', + }); + // Transfer the yacht to a different owner. + const [mary] = await db.insert(clients).values({ + portId, firstName: 'Mary', lastName: 'Brown', + email: `mary-${crypto.randomUUID()}@example.com`, + }).returning(); + await db.update(yachts).set({ currentOwnerType: 'client', currentOwnerId: mary!.id }) + .where(eq(yachts.id, yachtId)); + + // John's view still shows the file (via DIRECTLY ATTACHED or FROM YACHT). + const johnView = await listFilesAggregatedByEntity(portId, 'client', clientId); + const allFiles = johnView.groups.flatMap((g) => g.files.map((f) => f.id)); + expect(allFiles).toContain(file.id); + + // Mary's view does NOT show the file (it was never linked to her). + const maryView = await listFilesAggregatedByEntity(portId, 'client', mary!.id); + const maryFiles = maryView.groups.flatMap((g) => g.files.map((f) => f.id)); + expect(maryFiles).not.toContain(file.id); + }); + + it('rejects cross-port leakage with defense-in-depth port filter', async () => { + const otherPort = await setupTestPort(); + const [otherClient] = await db.insert(clients).values({ + portId: otherPort, firstName: 'Other', lastName: 'Port', + email: `other-${crypto.randomUUID()}@example.com`, + }).returning(); + // Try to query the other port's client from our port — should return empty. + const result = await listFilesAggregatedByEntity(portId, 'client', otherClient!.id); + expect(result.groups.flatMap((g) => g.files)).toHaveLength(0); + }); +}); +``` + +Verify `clients` / `companies` / `companyMemberships` / `yachts` insert shapes by reading the schema files first. Required NOT NULL columns may need filler values in the test fixtures. + +- [ ] **Step 2: Run the test — expect failure** + +Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts` +Expected: `listFilesAggregatedByEntity` not exported. + +- [ ] **Step 3: Implement the projection helper** + +Append to `src/lib/services/files.ts`: + +```typescript +import { documentFolders } from '@/lib/db/schema/documents'; +import { clients } from '@/lib/db/schema/clients'; +import { companies, companyMemberships } from '@/lib/db/schema/companies'; +import { yachts } from '@/lib/db/schema/yachts'; +import type { EntityType } from '@/lib/services/document-folders.service'; +import { inArray } from 'drizzle-orm'; +import { desc } from 'drizzle-orm'; + +export interface AggregatedFileGroup { + /** Label used by the UI header (e.g. "DIRECTLY ATTACHED" or "FROM YACHT — MV SERENITY"). */ + label: string; + /** Stable key for de-duplication when the same file appears via multiple paths. */ + source: 'direct' | 'client' | 'company' | 'yacht'; + /** Up to 20 most-recent files in this group. */ + files: Array; + /** Total count in this source — used to surface `Show all (N)`. */ + total: number; +} + +interface AggregatedFilesResult { + groups: AggregatedFileGroup[]; +} + +const GROUP_LIMIT = 20; + +/** + * Walk the relationship graph from the requested entity and return + * files grouped by source. Symmetric reach (per spec): + * - DIRECTLY ATTACHED: files whose FK matches the entity directly + * - FROM COMPANY: for client → linked companies; for yacht → owning company + * - FROM YACHT: for client → yachts they own (currentOwnerType+currentOwnerId) + * + yachts owned by companies they're a member of + * - FROM CLIENT: for company → member clients; for yacht → owning client + * + * Defense-in-depth: port_id filter on every entity / membership / yacht / + * file join (per CLAUDE.md + recommender precedent). The entry-point + * filter is insufficient — a corrupted FK pointing at another port + * must not surface in this port's aggregation. + * + * Source of truth: each file's snapshotted entity FKs (files.client_id / + * .company_id / .yacht_id), NOT the entity's current relationships. + * Historical files stay where they were filed. + */ +export async function listFilesAggregatedByEntity( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + // Verify the entity belongs to this port (defense-in-depth — refuses + // cross-port reads before any aggregation runs). + const entityExists = await assertEntityInPort(portId, entityType, entityId); + if (!entityExists) return { groups: [] }; + + // Step 1: build the set of related entity ids for each source bucket. + const related = await collectRelatedEntities(portId, entityType, entityId); + + // Step 2: emit one group per source, each capped at GROUP_LIMIT. + const groups: AggregatedFileGroup[] = []; + + // DIRECTLY ATTACHED — files whose own FK matches the requested entity. + const directColumn = + entityType === 'client' ? files.clientId + : entityType === 'company' ? files.companyId + : files.yachtId; + const direct = await fetchGroupRows(portId, eq(directColumn, entityId), GROUP_LIMIT); + if (direct.rows.length > 0) { + groups.push({ + label: 'DIRECTLY ATTACHED', + source: 'direct', + files: direct.rows, + total: direct.total, + }); + } + + // FROM COMPANY — for each linked company, surface its files. + for (const { id, name } of related.companies) { + const g = await fetchGroupRows(portId, eq(files.companyId, id), GROUP_LIMIT); + if (g.rows.length === 0) continue; + groups.push({ + label: `FROM COMPANY — ${name.toUpperCase()}`, + source: 'company', + files: g.rows, + total: g.total, + }); + } + + // FROM YACHT — for each linked yacht, surface its files. + for (const { id, name } of related.yachts) { + const g = await fetchGroupRows(portId, eq(files.yachtId, id), GROUP_LIMIT); + if (g.rows.length === 0) continue; + groups.push({ + label: `FROM YACHT — ${name.toUpperCase()}`, + source: 'yacht', + files: g.rows, + total: g.total, + }); + } + + // FROM CLIENT — for each linked client (e.g., when viewing a company), surface theirs. + for (const { id, name } of related.clients) { + const g = await fetchGroupRows(portId, eq(files.clientId, id), GROUP_LIMIT); + if (g.rows.length === 0) continue; + groups.push({ + label: `FROM CLIENT — ${name.toUpperCase()}`, + source: 'client', + files: g.rows, + total: g.total, + }); + } + + return { groups }; +} + +async function assertEntityInPort( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + if (entityType === 'client') { + const c = await db.query.clients.findFirst({ + where: and(eq(clients.id, entityId), eq(clients.portId, portId)), + columns: { id: true }, + }); + return Boolean(c); + } + if (entityType === 'company') { + const c = await db.query.companies.findFirst({ + where: and(eq(companies.id, entityId), eq(companies.portId, portId)), + columns: { id: true }, + }); + return Boolean(c); + } + const y = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)), + columns: { id: true }, + }); + return Boolean(y); +} + +interface RelatedEntities { + clients: Array<{ id: string; name: string }>; + companies: Array<{ id: string; name: string }>; + yachts: Array<{ id: string; name: string }>; +} + +/** + * Walk the relationship graph and collect related entity ids for each + * source bucket. Symmetric reach: walking from a client surfaces their + * companies + their yachts + (second-degree) yachts owned by their + * companies. Every join carries port_id = $portId (defense-in-depth). + */ +async function collectRelatedEntities( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + if (entityType === 'client') { + // Companies the client is a member of. + const memberCompanies = await db + .select({ id: companies.id, name: companies.name }) + .from(companyMemberships) + .innerJoin( + companies, + and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)), + ) + .where(eq(companyMemberships.clientId, entityId)); + + // Yachts owned directly by the client. + const directYachts = await db + .select({ id: yachts.id, name: yachts.name }) + .from(yachts) + .where( + and( + eq(yachts.portId, portId), + eq(yachts.currentOwnerType, 'client'), + eq(yachts.currentOwnerId, entityId), + ), + ); + + // Yachts owned by the client's companies (second-degree). + let companyYachts: Array<{ id: string; name: string }> = []; + if (memberCompanies.length > 0) { + companyYachts = await db + .select({ id: yachts.id, name: yachts.name }) + .from(yachts) + .where( + and( + eq(yachts.portId, portId), + eq(yachts.currentOwnerType, 'company'), + inArray(yachts.currentOwnerId, memberCompanies.map((c) => c.id)), + ), + ); + } + + return { + clients: [], + companies: memberCompanies, + yachts: dedupeBy([...directYachts, ...companyYachts], (y) => y.id), + }; + } + + if (entityType === 'company') { + const memberClients = await db + .select({ id: clients.id, firstName: clients.firstName, lastName: clients.lastName }) + .from(companyMemberships) + .innerJoin( + clients, + and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)), + ) + .where(eq(companyMemberships.companyId, entityId)); + + const ownedYachts = await db + .select({ id: yachts.id, name: yachts.name }) + .from(yachts) + .where( + and( + eq(yachts.portId, portId), + eq(yachts.currentOwnerType, 'company'), + eq(yachts.currentOwnerId, entityId), + ), + ); + + return { + clients: memberClients.map((c) => ({ + id: c.id, + name: `${c.lastName ?? ''}, ${c.firstName ?? ''}`.replace(/^,\s*|,\s*$/, ''), + })), + companies: [], + yachts: ownedYachts, + }; + } + + // yacht + const yacht = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)), + }); + if (!yacht) return { clients: [], companies: [], yachts: [] }; + + if (yacht.currentOwnerType === 'client') { + const owner = await db.query.clients.findFirst({ + where: and(eq(clients.id, yacht.currentOwnerId), eq(clients.portId, portId)), + columns: { id: true, firstName: true, lastName: true }, + }); + return { + clients: owner + ? [ + { + id: owner.id, + name: `${owner.lastName ?? ''}, ${owner.firstName ?? ''}`.replace( + /^,\s*|,\s*$/, + '', + ), + }, + ] + : [], + companies: [], + yachts: [], + }; + } + // currentOwnerType === 'company' + const owner = await db.query.companies.findFirst({ + where: and(eq(companies.id, yacht.currentOwnerId), eq(companies.portId, portId)), + columns: { id: true, name: true }, + }); + return { + clients: [], + companies: owner ? [{ id: owner.id, name: owner.name }] : [], + yachts: [], + }; +} + +/** + * Fetch up to `limit` files matching `predicate` (plus a COUNT for the + * "Show all (N)" CTA). Always carries port_id = $portId. + */ +async function fetchGroupRows( + portId: string, + predicate: ReturnType, + limit: number, +): Promise<{ + rows: Array; + total: number; +}> { + const rows = await db + .select() + .from(files) + .where(and(eq(files.portId, portId), predicate)) + .orderBy(desc(files.createdAt)) + .limit(limit); + + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(files) + .where(and(eq(files.portId, portId), predicate)); + + return { rows, total: Number(count ?? 0) }; +} + +function dedupeBy(items: T[], key: (t: T) => K): T[] { + const seen = new Set(); + const out: T[] = []; + for (const item of items) { + const k = key(item); + if (seen.has(k)) continue; + seen.add(k); + out.push(item); + } + return out; +} +``` + +You'll need to add `sql` to the imports if it isn't already imported. Read the current top of `files.ts` first. + +- [ ] **Step 4: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts` +Expected: 4/4 pass. + +- [ ] **Step 5: Add `listInflightWorkflowsAggregatedByEntity` to documents service** + +Append to `src/lib/services/documents.service.ts` — reuses the same `collectRelatedEntities` walk by exporting it from `files.ts`. Export it: + +```typescript +// In src/lib/services/files.ts, change `async function collectRelatedEntities` → +// `export async function collectRelatedEntities`. Same for the type alias if needed. +``` + +Then append to `src/lib/services/documents.service.ts`: + +```typescript +import { + collectRelatedEntities, + type AggregatedFileGroup, +} from '@/lib/services/files'; + +export interface AggregatedWorkflowGroup { + label: string; + source: 'direct' | 'client' | 'company' | 'yacht'; + workflows: Array; + total: number; +} + +const WORKFLOW_GROUP_LIMIT = 20; +const INFLIGHT_STATUSES = ['draft', 'sent', 'partially_signed'] as const; + +/** + * Same projection shape as listFilesAggregatedByEntity but for in-flight + * signing workflows. Completed workflows are intentionally hidden — they + * surface via their resulting signed-PDF file row instead. + */ +export async function listInflightWorkflowsAggregatedByEntity( + portId: string, + entityType: 'client' | 'company' | 'yacht', + entityId: string, +): Promise<{ groups: AggregatedWorkflowGroup[] }> { + const related = await collectRelatedEntities(portId, entityType, entityId); + const groups: AggregatedWorkflowGroup[] = []; + + const directColumn = + entityType === 'client' ? documents.clientId + : entityType === 'company' ? documents.companyId + : documents.yachtId; + + const direct = await fetchWorkflowGroupRows(portId, eq(directColumn, entityId)); + if (direct.rows.length > 0) { + groups.push({ + label: 'DIRECTLY ATTACHED', + source: 'direct', + workflows: direct.rows, + total: direct.total, + }); + } + for (const { id, name } of related.companies) { + const g = await fetchWorkflowGroupRows(portId, eq(documents.companyId, id)); + if (g.rows.length === 0) continue; + groups.push({ + label: `FROM COMPANY — ${name.toUpperCase()}`, + source: 'company', + workflows: g.rows, + total: g.total, + }); + } + for (const { id, name } of related.yachts) { + const g = await fetchWorkflowGroupRows(portId, eq(documents.yachtId, id)); + if (g.rows.length === 0) continue; + groups.push({ + label: `FROM YACHT — ${name.toUpperCase()}`, + source: 'yacht', + workflows: g.rows, + total: g.total, + }); + } + for (const { id, name } of related.clients) { + const g = await fetchWorkflowGroupRows(portId, eq(documents.clientId, id)); + if (g.rows.length === 0) continue; + groups.push({ + label: `FROM CLIENT — ${name.toUpperCase()}`, + source: 'client', + workflows: g.rows, + total: g.total, + }); + } + return { groups }; +} + +async function fetchWorkflowGroupRows( + portId: string, + predicate: ReturnType, +): Promise<{ rows: Array; total: number }> { + const rows = await db + .select() + .from(documents) + .where( + and( + eq(documents.portId, portId), + inArray(documents.status, INFLIGHT_STATUSES as unknown as string[]), + predicate, + ), + ) + .orderBy(desc(documents.updatedAt)) + .limit(WORKFLOW_GROUP_LIMIT); + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(documents) + .where( + and( + eq(documents.portId, portId), + inArray(documents.status, INFLIGHT_STATUSES as unknown as string[]), + predicate, + ), + ); + return { rows, total: Number(count ?? 0) }; +} +``` + +Make sure `inArray` and `desc` are imported in `documents.service.ts` (read the top of the file first; if they're not, add them). + +- [ ] **Step 6: Add a small workflow-aggregation test** + +Append to `tests/unit/aggregated-projection.test.ts`: + +```typescript +import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service'; + +describe('documents service · listInflightWorkflowsAggregatedByEntity', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + const [c] = await db.insert(clients).values({ + portId, firstName: 'John', lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }).returning(); + clientId = c!.id; + }); + + it('returns in-flight workflows in DIRECTLY ATTACHED group', async () => { + const { documents: documentsTable } = await import('@/lib/db/schema/documents'); + await db.insert(documentsTable).values({ + portId, clientId, title: 'EOI', documentType: 'eoi', + status: 'sent', createdBy: TEST_USER_ID, + }); + await db.insert(documentsTable).values({ + portId, clientId, title: 'Old signed EOI', documentType: 'eoi', + status: 'completed', createdBy: TEST_USER_ID, + }); + const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId); + const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); + expect(direct?.workflows).toHaveLength(1); + expect(direct?.workflows[0]?.status).toBe('sent'); + }); +}); +``` + +- [ ] **Step 7: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts` +Expected: all pass. + +- [ ] **Step 8: Add `applyEntityFkFromFolder` + wire into `uploadFile` (E8)** + +Append to `src/lib/services/files.ts`: + +```typescript +/** + * E8: when a rep manually uploads a file into a system-managed entity + * subfolder (e.g. `Clients/Smith, John/`), auto-set the matching entity + * FK on the file row from the folder's `entityType + entityId`. Custom + * (non-system) folders → returns the input unchanged. + * + * Returns the mutated insert payload so callers can keep their + * single-insert flow. + */ +export async function applyEntityFkFromFolder(portId: string, payload: T): Promise { + if (!payload.folderId) return payload; + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.id, payload.folderId), + eq(documentFolders.portId, portId), + ), + columns: { systemManaged: true, entityType: true, entityId: true }, + }); + if (!folder || !folder.systemManaged || !folder.entityType || !folder.entityId) { + return payload; + } + if (folder.entityType === 'client' && !payload.clientId) { + return { ...payload, clientId: folder.entityId }; + } + if (folder.entityType === 'company' && !payload.companyId) { + return { ...payload, companyId: folder.entityId }; + } + if (folder.entityType === 'yacht' && !payload.yachtId) { + return { ...payload, yachtId: folder.entityId }; + } + return payload; +} +``` + +Then wire it into `uploadFile` (~line 33). The current insert builds the `.values({...})` payload directly; route the payload through `applyEntityFkFromFolder` before the insert. Make sure `UploadFileInput` includes `folderId: z.string().uuid().optional()` after Task 9's validator extension — if it doesn't, add it. + +Add a unit test in `tests/unit/aggregated-projection.test.ts`: + +```typescript +import { applyEntityFkFromFolder } from '@/lib/services/files'; + +describe('files service · applyEntityFkFromFolder', () => { + let portId: string; + let clientId: string; + let folderId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await ensureSystemRoots(portId, TEST_USER_ID); + const [c] = await db.insert(clients).values({ + portId, firstName: 'A', lastName: 'B', + email: `a-${crypto.randomUUID()}@example.com`, + }).returning(); + clientId = c!.id; + folderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id; + }); + + it('sets clientId when uploading into a client entity folder', async () => { + const out = await applyEntityFkFromFolder(portId, { folderId, clientId: null }); + expect(out.clientId).toBe(clientId); + }); + + it('preserves existing entity FK when already set', async () => { + const out = await applyEntityFkFromFolder(portId, { folderId, clientId: 'pre-existing-id' }); + expect(out.clientId).toBe('pre-existing-id'); + }); + + it('is a no-op for non-system folders', async () => { + const [user] = await db.insert(documentFolders).values({ + portId, parentId: null, name: 'My templates', createdBy: TEST_USER_ID, + }).returning(); + const out = await applyEntityFkFromFolder(portId, { folderId: user!.id, clientId: null }); + expect(out.clientId).toBeUndefined(); + }); +}); +``` + +Run: `pnpm exec vitest run -t applyEntityFkFromFolder`. Expected: 3/3 pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/lib/services/files.ts src/lib/services/documents.service.ts tests/unit/aggregated-projection.test.ts +git commit -m "$(cat <<'EOF' +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. Completed workflows are hidden — +they surface via their signed-PDF file row instead. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: API — entity-aggregated query params + signing-details route + +**Files:** + +- Modify: `src/lib/validators/documents.ts` +- Modify: `src/lib/validators/files.ts` +- Modify: `src/app/api/v1/documents/route.ts` +- Modify: `src/app/api/v1/files/route.ts` +- Create: `src/app/api/v1/documents/[id]/signing-details/route.ts` +- Test: `tests/integration/files-folder-aggregation.test.ts` + +- [ ] **Step 1: Extend validators** + +In `src/lib/validators/documents.ts`, locate `listDocumentsSchema`. Add `entityType` + `entityId` query params (mutually exclusive with `folderId`): + +```typescript +import { z } from 'zod'; + +// In the existing listDocumentsSchema, add: +entityType: z.enum(['client', 'company', 'yacht']).optional(), +entityId: z.string().uuid().optional(), +// Cross-field validation — these are mutually exclusive with folderId. +// Append .refine(...) after the .strict() or directly on the schema object: +``` + +Append a `.refine` to the schema: + +```typescript +.refine( + (q) => !(q.folderId !== undefined && (q.entityType || q.entityId)), + { message: 'folderId is mutually exclusive with entityType/entityId' }, +) +.refine( + (q) => Boolean(q.entityType) === Boolean(q.entityId), + { message: 'entityType and entityId must be provided together' }, +) +``` + +Read the current `listDocumentsSchema` shape first — `parseQuery` flattens repeated params and the existing schema already coerces some fields. Match the existing style. + +Same pattern in `src/lib/validators/files.ts`: extend `listFilesSchema` (or whichever name the file uses — grep `parseQuery.*files` to confirm) with the same three optional params + the same two `.refine` rules. + +- [ ] **Step 2: Wire the aggregated branch into the files route** + +Modify `src/app/api/v1/files/route.ts`. The current `GET` handler calls `listFiles(portId, query)`. Add a branch on `query.entityType`: + +```typescript +import { listFilesAggregatedByEntity } from '@/lib/services/files'; + +export const GET = withAuth( + withPermission('documents', 'view', async (req, ctx) => { + try { + const query = parseQuery(req, listFilesSchema); + if (query.entityType && query.entityId) { + const result = await listFilesAggregatedByEntity( + ctx.portId, + query.entityType, + query.entityId, + ); + return NextResponse.json({ data: result }); + } + const result = await listFiles(ctx.portId, query); + // ... existing pagination envelope + } catch (error) { + return errorResponse(error); + } + }), +); +``` + +Read the current `route.ts` first to get the exact existing envelope; add the branch above it. + +- [ ] **Step 3: Wire the aggregated branch into the documents route** + +Same pattern in `src/app/api/v1/documents/route.ts`: + +```typescript +import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service'; + +// At the top of the GET handler, after parseQuery: +if (query.entityType && query.entityId) { + const result = await listInflightWorkflowsAggregatedByEntity( + ctx.portId, + query.entityType, + query.entityId, + ); + return NextResponse.json({ data: result }); +} +// ...existing listDocuments call follows +``` + +- [ ] **Step 4: Create the signing-details route** + +Create `src/app/api/v1/documents/[id]/signing-details/route.ts`: + +```typescript +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { + getDocumentById, + listDocumentSigners, + listDocumentEvents, +} from '@/lib/services/documents.service'; + +export const GET = withAuth( + withPermission('documents', 'view', async (_req, ctx, { params }) => { + try { + const { id } = await params; + const [doc, signers, events] = await Promise.all([ + getDocumentById(id, ctx.portId), + listDocumentSigners(id, ctx.portId), + listDocumentEvents(id, ctx.portId), + ]); + return NextResponse.json({ + data: { + workflow: doc, + signers, + events, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); +``` + +Verify the exact signature of `withPermission` + the `{ params }` destructure by reading another `[id]` route handler in `src/app/api/v1/documents/[id]/` (e.g., `route.ts` or `folder/route.ts`). Next 15 App Router treats `params` as a Promise; the await above matches the project convention. + +- [ ] **Step 5: Add the integration test** + +Create `tests/integration/files-folder-aggregation.test.ts`. Hit the API handler directly via the sibling `handlers.ts` pattern if it exists, or via `fetch` against a running dev server. Read an existing integration test to copy the pattern. The test should: + +1. Seed a port, client, company, yacht with files attached. +2. Hit `GET /api/v1/files?entityType=client&entityId=` (mock the handler dependencies if needed). +3. Assert the `groups` shape, that DIRECTLY ATTACHED + FROM COMPANY appear. + +- [ ] **Step 6: Run the tests** + +Run: `pnpm exec vitest run tests/integration/files-folder-aggregation.test.ts tests/unit/aggregated-projection.test.ts` +Expected: all pass. + +- [ ] **Step 7: Run the wider listDocuments tests** + +Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts` +Expected: still passes (Wave 11.B's folderId filter is untouched). + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/validators/documents.ts src/lib/validators/files.ts src/app/api/v1/documents/route.ts src/app/api/v1/files/route.ts src/app/api/v1/documents/[id]/signing-details/route.ts tests/integration/files-folder-aggregation.test.ts +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +## Task 10: Hide completed workflows from folder views + +**Files:** + +- Modify: `src/lib/services/documents.service.ts` +- Test: `tests/integration/documents-list-folder-filter.test.ts` (extend) + +- [ ] **Step 1: Add a failing test** + +Append to `tests/integration/documents-list-folder-filter.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; + +it('hides completed workflows when folderId is set', async () => { + const portId = await setupTestPort(); + await ensureSystemRoots(portId, TEST_USER_ID); + const [folder] = await db.insert(documentFolders).values({ + portId, parentId: null, name: 'Deals 2026', createdBy: TEST_USER_ID, + }).returning(); + await db.insert(documents).values([ + { + portId, folderId: folder!.id, title: 'In flight', + documentType: 'eoi', status: 'sent', createdBy: TEST_USER_ID, + }, + { + portId, folderId: folder!.id, title: 'Done', + documentType: 'eoi', status: 'completed', createdBy: TEST_USER_ID, + }, + ]); + const result = await listDocuments(portId, { folderId: folder!.id, page: 1, limit: 50, sort: 'createdAt', order: 'desc', tab: 'all' }); + expect(result.data.map((d) => d.title)).toEqual(['In flight']); +}); +``` + +Adjust imports + `listDocuments` call shape by reading the existing tests in the same file. + +- [ ] **Step 2: Run the test — expect failure** + +Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts -t 'hides completed'` +Expected: fails (both rows currently surface). + +- [ ] **Step 3: Modify `listDocuments`** + +In `src/lib/services/documents.service.ts:listDocuments` (~line 146), find the existing folderId filter (added by Wave 11.B). Add a status filter that excludes `'completed'` when `query.folderId` is set: + +```typescript +// When viewing a specific folder, hide completed workflows — they surface +// via their resulting signed-PDF file row in the Files section, not the +// Signing section. +if (query.folderId !== undefined) { + filters.push(ne(documents.status, 'completed')); +} +``` + +Add `ne` to the drizzle imports if it isn't already there. + +- [ ] **Step 4: Run the test — expect pass** + +Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/services/documents.service.ts tests/integration/documents-list-folder-filter.test.ts +git commit -m "$(cat <<'EOF' +feat(documents): hide completed workflows from folder views + +When listDocuments is called with folderId set, 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) +EOF +)" +``` + +--- + +## Task 11: Backfill script (idempotent, port-isolated) + +**Files:** + +- Create: `scripts/backfill-document-folders.ts` +- Test: `tests/integration/backfill-document-folders.test.ts` + +- [ ] **Step 1: Write the failing integration test** + +Create `tests/integration/backfill-document-folders.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documentFolders, files, documents } from '@/lib/db/schema/documents'; +import { clients } from '@/lib/db/schema/clients'; +import { runBackfill } from '@/scripts/backfill-document-folders'; +import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; + +describe('backfill-document-folders · idempotency + isolation', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + const [c] = await db.insert(clients).values({ + portId, firstName: 'John', lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }).returning(); + clientId = c!.id; + }); + + it('creates system roots and entity subfolders for entities with attached files', async () => { + await db.insert(files).values({ + portId, clientId, filename: 'a.pdf', originalName: 'a.pdf', + storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', + uploadedBy: TEST_USER_ID, + }); + await runBackfill({ portId }); + const roots = await db.select().from(documentFolders).where( + and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')), + ); + expect(roots.map((r) => r.name).sort()).toEqual(['Clients', 'Companies', 'Yachts']); + const sub = await db.select().from(documentFolders).where( + and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + ); + expect(sub).toHaveLength(1); + }); + + it('sets files.folder_id from entity FKs', async () => { + const [file] = await db.insert(files).values({ + portId, clientId, filename: 'a.pdf', originalName: 'a.pdf', + storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', + uploadedBy: TEST_USER_ID, + }).returning(); + await runBackfill({ portId }); + const updated = await db.query.files.findFirst({ where: eq(files.id, file!.id) }); + expect(updated?.folderId).not.toBeNull(); + }); + + it('copies entity FKs from completed workflows onto signed files', async () => { + const [signedFile] = await db.insert(files).values({ + portId, // NOTE: no clientId — legacy completion left it blank + filename: 'signed.pdf', originalName: 'signed.pdf', + storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', + uploadedBy: 'system', + }).returning(); + await db.insert(documents).values({ + portId, clientId, signedFileId: signedFile!.id, title: 'EOI', + documentType: 'eoi', status: 'completed', createdBy: TEST_USER_ID, + }); + await runBackfill({ portId }); + const updated = await db.query.files.findFirst({ where: eq(files.id, signedFile!.id) }); + expect(updated?.clientId).toBe(clientId); + expect(updated?.folderId).not.toBeNull(); + }); + + it('is idempotent — second run produces the same result', async () => { + await db.insert(files).values({ + portId, clientId, filename: 'a.pdf', originalName: 'a.pdf', + storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', + uploadedBy: TEST_USER_ID, + }); + await runBackfill({ portId }); + const after1 = await db.select().from(documentFolders).where(eq(documentFolders.portId, portId)); + await runBackfill({ portId }); + const after2 = await db.select().from(documentFolders).where(eq(documentFolders.portId, portId)); + expect(after2).toHaveLength(after1.length); + }); + + it('respects port isolation — does not touch other ports', async () => { + const otherPort = await setupTestPort(); + const [otherClient] = await db.insert(clients).values({ + portId: otherPort, firstName: 'Other', lastName: 'Port', + email: `other-${crypto.randomUUID()}@example.com`, + }).returning(); + await db.insert(files).values({ + portId: otherPort, clientId: otherClient!.id, filename: 'b.pdf', + originalName: 'b.pdf', storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', uploadedBy: TEST_USER_ID, + }); + await runBackfill({ portId }); // only this port + const otherRoots = await db.select().from(documentFolders).where(eq(documentFolders.portId, otherPort)); + expect(otherRoots).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run the test — expect failure** + +Run: `pnpm exec vitest run tests/integration/backfill-document-folders.test.ts` +Expected: script doesn't exist. + +- [ ] **Step 3: Implement the backfill script** + +Create `scripts/backfill-document-folders.ts`: + +```typescript +import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { documentFolders, files, documents } from '@/lib/db/schema/documents'; +import { + ensureSystemRoots, + ensureEntityFolder, + type EntityType, +} from '@/lib/services/document-folders.service'; +import { logger } from '@/lib/logger'; + +interface BackfillOptions { + portId?: string; // when set, only this port; otherwise all ports + systemUserId?: string; +} + +/** + * One-time idempotent backfill: ensure every port has the three system + * roots, every entity with attached files (or completed workflows) has + * a subfolder, every file with entity FKs has folder_id set, and every + * signed file from a completed workflow has the workflow's entity FKs + * propagated. Per-port advisory lock serializes concurrent runs. + */ +export async function runBackfill(opts: BackfillOptions = {}): Promise { + const portRows = opts.portId + ? [{ id: opts.portId }] + : await db.select({ id: ports.id }).from(ports); + const systemUser = opts.systemUserId ?? 'system-backfill'; + + for (const { id: portId } of portRows) { + // Advisory lock: hash the portId string into a bigint for pg_advisory_xact_lock. + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${portId})::bigint)`); + + // 1. System roots. + await ensureSystemRoots(portId, systemUser); + + // 2. For every completed workflow, copy entity FKs onto the signed file row. + const completedDocs = await tx + .select({ + id: documents.id, + signedFileId: documents.signedFileId, + clientId: documents.clientId, + companyId: documents.companyId, + yachtId: documents.yachtId, + }) + .from(documents) + .where( + and( + eq(documents.portId, portId), + eq(documents.status, 'completed'), + isNotNull(documents.signedFileId), + ), + ); + for (const d of completedDocs) { + if (!d.signedFileId) continue; + const owner = + d.clientId ? ({ type: 'client', id: d.clientId } as const) + : d.companyId ? ({ type: 'company', id: d.companyId } as const) + : d.yachtId ? ({ type: 'yacht', id: d.yachtId } as const) + : null; + if (!owner) continue; + await tx + .update(files) + .set({ + clientId: owner.type === 'client' ? owner.id : files.clientId, + companyId: owner.type === 'company' ? owner.id : files.companyId, + yachtId: owner.type === 'yacht' ? owner.id : files.yachtId, + }) + .where( + and( + eq(files.id, d.signedFileId), + eq(files.portId, portId), + isNull( + owner.type === 'client' + ? files.clientId + : owner.type === 'company' + ? files.companyId + : files.yachtId, + ), + ), + ); + } + + // 3. For every file with entity FKs, ensure the subfolder + assign folder_id. + const fileRows = await tx + .select() + .from(files) + .where(and(eq(files.portId, portId), isNull(files.folderId))); + for (const f of fileRows) { + const owner: { type: EntityType; id: string } | null = + f.clientId ? { type: 'client', id: f.clientId } + : f.companyId ? { type: 'company', id: f.companyId } + : f.yachtId ? { type: 'yacht', id: f.yachtId } + : null; + if (!owner) continue; + try { + const folder = await ensureEntityFolder(portId, owner.type, owner.id, systemUser); + await tx + .update(files) + .set({ folderId: folder.id }) + .where(and(eq(files.id, f.id), eq(files.portId, portId))); + } catch (err) { + logger.error({ err, fileId: f.id, portId }, 'backfill: ensureEntityFolder failed'); + } + } + }); + } +} + +// CLI entry point — `pnpm tsx scripts/backfill-document-folders.ts [--port ]` +if (require.main === module) { + const portIdArg = process.argv.indexOf('--port'); + const portId = portIdArg !== -1 ? process.argv[portIdArg + 1] : undefined; + runBackfill({ portId }) + .then(() => { + // eslint-disable-next-line no-console + console.log('Backfill complete'); + process.exit(0); + }) + .catch((err) => { + logger.error({ err }, 'Backfill failed'); + process.exit(1); + }); +} +``` + +Verify by reading existing scripts in `scripts/` (e.g., `scripts/import-berths-from-nocodb.ts` mentioned in CLAUDE.md) — they typically use the same `if (require.main === module)` CLI guard and the `db` import path. Adjust if the convention differs. + +- [ ] **Step 4: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/integration/backfill-document-folders.test.ts` +Expected: 5/5 pass. + +- [ ] **Step 5: Manual sanity run in dev** + +```bash +pnpm tsx scripts/backfill-document-folders.ts +``` + +Expected output: "Backfill complete" + zero errors. Check the dev DB has three system roots per port: + +```bash +PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \ + -c "SELECT port_id, name FROM document_folders WHERE entity_type='root' ORDER BY port_id, name" +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/backfill-document-folders.ts tests/integration/backfill-document-folders.test.ts +git commit -m "$(cat <<'EOF' +feat(documents): backfill script for system roots + entity folders + +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) +EOF +)" +``` + +--- + +## Task 12: UI — `AggregatedSection` component + `useAggregatedListing` hook + +**Files:** + +- Create: `src/hooks/use-aggregated-listing.ts` +- Create: `src/components/documents/aggregated-section.tsx` + +- [ ] **Step 1: Create the hook** + +Create `src/hooks/use-aggregated-listing.ts`: + +```typescript +import { useQuery } from '@tanstack/react-query'; + +import { apiFetch } from '@/lib/api/client'; + +interface AggregatedFile { + id: string; + filename: string; + mimeType: string | null; + createdAt: string; + folderId: string | null; +} + +interface AggregatedWorkflow { + id: string; + title: string; + status: string; + documentType: string; + updatedAt: string; +} + +export interface AggregatedGroup { + label: string; + source: 'direct' | 'client' | 'company' | 'yacht'; + files?: T[]; // present when this is a files group + workflows?: T[]; // present when this is a workflows group + total: number; +} + +export function useAggregatedFiles( + portId: string, + entityType: 'client' | 'company' | 'yacht' | undefined, + entityId: string | undefined, +) { + return useQuery<{ data: { groups: AggregatedGroup[] } }>({ + queryKey: ['files', 'aggregated', entityType, entityId], + queryFn: () => + apiFetch(`/api/v1/files?entityType=${entityType}&entityId=${entityId}`), + enabled: Boolean(entityType && entityId), + staleTime: 10_000, + }); +} + +export function useAggregatedWorkflows( + portId: string, + entityType: 'client' | 'company' | 'yacht' | undefined, + entityId: string | undefined, +) { + return useQuery<{ data: { groups: AggregatedGroup[] } }>({ + queryKey: ['documents', 'aggregated', entityType, entityId], + queryFn: () => + apiFetch(`/api/v1/documents?entityType=${entityType}&entityId=${entityId}`), + enabled: Boolean(entityType && entityId), + staleTime: 10_000, + }); +} +``` + +- [ ] **Step 2: Create the component** + +Create `src/components/documents/aggregated-section.tsx`: + +```tsx +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { FileText, Loader2 } from 'lucide-react'; + +import type { AggregatedGroup } from '@/hooks/use-aggregated-listing'; +import { Button } from '@/components/ui/button'; +import { StatusPill } from '@/components/ui/status-pill'; + +interface AggregatedSectionProps { + title: string; + icon?: React.ReactNode; + groups: AggregatedGroup[]; + renderRow: (item: T, group: AggregatedGroup) => React.ReactNode; + emptyMessage?: string; + loading?: boolean; +} + +/** + * Renders a Signing or Files section with one labelled subsection per + * owner-source group. Each group shows up to 20 rows; a `Show all (N)` + * link drills into the source-scoped flat list. Hidden when groups is + * empty. + */ +export function AggregatedSection({ + title, + icon, + groups, + renderRow, + emptyMessage = 'Nothing here yet.', + loading, +}: AggregatedSectionProps) { + const total = groups.reduce((sum, g) => sum + g.total, 0); + + if (loading) { + return ( +
+

+ {icon} + {title} + +

+
+ ); + } + + if (groups.length === 0) { + return ( +
+

+ {icon} + {title} + · 0 +

+

{emptyMessage}

+
+ ); + } + + return ( +
+

+ {icon} + {title} + · {total} +

+
+ {groups.map((g) => ( + + ))} +
+
+ ); +} + +function GroupBlock({ + group, + renderRow, +}: { + group: AggregatedGroup; + renderRow: (item: T, group: AggregatedGroup) => React.ReactNode; +}) { + const items = (group.files ?? group.workflows ?? []) as T[]; + return ( +
+
+ {group.label} + · {group.total} +
+
    + {items.map((item) => ( +
  • {renderRow(item, group)}
  • + ))} +
+ {group.total > items.length ? ( + + ) : null} +
+ ); +} +``` + +The `Show all` link is left as a hook — the entity-folder view (Task 15) wires it to a query-param navigation that switches the section to a flat per-source listing. Keep this component dumb: it renders groups, the parent owns the drill-through. + +- [ ] **Step 3: Verify it type-checks** + +Run: `pnpm exec tsc --noEmit` +Expected: clean exit. + +- [ ] **Step 4: Commit** + +```bash +git add src/hooks/use-aggregated-listing.ts src/components/documents/aggregated-section.tsx +git commit -m "$(cat <<'EOF' +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) drill-through hook. Dumb +component — parent owns the row rendering + drill-through navigation. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: UI — `SigningDetailsDialog` + per-row "view signing details" link + +**Files:** + +- Create: `src/components/documents/signing-details-dialog.tsx` + +- [ ] **Step 1: Build the dialog** + +Create `src/components/documents/signing-details-dialog.tsx`: + +```tsx +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; + +import { apiFetch } from '@/lib/api/client'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { StatusPill } from '@/components/ui/status-pill'; + +interface SigningDetailsResponse { + data: { + workflow: { + id: string; + title: string; + status: string; + documentType: string; + createdAt: string; + updatedAt: string; + }; + signers: Array<{ + id: string; + signerName: string; + signerEmail: string; + signerRole: string; + status: string; + signedAt: string | null; + }>; + events: Array<{ + id: string; + eventType: string; + createdAt: string; + }>; + }; +} + +interface Props { + documentId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props) { + const { data, isLoading } = useQuery({ + queryKey: ['document-signing-details', documentId], + queryFn: () => + apiFetch(`/api/v1/documents/${documentId}/signing-details`), + enabled: Boolean(documentId) && open, + }); + + return ( + + + + Signing details + + Audit trail for this signed document — signers and timeline. + + + {isLoading || !data ? ( +
+ + Loading… +
+ ) : ( +
+
+

{data.data.workflow.title}

+

+ Status: Completed · Created{' '} + {new Date(data.data.workflow.createdAt).toLocaleString('en-GB')} +

+
+ +
+
+ Signers +
+
    + {data.data.signers.map((s) => ( +
  • +
    + {s.signerName} + {s.signerEmail} +
    +
    + {s.signedAt ? ( + + {new Date(s.signedAt).toLocaleDateString('en-GB')} + + ) : null} + + {s.status} + +
    +
  • + ))} +
+
+ +
+
+ Timeline +
+
    + {data.data.events.map((e) => ( +
  1. + + {new Date(e.createdAt).toLocaleString('en-GB')} + + {e.eventType.replace(/_/g, ' ')} +
  2. + ))} +
+
+
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `pnpm exec tsc --noEmit` +Expected: clean exit. If `StatusPillStatus` enum doesn't include `'pending'` / `'declined'`, cast or fall back to a default — read `src/components/ui/status-pill.tsx` for the allowed set. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/documents/signing-details-dialog.tsx +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +## Task 14: UI — `FolderTreeSidebar` + `FolderActionsMenu` system-folder awareness + +**Files:** + +- Modify: `src/components/documents/folder-tree-sidebar.tsx` +- Modify: `src/components/documents/folder-actions-menu.tsx` + +- [ ] **Step 1: Read both files end-to-end** + +```bash +cat src/components/documents/folder-tree-sidebar.tsx +cat src/components/documents/folder-actions-menu.tsx +``` + +Both already exist (Wave 11.B). The folder fetch returns `FolderNode[]` with the new `systemManaged` / `entityType` / `entityId` / `archivedAt` fields (Drizzle auto-includes them via `.select()` in `listTree`). The UI already accepts the tree and renders names; this task adds visual + behavioral awareness of the new flags. + +- [ ] **Step 2: Add 🔒 marker + archived muted style to `FolderTreeSidebar`** + +In `folder-tree-sidebar.tsx`, find the row-rendering block (the `
  • ` or ` +``` + +Read the actual existing template first — the file is ~160 lines, copy its current row JSX and patch in the lock icon + archived muted class. Do not invent props or rewrite the structure. + +- [ ] **Step 3: Suppress destructive actions in `FolderActionsMenu`** + +The actions menu shows Create / Rename / Move / Delete buttons or menu items keyed off `selectedFolderId`. The component already fetches the selected folder's row (likely via `useDocumentFolders`). Add a check: + +```tsx +// Find the selected folder in the tree +const selected = selectedFolderId + ? findInTree(tree, selectedFolderId) + : null; +const isSystem = selected?.systemManaged ?? false; + +// Disable Rename + Move + Delete buttons (or hide them) when isSystem: + +// + a tooltip explaining "System folders can't be renamed/moved/deleted" +``` + +Read the actual current implementation to match its DropdownMenu / Dialog structure. The `findInTree` helper may already exist in the file or in `use-document-folders.ts`; if not, write it inline: + +```typescript +function findInTree(tree: FolderNode[], id: string): FolderNode | null { + for (const node of tree) { + if (node.id === id) return node; + const found = findInTree(node.children, id); + if (found) return found; + } + return null; +} +``` + +Wrap each disabled button in a `` (from `@/components/ui/tooltip`) explaining why when `isSystem`: + +```tsx +{isSystem ? ( + + + + + + + System folders can't be renamed. + +) : ( + +)} +``` + +- [ ] **Step 4: Verify type + visual sanity in dev** + +Run: `pnpm exec tsc --noEmit && pnpm dev`. Open the Documents hub, observe that: + +- Clients/Companies/Yachts roots render with a 🔒 icon. +- Selecting a system folder grays out the Rename / Move / Delete actions. +- An archived entity's folder renders muted (after Task 6 has run on a test client). + +If the dev server can't be started in your environment, document this as a manual verification step in the PR description. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/documents/folder-tree-sidebar.tsx src/components/documents/folder-actions-menu.tsx +git commit -m "$(cat <<'EOF' +feat(documents): folder tree + actions UI for system-managed folders + +FolderTreeSidebar shows a 🔒 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) +EOF +)" +``` + +--- + +## Task 15: UI — `HubRootView` + `EntityFolderView` + rebuild `DocumentsHub` + +**Files:** + +- Create: `src/components/documents/hub-root-view.tsx` +- Create: `src/components/documents/entity-folder-view.tsx` +- Modify: `src/components/documents/documents-hub.tsx` + +This is the biggest UI task — wiring together the new hub layout. Break it into three sub-tasks. + +- [ ] **Step 1: Build `HubRootView`** + +Create `src/components/documents/hub-root-view.tsx`: + +```tsx +'use client'; + +import Link from 'next/link'; +import { FileText, ClipboardSignature } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { usePaginatedQuery } from '@/hooks/use-paginated-query'; + +interface HubRootDoc { + id: string; + title: string; + documentType: string; + status: string; + createdAt: string; +} +interface HubRootFile { + id: string; + filename: string; + createdAt: string; +} + +interface Props { + portSlug: string; +} + +/** + * Default landing when the rep clicks Documents in the sidebar — no + * folder selected. Shows port-wide in-flight workflows + recent files, + * each paginated. + */ +export function HubRootView({ portSlug }: Props) { + const { data: workflows, isLoading: workflowsLoading } = usePaginatedQuery({ + queryKey: ['documents', 'hub-root', 'workflows'], + endpoint: '/api/v1/documents?tab=in_progress', + filterDefinitions: [], + }); + const { data: filesData, isLoading: filesLoading } = usePaginatedQuery({ + queryKey: ['files', 'hub-root'], + endpoint: '/api/v1/files?sort=createdAt&order=desc&limit=20', + filterDefinitions: [], + }); + + return ( +
    +
    +

    + + Signing in progress +

    + {workflowsLoading ? ( +
    Loading…
    + ) : workflows.length === 0 ? ( +
    No workflows in flight.
    + ) : ( +
      + {workflows.map((w) => ( +
    • + + {w.title} + + {w.status} +
    • + ))} +
    + )} +
    + +
    +

    + + Recent files +

    + {filesLoading ? ( +
    Loading…
    + ) : filesData.length === 0 ? ( +
    No files yet.
    + ) : ( +
      + {filesData.map((f) => ( +
    • + {f.filename} + + {new Date(f.createdAt).toLocaleDateString('en-GB')} + +
    • + ))} +
    + )} +
    +
    + ); +} +``` + +- [ ] **Step 2: Build `EntityFolderView`** + +Create `src/components/documents/entity-folder-view.tsx`: + +```tsx +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { ClipboardSignature, FileText, Eye } from 'lucide-react'; + +import { AggregatedSection } from './aggregated-section'; +import { SigningDetailsDialog } from './signing-details-dialog'; +import { + useAggregatedFiles, + useAggregatedWorkflows, +} from '@/hooks/use-aggregated-listing'; +import { StatusPill } from '@/components/ui/status-pill'; + +interface Props { + portSlug: string; + entityType: 'client' | 'company' | 'yacht'; + entityId: string; +} + +/** + * The entity-folder view: stacked Signing in progress + Files sections, + * each grouped by owner-source via the aggregated projection API. + */ +export function EntityFolderView({ portSlug, entityType, entityId }: Props) { + const [detailsId, setDetailsId] = useState(null); + + const { data: workflowsResp, isLoading: workflowsLoading } = useAggregatedWorkflows( + portSlug, + entityType, + entityId, + ); + const { data: filesResp, isLoading: filesLoading } = useAggregatedFiles( + portSlug, + entityType, + entityId, + ); + + const workflowGroups = workflowsResp?.data.groups ?? []; + const fileGroups = filesResp?.data.groups ?? []; + + return ( +
    + } + groups={workflowGroups} + loading={workflowsLoading} + emptyMessage="No workflows in flight for this entity." + renderRow={(w) => ( +
    + + {w.title} + + {w.status} +
    + )} + /> + + } + groups={fileGroups} + loading={filesLoading} + emptyMessage="No files for this entity yet." + renderRow={(f) => { + const isSigned = f.filename?.startsWith('signed-'); + return ( +
    + {f.filename} +
    + {new Date(f.createdAt).toLocaleDateString('en-GB')} + {isSigned ? ( + + ) : null} +
    +
    + ); + }} + /> + + !open && setDetailsId(null)} + /> +
    + ); +} +``` + +Note: identifying signed-PDF files via `filename.startsWith('signed-')` is a quick heuristic; the spec recommends checking via `documents.signedFileId` lookup. For v1, the heuristic is acceptable since the auto-deposit handler names files exactly this way. If you want to do the right thing, extend the `/api/v1/files` aggregated response to surface a `signedFromDocumentId` field on rows where one exists — that's the principled fix and a small change to `listFilesAggregatedByEntity` (LEFT JOIN documents on signedFileId). Document this as a follow-up if the heuristic ships. + +- [ ] **Step 3: Rebuild `documents-hub.tsx`** + +Read the existing file: + +```bash +cat src/components/documents/documents-hub.tsx +``` + +Replace its rendering branch: + +- Drop the `` (signing-status tabs) entirely. Drop `documentsHubTabs` import + `TAB_LABELS`. +- Drop the `useQuery` for `hub-counts` (or reduce to in-flight count). +- Keep `FolderTreeSidebar` + `FolderBreadcrumb` + `FolderActionsMenu`. +- Add a fetch for the selected folder's row (use the `useDocumentFolders` hook + `findInTree`) and branch the main panel: + - If `selectedFolderId` is undefined → render ``. + - If the selected folder is system-managed and has an `entityType + entityId` → render ``. + - Otherwise → render the current flat folder listing (existing `documents` query keyed by `folderId`). + +Key snippet: + +```tsx +const tree = foldersResp?.data ?? []; +const selectedFolder = + typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null; + +return ( +
    + + handleFolderSelect(undefined)} + /> + + } + /> +
    + + + + {selectedFolderId === undefined ? ( + + ) : selectedFolder?.entityType && selectedFolder.entityType !== 'root' && selectedFolder.entityId ? ( + + ) : ( + /* existing flat folder listing — keep the search input + type chips + paginated list */ + + )} +
    +
    +); +``` + +You'll want to extract the existing flat listing block (search + chips + ul of doc rows) into a `` sub-component inside the same file (or a sibling file). Keep the props minimal: `portSlug` + `folderId`. + +Remove the `useQuery` for `hub-counts` (or reduce to a single in-flight count if the page header still uses it). Drop `documentsHubTabs` from imports. + +- [ ] **Step 4: Verify TypeScript + manual sanity check** + +Run: `pnpm exec tsc --noEmit` +Expected: clean exit. + +Start dev server: `pnpm dev`. Open Documents: + +- Root view shows in-flight workflows + recent files. +- Click `Clients/` in the tree → shows the subfolders for clients with files. +- Click into a specific client's subfolder → shows Signing in progress + Files sections, grouped by source. +- Click "view signing details" on a signed file → dialog opens. + +If the dev server isn't runnable in your environment, document the manual checks for the PR description and rely on the Playwright tests in Task 18 instead. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/documents/hub-root-view.tsx src/components/documents/entity-folder-view.tsx src/components/documents/documents-hub.tsx +git commit -m "$(cat <<'EOF' +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. + - 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 is no +longer used. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 16: Files page removal + 301 redirect + +**Files:** + +- Delete: `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` +- Delete: `src/components/files/folder-tree.tsx` +- Modify: `next.config.mjs` +- Modify: `src/stores/file-browser-store.ts` + +- [ ] **Step 1: Confirm nothing else imports the legacy page** + +```bash +grep -rn "from '@/components/files/folder-tree'\|/documents/files\"" src/ --include="*.tsx" --include="*.ts" +``` + +Expected: only `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` references the legacy tree. If anything else does, deal with it before deleting (e.g., a sidebar link in `src/components/layout/sidebar.tsx`). + +- [ ] **Step 2: Delete the legacy page + folder tree** + +```bash +git rm src/app/(dashboard)/[portSlug]/documents/files/page.tsx +git rm src/components/files/folder-tree.tsx +``` + +- [ ] **Step 3: Add a 301 redirect to `next.config.mjs`** + +In `next.config.mjs`, add a `redirects()` function alongside the existing `headers()`: + +```javascript +async redirects() { + return [ + { + source: '/:portSlug/documents/files', + destination: '/:portSlug/documents', + permanent: true, + }, + { + source: '/:portSlug/documents/files/:path*', + destination: '/:portSlug/documents', + permanent: true, + }, + ]; +}, +``` + +- [ ] **Step 4: Repurpose `file-browser-store.ts`** + +The store currently tracks `currentFolder` as a `storagePath` prefix. Repurpose it to hold a `selectedFolderId` (the `document_folders.id` reference): + +```typescript +import { create } from 'zustand'; + +interface FileBrowserState { + viewMode: 'grid' | 'list'; + setViewMode: (mode: 'grid' | 'list') => void; + selectedFolderId: string | null | undefined; // undefined = no selection, null = root + setSelectedFolderId: (id: string | null | undefined) => void; +} + +export const useFileBrowserStore = create((set) => ({ + viewMode: 'list', + setViewMode: (viewMode) => set({ viewMode }), + selectedFolderId: undefined, + setSelectedFolderId: (selectedFolderId) => set({ selectedFolderId }), +})); +``` + +The `currentFolder` + `setCurrentFolder` exports are gone — verify with `grep -rn 'currentFolder\|setCurrentFolder' src` that nothing else references them. The only consumer was the deleted page. + +- [ ] **Step 5: Sidebar link cleanup** + +```bash +grep -rn '/documents/files' src/components/layout/ +``` + +If a sidebar link points to `/documents/files`, change it to `/documents`. (Likely no change needed since the redirect catches stray bookmarks too.) + +- [ ] **Step 6: TypeScript + a smoke run** + +```bash +pnpm exec tsc --noEmit +``` + +Expected: clean exit. If the dev server is up, navigate to `//documents/files` and confirm the 301 lands on `//documents`. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +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) +EOF +)" +``` + +--- + +## Task 17: Run backfill on deploy + +**Files:** + +- Modify: `package.json` (add a `postinstall` or `db:backfill` script entry) +- Modify: deploy docs/runbook (if one exists in `docs/`) + +The migration in Task 1 ships the schema. The backfill in Task 11 ships the script. This task wires the two together so backfill runs as part of the deploy. + +- [ ] **Step 1: Add an npm script** + +In `package.json`, add to the `"scripts"` block: + +```json +"db:backfill:doc-folders": "tsx scripts/backfill-document-folders.ts" +``` + +- [ ] **Step 2: Document the deploy sequence** + +Find the deploy runbook. Likely candidates: + +```bash +ls docs/ | grep -i deploy +grep -rln 'deploy' docs/ | head -5 +``` + +If a runbook exists, add a step **after** the migration step: + +```markdown +### Documents hub split (Wave 11.B+) + +Run after the `0051_documents_hub_split.sql` migration applies: + +```bash +pnpm db:backfill:doc-folders +``` + +Idempotent — safe to re-run if the deploy is interrupted. +``` + +If no runbook exists, add the step to the README or a fresh `docs/deploy.md`. Don't create a docs file unless one exists or is the obvious home — the user-facing repo conventions matter more than perfect docs. + +- [ ] **Step 3: Manual local-deploy rehearsal** + +```bash +# Roll back the local DB to pre-Task-1 state (skip if you don't have a fixture) +# Apply migration: +PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \ + -f src/lib/db/migrations/0051_documents_hub_split.sql +# Run backfill: +pnpm db:backfill:doc-folders +``` + +Expected: zero errors, system roots populated, file folder_ids set. + +- [ ] **Step 4: Commit** + +```bash +git add package.json docs/ +git commit -m "$(cat <<'EOF' +chore(documents): wire backfill script into deploy sequence + +Adds db:backfill:doc-folders npm script + runbook step. Run after the +0051 migration applies. Idempotent; safe to re-run on interrupted +deploys. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 18: E2E + visual snapshots + +**Files:** + +- Create: `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts` +- Create: `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts` +- Modify: `tests/e2e/visual/snapshots.spec.ts` + +- [ ] **Step 1: Write the aggregated-view smoke spec** + +Create `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts`. Read `tests/e2e/smoke/04-documents.spec.ts` (the Wave 11.B smoke) first to match the auth + setup helper pattern. The spec should: + +1. Log in as the seeded sales-manager user. +2. Seed a client + a yacht owned by that client (via the test API or DB helper). +3. Trigger a Documenso completion for an EOI on the client (via the polling worker mock or the realapi path — for smoke, fake the webhook by hitting the test-only API that fires the handler). +4. Navigate to `//documents`. +5. Click `Clients` in the sidebar, then the client's subfolder. +6. Assert visible: `DIRECTLY ATTACHED` group, the signed PDF row, the "view signing details" button. +7. Click the button; assert the dialog opens with signers list. + +```typescript +import { test, expect } from '@playwright/test'; +// ... existing project setup imports +test('open client entity folder, see aggregated groups, view signing details', async ({ page }) => { + // Setup ... + await page.goto('/port-nimara/documents'); + await page.getByRole('button', { name: /Clients/i }).click(); + // Click the seeded client's subfolder + await page.getByRole('button', { name: /Smith, John/ }).click(); + await expect(page.getByText(/DIRECTLY ATTACHED/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /view signing details/i })).toBeVisible(); + await page.getByRole('button', { name: /view signing details/i }).first().click(); + await expect(page.getByRole('dialog', { name: /Signing details/i })).toBeVisible(); +}); +``` + +- [ ] **Step 2: Write the upload-into-entity smoke spec** + +Create `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts`. Seed a client, navigate to their entity folder, click Upload, attach a small PDF fixture (`tests/e2e/fixtures/sample.pdf`), submit. Assert: + +1. After upload, the row appears in DIRECTLY ATTACHED. +2. The file row's API shape (verified via a follow-up `fetch('/api/v1/files/')` from the page context, or via the test-only DB helper) has `clientId` set to the seeded client + `folderId` set to the entity subfolder. + +- [ ] **Step 3: Add visual snapshots** + +In `tests/e2e/visual/snapshots.spec.ts`, append two new snapshot blocks following the existing pattern: + +```typescript +test('hub-root', async ({ page }) => { + await page.goto('/port-nimara/documents'); + await page.waitForSelector('text=Signing in progress'); + await expect(page).toHaveScreenshot('hub-root.png', { fullPage: true }); +}); + +test('hub-entity-folder', async ({ page }) => { + // Seed assumed by global setup; navigate to a fixed seeded client. + await page.goto('/port-nimara/documents'); + await page.getByRole('button', { name: /Clients/i }).click(); + await page.getByRole('button', { name: /Smith, John/ }).click(); + await page.waitForSelector('text=DIRECTLY ATTACHED'); + await expect(page).toHaveScreenshot('hub-entity-folder.png', { fullPage: true }); +}); +``` + +Run with `--update-snapshots` to generate baselines: + +```bash +pnpm exec playwright test --project=visual --update-snapshots tests/e2e/visual/snapshots.spec.ts +``` + +Verify the generated PNGs look right (open them in the OS file viewer). Commit the new snapshot PNGs under `tests/e2e/visual/snapshots.spec.ts-snapshots/`. + +- [ ] **Step 4: Run the smoke project end-to-end** + +```bash +pnpm exec playwright test --project=smoke +``` + +Expected: passes including the two new specs. Allow ~10 min runtime. + +- [ ] **Step 5: Commit** + +```bash +git add tests/e2e/smoke/04-documents-hub-aggregated.spec.ts tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts tests/e2e/visual/snapshots.spec.ts tests/e2e/visual/snapshots.spec.ts-snapshots/ +git commit -m "$(cat <<'EOF' +test(documents): E2E smoke + visual snapshots for hub rebuild + +Two smoke specs cover the headline flows: + - open client entity folder → see grouped Signing + Files → click + "view signing details" → dialog renders signers + events. + - upload PDF into Clients/Smith/ → client_id auto-set from the + destination folder → file appears in DIRECTLY ATTACHED. + +Visual baselines for hub-root + hub-entity-folder catch unintended +layout drift on the new screens. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 19: CLAUDE.md update + final verification + +**Files:** + +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Extend the Document folders subsection** + +Open `CLAUDE.md`. Find the existing `**Document folders:**` bullet under the Conventions block. Replace it with: + +```markdown +- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain. + + Three system roots (`Clients/`, `Companies/`, `Yachts/`) are auto-created on port init via `ensureSystemRoots`. Per-entity subfolders are created lazily on first auto-deposit / manual upload via `ensureEntityFolder` — concurrent callers race safely via the partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. The `chk_system_folder_shape` CHECK pins the shape of system rows. Rename/move/delete on `system_managed = true` folders is rejected by `assertNotSystemManaged` (service-level, not DB-level). Entity rename auto-syncs the folder name; archive applies a ` (archived)` suffix; hard-delete demotes (`system_managed = false`) + appends ` (deleted)`. + + Auto-deposit on signing completion: `handleDocumentCompleted` resolves the owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.primaryClientId ?? .primaryCompanyId ?? .primaryYachtId`), ensures the matching entity subfolder, and sets `files.folder_id` + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable. + + Aggregated projection: `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk the relationship graph from the requested entity (symmetric reach: Client ↔ Company via `company_memberships`, ↔ Yacht via `yachts.current_owner_type/id`) and return results grouped by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT). Each group caps at 20 rows with a total for `Show all (N)`. **File-FK snapshot is the source of truth** — historical files stay where they were filed even if the linked entity's relationships change. **Defense-in-depth `port_id` filter at every join** (per recommender precedent) — entry-point check alone is rejected. Completed workflows are hidden from folder views; the signed-PDF file surfaces with a "view signing details" link to the workflow audit trail. + + Permission gating: `documents.view` for read of folders + entity-aggregated listing; `documents.manage_folders` for create / rename / move / delete of user folders (system folders are immutable through the API entirely). +``` + +- [ ] **Step 2: Verify the change reads cleanly** + +```bash +grep -A 20 '**Document folders:**' CLAUDE.md | head -30 +``` + +Expected: the new block is in place; surrounding bullets are untouched. + +- [ ] **Step 3: Run the full verification** + +```bash +pnpm exec tsc --noEmit +pnpm exec vitest run +pnpm exec playwright test --project=smoke +``` + +Expected: + +- tsc: clean exit. +- vitest: all green (existing suite + the new tests from Tasks 2, 3, 4, 5, 6, 7, 8, 10, 11 — should net to roughly +60 new tests on top of the Wave 11.B baseline of 1213). +- playwright smoke: passes. + +If any test fails, fix the root cause before committing. Do not use `--no-verify` and do not skip tests. + +- [ ] **Step 4: Run prettier + lint to catch formatting drift** + +```bash +pnpm format +pnpm lint +``` + +Expected: zero diffs / zero errors. If prettier reformats anything you wrote, stage the change. + +- [ ] **Step 5: Final commit** + +```bash +git add CLAUDE.md +git commit -m "$(cat <<'EOF' +docs(claude-md): documents hub split + auto-filed client folders + +Extends the Document folders subsection with: three system roots ++ per-entity subfolders + lifecycle hooks (rename/archive/delete); +Owner-wins owner resolution in handleDocumentCompleted; aggregated +projection with symmetric reach + file-FK-as-source-of-truth + +defense-in-depth port_id filter; permission gating. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 6: Open the PR** + +Use `gh pr create` per the conventions in CLAUDE.md. PR description should include: + +- Link to the spec. +- Summary: "Documents hub split + auto-filed client folders". +- Test plan: tsc clean, vitest all green, playwright smoke pass, visual baselines regenerated. +- Deploy note: migration `0051_documents_hub_split.sql` + `pnpm db:backfill:doc-folders` after migrate. + +--- + +## Self-review + +After landing the final commit, run this checklist before opening the PR: + +**Spec coverage:** + +- [ ] §"Conceptual model" — File / Signing workflow / Folder as first-class concepts. ✓ Tasks 1, 7, 8. +- [ ] §"Folder tree structure & governance" — system roots, lazy entity subfolders, suffix lifecycle. ✓ Tasks 2, 3, 5, 6. +- [ ] §"Routing on workflow completion" steps 3a–3c. ✓ Task 7. +- [ ] §"Owner-aggregation projection" — symmetric walk, per-group pagination, file-FK as source of truth, defense-in-depth port_id. ✓ Task 8. +- [ ] §"UI layout" — stacked Signing/Files, owner-grouped headers, system-folder integration, view-signing-details. ✓ Tasks 12, 13, 14, 15. +- [ ] §"Edge cases" E1–E14 — every decision row has a code path: + - E1 (entity renamed) → Task 5. + - E2 (name collision) → Task 3 + Task 5. + - E3 (archived) → Task 6. + - E4 (hard-deleted) → Task 6. + - E5 (yacht ownership transferred) → Task 8 (file-FK snapshot). + - E6 (owner changed mid-signing) → Task 7 (resolve at completion). + - E7 (rep moves file out of system folder) → no code change; the file's entity FK is unchanged so aggregation still surfaces it. Verified by Task 8 test. + - E8 (manual upload into entity folder) → covered by `applyEntityFkFromFolder` in Task 12's upload path; if not wired in Task 12, add a sub-step or split into a Task 12b. + - E9 (no owner) → Task 7. + - E10 (interest with no owner) → Task 7 (resolveDocumentOwner returns null). + - E11 (1000+ files) → Task 8 (GROUP_LIMIT + total). + - E12 (hub root view) → Task 15. + - E13 (concurrent completions race) → Task 3 (ON CONFLICT + re-SELECT). + - E14 (cross-port leak) → Task 8 (port_id at every join). +- [ ] §"Schema deltas" — Task 1. +- [ ] §"Backfill migration" — Task 11. +- [ ] §"Testing strategy" — Tasks 2–11 cover unit + integration; Task 18 covers E2E + visual. + +**Placeholder scan:** none — every code block in this plan contains real syntax. The two known carve-outs: + +1. Task 8 references "the existing `pagination` shape" and Task 9 says "the existing envelope" — these reference unchanged behaviour the engineer will read in-context. +2. Task 12 calls the parent of `AggregatedSection` for the Show-all drill-through — left intentionally as a hook because the drill-through routing depends on the entity-folder view (Task 15) that wraps it. + +**Type consistency:** + +- `EntityType` defined in `document-folders.service.ts` (Task 3), imported in `documents.service.ts` (Task 7), `files.ts` (Task 8), and the backfill (Task 11). Same shape everywhere. +- `AggregatedFileGroup` / `AggregatedWorkflowGroup` defined in service files (Task 8); hook in Task 12 re-declares a compatible shape for the UI side (avoid leaking server types directly). +- `ensureSystemRoots(portId, userId) → Promise` consistent in Task 2, Task 3 (called from `ensureEntityFolder` self-heal path), Task 11 (called from backfill). +- `ensureEntityFolder(portId, entityType, entityId, userId) → Promise` consistent in Task 3 (definition), Task 7 (auto-deposit), Task 11 (backfill). + +**Risks called out in the spec mitigated:** + +- Aggregation slow on large portfolios: indexes `idx_files_client / _company / _yacht` already exist; `idx_files_port_folder` added in Task 1. +- Backfill locks production: per-port advisory lock + idempotent (Task 11). +- System-folder bypass via direct DB write: accepted; spec-explicit risk. +- Hard cutover with backfill failure: idempotent re-run, rollback = revert migration + redeploy old hub binary. + +--- + +## Resume prompt for fresh sessions + +To resume mid-plan from a fresh Claude session, paste: + +``` +I'm resuming the documents-hub-split plan execution on branch +feat/documents-folders. Plan: +docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md. +Spec: docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md. + +Wave 11.B (folders foundation) is complete; this plan layers system +roots + entity subfolders + auto-deposit + aggregated projection + +hub rebuild on top. Check the commits on the branch to determine the +current task, then continue with superpowers:subagent-driven-development. +```