# 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.
**Source design:** `docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md` (commit `286eb51`). Read end-to-end before starting — the spec captures every locked decision (edge cases E1–E14, aggregation reach, rollout strategy, governance).
**Builds on:** Wave 11.B (branch `feat/documents-folders`, already merged into current branch). Tasks 1–19 in `docs/superpowers/plans/2026-05-09-documents-folders.md` are **done**; this plan continues on the same branch without altering Wave 11.B's commits.
**Decisions locked (from the spec):**
- **Rollout:** hard cutover, no feature flag, backfill runs in the migration.
- **Aggregation reach:** symmetric (Client ↔ Company ↔ Yacht walk in both directions).
- **Source of truth for aggregation:** snapshotted file FKs (`files.client_id` / `files.company_id` / `files.yacht_id`), not the linked entity's current relationships.
- **Per-group pagination:** top 20 by `created_at desc`, `Show all (N)` drilldown into a flat paginated list scoped to the source.
- **System folder governance:** rename/move/delete blocked at API + UI when `system_managed = true`; UI shows 🔒 marker.
- **Entity rename:** syncs system folder name in the same transaction.
- **Entity 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.
- 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`.
- Modify: `src/lib/services/files.ts` — add `listFilesInFolder`, `listFilesAggregatedByEntity`, `applyEntityFkFromFolder` (E8 auto-mapping). Extend `uploadFile` to call `applyEntityFkFromFolder` when `folderId` is set.
- Modify: `src/lib/services/documents.service.ts` — extend `handleDocumentCompleted` with owner-resolve + ensure-folder + entity-FK-copy steps (3a–3c). Add `listInflightWorkflowsAggregatedByEntity`. Hide `status='completed'` workflows from `listDocuments` when `folderId` is set.
- Modify: `src/lib/services/ports.service.ts` — call `ensureSystemRoots(port.id, port.id /* system user */)` after `createPort` insert.
- Modify: `src/lib/services/clients.service.ts` — call `syncEntityFolderName` on rename in `updateClient`; `applyEntityArchivedSuffix` in `archiveClient`; `applyEntityRestoredSuffix` in `restoreClient`.
- Modify: `src/lib/services/companies.service.ts` — same hooks in `updateCompany` / `archiveCompany` / restore.
- Modify: `src/lib/services/yachts.service.ts` — same hooks in `updateYacht` / `archiveYacht`.
**Validators (2 files modified):**
- Modify: `src/lib/validators/documents.ts` — extend `listDocumentsSchema` with `entityType` + `entityId` query params (mutually exclusive with `folderId`).
- Modify: `src/app/api/v1/documents/route.ts` — accept `entityType + entityId` query params; route to aggregated projection or flat list.
- Modify: `src/app/api/v1/files/route.ts` — same.
- Modify: `src/app/api/v1/document-folders/[id]/route.ts` — return `400 ConflictError` when caller tries to rename / move / delete a `system_managed = true` folder (handled in the service; route just needs to pass `userId` through).
- Create: `src/app/api/v1/documents/[id]/signing-details/route.ts` — `GET` returns `{ workflow, signers, events }` for the signing-details dialog. Wraps `getDocumentDetail` from `documents.service.ts`.
**Webhook handler (1 file modified):**
- Modify: `src/lib/services/documents.service.ts:handleDocumentCompleted` — extend with steps 3a (resolve owner via the Owner-wins chain), 3b (ensure entity folder), 3c (set `files.folder_id` + copy entity FKs onto the signed file). The same handler is called by `src/app/api/webhooks/documenso/route.ts:187` and `src/jobs/processors/documenso-poll.ts:59` — no changes needed at those call sites.
- 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 <path>" 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`.
- 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.
- Create: `tests/integration/documents-completion-auto-deposit.test.ts` — `handleDocumentCompleted` with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner) — verifies the signed file row gets `folder_id` set + entity FKs copied.
- Create: `tests/integration/files-folder-aggregation.test.ts` — `GET /api/v1/files?entityType=client&entityId=…` returns the owner-aggregated payload with correct group counts.
- Create: `tests/integration/backfill-document-folders.test.ts` — backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows.
- Create: `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts` — open Clients/Smith/, see grouped Signing + Files, click "view signing details" → dialog opens.
- Create: `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts` — upload PDF into Clients/Smith/, verify `client_id` auto-set and file appears in the entity folder.
- Modify: `tests/e2e/visual/snapshots.spec.ts` — add `hub-root` and `hub-entity-folder` snapshots; regenerate baselines after intentional UI changes.
- Modify: `tests/integration/documents-list-folder-filter.test.ts` — assert completed workflows are hidden when `folderId` is set.
- Modify: `tests/e2e/realapi/eoi-documenso-completion.spec.ts` (or whichever realapi spec covers the round-trip) — assert the signed PDF lands in the owner's entity folder.
**Docs (1 modified):**
- Modify: `CLAUDE.md` — extend the existing "Document folders" subsection with: system-managed roots + entity subfolders, owner-aggregation projection, file-FK-as-source-of-truth invariant for aggregation, defense-in-depth `port_id` filter in aggregation SQL.
---
## Execution order
Tasks are ordered so each one ships a self-contained increment. Hard prerequisite chains (schema → service → API → UI) are respected, but inside each layer tasks are independent and can be parallelised by `subagent-driven-development`.
In `src/lib/db/schema/documents.ts`, replace the existing `documentFolders` declaration (the block beginning `export const documentFolders = pgTable(...)`) with one that adds four columns and one partial unique index:
```typescript
export const documentFolders = pgTable(
'document_folders',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
parentId: text('parent_id'),
name: text('name').notNull(),
/** True = folder is managed by the system (one of the three roots
* Clients/Companies/Yachts, or an auto-created entity subfolder).
* System folders reject rename/move/delete at the API layer. Demoted
* to false when the owning entity is hard-deleted. */
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.
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:
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**
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.
Adjust the import path for `setupTestPort` / `TEST_USER_ID` to match whatever helper layout the integration tests use — read `tests/integration/document-folders-crud.test.ts` for the convention (Wave 11.B uses this same fixture file).
- [ ]**Step 2: Run the test — expect import failure**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
Expected: `ensureSystemRoots` is not exported by `document-folders.service.ts` — module-level error.
- [ ]**Step 3: Implement `ensureSystemRoots`**
Append to `src/lib/services/document-folders.service.ts`:
```typescript
const SYSTEM_ROOT_NAMES = ['Clients', 'Companies', 'Yachts'] as const;
type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number];
/**
* Idempotently create the three system root folders for a port
* (`Clients/`, `Companies/`, `Yachts/`). Returns the rows in stable
* order. Safe to call on every port-init and on every backfill run.
*
* Uses INSERT … ON CONFLICT … DO NOTHING via the sibling-name unique
* index (`uniq_document_folders_sibling_name`) so a concurrent caller
* can't race two inserts of the same root. Re-SELECTs on conflict so
// Preserve SYSTEM_ROOT_NAMES order for callers (stable test assertions
// and a stable UI render order).
return SYSTEM_ROOT_NAMES.map((name) => {
const row = rows.find((r) => r.name === name);
if (!row) throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
return row;
});
}
```
You'll also need to add `sql` to the imports at the top of the file (it's not currently imported). Update the import line:
```typescript
import { and, asc, eq, sql } from 'drizzle-orm';
```
- [ ]**Step 4: Run the test — expect pass**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
Expected: 3/3 pass.
- [ ]**Step 5: Wire `ensureSystemRoots` into `createPort`**
Open `src/lib/services/ports.service.ts`. Find `createPort` (~line 23). After the `.returning()` insert, before the audit log call (or before the function returns), call `ensureSystemRoots`:
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**
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`
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:
if (candidate === folder.name) return; // No-op rename.
try {
const [updated] = await db
.update(documentFolders)
.set({ name: candidate, updatedAt: new Date() })
.where(eq(documentFolders.id, folder.id))
.returning();
if (updated) return;
} catch (err) {
if (isSiblingNameConflict(err)) continue;
throw err;
}
}
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}
```
- [ ]**Step 4: Run the tests — expect pass**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t syncEntityFolderName`
Expected: 3/3 pass.
- [ ]**Step 5: Wire into `clients.service.ts:updateClient`**
Open `src/lib/services/clients.service.ts` and find `updateClient` (~line 489). The function takes a partial update object. After the `db.update(clients).set(...).returning()` succeeds, but **only** when `firstName` or `lastName` is in the update payload, call:
```typescript
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
// Inside updateClient, after the .returning() call:
if (data.firstName !== undefined || data.lastName !== undefined) {
// Best-effort — folder sync must not fail the entity update.
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`:
logger.error({ err, companyId: id }, 'Failed to apply archived suffix to company folder');
});
```
If `restoreCompany` exists, wire it too with `applyEntityRestoredSuffix`. If it doesn't exist (grep first: `grep -n 'restoreCompany\|export async function restore' src/lib/services/companies.service.ts`), skip restore for companies — note the gap in the commit message.
Same for `yachts.service.ts:archiveYacht` (~line 167) — wire archive with `applyEntityArchivedSuffix('yacht', …)`. Grep for `restoreYacht`; if absent, skip.
- [ ]**Step 7: Wire hard-delete demote into delete paths**
Grep for the delete service functions:
```bash
grep -n 'export async function delete' src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts
```
If a hard-delete function exists (e.g., `deleteClient` outside the archive path), add `demoteSystemFolderOnEntityDelete(portId, '<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**
Read `tests/integration/document-folders-crud.test.ts` first to copy the exact `setupTestPort` import path. Check `tests/helpers/` for the `getStorageBackend` mock — most integration tests stub it to write to a temp filesystem. If they don't, you'll need to mock it inline.
The `interest.primaryClientId` field name may differ — confirm by reading `src/lib/db/schema/interests.ts`. The spec calls it that; the schema may name it `primary_client_id` (snake case) which Drizzle exposes as `primaryClientId`.
- [ ]**Step 2: Run the tests — expect failure**
Run: `pnpm exec vitest run tests/integration/documents-completion-auto-deposit.test.ts`
Expected: tests fail because the handler doesn't set `folder_id` yet.
- [ ]**Step 3: Add an owner-resolution helper to `documents.service.ts`**
In `src/lib/services/documents.service.ts`, add this internal helper near the top of the file (after the imports). It encapsulates the Owner-wins chain from the spec:
```typescript
import { ensureEntityFolder, type EntityType } from '@/lib/services/document-folders.service';
interface ResolvedOwner {
entityType: EntityType;
entityId: string;
}
/**
* Owner-wins owner resolution chain — see spec §"Routing on workflow
* completion" §3a. Returns the first non-null candidate in priority
* order: direct client/company/yacht FK on the document, then the
* linked interest's primary entity. Returns null when no owner is
Verify the actual column names on `interests` first. Read `src/lib/db/schema/interests.ts`. If the columns are named differently (e.g., `clientId` instead of `primaryClientId`), adjust the projection columns and the field reads.
- [ ]**Step 4: Modify `handleDocumentCompleted` to set folder_id + copy entity FKs**
In the same file, find `handleDocumentCompleted` (~line 1065). Locate the block that inserts the signed file row (~line 1088):
```typescript
const [fileRecord] = await db
.insert(files)
.values({
portId: doc.portId,
clientId: doc.clientId ?? null,
filename: `signed-${doc.id}.pdf`,
// ...
})
.returning();
```
Replace it with a version that resolves the owner and ensures the entity folder before inserting:
```typescript
// Resolve owner via the Owner-wins chain. The signed PDF lands in
// this owner's auto-created entity subfolder (or at root if no owner).
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`
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`**
Verify `clients` / `companies` / `companyMemberships` / `yachts` insert shapes by reading the schema files first. Required NOT NULL columns may need filler values in the test fixtures.
- [ ]**Step 2: Run the test — expect failure**
Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts`
Expected: `listFilesAggregatedByEntity` not exported.
- [ ]**Step 3: Implement the projection helper**
Append to `src/lib/services/files.ts`:
```typescript
import { documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import type { EntityType } from '@/lib/services/document-folders.service';
import { inArray } from 'drizzle-orm';
import { desc } from 'drizzle-orm';
export interface AggregatedFileGroup {
/** Label used by the UI header (e.g. "DIRECTLY ATTACHED" or "FROM YACHT — MV SERENITY"). */
label: string;
/** Stable key for de-duplication when the same file appears via multiple paths. */
if (folder.entityType === 'yacht' && !payload.yachtId) {
return { ...payload, yachtId: folder.entityId };
}
return payload;
}
```
Then wire it into `uploadFile` (~line 33). The current insert builds the `.values({...})` payload directly; route the payload through `applyEntityFkFromFolder` before the insert. Make sure `UploadFileInput` includes `folderId: z.string().uuid().optional()` after Task 9's validator extension — if it doesn't, add it.
Add a unit test in `tests/unit/aggregated-projection.test.ts`:
```typescript
import { applyEntityFkFromFolder } from '@/lib/services/files';
describe('files service · applyEntityFkFromFolder', () => {
{ message: 'entityType and entityId must be provided together' },
)
```
Read the current `listDocumentsSchema` shape first — `parseQuery` flattens repeated params and the existing schema already coerces some fields. Match the existing style.
Same pattern in `src/lib/validators/files.ts`: extend `listFilesSchema` (or whichever name the file uses — grep `parseQuery.*files` to confirm) with the same three optional params + the same two `.refine` rules.
- [ ]**Step 2: Wire the aggregated branch into the files route**
Modify `src/app/api/v1/files/route.ts`. The current `GET` handler calls `listFiles(portId, query)`. Add a branch on `query.entityType`:
```typescript
import { listFilesAggregatedByEntity } from '@/lib/services/files';
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).
Adjust imports + `listDocuments` call shape by reading the existing tests in the same file.
- [ ]**Step 2: Run the test — expect failure**
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts -t 'hides completed'`
Expected: fails (both rows currently surface).
- [ ]**Step 3: Modify `listDocuments`**
In `src/lib/services/documents.service.ts:listDocuments` (~line 146), find the existing folderId filter (added by Wave 11.B). Add a status filter that excludes `'completed'` when `query.folderId` is set:
```typescript
// When viewing a specific folder, hide completed workflows — they surface
// via their resulting signed-PDF file row in the Files section, not the
// Signing section.
if (query.folderId !== undefined) {
filters.push(ne(documents.status, 'completed'));
}
```
Add `ne` to the drizzle imports if it isn't already there.
- [ ]**Step 4: Run the test — expect pass**
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts`
Verify by reading existing scripts in `scripts/` (e.g., `scripts/import-berths-from-nocodb.ts` mentioned in CLAUDE.md) — they typically use the same `if (require.main === module)` CLI guard and the `db` import path. Adjust if the convention differs.
- [ ]**Step 4: Run the tests — expect pass**
Run: `pnpm exec vitest run tests/integration/backfill-document-folders.test.ts`
Expected: 5/5 pass.
- [ ]**Step 5: Manual sanity run in dev**
```bash
pnpm tsx scripts/backfill-document-folders.ts
```
Expected output: "Backfill complete" + zero errors. Check the dev DB has three system roots per port:
// 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.
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.
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.
In `folder-tree-sidebar.tsx`, find the row-rendering block (the `<li>` or `<button>` that renders one folder's name + chevron). Add conditional rendering:
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:
// + a tooltip explaining "System folders can't be renamed/moved/deleted"
```
Read the actual current implementation to match its DropdownMenu / Dialog structure. The `findInTree` helper may already exist in the file or in `use-document-folders.ts`; if not, write it inline:
```typescript
function findInTree(tree: FolderNode[], id: string): FolderNode | null {
for (const node of tree) {
if (node.id === id) return node;
const found = findInTree(node.children, id);
if (found) return found;
}
return null;
}
```
Wrap each disabled button in a `<Tooltip>` (from `@/components/ui/tooltip`) explaining why when `isSystem`:
Note: identifying signed-PDF files via `filename.startsWith('signed-')` is a quick heuristic; the spec recommends checking via `documents.signedFileId` lookup. For v1, the heuristic is acceptable since the auto-deposit handler names files exactly this way. If you want to do the right thing, extend the `/api/v1/files` aggregated response to surface a `signedFromDocumentId` field on rows where one exists — that's the principled fix and a small change to `listFilesAggregatedByEntity` (LEFT JOIN documents on signedFileId). Document this as a follow-up if the heuristic ships.
- [ ]**Step 3: Rebuild `documents-hub.tsx`**
Read the existing file:
```bash
cat src/components/documents/documents-hub.tsx
```
Replace its rendering branch:
- Drop the `<Tabs>` (signing-status tabs) entirely. Drop `documentsHubTabs` import + `TAB_LABELS`.
- Drop the `useQuery` for `hub-counts` (or reduce to in-flight count).
- 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`).
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.
- 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.
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`).
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.
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.
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)
- [ ]**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.
- [ ]**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:
```typescript
test('hub-root', async ({ page }) => {
await page.goto('/port-nimara/documents');
await page.waitForSelector('text=Signing in progress');
Run with `--update-snapshots` to generate baselines:
```bash
pnpm exec playwright test --project=visual --update-snapshots tests/e2e/visual/snapshots.spec.ts
```
Verify the generated PNGs look right (open them in the OS file viewer). Commit the new snapshot PNGs under `tests/e2e/visual/snapshots.spec.ts-snapshots/`.
- [ ]**Step 4: Run the smoke project end-to-end**
```bash
pnpm exec playwright test --project=smoke
```
Expected: passes including the two new specs. Allow ~10 min runtime.
- 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:
```markdown
- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain.
Three system roots (`Clients/`, `Companies/`, `Yachts/`) are auto-created on port init via `ensureSystemRoots`. Per-entity subfolders are created lazily on first auto-deposit / manual upload via `ensureEntityFolder` — concurrent callers race safely via the partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. The `chk_system_folder_shape` CHECK pins the shape of system rows. Rename/move/delete on `system_managed = true` folders is rejected by `assertNotSystemManaged` (service-level, not DB-level). Entity rename auto-syncs the folder name; archive applies a ` (archived)` suffix; hard-delete demotes (`system_managed = false`) + appends ` (deleted)`.
Auto-deposit on signing completion: `handleDocumentCompleted` resolves the owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.primaryClientId ?? .primaryCompanyId ?? .primaryYachtId`), ensures the matching entity subfolder, and sets `files.folder_id` + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable.
Aggregated projection: `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk the relationship graph from the requested entity (symmetric reach: Client ↔ Company via `company_memberships`, ↔ Yacht via `yachts.current_owner_type/id`) and return results grouped by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT). Each group caps at 20 rows with a total for `Show all (N)`. **File-FK snapshot is the source of truth** — historical files stay where they were filed even if the linked entity's relationships change. **Defense-in-depth `port_id` filter at every join** (per recommender precedent) — entry-point check alone is rejected. Completed workflows are hidden from folder views; the signed-PDF file surfaces with a "view signing details" link to the workflow audit trail.
Permission gating: `documents.view` for read of folders + entity-aggregated listing; `documents.manage_folders` for create / rename / move / delete of user folders (system folders are immutable through the API entirely).
```
- [ ]**Step 2: Verify the change reads cleanly**
```bash
grep -A 20 '**Document folders:**' CLAUDE.md | head -30
```
Expected: the new block is in place; surrounding bullets are untouched.
- [ ]**Step 3: Run the full verification**
```bash
pnpm exec tsc --noEmit
pnpm exec vitest run
pnpm exec playwright test --project=smoke
```
Expected:
- tsc: clean exit.
- vitest: all green (existing suite + the new tests from Tasks 2, 3, 4, 5, 6, 7, 8, 10, 11 — should net to roughly +60 new tests on top of the Wave 11.B baseline of 1213).
- playwright smoke: passes.
If any test fails, fix the root cause before committing. Do not use `--no-verify` and do not skip tests.
- [ ]**Step 4: Run prettier + lint to catch formatting drift**
```bash
pnpm format
pnpm lint
```
Expected: zero diffs / zero errors. If prettier reformats anything you wrote, stage the change.
- 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).
**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).