Files
pn-new-crm/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md
Matt 0e8feb1073 chore: prettier format pass on branch files
Auto-format all files modified during the documents-hub-split feature
branch that were not yet aligned with the project's Prettier config
(single quotes, semicolons, trailing commas).

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

167 KiB
Raw Blame History

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 E1E14, aggregation reach, rollout strategy, governance).

Builds on: Wave 11.B (branch feat/documents-folders, already merged into current branch). Tasks 119 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: <T> } 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 (3a3c). 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.tsGET 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(<portIdHash>) 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.tsensureEntityFolder idempotency, syncEntityFolderName collision (numeric suffix), applyEntityArchivedSuffix round-trip, demoteSystemFolderOnEntityDelete flips system_managed, system-folder rename/move/delete rejected.
  • Create: tests/unit/aggregated-projection.test.tslistFilesAggregatedByEntity symmetric walk, per-group pagination, file-FK-as-source-of-truth (yacht-transfer scenario).
  • Create: tests/integration/documents-completion-auto-deposit.test.tshandleDocumentCompleted 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.tsGET /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:

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:

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:

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:

-- 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:

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:

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
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) <noreply@anthropic.com>
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:

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:

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<DocumentFolder[]> {
  // 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:

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:

// 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:

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
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) <noreply@anthropic.com>
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:

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:

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<EntityType>(['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<string> {
  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<DocumentFolder> {
  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
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) <noreply@anthropic.com>
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:

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):

/**
 * 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<DocumentFolder> {
  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:

const existing = await db.query.documentFolders.findFirst({
  where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
});
if (!existing) throw new NotFoundError('Folder');

with:

const existing = await assertNotSystemManaged(portId, folderId, 'rename');

For moveFolder (current line ~168), replace:

const folder = await db.query.documentFolders.findFirst({
  where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
});
if (!folder) throw new NotFoundError('Folder');

with:

const folder = await assertNotSystemManaged(portId, folderId, 'move');

For deleteFolderSoftRescue (current line ~242), replace:

const folder = await db.query.documentFolders.findFirst({
  where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
});
if (!folder) throw new NotFoundError('Folder');

with:

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:

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
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) <noreply@anthropic.com>
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:

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:

/**
 * 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<void> {
  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:

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:

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):

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:

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
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) <noreply@anthropic.com>
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:

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:

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<void> {
  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<void> {
  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<void> {
  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:

import {
  applyEntityArchivedSuffix,
  applyEntityRestoredSuffix,
} from '@/lib/services/document-folders.service';

Inside archiveClient (~line 537), after the entity update succeeds:

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:

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):

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:

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, '<type>', 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:

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
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) <noreply@anthropic.com>
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:

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<typeof import('@/lib/services/documenso-client')>();
  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:

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<ResolvedOwner | null> {
  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):

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:

// 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
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) <noreply@anthropic.com>
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:

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:

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<typeof files.$inferSelect & { livesInFolderPath?: string }>;
  /** 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<AggregatedFilesResult> {
  // 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<boolean> {
  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<RelatedEntities> {
  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<typeof eq>,
  limit: number,
): Promise<{
  rows: Array<typeof files.$inferSelect>;
  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<number>`count(*)::int` })
    .from(files)
    .where(and(eq(files.portId, portId), predicate));

  return { rows, total: Number(count ?? 0) };
}

function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {
  const seen = new Set<K>();
  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:

// 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:

import { collectRelatedEntities, type AggregatedFileGroup } from '@/lib/services/files';

export interface AggregatedWorkflowGroup {
  label: string;
  source: 'direct' | 'client' | 'company' | 'yacht';
  workflows: Array<typeof documents.$inferSelect>;
  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<typeof eq>,
): Promise<{ rows: Array<typeof documents.$inferSelect>; 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<number>`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:

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:

/**
 * 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<
  T extends {
    clientId?: string | null;
    companyId?: string | null;
    yachtId?: string | null;
    folderId?: string | null;
  },
>(portId: string, payload: T): Promise<T> {
  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:

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
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) <noreply@anthropic.com>
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):

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:

.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:

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:

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:

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=<id> (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
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) <noreply@anthropic.com>
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:

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:

// 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
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) <noreply@anthropic.com>
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:

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:

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<void> {
  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 <id>]`
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
pnpm tsx scripts/backfill-document-folders.ts

Expected output: "Backfill complete" + zero errors. Check the dev DB has three system roots per port:

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
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) <noreply@anthropic.com>
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:

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<T> {
  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<AggregatedFile>[] } }>({
    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<AggregatedWorkflow>[] } }>({
    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:

'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<T> {
  title: string;
  icon?: React.ReactNode;
  groups: AggregatedGroup<T>[];
  renderRow: (item: T, group: AggregatedGroup<T>) => 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<T>({
  title,
  icon,
  groups,
  renderRow,
  emptyMessage = 'Nothing here yet.',
  loading,
}: AggregatedSectionProps<T>) {
  const total = groups.reduce((sum, g) => sum + g.total, 0);

  if (loading) {
    return (
      <section className="rounded-md border bg-white p-3">
        <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
          {icon}
          {title}
          <Loader2 className="ml-1 h-3.5 w-3.5 animate-spin text-muted-foreground" />
        </h3>
      </section>
    );
  }

  if (groups.length === 0) {
    return (
      <section className="rounded-md border bg-white p-3 text-sm text-muted-foreground">
        <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
          {icon}
          {title}
          <span className="ml-1 text-xs text-muted-foreground tabular-nums">· 0</span>
        </h3>
        <p className="mt-2">{emptyMessage}</p>
      </section>
    );
  }

  return (
    <section className="rounded-md border bg-white">
      <h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold text-foreground">
        {icon}
        {title}
        <span className="ml-1 text-xs text-muted-foreground tabular-nums">· {total}</span>
      </h3>
      <div className="divide-y">
        {groups.map((g) => (
          <GroupBlock key={`${g.source}-${g.label}`} group={g} renderRow={renderRow} />
        ))}
      </div>
    </section>
  );
}

function GroupBlock<T>({
  group,
  renderRow,
}: {
  group: AggregatedGroup<T>;
  renderRow: (item: T, group: AggregatedGroup<T>) => React.ReactNode;
}) {
  const items = (group.files ?? group.workflows ?? []) as T[];
  return (
    <div className="px-3 py-2">
      <header className="mb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
        {group.label}
        <span className="ml-1.5 text-muted-foreground/70 tabular-nums">· {group.total}</span>
      </header>
      <ul className="space-y-1">
        {items.map((item) => (
          <li key={(item as { id: string }).id}>{renderRow(item, group)}</li>
        ))}
      </ul>
      {group.total > items.length ? (
        <button
          type="button"
          className="mt-1 text-xs text-brand hover:underline"
          onClick={() => {
            // Show-all handler is wired by the parent — emit a custom event
            // or accept an onShowAll callback. For v1 a query-param navigation
            // is sufficient; the parent passes the wired handler in renderRow.
          }}
        >
          Show all ({group.total})
        </button>
      ) : null}
    </div>
  );
}

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
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) <noreply@anthropic.com>
EOF
)"

Files:

  • Create: src/components/documents/signing-details-dialog.tsx

  • Step 1: Build the dialog

Create src/components/documents/signing-details-dialog.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<SigningDetailsResponse>({
    queryKey: ['document-signing-details', documentId],
    queryFn: () =>
      apiFetch<SigningDetailsResponse>(`/api/v1/documents/${documentId}/signing-details`),
    enabled: Boolean(documentId) && open,
  });

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>Signing details</DialogTitle>
          <DialogDescription>
            Audit trail for this signed document  signers and timeline.
          </DialogDescription>
        </DialogHeader>
        {isLoading || !data ? (
          <div className="flex items-center justify-center py-12 text-muted-foreground">
            <Loader2 className="mr-2 h-4 w-4 animate-spin" />
            Loading
          </div>
        ) : (
          <div className="space-y-4">
            <section>
              <h4 className="mb-1 text-sm font-semibold">{data.data.workflow.title}</h4>
              <p className="text-xs text-muted-foreground">
                Status: <StatusPill status="completed">Completed</StatusPill> · Created{' '}
                {new Date(data.data.workflow.createdAt).toLocaleString('en-GB')}
              </p>
            </section>

            <section>
              <h5 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                Signers
              </h5>
              <ul className="divide-y rounded border bg-muted/30">
                {data.data.signers.map((s) => (
                  <li
                    key={s.id}
                    className="flex items-center justify-between gap-2 px-3 py-2 text-xs"
                  >
                    <div className="min-w-0">
                      <span className="font-medium">{s.signerName}</span>
                      <span className="ml-2 text-muted-foreground">{s.signerEmail}</span>
                    </div>
                    <div className="flex items-center gap-2">
                      {s.signedAt ? (
                        <span className="tabular-nums text-muted-foreground">
                          {new Date(s.signedAt).toLocaleDateString('en-GB')}
                        </span>
                      ) : null}
                      <StatusPill status={s.status as 'signed' | 'pending' | 'declined'}>
                        {s.status}
                      </StatusPill>
                    </div>
                  </li>
                ))}
              </ul>
            </section>

            <section>
              <h5 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                Timeline
              </h5>
              <ol className="space-y-1 text-xs">
                {data.data.events.map((e) => (
                  <li key={e.id} className="flex items-center gap-2 text-muted-foreground">
                    <span className="tabular-nums">
                      {new Date(e.createdAt).toLocaleString('en-GB')}
                    </span>
                    <span>{e.eventType.replace(/_/g, ' ')}</span>
                  </li>
                ))}
              </ol>
            </section>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}
  • 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
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) <noreply@anthropic.com>
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

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 <li> or <button> that renders one folder's name + chevron). Add conditional rendering:

import { Lock } from 'lucide-react';

// Inside the row template:
<button
  type="button"
  className={cn(
    'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-accent',
    selectedFolderId === node.id && 'bg-accent font-medium',
    node.archivedAt && 'text-muted-foreground',
  )}
  onClick={() => onSelect(node.id)}
>
  {/* existing chevron + folder icon */}
  <span className="truncate">{node.name}</span>
  {node.systemManaged ? (
    <Lock className="ml-1 h-3 w-3 text-muted-foreground" aria-label="System folder" />
  ) : null}
