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

4502 lines
167 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.ts``GET` returns `{ workflow, signers, events }` for the signing-details dialog. Wraps `getDocumentDetail` from `documents.service.ts`.
**Webhook handler (1 file modified):**
- Modify: `src/lib/services/documents.service.ts:handleDocumentCompleted` — extend with steps 3a (resolve owner via the Owner-wins chain), 3b (ensure entity folder), 3c (set `files.folder_id` + copy entity FKs onto the signed file). The same handler is called by `src/app/api/webhooks/documenso/route.ts:187` and `src/jobs/processors/documenso-poll.ts:59` — no changes needed at those call sites.
**UI components (5 files created, 4 files modified, 2 files deleted):**
- Create: `src/components/documents/aggregated-section.tsx` — renders a Signing or Files section grouped by owner-source with per-group pagination + per-row "lives in <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`.
- 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.ts``ensureEntityFolder` idempotency, `syncEntityFolderName` collision (numeric suffix), `applyEntityArchivedSuffix` round-trip, `demoteSystemFolderOnEntityDelete` flips `system_managed`, system-folder rename/move/delete rejected.
- Create: `tests/unit/aggregated-projection.test.ts``listFilesAggregatedByEntity` symmetric walk, per-group pagination, file-FK-as-source-of-truth (yacht-transfer scenario).
- Create: `tests/integration/documents-completion-auto-deposit.test.ts``handleDocumentCompleted` with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner) — verifies the signed file row gets `folder_id` set + entity FKs copied.
- Create: `tests/integration/documents-hub-system-folders.test.ts` — API-level: aggregated listing, system folder protection (rename/move/delete return 4xx), entity rename round-trips folder name, archive/restore lifecycle.
- Create: `tests/integration/files-folder-aggregation.test.ts``GET /api/v1/files?entityType=client&entityId=…` returns the owner-aggregated payload with correct group counts.
- Create: `tests/integration/backfill-document-folders.test.ts` — backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows.
- Create: `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts` — open Clients/Smith/, see grouped Signing + Files, click "view signing details" → dialog opens.
- Create: `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts` — upload PDF into Clients/Smith/, verify `client_id` auto-set and file appears in the entity folder.
- Modify: `tests/e2e/visual/snapshots.spec.ts` — add `hub-root` and `hub-entity-folder` snapshots; regenerate baselines after intentional UI changes.
- Modify: `tests/integration/documents-list-folder-filter.test.ts` — assert completed workflows are hidden when `folderId` is set.
- Modify: `tests/e2e/realapi/eoi-documenso-completion.spec.ts` (or whichever realapi spec covers the round-trip) — assert the signed PDF lands in the owner's entity folder.
**Docs (1 modified):**
- Modify: `CLAUDE.md` — extend the existing "Document folders" subsection with: system-managed roots + entity subfolders, owner-aggregation projection, file-FK-as-source-of-truth invariant for aggregation, defense-in-depth `port_id` filter in aggregation SQL.
---
## Execution order
Tasks are ordered so each one ships a self-contained increment. Hard prerequisite chains (schema → service → API → UI) are respected, but inside each layer tasks are independent and can be parallelised by `subagent-driven-development`.
1. **Task 1** — Schema: column adds + partial unique index + CHECK constraint (migration `0051`).
2. **Task 2** — Service: `ensureSystemRoots` + port-init wiring.
3. **Task 3** — Service: `ensureEntityFolder` (concurrent-safe).
4. **Task 4** — Service: system-folder protection (extend `renameFolder` / `moveFolder` / `deleteFolderSoftRescue`).
5. **Task 5** — Service: `syncEntityFolderName` + collision suffixing + wire into clients / companies / yachts services.
6. **Task 6** — Service: archive / restore / hard-delete suffix helpers + wire into entity services.
7. **Task 7** — Webhook: extend `handleDocumentCompleted` with owner-resolve + ensure-folder + entity-FK-copy steps.
8. **Task 8** — Service: aggregated projection (`listFilesAggregatedByEntity` + `listInflightWorkflowsAggregatedByEntity`).
9. **Task 9** — API: `files` + `documents` routes accept `entityType + entityId` query params; new `signing-details` route.
10. **Task 10** — API: hide completed workflows from `listDocuments` when `folderId` is set.
11. **Task 11** — Backfill script + idempotency tests.
12. **Task 12** — UI: `AggregatedSection` component + `useAggregatedListing` hook.
13. **Task 13** — UI: `SigningDetailsDialog` + per-row "view signing details" link.
14. **Task 14** — UI: `FolderTreeSidebar` + `FolderActionsMenu` system-folder awareness (🔒 marker, archived muted, action suppression).
15. **Task 15** — UI: `HubRootView` + `EntityFolderView` + rebuild `DocumentsHub` to compose them.
16. **Task 16** — Files page removal + 301 redirect + legacy `folder-tree.tsx` deletion.
17. **Task 17** — Backfill on deploy: run the script from the migration (or as a step in deploy).
18. **Task 18** — E2E: smoke + visual snapshots.
19. **Task 19** — CLAUDE.md update + final verification (`pnpm exec tsc --noEmit` + full vitest + playwright smoke).
---
## Task 1: Schema — `document_folders` system columns + `files.folder_id`
**Files:**
- Modify: `src/lib/db/schema/documents.ts`
- Create: `src/lib/db/migrations/0051_documents_hub_split.sql`
- [ ] **Step 1: Extend `documentFolders` table definition**
In `src/lib/db/schema/documents.ts`, replace the existing `documentFolders` declaration (the block beginning `export const documentFolders = pgTable(...)`) with one that adds four columns and one partial unique index:
```typescript
export const documentFolders = pgTable(
'document_folders',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
parentId: text('parent_id'),
name: text('name').notNull(),
/** True = folder is managed by the system (one of the three roots
* Clients/Companies/Yachts, or an auto-created entity subfolder).
* System folders reject rename/move/delete at the API layer. Demoted
* to false when the owning entity is hard-deleted. */
systemManaged: boolean('system_managed').notNull().default(false),
/** null | 'root' | 'client' | 'company' | 'yacht'. 'root' is the
* three system roots; the entity values mark per-entity subfolders. */
entityType: text('entity_type'),
/** Null when entityType is null or 'root'; the entity's id otherwise.
* Combined with entityType to dedupe entity folders per port. */
entityId: text('entity_id'),
/** Mirrors the entity's archive state. Non-null = folder muted in UI
* and auto-deposit halted. Cleared on entity restore. */
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_document_folders_port').on(table.portId),
index('idx_document_folders_parent').on(table.parentId),
uniqueIndex('uniq_document_folders_sibling_name').on(
table.portId,
sql`COALESCE(${table.parentId}, '__root__')`,
sql`LOWER(${table.name})`,
),
// One subfolder per entity per port. Excludes 'root' folders (the
// three system roots are deduped by sibling-name uniqueness).
uniqueIndex('uniq_document_folders_entity')
.on(table.portId, table.entityType, table.entityId)
.where(sql`${table.entityId} IS NOT NULL`),
],
);
```
Make sure `boolean` is in the import list at the top of the file — it should already be imported (the `files` table uses it elsewhere). If not, add `boolean` to the `drizzle-orm/pg-core` import.
- [ ] **Step 2: Add `folderId` column to `files` table definition**
In the same file, find the `files` table declaration (~line 21) and add `folderId` next to the existing entity-FK columns. Also add an index on `(port_id, folder_id)` for the aggregated lookup:
```typescript
clientId: text('client_id').references(() => clients.id),
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }),
folderId: text('folder_id').references((): AnyPgColumn => documentFolders.id, {
onDelete: 'set null',
}),
```
And inside the `(table) => [...]` index list:
```typescript
index('idx_files_folder').on(table.folderId),
index('idx_files_port_folder').on(table.portId, table.folderId),
```
`AnyPgColumn` is already imported at the top of the file (`documents.folderId` uses it).
- [ ] **Step 3: Verify TypeScript compiles**
Run: `pnpm exec tsc --noEmit`
Expected: clean exit (no output).
- [ ] **Step 4: Write the migration SQL**
Create `src/lib/db/migrations/0051_documents_hub_split.sql`:
```sql
-- Wave 11.B+: documents hub split + auto-filed client folders.
-- Adds system-managed folder lifecycle columns to document_folders
-- (Clients/Companies/Yachts roots + per-entity subfolders), adds the
-- folder_id pointer to files, and backfills the structure for every
-- existing port + file. Idempotent — safe to re-run.
-- ─── document_folders: lifecycle columns ──────────────────────────────────
ALTER TABLE "document_folders"
ADD COLUMN IF NOT EXISTS "system_managed" boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "entity_type" text,
ADD COLUMN IF NOT EXISTS "entity_id" text,
ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;
-- Shape guard: system_managed=true implies a known shape. Either a root
-- (entity_type='root', entity_id null) or a per-entity subfolder
-- (entity_type in {client,company,yacht} AND entity_id NOT NULL).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_system_folder_shape'
) THEN
ALTER TABLE "document_folders"
ADD CONSTRAINT "chk_system_folder_shape" CHECK (
NOT system_managed
OR entity_type = 'root'
OR (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL)
);
END IF;
END $$;
-- Partial unique index: one subfolder per (port, entity_type, entity_id).
-- Excludes root folders (entity_id IS NULL) — those are deduped by the
-- existing sibling-name uniqueness index.
CREATE UNIQUE INDEX IF NOT EXISTS "uniq_document_folders_entity"
ON "document_folders" ("port_id", "entity_type", "entity_id")
WHERE "entity_id" IS NOT NULL;
-- ─── files: folder pointer ────────────────────────────────────────────────
ALTER TABLE "files"
ADD COLUMN IF NOT EXISTS "folder_id" text REFERENCES "document_folders" ("id")
ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS "idx_files_folder" ON "files" ("folder_id");
CREATE INDEX IF NOT EXISTS "idx_files_port_folder" ON "files" ("port_id", "folder_id");
```
The migration intentionally does NOT include the data backfill — the backfill is in a separate script (`scripts/backfill-document-folders.ts`, Task 11) so the schema change can deploy first and the backfill can be re-run idempotently after.
- [ ] **Step 5: Apply the migration to the dev database**
Run:
```bash
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
-f src/lib/db/migrations/0051_documents_hub_split.sql
```
Expected output: `ALTER TABLE`, `DO`, `CREATE UNIQUE INDEX`, `ALTER TABLE`, `CREATE INDEX` × 2. No errors.
If `next dev` is running, restart it (per CLAUDE.md — postgres.js prepared statement cache).
- [ ] **Step 6: Sanity check the schema**
Run:
```bash
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
-c "\d document_folders" \
-c "\d files"
```
Expected: `document_folders` shows `system_managed`, `entity_type`, `entity_id`, `archived_at` columns plus the new partial unique index and the check constraint. `files` shows `folder_id` plus the two new indexes.
- [ ] **Step 7: Commit**
```bash
git add src/lib/db/schema/documents.ts src/lib/db/migrations/0051_documents_hub_split.sql
git commit -m "$(cat <<'EOF'
feat(documents): schema for hub split + entity-folder lifecycle
Adds system_managed / entity_type / entity_id / archived_at to
document_folders for the three system roots (Clients/Companies/
Yachts) + per-entity auto-subfolders. Adds files.folder_id so a
file's home is a first-class field (not derived from storagePath
prefix). Partial unique index uniq_document_folders_entity dedupes
entity subfolders per port; chk_system_folder_shape pins the shape
of system rows. Migration is idempotent and ships without backfill —
the backfill script runs as a separate deploy step.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders } from '@/lib/db/schema/documents';
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
describe('document-folders service · ensureSystemRoots', () => {
let portId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
});
it('creates Clients, Companies, and Yachts root folders with system_managed=true', async () => {
await ensureSystemRoots(portId, TEST_USER_ID);
const rows = await db
.select()
.from(documentFolders)
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
expect(rows.map((r) => r.name).sort()).toEqual(['Clients', 'Companies', 'Yachts']);
for (const r of rows) {
expect(r.systemManaged).toBe(true);
expect(r.parentId).toBeNull();
expect(r.entityId).toBeNull();
}
});
it('is idempotent — second call does not create duplicates', async () => {
await ensureSystemRoots(portId, TEST_USER_ID);
await ensureSystemRoots(portId, TEST_USER_ID);
const rows = await db
.select()
.from(documentFolders)
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
expect(rows).toHaveLength(3);
});
it('returns the three root rows in a stable order (Clients, Companies, Yachts)', async () => {
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
expect(roots.map((r) => r.name)).toEqual(['Clients', 'Companies', 'Yachts']);
});
});
```
Adjust the import path for `setupTestPort` / `TEST_USER_ID` to match whatever helper layout the integration tests use — read `tests/integration/document-folders-crud.test.ts` for the convention (Wave 11.B uses this same fixture file).
- [ ] **Step 2: Run the test — expect import failure**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
Expected: `ensureSystemRoots` is not exported by `document-folders.service.ts` — module-level error.
- [ ] **Step 3: Implement `ensureSystemRoots`**
Append to `src/lib/services/document-folders.service.ts`:
```typescript
const SYSTEM_ROOT_NAMES = ['Clients', 'Companies', 'Yachts'] as const;
type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number];
/**
* Idempotently create the three system root folders for a port
* (`Clients/`, `Companies/`, `Yachts/`). Returns the rows in stable
* order. Safe to call on every port-init and on every backfill run.
*
* Uses INSERT … ON CONFLICT … DO NOTHING via the sibling-name unique
* index (`uniq_document_folders_sibling_name`) so a concurrent caller
* can't race two inserts of the same root. Re-SELECTs on conflict so
* the return shape is always populated.
*/
export async function ensureSystemRoots(portId: string, userId: string): Promise<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:
```typescript
import { and, asc, eq, sql } from 'drizzle-orm';
```
- [ ] **Step 4: Run the test — expect pass**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
Expected: 3/3 pass.
- [ ] **Step 5: Wire `ensureSystemRoots` into `createPort`**
Open `src/lib/services/ports.service.ts`. Find `createPort` (~line 23). After the `.returning()` insert, before the audit log call (or before the function returns), call `ensureSystemRoots`:
```typescript
// After: const [row] = await db.insert(ports).values(...).returning();
// Before: createAuditLog(...) / return row;
await ensureSystemRoots(row.id, meta.userId);
```
Add the import at the top:
```typescript
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
```
Read the existing file first to get the exact structure — line 23 is `createPort`'s signature, but the insert + audit-log block sits inside it. Place the call after the row insert succeeds and before any return.
- [ ] **Step 6: Verify the wiring with a smoke test**
Run: `pnpm exec tsc --noEmit && pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
Expected: clean tsc + 3/3 pass.
- [ ] **Step 7: Commit**
```bash
git add src/lib/services/document-folders.service.ts src/lib/services/ports.service.ts tests/unit/document-folders-system-folders.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): ensureSystemRoots + wire into createPort
Adds idempotent root-folder bootstrap (Clients/Companies/Yachts)
called on every port-init. ON CONFLICT DO NOTHING on the sibling-name
unique index prevents racing inserts; the re-SELECT returns the stable
row set in SYSTEM_ROOT_NAMES order. Same helper is invoked by the
backfill script in a later task.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
import { clients } from '@/lib/db/schema/clients';
describe('document-folders service · ensureEntityFolder', () => {
let portId: string;
let clientId: string;
let rootId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
rootId = roots.find((r) => r.name === 'Clients')!.id;
const [client] = await db
.insert(clients)
.values({
portId,
firstName: 'John',
lastName: 'Smith',
email: `john-${crypto.randomUUID()}@example.com`,
})
.returning();
clientId = client!.id;
});
it('creates a subfolder under the matching system root with system_managed=true', async () => {
const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
expect(folder.systemManaged).toBe(true);
expect(folder.entityType).toBe('client');
expect(folder.entityId).toBe(clientId);
expect(folder.parentId).toBe(rootId);
expect(folder.name).toBe('Smith, John'); // lastName, firstName per the entity-display convention
});
it('is idempotent — returns the same row on second call', async () => {
const a = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
const b = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
expect(a.id).toBe(b.id);
const all = await db
.select()
.from(documentFolders)
.where(and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)));
expect(all).toHaveLength(1);
});
it('appends a numeric suffix on name collision with an existing folder', async () => {
// Pre-seed a folder with the same name (e.g., a second client called John Smith)
const [collidingClient] = await db
.insert(clients)
.values({
portId,
firstName: 'John',
lastName: 'Smith',
email: `john-${crypto.randomUUID()}@example.com`,
})
.returning();
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
const second = await ensureEntityFolder(portId, 'client', collidingClient!.id, TEST_USER_ID);
expect(second.name).toBe('Smith, John (2)');
});
it('rejects unknown entity types', async () => {
await expect(
// @ts-expect-error -- runtime check
ensureEntityFolder(portId, 'boat', clientId, TEST_USER_ID),
).rejects.toThrow(/entity type/i);
});
});
```
- [ ] **Step 2: Run the test — expect failure**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t ensureEntityFolder`
Expected: `ensureEntityFolder` is not exported.
- [ ] **Step 3: Implement `ensureEntityFolder`**
Append to `src/lib/services/document-folders.service.ts`:
```typescript
import { clients } from '@/lib/db/schema/clients';
import { companies } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
export type EntityType = 'client' | 'company' | 'yacht';
const ENTITY_TYPES = new Set<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**
```bash
git add src/lib/services/document-folders.service.ts tests/unit/document-folders-system-folders.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): ensureEntityFolder (concurrent-safe + suffix on collision)
Idempotent per-entity subfolder creation under the matching system
root. Fast-path SELECT short-circuits the common case. Inserts race
safely via uniq_document_folders_entity (partial unique on
port_id+entity_type+entity_id) — the loser re-SELECTs the winner's
row. Sibling-name collisions between two entities with the same
display name append (2), (3), … to the new folder; existing folders
never rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import {
deleteFolderSoftRescue,
moveFolder,
renameFolder,
} from '@/lib/services/document-folders.service';
describe('document-folders service · system folder protection', () => {
let portId: string;
let rootId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
rootId = roots.find((r) => r.name === 'Clients')!.id;
});
it('rejects rename of a system-managed root', async () => {
await expect(renameFolder(portId, rootId, 'Customers', TEST_USER_ID)).rejects.toThrow(
/system folder/i,
);
});
it('rejects move of a system-managed root', async () => {
const other = await ensureSystemRoots(portId, TEST_USER_ID);
const companies = other.find((r) => r.name === 'Companies')!;
await expect(moveFolder(portId, rootId, companies.id, TEST_USER_ID)).rejects.toThrow(
/system folder/i,
);
});
it('rejects delete of a system-managed root', async () => {
await expect(deleteFolderSoftRescue(portId, rootId, TEST_USER_ID)).rejects.toThrow(
/system folder/i,
);
});
it('allows rename/delete of a user folder under a system root', async () => {
// Create a normal subfolder under Clients/ (user-managed).
const user = await db
.insert(documentFolders)
.values({
portId,
parentId: rootId,
name: 'Templates',
systemManaged: false,
createdBy: TEST_USER_ID,
})
.returning();
await expect(
renameFolder(portId, user[0]!.id, 'My Templates', TEST_USER_ID),
).resolves.toBeDefined();
});
});
```
- [ ] **Step 2: Run the tests — expect 3 failures**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'system folder protection'`
Expected: the rename/move/delete protection tests fail (they currently succeed because there's no guard).
- [ ] **Step 3: Add the protection guard helper**
In `src/lib/services/document-folders.service.ts`, add this internal helper near the top of the file (after `isSiblingNameConflict`):
```typescript
/**
* Throws ConflictError if the folder is system-managed. Centralises the
* rejection so rename/move/delete all surface identical error shapes.
*/
async function assertNotSystemManaged(
portId: string,
folderId: string,
action: 'rename' | 'move' | 'delete',
): Promise<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:
```typescript
const existing = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
});
if (!existing) throw new NotFoundError('Folder');
```
with:
```typescript
const existing = await assertNotSystemManaged(portId, folderId, 'rename');
```
For `moveFolder` (current line ~168), replace:
```typescript
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
});
if (!folder) throw new NotFoundError('Folder');
```
with:
```typescript
const folder = await assertNotSystemManaged(portId, folderId, 'move');
```
For `deleteFolderSoftRescue` (current line ~242), replace:
```typescript
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
});
if (!folder) throw new NotFoundError('Folder');
```
with:
```typescript
const folder = await assertNotSystemManaged(portId, folderId, 'delete');
```
- [ ] **Step 5: Run the tests — expect pass**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'system folder protection'`
Expected: 4/4 pass. Also run the full file to verify the other tests still pass:
```bash
pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts
```
Expected: all tests pass.
- [ ] **Step 6: Run the wider folder test suite to catch regressions**
Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts tests/integration/document-folders-soft-delete.test.ts`
Expected: all Wave 11.B tests still pass (the new guard is fail-closed but doesn't alter the user-folder paths).
- [ ] **Step 7: Commit**
```bash
git add src/lib/services/document-folders.service.ts tests/unit/document-folders-system-folders.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): block rename/move/delete on system folders
assertNotSystemManaged centralises the guard so the three mutation
paths surface identical ConflictError shapes. System roots and per-
entity subfolders are immutable through the rep-facing API; the only
way for system_managed to flip back to false is the entity-hard-
delete demotion path (next task).
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
describe('document-folders service · syncEntityFolderName', () => {
let portId: string;
let clientId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
await ensureSystemRoots(portId, TEST_USER_ID);
const [client] = await db
.insert(clients)
.values({
portId,
firstName: 'John',
lastName: 'Smith',
email: `john-${crypto.randomUUID()}@example.com`,
})
.returning();
clientId = client!.id;
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
});
it('renames the entity subfolder when the entity is renamed', async () => {
await db.update(clients).set({ firstName: 'Jonathan' }).where(eq(clients.id, clientId));
await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe('Smith, Jonathan');
});
it('is a no-op when the folder does not exist (lazy creation)', async () => {
const otherPort = await setupTestPort();
await ensureSystemRoots(otherPort, TEST_USER_ID);
const [otherClient] = await db
.insert(clients)
.values({
portId: otherPort,
firstName: 'Jane',
lastName: 'Doe',
email: `jane-${crypto.randomUUID()}@example.com`,
})
.returning();
// No folder created. Sync should not throw.
await expect(
syncEntityFolderName(otherPort, 'client', otherClient!.id, TEST_USER_ID),
).resolves.toBeUndefined();
});
it('appends numeric suffix on rename collision (target name already taken)', async () => {
// Create a second client called Smith, Jane and give them a folder.
const [collider] = await db
.insert(clients)
.values({
portId,
firstName: 'Jane',
lastName: 'Smith',
email: `jane-${crypto.randomUUID()}@example.com`,
})
.returning();
await ensureEntityFolder(portId, 'client', collider!.id, TEST_USER_ID);
// Rename John → Jane (collision with the other Smith, Jane).
await db.update(clients).set({ firstName: 'Jane' }).where(eq(clients.id, clientId));
await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe('Smith, Jane (2)');
});
});
```
- [ ] **Step 2: Run the tests — expect failure**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t syncEntityFolderName`
Expected: `syncEntityFolderName` not exported.
- [ ] **Step 3: Implement `syncEntityFolderName`**
Append to `src/lib/services/document-folders.service.ts`:
```typescript
/**
* Rename the per-entity subfolder to match the entity's current display
* name. Called from the entity rename services (`updateClient`,
* `updateCompany`, `updateYacht`). No-op when the folder does not exist
* (lazy creation — entities without a folder skip the sync entirely).
*
* Sibling-name collision is resolved by suffix bump (matches
* `ensureEntityFolder` semantics).
*
* Intentionally does NOT call `assertNotSystemManaged` — this helper
* is the legitimate path for renaming a system folder.
*/
export async function syncEntityFolderName(
portId: string,
entityType: EntityType,
entityId: string,
_userId: string,
): Promise<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:
```typescript
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
// Inside updateClient, after the .returning() call:
if (data.firstName !== undefined || data.lastName !== undefined) {
// Best-effort — folder sync must not fail the entity update.
await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => {
logger.error({ err, clientId: id }, 'Failed to sync client folder name');
});
}
```
Read the actual `updateClient` signature first — `meta.userId` and `logger` are the conventions in the codebase; adjust if the file uses a different name. `logger` is imported at the top of most service files; if it isn't here, add: `import { logger } from '@/lib/logger';`.
- [ ] **Step 6: Wire into `companies.service.ts:updateCompany`**
Same pattern in `src/lib/services/companies.service.ts` (~line 133). The trigger condition is `data.name !== undefined`:
```typescript
if (data.name !== undefined) {
await syncEntityFolderName(portId, 'company', id, meta.userId).catch((err) => {
logger.error({ err, companyId: id }, 'Failed to sync company folder name');
});
}
```
- [ ] **Step 7: Wire into `yachts.service.ts:updateYacht`**
Same pattern in `src/lib/services/yachts.service.ts` (~line 113):
```typescript
if (data.name !== undefined) {
await syncEntityFolderName(portId, 'yacht', id, meta.userId).catch((err) => {
logger.error({ err, yachtId: id }, 'Failed to sync yacht folder name');
});
}
```
- [ ] **Step 8: Verify TypeScript compiles**
Run: `pnpm exec tsc --noEmit`
Expected: clean exit.
- [ ] **Step 9: Run the full folder test suite + entity service tests**
Run:
```bash
pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts tests/integration/document-folders-crud.test.ts tests/integration/clients tests/integration/companies tests/integration/yachts
```
Expected: all pass. (The `.catch` swallows folder errors so failing sync paths don't break entity updates.)
- [ ] **Step 10: Commit**
```bash
git add src/lib/services/document-folders.service.ts src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts tests/unit/document-folders-system-folders.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): syncEntityFolderName + entity-rename hooks
Per-entity subfolder names mirror the entity's current display string.
Wired into updateClient / updateCompany / updateYacht; runs only when
the name fields change. Best-effort (logged + swallowed) so a folder-
sync error never fails an entity update. Preserves the (archived)
suffix when present; skips entirely when the folder has been demoted
to (deleted) — the rep owns the name at that point.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import {
applyEntityArchivedSuffix,
applyEntityRestoredSuffix,
demoteSystemFolderOnEntityDelete,
} from '@/lib/services/document-folders.service';
describe('document-folders service · archive lifecycle', () => {
let portId: string;
let clientId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
await ensureSystemRoots(portId, TEST_USER_ID);
const [client] = await db
.insert(clients)
.values({
portId,
firstName: 'John',
lastName: 'Smith',
email: `john-${crypto.randomUUID()}@example.com`,
})
.returning();
clientId = client!.id;
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
});
it('appends (archived) suffix and stamps archived_at on archive', async () => {
await applyEntityArchivedSuffix(portId, 'client', clientId);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe('Smith, John (archived)');
expect(folder?.archivedAt).toBeInstanceOf(Date);
expect(folder?.systemManaged).toBe(true); // still system-managed
});
it('removes (archived) suffix and clears archived_at on restore', async () => {
await applyEntityArchivedSuffix(portId, 'client', clientId);
await applyEntityRestoredSuffix(portId, 'client', clientId);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe('Smith, John');
expect(folder?.archivedAt).toBeNull();
});
it('appends (deleted) and flips system_managed=false on entity hard-delete', async () => {
await demoteSystemFolderOnEntityDelete(portId, 'client', clientId);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe('Smith, John (deleted)');
expect(folder?.systemManaged).toBe(false);
});
it('is a no-op when the folder does not exist', async () => {
const otherPort = await setupTestPort();
await ensureSystemRoots(otherPort, TEST_USER_ID);
const [other] = await db
.insert(clients)
.values({
portId: otherPort,
firstName: 'Lone',
lastName: 'Wolf',
email: `lone-${crypto.randomUUID()}@example.com`,
})
.returning();
// No folder created; archive should not throw.
await expect(
applyEntityArchivedSuffix(otherPort, 'client', other!.id),
).resolves.toBeUndefined();
});
});
```
- [ ] **Step 2: Run the tests — expect failure**
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'archive lifecycle'`
Expected: helpers not exported.
- [ ] **Step 3: Implement the three helpers**
Append to `src/lib/services/document-folders.service.ts`:
```typescript
const ARCHIVED_SUFFIX = ' (archived)';
const DELETED_SUFFIX = ' (deleted)';
/**
* Stamp an entity's subfolder as archived: append " (archived)" to the
* name (idempotent — won't double-append) and set archived_at. No-op
* when the folder does not exist (lazy creation). Used by the entity
* archive paths in clients / companies / yachts services.
*/
export async function applyEntityArchivedSuffix(
portId: string,
entityType: EntityType,
entityId: string,
): Promise<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:
```typescript
import {
applyEntityArchivedSuffix,
applyEntityRestoredSuffix,
} from '@/lib/services/document-folders.service';
```
Inside `archiveClient` (~line 537), after the entity update succeeds:
```typescript
await applyEntityArchivedSuffix(portId, 'client', id).catch((err) => {
logger.error({ err, clientId: id }, 'Failed to apply archived suffix to client folder');
});
```
Inside `restoreClient` (~line 565), after the entity update succeeds:
```typescript
await applyEntityRestoredSuffix(portId, 'client', id).catch((err) => {
logger.error({ err, clientId: id }, 'Failed to clear archived suffix on client folder');
});
```
- [ ] **Step 6: Wire archive into companies + yachts services**
In `companies.service.ts:archiveCompany` (~line 189):
```typescript
import {
applyEntityArchivedSuffix,
applyEntityRestoredSuffix,
} from '@/lib/services/document-folders.service';
// After the entity update:
await applyEntityArchivedSuffix(portId, 'company', id).catch((err) => {
logger.error({ err, companyId: id }, 'Failed to apply archived suffix to company folder');
});
```
If `restoreCompany` exists, wire it too with `applyEntityRestoredSuffix`. If it doesn't exist (grep first: `grep -n 'restoreCompany\|export async function restore' src/lib/services/companies.service.ts`), skip restore for companies — note the gap in the commit message.
Same for `yachts.service.ts:archiveYacht` (~line 167) — wire archive with `applyEntityArchivedSuffix('yacht', …)`. Grep for `restoreYacht`; if absent, skip.
- [ ] **Step 7: Wire hard-delete demote into delete paths**
Grep for the delete service functions:
```bash
grep -n 'export async function delete' src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts
```
If a hard-delete function exists (e.g., `deleteClient` outside the archive path), add `demoteSystemFolderOnEntityDelete(portId, '<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:
```bash
pnpm exec tsc --noEmit && pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts tests/integration/document-folders-crud.test.ts tests/integration/document-folders-soft-delete.test.ts
```
Expected: clean tsc + all pass.
- [ ] **Step 9: Commit**
```bash
git add src/lib/services/document-folders.service.ts src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts tests/unit/document-folders-system-folders.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): entity-folder archive / restore / demote helpers
applyEntityArchivedSuffix stamps " (archived)" + archived_at on the
entity subfolder so the UI mutes it and auto-deposit halts. Restore is
the inverse. demoteSystemFolderOnEntityDelete flips system_managed=
false, appends " (deleted)", and clears the entity FK so the partial
unique index releases the slot — orphaned files retain their entity
FK snapshots and surface in the rep's clean-up view.
All three helpers are best-effort from the entity-side hooks; folder
errors are logged but do not fail the entity-update operation.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documents, files, documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { handleDocumentCompleted } from '@/lib/services/documents.service';
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
// Stub the Documenso download so the handler doesn't hit the network.
vi.mock('@/lib/services/documenso-client', async (orig) => {
const real = await orig<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:
```typescript
import { ensureEntityFolder, type EntityType } from '@/lib/services/document-folders.service';
interface ResolvedOwner {
entityType: EntityType;
entityId: string;
}
/**
* Owner-wins owner resolution chain — see spec §"Routing on workflow
* completion" §3a. Returns the first non-null candidate in priority
* order: direct client/company/yacht FK on the document, then the
* linked interest's primary entity. Returns null when no owner is
* resolvable (signed PDF will land at root).
*/
async function resolveDocumentOwner(doc: {
clientId: string | null;
companyId: string | null;
yachtId: string | null;
interestId: string | null;
}): Promise<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):
```typescript
const [fileRecord] = await db
.insert(files)
.values({
portId: doc.portId,
clientId: doc.clientId ?? null,
filename: `signed-${doc.id}.pdf`,
// ...
})
.returning();
```
Replace it with a version that resolves the owner and ensures the entity folder before inserting:
```typescript
// Resolve owner via the Owner-wins chain. The signed PDF lands in
// this owner's auto-created entity subfolder (or at root if no owner).
const owner = await resolveDocumentOwner(doc);
let entityFolderId: string | null = null;
if (owner) {
try {
const folder = await ensureEntityFolder(doc.portId, owner.entityType, owner.entityId, 'system');
entityFolderId = folder.id;
} catch (err) {
// Folder creation is best-effort — signed file still lands, just
// at root. Log so we can clean up post-deploy.
logger.error(
{ err, documentId: doc.id, owner },
'ensureEntityFolder failed during document completion',
);
}
}
const [fileRecord] = await db
.insert(files)
.values({
portId: doc.portId,
clientId: owner?.entityType === 'client' ? owner.entityId : (doc.clientId ?? null),
companyId: owner?.entityType === 'company' ? owner.entityId : (doc.companyId ?? null),
yachtId: owner?.entityType === 'yacht' ? owner.entityId : (doc.yachtId ?? null),
folderId: entityFolderId,
filename: `signed-${doc.id}.pdf`,
originalName: `signed-${doc.id}.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(signedPdfBuffer.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: 'system',
})
.returning();
```
Make sure the existing `companyId` / `yachtId` propagation is preserved — the helper writes the resolved owner's id onto the matching FK, while leaving non-resolved FKs at whatever the workflow had. The `doc` object already carries `clientId / companyId / yachtId / interestId` from `resolveWebhookDocument`.
- [ ] **Step 5: Run the tests — expect pass**
Run: `pnpm exec vitest run tests/integration/documents-completion-auto-deposit.test.ts`
Expected: 3/3 pass.
- [ ] **Step 6: Run the wider documents suite to catch regressions**
Run: `pnpm exec vitest run tests/integration/documents tests/unit/documents`
Expected: all pre-existing tests still pass.
- [ ] **Step 7: Commit**
```bash
git add src/lib/services/documents.service.ts tests/integration/documents-completion-auto-deposit.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): auto-deposit signed PDFs into entity folders
handleDocumentCompleted resolves the workflow owner via the Owner-wins
chain (client → company → yacht → interest.primaryClientId →
.primaryCompanyId → .primaryYachtId), ensures the matching entity
subfolder exists, and sets files.folder_id + the matching entity FK
on the signed file row. Falls back to root (folder_id=null) when no
owner is resolvable. ensureEntityFolder failures are logged but never
fail the completion — the signed PDF always lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders, files } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { listFilesAggregatedByEntity } from '@/lib/services/files';
import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
describe('files service · listFilesAggregatedByEntity', () => {
let portId: string;
let clientId: string;
let companyId: string;
let yachtId: string;
let clientFolderId: string;
let companyFolderId: string;
let yachtFolderId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
await ensureSystemRoots(portId, TEST_USER_ID);
const [c] = await db
.insert(clients)
.values({
portId,
firstName: 'John',
lastName: 'Smith',
email: `john-${crypto.randomUUID()}@example.com`,
})
.returning();
clientId = c!.id;
const [co] = await db
.insert(companies)
.values({
portId,
name: 'Smith Marine LLC',
})
.returning();
companyId = co!.id;
const [y] = await db
.insert(yachts)
.values({
portId,
name: 'MV Serenity',
currentOwnerType: 'client',
currentOwnerId: clientId,
})
.returning();
yachtId = y!.id;
await db.insert(companyMemberships).values({
companyId,
clientId,
});
clientFolderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id;
companyFolderId = (await ensureEntityFolder(portId, 'company', companyId, TEST_USER_ID)).id;
yachtFolderId = (await ensureEntityFolder(portId, 'yacht', yachtId, TEST_USER_ID)).id;
});
async function insertFile(opts: {
clientId?: string | null;
companyId?: string | null;
yachtId?: string | null;
folderId: string;
filename: string;
}) {
const [row] = await db
.insert(files)
.values({
portId,
clientId: opts.clientId ?? null,
companyId: opts.companyId ?? null,
yachtId: opts.yachtId ?? null,
folderId: opts.folderId,
filename: opts.filename,
originalName: opts.filename,
mimeType: 'application/pdf',
storagePath: `test/${crypto.randomUUID()}`,
storageBucket: 'test',
uploadedBy: TEST_USER_ID,
})
.returning();
return row!;
}
it('groups DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT for a client view', async () => {
await insertFile({ clientId, folderId: clientFolderId, filename: 'Passport.pdf' });
await insertFile({ companyId, folderId: companyFolderId, filename: 'Articles.pdf' });
await insertFile({ yachtId, folderId: yachtFolderId, filename: 'Survey.pdf' });
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
const groupNames = result.groups.map((g) => g.label);
expect(groupNames).toContain('DIRECTLY ATTACHED');
expect(groupNames.some((n) => n.startsWith('FROM COMPANY'))).toBe(true);
expect(groupNames.some((n) => n.startsWith('FROM YACHT'))).toBe(true);
const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(direct?.files.map((f) => f.filename)).toContain('Passport.pdf');
const company = result.groups.find((g) => g.label.startsWith('FROM COMPANY'));
expect(company?.files.map((f) => f.filename)).toContain('Articles.pdf');
const yacht = result.groups.find((g) => g.label.startsWith('FROM YACHT'));
expect(yacht?.files.map((f) => f.filename)).toContain('Survey.pdf');
});
it('caps each group at 20 rows and surfaces total for Show all', async () => {
for (let i = 0; i < 25; i += 1) {
await insertFile({ clientId, folderId: clientFolderId, filename: `direct-${i}.pdf` });
}
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(direct?.files).toHaveLength(20);
expect(direct?.total).toBe(25);
});
it('snapshots are file-FK-based — yacht transfer does not move historical files', async () => {
const file = await insertFile({
yachtId,
clientId,
folderId: yachtFolderId,
filename: 'Historic.pdf',
});
// Transfer the yacht to a different owner.
const [mary] = await db
.insert(clients)
.values({
portId,
firstName: 'Mary',
lastName: 'Brown',
email: `mary-${crypto.randomUUID()}@example.com`,
})
.returning();
await db
.update(yachts)
.set({ currentOwnerType: 'client', currentOwnerId: mary!.id })
.where(eq(yachts.id, yachtId));
// John's view still shows the file (via DIRECTLY ATTACHED or FROM YACHT).
const johnView = await listFilesAggregatedByEntity(portId, 'client', clientId);
const allFiles = johnView.groups.flatMap((g) => g.files.map((f) => f.id));
expect(allFiles).toContain(file.id);
// Mary's view does NOT show the file (it was never linked to her).
const maryView = await listFilesAggregatedByEntity(portId, 'client', mary!.id);
const maryFiles = maryView.groups.flatMap((g) => g.files.map((f) => f.id));
expect(maryFiles).not.toContain(file.id);
});
it('rejects cross-port leakage with defense-in-depth port filter', async () => {
const otherPort = await setupTestPort();
const [otherClient] = await db
.insert(clients)
.values({
portId: otherPort,
firstName: 'Other',
lastName: 'Port',
email: `other-${crypto.randomUUID()}@example.com`,
})
.returning();
// Try to query the other port's client from our port — should return empty.
const result = await listFilesAggregatedByEntity(portId, 'client', otherClient!.id);
expect(result.groups.flatMap((g) => g.files)).toHaveLength(0);
});
});
```
Verify `clients` / `companies` / `companyMemberships` / `yachts` insert shapes by reading the schema files first. Required NOT NULL columns may need filler values in the test fixtures.
- [ ] **Step 2: Run the test — expect failure**
Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts`
Expected: `listFilesAggregatedByEntity` not exported.
- [ ] **Step 3: Implement the projection helper**
Append to `src/lib/services/files.ts`:
```typescript
import { documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import type { EntityType } from '@/lib/services/document-folders.service';
import { inArray } from 'drizzle-orm';
import { desc } from 'drizzle-orm';
export interface AggregatedFileGroup {
/** Label used by the UI header (e.g. "DIRECTLY ATTACHED" or "FROM YACHT — MV SERENITY"). */
label: string;
/** Stable key for de-duplication when the same file appears via multiple paths. */
source: 'direct' | 'client' | 'company' | 'yacht';
/** Up to 20 most-recent files in this group. */
files: Array<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:
```typescript
// In src/lib/services/files.ts, change `async function collectRelatedEntities` →
// `export async function collectRelatedEntities`. Same for the type alias if needed.
```
Then append to `src/lib/services/documents.service.ts`:
```typescript
import { collectRelatedEntities, type AggregatedFileGroup } from '@/lib/services/files';
export interface AggregatedWorkflowGroup {
label: string;
source: 'direct' | 'client' | 'company' | 'yacht';
workflows: Array<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`:
```typescript
import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service';
describe('documents service · listInflightWorkflowsAggregatedByEntity', () => {
let portId: string;
let clientId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
await ensureSystemRoots(portId, TEST_USER_ID);
const [c] = await db
.insert(clients)
.values({
portId,
firstName: 'John',
lastName: 'Smith',
email: `john-${crypto.randomUUID()}@example.com`,
})
.returning();
clientId = c!.id;
});
it('returns in-flight workflows in DIRECTLY ATTACHED group', async () => {
const { documents: documentsTable } = await import('@/lib/db/schema/documents');
await db.insert(documentsTable).values({
portId,
clientId,
title: 'EOI',
documentType: 'eoi',
status: 'sent',
createdBy: TEST_USER_ID,
});
await db.insert(documentsTable).values({
portId,
clientId,
title: 'Old signed EOI',
documentType: 'eoi',
status: 'completed',
createdBy: TEST_USER_ID,
});
const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId);
const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
expect(direct?.workflows).toHaveLength(1);
expect(direct?.workflows[0]?.status).toBe('sent');
});
});
```
- [ ] **Step 7: Run the tests — expect pass**
Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts`
Expected: all pass.
- [ ] **Step 8: Add `applyEntityFkFromFolder` + wire into `uploadFile` (E8)**
Append to `src/lib/services/files.ts`:
```typescript
/**
* E8: when a rep manually uploads a file into a system-managed entity
* subfolder (e.g. `Clients/Smith, John/`), auto-set the matching entity
* FK on the file row from the folder's `entityType + entityId`. Custom
* (non-system) folders → returns the input unchanged.
*
* Returns the mutated insert payload so callers can keep their
* single-insert flow.
*/
export async function applyEntityFkFromFolder<
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`:
```typescript
import { applyEntityFkFromFolder } from '@/lib/services/files';
describe('files service · applyEntityFkFromFolder', () => {
let portId: string;
let clientId: string;
let folderId: string;
beforeEach(async () => {
portId = await setupTestPort();
await ensureSystemRoots(portId, TEST_USER_ID);
const [c] = await db
.insert(clients)
.values({
portId,
firstName: 'A',
lastName: 'B',
email: `a-${crypto.randomUUID()}@example.com`,
})
.returning();
clientId = c!.id;
folderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id;
});
it('sets clientId when uploading into a client entity folder', async () => {
const out = await applyEntityFkFromFolder(portId, { folderId, clientId: null });
expect(out.clientId).toBe(clientId);
});
it('preserves existing entity FK when already set', async () => {
const out = await applyEntityFkFromFolder(portId, { folderId, clientId: 'pre-existing-id' });
expect(out.clientId).toBe('pre-existing-id');
});
it('is a no-op for non-system folders', async () => {
const [user] = await db
.insert(documentFolders)
.values({
portId,
parentId: null,
name: 'My templates',
createdBy: TEST_USER_ID,
})
.returning();
const out = await applyEntityFkFromFolder(portId, { folderId: user!.id, clientId: null });
expect(out.clientId).toBeUndefined();
});
});
```
Run: `pnpm exec vitest run -t applyEntityFkFromFolder`. Expected: 3/3 pass.
- [ ] **Step 9: Commit**
```bash
git add src/lib/services/files.ts src/lib/services/documents.service.ts tests/unit/aggregated-projection.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): owner-aggregated projection (files + workflows)
listFilesAggregatedByEntity walks the relationship graph (symmetric
reach: clients ↔ companies via memberships, ↔ yachts via current
ownership) and groups results by source: DIRECTLY ATTACHED + FROM
COMPANY/YACHT/CLIENT. File-FK snapshot is the source of truth so
historical files survive yacht-ownership transfer. Each group caps at
20 rows + a total for "Show all (N)" drill-through. Defense-in-depth
port_id filter at every join.
listInflightWorkflowsAggregatedByEntity reuses the same graph walk
for in-flight signing workflows. Completed workflows are hidden —
they surface via their signed-PDF file row instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`):
```typescript
import { z } from 'zod';
// In the existing listDocumentsSchema, add:
entityType: z.enum(['client', 'company', 'yacht']).optional(),
entityId: z.string().uuid().optional(),
// Cross-field validation — these are mutually exclusive with folderId.
// Append .refine(...) after the .strict() or directly on the schema object:
```
Append a `.refine` to the schema:
```typescript
.refine(
(q) => !(q.folderId !== undefined && (q.entityType || q.entityId)),
{ message: 'folderId is mutually exclusive with entityType/entityId' },
)
.refine(
(q) => Boolean(q.entityType) === Boolean(q.entityId),
{ message: 'entityType and entityId must be provided together' },
)
```
Read the current `listDocumentsSchema` shape first — `parseQuery` flattens repeated params and the existing schema already coerces some fields. Match the existing style.
Same pattern in `src/lib/validators/files.ts`: extend `listFilesSchema` (or whichever name the file uses — grep `parseQuery.*files` to confirm) with the same three optional params + the same two `.refine` rules.
- [ ] **Step 2: Wire the aggregated branch into the files route**
Modify `src/app/api/v1/files/route.ts`. The current `GET` handler calls `listFiles(portId, query)`. Add a branch on `query.entityType`:
```typescript
import { listFilesAggregatedByEntity } from '@/lib/services/files';
export const GET = withAuth(
withPermission('documents', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listFilesSchema);
if (query.entityType && query.entityId) {
const result = await listFilesAggregatedByEntity(
ctx.portId,
query.entityType,
query.entityId,
);
return NextResponse.json({ data: result });
}
const result = await listFiles(ctx.portId, query);
// ... existing pagination envelope
} catch (error) {
return errorResponse(error);
}
}),
);
```
Read the current `route.ts` first to get the exact existing envelope; add the branch above it.
- [ ] **Step 3: Wire the aggregated branch into the documents route**
Same pattern in `src/app/api/v1/documents/route.ts`:
```typescript
import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service';
// At the top of the GET handler, after parseQuery:
if (query.entityType && query.entityId) {
const result = await listInflightWorkflowsAggregatedByEntity(
ctx.portId,
query.entityType,
query.entityId,
);
return NextResponse.json({ data: result });
}
// ...existing listDocuments call follows
```
- [ ] **Step 4: Create the signing-details route**
Create `src/app/api/v1/documents/[id]/signing-details/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import {
getDocumentById,
listDocumentSigners,
listDocumentEvents,
} from '@/lib/services/documents.service';
export const GET = withAuth(
withPermission('documents', 'view', async (_req, ctx, { params }) => {
try {
const { id } = await params;
const [doc, signers, events] = await Promise.all([
getDocumentById(id, ctx.portId),
listDocumentSigners(id, ctx.portId),
listDocumentEvents(id, ctx.portId),
]);
return NextResponse.json({
data: {
workflow: doc,
signers,
events,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
```
Verify the exact signature of `withPermission` + the `{ params }` destructure by reading another `[id]` route handler in `src/app/api/v1/documents/[id]/` (e.g., `route.ts` or `folder/route.ts`). Next 15 App Router treats `params` as a Promise; the await above matches the project convention.
- [ ] **Step 5: Add the integration test**
Create `tests/integration/files-folder-aggregation.test.ts`. Hit the API handler directly via the sibling `handlers.ts` pattern if it exists, or via `fetch` against a running dev server. Read an existing integration test to copy the pattern. The test should:
1. Seed a port, client, company, yacht with files attached.
2. Hit `GET /api/v1/files?entityType=client&entityId=<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**
```bash
git add src/lib/validators/documents.ts src/lib/validators/files.ts src/app/api/v1/documents/route.ts src/app/api/v1/files/route.ts src/app/api/v1/documents/[id]/signing-details/route.ts tests/integration/files-folder-aggregation.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): entity-aggregated query params + signing-details API
GET /api/v1/files?entityType=client&entityId=… and the same params on
the documents route return the owner-aggregated projection
{ groups: [{ label, source, files|workflows, total }] }. folderId
remains for direct-folder listing; the two modes are mutually
exclusive (zod refine).
GET /api/v1/documents/[id]/signing-details returns
{ workflow, signers, events } for the "view signing details" dialog
on signed-PDF rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
it('hides completed workflows when folderId is set', async () => {
const portId = await setupTestPort();
await ensureSystemRoots(portId, TEST_USER_ID);
const [folder] = await db
.insert(documentFolders)
.values({
portId,
parentId: null,
name: 'Deals 2026',
createdBy: TEST_USER_ID,
})
.returning();
await db.insert(documents).values([
{
portId,
folderId: folder!.id,
title: 'In flight',
documentType: 'eoi',
status: 'sent',
createdBy: TEST_USER_ID,
},
{
portId,
folderId: folder!.id,
title: 'Done',
documentType: 'eoi',
status: 'completed',
createdBy: TEST_USER_ID,
},
]);
const result = await listDocuments(portId, {
folderId: folder!.id,
page: 1,
limit: 50,
sort: 'createdAt',
order: 'desc',
tab: 'all',
});
expect(result.data.map((d) => d.title)).toEqual(['In flight']);
});
```
Adjust imports + `listDocuments` call shape by reading the existing tests in the same file.
- [ ] **Step 2: Run the test — expect failure**
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts -t 'hides completed'`
Expected: fails (both rows currently surface).
- [ ] **Step 3: Modify `listDocuments`**
In `src/lib/services/documents.service.ts:listDocuments` (~line 146), find the existing folderId filter (added by Wave 11.B). Add a status filter that excludes `'completed'` when `query.folderId` is set:
```typescript
// When viewing a specific folder, hide completed workflows — they surface
// via their resulting signed-PDF file row in the Files section, not the
// Signing section.
if (query.folderId !== undefined) {
filters.push(ne(documents.status, 'completed'));
}
```
Add `ne` to the drizzle imports if it isn't already there.
- [ ] **Step 4: Run the test — expect pass**
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts`
Expected: all pass.
- [ ] **Step 5: Commit**
```bash
git add src/lib/services/documents.service.ts tests/integration/documents-list-folder-filter.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): hide completed workflows from folder views
When listDocuments is called with folderId set, exclude
status='completed' rows. The signed-PDF file appears in the Files
section with a "view signing details" link; the workflow row would
just be noise alongside the file.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders, files, documents } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { runBackfill } from '@/scripts/backfill-document-folders';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
describe('backfill-document-folders · idempotency + isolation', () => {
let portId: string;
let clientId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
const [c] = await db
.insert(clients)
.values({
portId,
firstName: 'John',
lastName: 'Smith',
email: `john-${crypto.randomUUID()}@example.com`,
})
.returning();
clientId = c!.id;
});
it('creates system roots and entity subfolders for entities with attached files', async () => {
await db.insert(files).values({
portId,
clientId,
filename: 'a.pdf',
originalName: 'a.pdf',
storagePath: `test/${crypto.randomUUID()}`,
storageBucket: 'test',
uploadedBy: TEST_USER_ID,
});
await runBackfill({ portId });
const roots = await db
.select()
.from(documentFolders)
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
expect(roots.map((r) => r.name).sort()).toEqual(['Clients', 'Companies', 'Yachts']);
const sub = await db
.select()
.from(documentFolders)
.where(and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)));
expect(sub).toHaveLength(1);
});
it('sets files.folder_id from entity FKs', async () => {
const [file] = await db
.insert(files)
.values({
portId,
clientId,
filename: 'a.pdf',
originalName: 'a.pdf',
storagePath: `test/${crypto.randomUUID()}`,
storageBucket: 'test',
uploadedBy: TEST_USER_ID,
})
.returning();
await runBackfill({ portId });
const updated = await db.query.files.findFirst({ where: eq(files.id, file!.id) });
expect(updated?.folderId).not.toBeNull();
});
it('copies entity FKs from completed workflows onto signed files', async () => {
const [signedFile] = await db
.insert(files)
.values({
portId, // NOTE: no clientId — legacy completion left it blank
filename: 'signed.pdf',
originalName: 'signed.pdf',
storagePath: `test/${crypto.randomUUID()}`,
storageBucket: 'test',
uploadedBy: 'system',
})
.returning();
await db.insert(documents).values({
portId,
clientId,
signedFileId: signedFile!.id,
title: 'EOI',
documentType: 'eoi',
status: 'completed',
createdBy: TEST_USER_ID,
});
await runBackfill({ portId });
const updated = await db.query.files.findFirst({ where: eq(files.id, signedFile!.id) });
expect(updated?.clientId).toBe(clientId);
expect(updated?.folderId).not.toBeNull();
});
it('is idempotent — second run produces the same result', async () => {
await db.insert(files).values({
portId,
clientId,
filename: 'a.pdf',
originalName: 'a.pdf',
storagePath: `test/${crypto.randomUUID()}`,
storageBucket: 'test',
uploadedBy: TEST_USER_ID,
});
await runBackfill({ portId });
const after1 = await db
.select()
.from(documentFolders)
.where(eq(documentFolders.portId, portId));
await runBackfill({ portId });
const after2 = await db
.select()
.from(documentFolders)
.where(eq(documentFolders.portId, portId));
expect(after2).toHaveLength(after1.length);
});
it('respects port isolation — does not touch other ports', async () => {
const otherPort = await setupTestPort();
const [otherClient] = await db
.insert(clients)
.values({
portId: otherPort,
firstName: 'Other',
lastName: 'Port',
email: `other-${crypto.randomUUID()}@example.com`,
})
.returning();
await db.insert(files).values({
portId: otherPort,
clientId: otherClient!.id,
filename: 'b.pdf',
originalName: 'b.pdf',
storagePath: `test/${crypto.randomUUID()}`,
storageBucket: 'test',
uploadedBy: TEST_USER_ID,
});
await runBackfill({ portId }); // only this port
const otherRoots = await db
.select()
.from(documentFolders)
.where(eq(documentFolders.portId, otherPort));
expect(otherRoots).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run the test — expect failure**
Run: `pnpm exec vitest run tests/integration/backfill-document-folders.test.ts`
Expected: script doesn't exist.
- [ ] **Step 3: Implement the backfill script**
Create `scripts/backfill-document-folders.ts`:
```typescript
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { documentFolders, files, documents } from '@/lib/db/schema/documents';
import {
ensureSystemRoots,
ensureEntityFolder,
type EntityType,
} from '@/lib/services/document-folders.service';
import { logger } from '@/lib/logger';
interface BackfillOptions {
portId?: string; // when set, only this port; otherwise all ports
systemUserId?: string;
}
/**
* One-time idempotent backfill: ensure every port has the three system
* roots, every entity with attached files (or completed workflows) has
* a subfolder, every file with entity FKs has folder_id set, and every
* signed file from a completed workflow has the workflow's entity FKs
* propagated. Per-port advisory lock serializes concurrent runs.
*/
export async function runBackfill(opts: BackfillOptions = {}): Promise<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**
```bash
pnpm tsx scripts/backfill-document-folders.ts
```
Expected output: "Backfill complete" + zero errors. Check the dev DB has three system roots per port:
```bash
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
-c "SELECT port_id, name FROM document_folders WHERE entity_type='root' ORDER BY port_id, name"
```
- [ ] **Step 6: Commit**
```bash
git add scripts/backfill-document-folders.ts tests/integration/backfill-document-folders.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): backfill script for system roots + entity folders
Idempotent one-time backfill that runs as part of the deploy:
1. Ensures Clients/Companies/Yachts roots per port.
2. Copies entity FKs from completed workflows onto signed file rows
(legacy completions ran before the auto-deposit handler shipped).
3. Ensures per-entity subfolders for every entity with attached
files and sets files.folder_id.
pg_advisory_xact_lock(hashtext(portId)::bigint) per port so concurrent
runs serialize. Safe to re-run; the SELECT-then-UPDATE pattern targets
only rows where folder_id IS NULL.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
interface AggregatedFile {
id: string;
filename: string;
mimeType: string | null;
createdAt: string;
folderId: string | null;
}
interface AggregatedWorkflow {
id: string;
title: string;
status: string;
documentType: string;
updatedAt: string;
}
export interface AggregatedGroup<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`:
```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**
```bash
git add src/hooks/use-aggregated-listing.ts src/components/documents/aggregated-section.tsx
git commit -m "$(cat <<'EOF'
feat(documents): AggregatedSection + useAggregatedListing
Two TanStack Query hooks fetch the entity-aggregated payload for files
and workflows; AggregatedSection renders one labelled subsection per
owner-source group with a Show all (N) drill-through hook. Dumb
component — parent owns the row rendering + drill-through navigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 13: UI — `SigningDetailsDialog` + per-row "view signing details" link
**Files:**
- Create: `src/components/documents/signing-details-dialog.tsx`
- [ ] **Step 1: Build the dialog**
Create `src/components/documents/signing-details-dialog.tsx`:
```tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { StatusPill } from '@/components/ui/status-pill';
interface SigningDetailsResponse {
data: {
workflow: {
id: string;
title: string;
status: string;
documentType: string;
createdAt: string;
updatedAt: string;
};
signers: Array<{
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
status: string;
signedAt: string | null;
}>;
events: Array<{
id: string;
eventType: string;
createdAt: string;
}>;
};
}
interface Props {
documentId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props) {
const { data, isLoading } = useQuery<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**
```bash
git add src/components/documents/signing-details-dialog.tsx
git commit -m "$(cat <<'EOF'
feat(documents): SigningDetailsDialog
Modal rendering workflow + signers + events for a signed-PDF file.
Wired to GET /api/v1/documents/[id]/signing-details. The "view signing
details" link on signed-file rows in the Files section opens this
dialog (wiring in the entity-folder view task).
Co-Authored-By: Claude Opus 4.7 (1M context) <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**
```bash
cat src/components/documents/folder-tree-sidebar.tsx
cat src/components/documents/folder-actions-menu.tsx
```
Both already exist (Wave 11.B). The folder fetch returns `FolderNode[]` with the new `systemManaged` / `entityType` / `entityId` / `archivedAt` fields (Drizzle auto-includes them via `.select()` in `listTree`). The UI already accepts the tree and renders names; this task adds visual + behavioral awareness of the new flags.
- [ ] **Step 2: Add 🔒 marker + archived muted style to `FolderTreeSidebar`**
In `folder-tree-sidebar.tsx`, find the row-rendering block (the `<li>` or `<button>` that renders one folder's name + chevron). Add conditional rendering:
```tsx
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:
```tsx
// Find the selected folder in the tree
const selected = selectedFolderId ? findInTree(tree, selectedFolderId) : null;
const isSystem = selected?.systemManaged ?? false;
// Disable Rename + Move + Delete buttons (or hide them) when isSystem:
<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:
```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`:
```tsx
{
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**
```bash
git add src/components/documents/folder-tree-sidebar.tsx src/components/documents/folder-actions-menu.tsx
git commit -m "$(cat <<'EOF'
feat(documents): folder tree + actions UI for system-managed folders
FolderTreeSidebar shows a 🔒 marker on system_managed rows and renders
archived entity folders muted. FolderActionsMenu disables Rename /
Move / Delete with a tooltip explanation when a system folder is
selected; the server-side guard (Task 4) is the authoritative
rejection — the UI is the friendly first line.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```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`:
```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:
```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).
- 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:
```tsx
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**
```bash
git add src/components/documents/hub-root-view.tsx src/components/documents/entity-folder-view.tsx src/components/documents/documents-hub.tsx
git commit -m "$(cat <<'EOF'
feat(documents): rebuild hub — root view + entity-folder view
Three rendering modes for the main panel:
- HubRootView (no folder selected): port-wide Signing + recent Files.
- EntityFolderView (system-managed entity subfolder selected):
AggregatedSection × 2 with owner-grouped subsections + per-row
"view signing details" link on signed files.
- FlatFolderListing (any other folder): existing search + chips + list.
Drops the signing-status tab strip (in_progress / awaiting_them / etc.)
— folders are the primary navigation now. hub-counts query is no
longer used.
Co-Authored-By: Claude Opus 4.7 (1M context) <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**
```bash
grep -rn "from '@/components/files/folder-tree'\|/documents/files\"" src/ --include="*.tsx" --include="*.ts"
```
Expected: only `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` references the legacy tree. If anything else does, deal with it before deleting (e.g., a sidebar link in `src/components/layout/sidebar.tsx`).
- [ ] **Step 2: Delete the legacy page + folder tree**
```bash
git rm src/app/(dashboard)/[portSlug]/documents/files/page.tsx
git rm src/components/files/folder-tree.tsx
```
- [ ] **Step 3: Add a 301 redirect to `next.config.mjs`**
In `next.config.mjs`, add a `redirects()` function alongside the existing `headers()`:
```javascript
async redirects() {
return [
{
source: '/:portSlug/documents/files',
destination: '/:portSlug/documents',
permanent: true,
},
{
source: '/:portSlug/documents/files/:path*',
destination: '/:portSlug/documents',
permanent: true,
},
];
},
```
- [ ] **Step 4: Repurpose `file-browser-store.ts`**
The store currently tracks `currentFolder` as a `storagePath` prefix. Repurpose it to hold a `selectedFolderId` (the `document_folders.id` reference):
```typescript
import { create } from 'zustand';
interface FileBrowserState {
viewMode: 'grid' | 'list';
setViewMode: (mode: 'grid' | 'list') => void;
selectedFolderId: string | null | undefined; // undefined = no selection, null = root
setSelectedFolderId: (id: string | null | undefined) => void;
}
export const useFileBrowserStore = create<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**
```bash
grep -rn '/documents/files' src/components/layout/
```
If a sidebar link points to `/documents/files`, change it to `/documents`. (Likely no change needed since the redirect catches stray bookmarks too.)
- [ ] **Step 6: TypeScript + a smoke run**
```bash
pnpm exec tsc --noEmit
```
Expected: clean exit. If the dev server is up, navigate to `/<port>/documents/files` and confirm the 301 lands on `/<port>/documents`.
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "$(cat <<'EOF'
chore(documents): remove legacy /documents/files route + folder tree
The /documents/files page rendered a storagePath-prefix folder tree
disconnected from document_folders. Replaced by the unified hub
(Task 15). 301 redirect catches stray bookmarks. file-browser-store
repurposed to hold the document_folders.id selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <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:
```json
"db:backfill:doc-folders": "tsx scripts/backfill-document-folders.ts"
```
- [ ] **Step 2: Document the deploy sequence**
Find the deploy runbook. Likely candidates:
```bash
ls docs/ | grep -i deploy
grep -rln 'deploy' docs/ | head -5
```
If a runbook exists, add a step **after** the migration step:
````markdown
### Documents hub split (Wave 11.B+)
Run after the `0051_documents_hub_split.sql` migration applies:
```bash
pnpm db:backfill:doc-folders
```
````
Idempotent — safe to re-run if the deploy is interrupted.
````
If no runbook exists, add the step to the README or a fresh `docs/deploy.md`. Don't create a docs file unless one exists or is the obvious home — the user-facing repo conventions matter more than perfect docs.
- [ ] **Step 3: Manual local-deploy rehearsal**
```bash
# Roll back the local DB to pre-Task-1 state (skip if you don't have a fixture)
# Apply migration:
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
-f src/lib/db/migrations/0051_documents_hub_split.sql
# Run backfill:
pnpm db:backfill:doc-folders
````
Expected: zero errors, system roots populated, file folder_ids set.
- [ ] **Step 4: Commit**
```bash
git add package.json docs/
git commit -m "$(cat <<'EOF'
chore(documents): wire backfill script into deploy sequence
Adds db:backfill:doc-folders npm script + runbook step. Run after the
0051 migration applies. Idempotent; safe to re-run on interrupted
deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <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.
```typescript
import { test, expect } from '@playwright/test';
// ... existing project setup imports
test('open client entity folder, see aggregated groups, view signing details', async ({ page }) => {
// Setup ...
await page.goto('/port-nimara/documents');
await page.getByRole('button', { name: /Clients/i }).click();
// Click the seeded client's subfolder
await page.getByRole('button', { name: /Smith, John/ }).click();
await expect(page.getByText(/DIRECTLY ATTACHED/i)).toBeVisible();
await expect(page.getByRole('button', { name: /view signing details/i })).toBeVisible();
await page
.getByRole('button', { name: /view signing details/i })
.first()
.click();
await expect(page.getByRole('dialog', { name: /Signing details/i })).toBeVisible();
});
```
- [ ] **Step 2: Write the upload-into-entity smoke spec**
Create `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts`. Seed a client, navigate to their entity folder, click Upload, attach a small PDF fixture (`tests/e2e/fixtures/sample.pdf`), submit. Assert:
1. After upload, the row appears in DIRECTLY ATTACHED.
2. The file row's API shape (verified via a follow-up `fetch('/api/v1/files/<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');
await expect(page).toHaveScreenshot('hub-root.png', { fullPage: true });
});
test('hub-entity-folder', async ({ page }) => {
// Seed assumed by global setup; navigate to a fixed seeded client.
await page.goto('/port-nimara/documents');
await page.getByRole('button', { name: /Clients/i }).click();
await page.getByRole('button', { name: /Smith, John/ }).click();
await page.waitForSelector('text=DIRECTLY ATTACHED');
await expect(page).toHaveScreenshot('hub-entity-folder.png', { fullPage: true });
});
```
Run with `--update-snapshots` to generate baselines:
```bash
pnpm exec playwright test --project=visual --update-snapshots tests/e2e/visual/snapshots.spec.ts
```
Verify the generated PNGs look right (open them in the OS file viewer). Commit the new snapshot PNGs under `tests/e2e/visual/snapshots.spec.ts-snapshots/`.
- [ ] **Step 4: Run the smoke project end-to-end**
```bash
pnpm exec playwright test --project=smoke
```
Expected: passes including the two new specs. Allow ~10 min runtime.
- [ ] **Step 5: Commit**
```bash
git add tests/e2e/smoke/04-documents-hub-aggregated.spec.ts tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts tests/e2e/visual/snapshots.spec.ts tests/e2e/visual/snapshots.spec.ts-snapshots/
git commit -m "$(cat <<'EOF'
test(documents): E2E smoke + visual snapshots for hub rebuild
Two smoke specs cover the headline flows:
- open client entity folder → see grouped Signing + Files → click
"view signing details" → dialog renders signers + events.
- upload PDF into Clients/Smith/ → client_id auto-set from the
destination folder → file appears in DIRECTLY ATTACHED.
Visual baselines for hub-root + hub-entity-folder catch unintended
layout drift on the new screens.
Co-Authored-By: Claude Opus 4.7 (1M context) <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.
- [ ] **Step 5: Final commit**
```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(claude-md): documents hub split + auto-filed client folders
Extends the Document folders subsection with: three system roots
+ per-entity subfolders + lifecycle hooks (rename/archive/delete);
Owner-wins owner resolution in handleDocumentCompleted; aggregated
projection with symmetric reach + file-FK-as-source-of-truth +
defense-in-depth port_id filter; permission gating.
Co-Authored-By: Claude Opus 4.7 (1M context) <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.
```