</button>;

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:

// 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:
<Button disabled={isSystem || pending} onClick={handleRename}>
  Rename
</Button>;
// + 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:

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 <Tooltip> (from @/components/ui/tooltip) explaining why when isSystem:

{
  isSystem ? (
    <Tooltip>
      <TooltipTrigger asChild>
        <span>
          <Button disabled>Rename</Button>
        </span>
      </TooltipTrigger>
      <TooltipContent>System folders can't be renamed.</TooltipContent>
    </Tooltip>
  ) : (
    <Button onClick={handleRename}>Rename</Button>
  );
}
  • 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
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) <noreply@anthropic.com>
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:

'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<HubRootDoc>({
    queryKey: ['documents', 'hub-root', 'workflows'],
    endpoint: '/api/v1/documents?tab=in_progress',
    filterDefinitions: [],
  });
  const { data: filesData, isLoading: filesLoading } = usePaginatedQuery<HubRootFile>({
    queryKey: ['files', 'hub-root'],
    endpoint: '/api/v1/files?sort=createdAt&order=desc&limit=20',
    filterDefinitions: [],
  });

  return (
    <div className="space-y-4">
      <section className="rounded-md border bg-white">
        <h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
          <ClipboardSignature className="h-4 w-4 text-muted-foreground" />
          Signing in progress
        </h3>
        {workflowsLoading ? (
          <div className="p-3 text-sm text-muted-foreground">Loading</div>
        ) : workflows.length === 0 ? (
          <div className="p-3 text-sm text-muted-foreground">No workflows in flight.</div>
        ) : (
          <ul className="divide-y">
            {workflows.map((w) => (
              <li key={w.id} className="px-3 py-2 text-sm">
                <Link href={`/${portSlug}/documents/${w.id}`} className="hover:underline">
                  {w.title}
                </Link>
                <span className="ml-2 text-xs text-muted-foreground">{w.status}</span>
              </li>
            ))}
          </ul>
        )}
      </section>

      <section className="rounded-md border bg-white">
        <h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
          <FileText className="h-4 w-4 text-muted-foreground" />
          Recent files
        </h3>
        {filesLoading ? (
          <div className="p-3 text-sm text-muted-foreground">Loading</div>
        ) : filesData.length === 0 ? (
          <div className="p-3 text-sm text-muted-foreground">No files yet.</div>
        ) : (
          <ul className="divide-y">
            {filesData.map((f) => (
              <li key={f.id} className="flex items-center justify-between px-3 py-2 text-sm">
                <span className="truncate">{f.filename}</span>
                <span className="text-xs text-muted-foreground tabular-nums">
                  {new Date(f.createdAt).toLocaleDateString('en-GB')}
                </span>
              </li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
}
  • Step 2: Build EntityFolderView

Create src/components/documents/entity-folder-view.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<string | null>(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 (
    <div className="space-y-4">
      <AggregatedSection
        title="Signing in progress"
        icon={<ClipboardSignature className="h-4 w-4 text-muted-foreground" />}
        groups={workflowGroups}
        loading={workflowsLoading}
        emptyMessage="No workflows in flight for this entity."
        renderRow={(w) => (
          <div className="flex items-center justify-between gap-2 text-sm">
            <Link href={`/${portSlug}/documents/${w.id}`} className="truncate hover:underline">
              {w.title}
            </Link>
            <StatusPill status={w.status as 'sent'}>{w.status}</StatusPill>
          </div>
        )}
      />

      <AggregatedSection
        title="Files"
        icon={<FileText className="h-4 w-4 text-muted-foreground" />}
        groups={fileGroups}
        loading={filesLoading}
        emptyMessage="No files for this entity yet."
        renderRow={(f) => {
          const isSigned = f.filename?.startsWith('signed-');
          return (
            <div className="flex items-center justify-between gap-2 text-sm">
              <span className="truncate">{f.filename}</span>
              <div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
                <span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
                {isSigned ? (
                  <button
                    type="button"
                    className="flex items-center gap-1 text-brand hover:underline"
                    onClick={() => setDetailsId(f.id)}
                  >
                    <Eye className="h-3 w-3" />
                    view signing details
                  </button>
                ) : null}
              </div>
            </div>
          );
        }}
      />

      <SigningDetailsDialog
        documentId={detailsId}
        open={Boolean(detailsId)}
        onOpenChange={(open) => !open && setDetailsId(null)}
      />
    </div>
  );
}

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:

cat src/components/documents/documents-hub.tsx

Replace its rendering branch:

  • Drop the <Tabs> (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 <HubRootView portSlug={portSlug} />.
    • If the selected folder is system-managed and has an entityType + entityId → render <EntityFolderView portSlug={portSlug} entityType={...} entityId={...} />.
    • Otherwise → render the current flat folder listing (existing documents query keyed by folderId).

Key snippet:

const tree = foldersResp?.data ?? [];
const selectedFolder =
  typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null;

return (
  <div className="flex flex-col sm:flex-row h-full">
    <FolderTreeSidebar
      selectedFolderId={selectedFolderId}
      onSelect={handleFolderSelect}
      footer={
        <PermissionGate resource="documents" action="manage_folders">
          <FolderActionsMenu
            selectedFolderId={selectedFolderId}
            onAfterDelete={() => handleFolderSelect(undefined)}
          />
        </PermissionGate>
      }
    />
    <div className="flex-1 min-w-0 p-4 space-y-4">
      <FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
      <PageHeader title="Documents" /* ... */ />

      {selectedFolderId === undefined ? (
        <HubRootView portSlug={portSlug} />
      ) : selectedFolder?.entityType &&
        selectedFolder.entityType !== 'root' &&
        selectedFolder.entityId ? (
        <EntityFolderView
          portSlug={portSlug}
          entityType={selectedFolder.entityType as 'client' | 'company' | 'yacht'}
          entityId={selectedFolder.entityId}
        />
      ) : (
        /* existing flat folder listing — keep the search input + type chips + paginated list */
        <FlatFolderListing portSlug={portSlug} folderId={selectedFolderId} />
      )}
    </div>
  </div>
);

You'll want to extract the existing flat listing block (search + chips + ul of doc rows) into a <FlatFolderListing> 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
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) <noreply@anthropic.com>
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

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
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():

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):

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<FileBrowserState>((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
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
pnpm exec tsc --noEmit

Expected: clean exit. If the dev server is up, navigate to /<port>/documents/files and confirm the 301 lands on /<port>/documents.

  • Step 7: Commit
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) <noreply@anthropic.com>
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:

"db:backfill:doc-folders": "tsx scripts/backfill-document-folders.ts"
  • Step 2: Document the deploy sequence

Find the deploy runbook. Likely candidates:

ls docs/ | grep -i deploy
grep -rln 'deploy' docs/ | head -5

If a runbook exists, add a step after the migration step:

### 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
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) <noreply@anthropic.com>
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 /<port>/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.
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/<id>') 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:

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:

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
pnpm exec playwright test --project=smoke

Expected: passes including the two new specs. Allow ~10 min runtime.

  • Step 5: Commit
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) <noreply@anthropic.com>
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:

- **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
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
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
pnpm format
pnpm lint

Expected: zero diffs / zero errors. If prettier reformats anything you wrote, stage the change.

  • Step 5: Final commit
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) <noreply@anthropic.com>
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 3a3c. ✓ 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" E1E14 — 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 211 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<DocumentFolder[]> consistent in Task 2, Task 3 (called from ensureEntityFolder self-heal path), Task 11 (called from backfill).
  • ensureEntityFolder(portId, entityType, entityId, userId) → Promise<DocumentFolder> 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.