diff --git a/docs/superpowers/plans/2026-05-09-documents-folders.md b/docs/superpowers/plans/2026-05-09-documents-folders.md new file mode 100644 index 00000000..682016f0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-documents-folders.md @@ -0,0 +1,2790 @@ +# Documents 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:** Add a port-wide nestable folder tree to documents, plus quality-of-life polish on the documents hub (drop the confusing "Signature-based only" pill, add an "In progress" tab, surface dynamic type-filter chips, gate the "Expired" tab on a per-port setting). + +**Architecture:** New `document_folders` table with a self-referencing `parent_id` (unlimited nesting via recursive CTE for path resolution). Add a nullable `folder_id` column to `documents`; null = root. Folder UI is a collapsed-by-default left sidebar tree plus a breadcrumb header on the documents hub. Folder delete moves children to the parent (soft rescue); audit-logged. Folder ops gated on a new `documents.manage_folders` permission, mirroring the existing `files.manage_folders`. All API routes follow the established `withAuth(withPermission(...))` + `parseBody` + `errorResponse` envelope. + +**Tech Stack:** Next.js 15 App Router (typedRoutes), TypeScript strict (noUncheckedIndexedAccess), Drizzle ORM on PostgreSQL, TanStack React Query, shadcn/ui (Radix Popover, Command, Dialog), Vitest (unit + integration), Playwright (smoke). + +**Decisions locked (from 2026-05-09 review):** +- **Folder scope:** port-wide (one tree per port). +- **Hub tabs:** stay flat across the port; folder is an orthogonal filter. +- **Signature-based only pill:** **drop entirely**. +- **Move permission:** new `documents.manage_folders` (mirrors `files.manage_folders`). +- **Folder UI:** collapsed sidebar tree + breadcrumb header. +- **Delete semantics:** move children to parent; audit-logged. Cascade NEVER. +- **In-progress filter:** `status IN (draft, sent, partially_signed) AND status != 'expired'`. +- **Folder watchers:** **out of scope** for this plan; doc-level watchers only. + +**Out of scope (separate work):** +- Folder watchers / subscriptions. +- Wider file-type allowlist (the upload route already accepts any MIME — no enforcement to widen). +- Bulk multi-select move (single-doc move only in v1). +- Folder color tags / icons (boring grey folders are fine for v1). + +**Conventions to honour (from `CLAUDE.md`):** +- Strict TypeScript, no `any`. Unused vars prefixed `_`. +- Prettier: single quotes, semicolons, trailing commas, 100-char width. +- Body parsing: ALWAYS use `parseBody(req, schema)` from `@/lib/api/route-helpers`. +- Response envelope: `{ data: }` for content; `204 No Content` for no-body mutations. +- 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` (only when integration tests need to bypass middleware). +- 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 `documentFolders` table; add `folderId` column to `documents`. +- Modify: `src/lib/db/schema/users.ts` — add `documents.manage_folders` to `RolePermissions['documents']`. +- Create: `src/lib/db/migrations/0050_document_folders.sql` — manual migration; backfill notes. + +**Validators (1 created, 1 modified):** +- Create: `src/lib/validators/document-folders.ts` — Zod schemas for create / rename / move. +- Modify: `src/lib/validators/documents.ts` — add `folderId` to `createDocumentSchema` and `listDocumentsSchema`. + +**Service (1 created, 1 modified):** +- Create: `src/lib/services/document-folders.service.ts` — `listTree`, `createFolder`, `renameFolder`, `moveFolder`, `deleteFolderSoftRescue`, `resolvePath`. +- Modify: `src/lib/services/documents.service.ts` — accept `folderId` in `createDocument`; filter on `folderId` in `listDocuments`. + +**API routes (3 created, 1 modified):** +- Create: `src/app/api/v1/document-folders/route.ts` — `GET` (whole tree), `POST` (create). +- Create: `src/app/api/v1/document-folders/[id]/route.ts` — `PATCH` (rename + move), `DELETE` (soft-rescue). +- Create: `src/app/api/v1/documents/[id]/folder/route.ts` — `PATCH` (move single document). Co-locates with the doc. +- Modify: `src/app/api/v1/documents/route.ts` — surface `folderId` filter in `GET` query parsing. + +**Roles seeding (1 modified):** +- Modify: `src/lib/db/seed-data/role-permissions.ts` (or wherever the `RolePermissions` defaults live — discover by `grep`) — set `documents.manage_folders: true` on `admin` + `sales_manager`, `false` on `sales_rep`. Adjust to match existing role granularity. + +**Hooks (1 created):** +- Create: `src/hooks/use-document-folders.ts` — TanStack Query wrapper for tree fetch + invalidation helpers. + +**UI components (4 created, 1 modified):** +- Create: `src/components/documents/folder-tree-sidebar.tsx` — collapsed-by-default left rail tree. +- Create: `src/components/documents/folder-breadcrumb.tsx` — header crumb trail with "Move up" / context menu. +- Create: `src/components/documents/folder-actions-menu.tsx` — Create / Rename / Delete dialogs. +- Create: `src/components/documents/move-to-folder-dialog.tsx` — per-document move picker (Combobox of folder paths). +- Modify: `src/components/documents/documents-hub.tsx` — wire sidebar + breadcrumb, drop `signatureOnly` toggle, swap type-filter to dynamic chip group, add "In progress" tab, gate "Expired" tab on a system_setting. + +**Admin settings (1 modified):** +- Modify: `src/components/admin/settings/settings-manager.tsx` — add `documents_show_expired_tab` boolean (default `true`) to the Feature Flags card. + +**Tests (4 created, 1 modified):** +- Create: `tests/unit/document-folders-validators.test.ts` — Zod validation edge cases. +- Create: `tests/integration/document-folders-crud.test.ts` — folder CRUD, port isolation, parent-cycle prevention. +- Create: `tests/integration/document-folders-soft-delete.test.ts` — children bubble up to parent on delete. +- Create: `tests/integration/documents-list-folder-filter.test.ts` — `listDocuments` with `folderId` and `includeDescendants` flags. +- Modify: `tests/e2e/smoke/04-documents.spec.ts` — add a folder smoke test (create folder, move doc, navigate). + +**Docs (1 modified):** +- Modify: `CLAUDE.md` — Add a "Documents folders" subsection under the Conventions block describing the folder model + the `documents.manage_folders` perm. + +--- + +## Task 1: Schema — `document_folders` table + `folder_id` on documents + +**Files:** +- Modify: `src/lib/db/schema/documents.ts` +- Create: `src/lib/db/migrations/0050_document_folders.sql` + +- [ ] **Step 1: Append the `documentFolders` table to `documents.ts`** + +Add at the bottom of `src/lib/db/schema/documents.ts` (before the type exports at end of file): + +```typescript +/** + * Per-port folder tree for organising documents. Self-referencing + * via parent_id; null parent = root. Unlimited depth — the UI is the + * gate (collapsed sidebar tree + breadcrumb header). Cycle prevention + * happens in the service layer (parent_id chain walk on insert/move). + * + * On folder delete: children (both subfolders and documents) bubble + * up to the deleted folder's parent. Never CASCADE. + */ +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(), + 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), + // Sibling-uniqueness: can't have two folders with the same name in + // the same parent (or two roots with the same name in a port). + // COALESCE makes NULL parents share a single bucket per port. + uniqueIndex('uniq_document_folders_sibling_name').on( + table.portId, + sql`COALESCE(${table.parentId}, '__root__')`, + sql`LOWER(${table.name})`, + ), + ], +); + +export type DocumentFolder = typeof documentFolders.$inferSelect; +export type NewDocumentFolder = typeof documentFolders.$inferInsert; +``` + +Add `folderId` column to the existing `documents` table definition (in the same file, inside the `pgTable('documents', { ... })` columns block — keep it next to the polymorphic FKs): + +```typescript +folderId: text('folder_id'), +``` + +And add an index for it inside the same table's `(table) => [...]` list: + +```typescript +index('idx_docs_folder').on(table.folderId), +``` + +You will also need to add `uniqueIndex` to the imports at the top of the file if it isn't already imported (it likely is — `documents.ts` already uses `index` and `uniqueIndex` is in the drizzle pg-core). + +- [ ] **Step 2: Verify the schema compiles** + +Run: `pnpm exec tsc --noEmit` +Expected: clean exit (no output). + +If TS complains about `sql` being unused, the import is already present (`documents.ts` uses it for SQL fragments). + +- [ ] **Step 3: Write the migration SQL** + +Create `src/lib/db/migrations/0050_document_folders.sql`: + +```sql +-- Document folders: per-port, unlimited-depth tree. parent_id references +-- another document_folders row; null = root. Sibling-name uniqueness is +-- enforced via a partial-uniqueness on (port_id, COALESCE(parent_id, +-- '__root__'), LOWER(name)) so two folders can't share a name inside +-- the same parent. The CRM checks parent_id chain for cycles in the +-- service layer; no DB-side cycle guard. +CREATE TABLE IF NOT EXISTS "document_folders" ( + "id" text PRIMARY KEY NOT NULL, + "port_id" text NOT NULL REFERENCES "ports" ("id"), + "parent_id" text, + "name" text NOT NULL, + "created_by" text NOT NULL, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- Self-FK with ON DELETE NO ACTION (the service implements +-- soft-rescue; bubbling children up to the parent in a single +-- transaction). Letting the DB cascade would silently destroy data. +ALTER TABLE "document_folders" + ADD CONSTRAINT "document_folders_parent_fk" + FOREIGN KEY ("parent_id") REFERENCES "document_folders" ("id") + ON DELETE NO ACTION; + +CREATE INDEX IF NOT EXISTS "idx_document_folders_port" + ON "document_folders" ("port_id"); +CREATE INDEX IF NOT EXISTS "idx_document_folders_parent" + ON "document_folders" ("parent_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_document_folders_sibling_name" + ON "document_folders" ("port_id", COALESCE("parent_id", '__root__'), LOWER("name")); + +-- Add folder_id to documents. Nullable; null = root. ON DELETE SET NULL +-- so a botched folder delete (or a rare DB-direct delete) can't take +-- documents with it. +ALTER TABLE "documents" + ADD COLUMN IF NOT EXISTS "folder_id" text REFERENCES "document_folders" ("id") ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS "idx_docs_folder" + ON "documents" ("folder_id"); +``` + +- [ ] **Step 4: 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/0050_document_folders.sql +``` + +Expected: `CREATE TABLE`, `ALTER TABLE`, `CREATE INDEX` (×4), `ALTER TABLE`, `CREATE INDEX`. No errors. + +If `next dev` is running, **restart it** (per `CLAUDE.md` — postgres.js prepared statements cache stale column lists otherwise). + +- [ ] **Step 5: Verify with a sanity SELECT** + +Run: + +```bash +PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \ + -c "\\d document_folders" +``` + +Expected: shows the new table with the 7 columns and the parent-FK. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/db/schema/documents.ts src/lib/db/migrations/0050_document_folders.sql +git commit -m "$(cat <<'EOF' +feat(documents): document_folders schema + folder_id on documents + +Adds a per-port folder tree (self-FK on parent_id, unlimited depth) +plus a nullable folder_id on documents (null = root). Sibling-name +uniqueness enforced via a unique index on (port_id, COALESCE(parent_id, +'__root__'), LOWER(name)) so two folders can't share a name inside +the same parent. ON DELETE SET NULL on documents.folder_id and ON +DELETE NO ACTION on the parent self-FK so a botched delete never +silently destroys data — the service layer implements soft-rescue +(bubble children up to parent) instead. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Add `documents.manage_folders` permission + +**Files:** +- Modify: `src/lib/db/schema/users.ts` +- Modify: `src/lib/db/seed-data/role-permissions.ts` (path may differ — `grep -rn 'manage_folders' src/lib` to discover) +- Modify: `src/lib/db/seed.ts` (re-seed roles) + +- [ ] **Step 1: Locate the role-permissions seed** + +Run: `grep -rn 'files: {' src/lib/db | head -5` + +You should find a seed file (likely `src/lib/db/seed-data/roles.ts` or similar) where each role lists its permissions. Open whichever file appears. + +- [ ] **Step 2: Add `manage_folders` to the documents permission type** + +In `src/lib/db/schema/users.ts`, the `RolePermissions['documents']` block currently has `view, create, edit, send_for_signing, upload_signed, delete`. Add `manage_folders: boolean;` so it reads: + +```typescript +documents: { + view: boolean; + create: boolean; + edit: boolean; + send_for_signing: boolean; + upload_signed: boolean; + delete: boolean; + manage_folders: boolean; +}; +``` + +- [ ] **Step 3: Backfill defaults in the role seed file** + +In whichever role-seed file you found, set `documents.manage_folders` for each role: +- `admin`: `true` +- `sales_manager`: `true` +- `sales_rep`: `false` +- `viewer`: `false` +- (Any other roles: `false` unless the role's intent is admin-equivalent.) + +Each role's `documents` block will need the new key. Keep the existing keys. + +- [ ] **Step 4: Verify TypeScript still compiles** + +Run: `pnpm exec tsc --noEmit` +Expected: clean. If TS complains about a role missing `manage_folders`, you missed one — the type now requires it. + +- [ ] **Step 5: Re-seed the dev database with the updated role permissions** + +The seed script re-writes role rows on every run. Run: + +```bash +pnpm db:seed +``` + +Expected: completes without error. (If the seed script aborts on existing data, look for a `--reset` or equivalent flag in `package.json`.) + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/db/schema/users.ts src/lib/db/seed-data/roles.ts # adjust path +git commit -m "$(cat <<'EOF' +feat(perms): add documents.manage_folders permission + +Mirrors files.manage_folders. Gates create / rename / move / delete of +document folders, plus moving documents between folders. Reps with +documents.edit but not manage_folders can rename docs in place but +can't reorganise the tree. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Folder service — types + listTree + +**Files:** +- Create: `src/lib/services/document-folders.service.ts` +- Test: `tests/integration/document-folders-crud.test.ts` + +- [ ] **Step 1: Write the failing integration test for `listTree`** + +Create `tests/integration/document-folders-crud.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { db } from '@/lib/db'; +import { documentFolders } from '@/lib/db/schema/documents'; +import { listTree, createFolder } from '@/lib/services/document-folders.service'; +import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; + +describe('document-folders service · listTree', () => { + let portId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders); + }); + + it('returns an empty array when no folders exist', async () => { + const tree = await listTree(portId); + expect(tree).toEqual([]); + }); + + it('returns root folders with children nested under them', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null }); + const child = await createFolder(portId, TEST_USER_ID, { + name: 'Q1', + parentId: root.id, + }); + const tree = await listTree(portId); + expect(tree).toHaveLength(1); + expect(tree[0]?.id).toBe(root.id); + expect(tree[0]?.children).toHaveLength(1); + expect(tree[0]?.children[0]?.id).toBe(child.id); + }); + + it('only returns folders for the requested port', async () => { + const otherPort = await setupTestPort(); + await createFolder(otherPort, TEST_USER_ID, { name: 'Other Port', parentId: null }); + const tree = await listTree(portId); + expect(tree).toEqual([]); + }); +}); +``` + +(Discover the actual helper imports by reading any existing integration test under `tests/integration/`, e.g. `documents-hub-eoi-queue.test.ts`. The helper names above — `setupTestPort`, `TEST_USER_ID` — are the typical convention; adjust if your codebase uses different ones.) + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts` +Expected: import error — service module doesn't exist yet. + +- [ ] **Step 3: Create the service skeleton with `listTree` + `createFolder`** + +Create `src/lib/services/document-folders.service.ts`: + +```typescript +import { and, asc, eq, isNull } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documentFolders, type DocumentFolder } from '@/lib/db/schema/documents'; +import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; + +export interface FolderNode extends DocumentFolder { + children: FolderNode[]; +} + +/** + * Returns the entire folder tree for a port, nested under their + * parents. Roots come back at the top level. Order is alphabetical + * (case-insensitive) within each parent — matches the sibling-uniqueness + * index ordering and gives reps a stable browsing experience. + * + * Uses a single SELECT + JS nesting rather than a recursive CTE; the + * folder tree is small (UI gates depth; thousands of folders would be + * a misuse) so the in-memory build is cheaper than a CTE round-trip. + */ +export async function listTree(portId: string): Promise { + const rows = await db + .select() + .from(documentFolders) + .where(eq(documentFolders.portId, portId)) + .orderBy(asc(documentFolders.name)); + + const byId = new Map(); + for (const row of rows) byId.set(row.id, { ...row, children: [] }); + + const roots: FolderNode[] = []; + for (const node of byId.values()) { + if (node.parentId === null) { + roots.push(node); + } else { + const parent = byId.get(node.parentId); + if (parent) parent.children.push(node); + // Orphan rows (parent_id pointing nowhere) are dropped from the + // tree but stay in the DB. Surface via a separate maintenance + // query if needed; never silently re-parent. + } + } + return roots; +} + +interface CreateFolderInput { + name: string; + parentId: string | null; +} + +/** + * Creates a folder under the given parent. Throws ConflictError when + * a sibling with the same case-insensitive name already exists (the DB + * unique index is the authoritative guard; this maps the Postgres + * 23505 to the typed error). Throws ValidationError when `parentId` + * doesn't belong to this port (cross-port leakage guard). + */ +export async function createFolder( + portId: string, + userId: string, + data: CreateFolderInput, +): Promise { + const trimmed = data.name.trim(); + if (!trimmed) throw new ValidationError('Folder name cannot be empty'); + if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars'); + + if (data.parentId !== null) { + const parent = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, data.parentId), eq(documentFolders.portId, portId)), + }); + if (!parent) throw new ValidationError('Parent folder not found in this port'); + } + + try { + const [row] = await db + .insert(documentFolders) + .values({ + portId, + parentId: data.parentId, + name: trimmed, + createdBy: userId, + }) + .returning(); + if (!row) throw new NotFoundError('Folder'); + return row; + } catch (err) { + if (err instanceof Error && err.message.includes('uniq_document_folders_sibling_name')) { + throw new ConflictError(`A folder named "${trimmed}" already exists here`); + } + throw err; + } +} +``` + +- [ ] **Step 4: Run the test — should pass now** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts` +Expected: 3/3 pass. + +- [ ] **Step 5: Add a duplicate-name test that exercises the unique index** + +Append to `tests/integration/document-folders-crud.test.ts`: + +```typescript +describe('document-folders service · createFolder unique-sibling guard', () => { + let portId: string; + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders); + }); + + it('rejects a duplicate sibling name (case-insensitive)', async () => { + await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null }); + await expect( + createFolder(portId, TEST_USER_ID, { name: 'deals 2026', parentId: null }), + ).rejects.toThrow(/already exists/i); + }); + + it('allows the same name under different parents', async () => { + const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null }); + const b = await createFolder(portId, TEST_USER_ID, { name: 'B', parentId: null }); + await createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: a.id }); + await expect( + createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: b.id }), + ).resolves.toBeDefined(); + }); + + it('rejects a parentId from another port', async () => { + const otherPort = await setupTestPort(); + const otherFolder = await createFolder(otherPort, TEST_USER_ID, { + name: 'Other', + parentId: null, + }); + await expect( + createFolder(portId, TEST_USER_ID, { name: 'Should fail', parentId: otherFolder.id }), + ).rejects.toThrow(/not found in this port/i); + }); +}); +``` + +- [ ] **Step 6: Run all folder tests — should be 6/6** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts` +Expected: 6/6 pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/services/document-folders.service.ts tests/integration/document-folders-crud.test.ts +git commit -m "$(cat <<'EOF' +feat(documents): folder service · listTree + createFolder + +In-memory tree build (single SELECT + JS nesting); the folder tree is +small enough that a recursive CTE buys nothing. Sibling-name conflict +maps the Postgres unique-index 23505 to a typed ConflictError so the +UI can render a clean toast. Cross-port parentId rejected at the +service boundary. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Folder service — rename, move (cycle prevention), soft-rescue delete + +**Files:** +- Modify: `src/lib/services/document-folders.service.ts` +- Test: `tests/integration/document-folders-crud.test.ts` (extend) +- Create: `tests/integration/document-folders-soft-delete.test.ts` + +- [ ] **Step 1: Write the failing test for rename** + +Append to `tests/integration/document-folders-crud.test.ts`: + +```typescript +import { renameFolder } from '@/lib/services/document-folders.service'; + +describe('document-folders service · renameFolder', () => { + let portId: string; + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders); + }); + + it('renames a folder and bumps updatedAt', async () => { + const folder = await createFolder(portId, TEST_USER_ID, { name: 'Old', parentId: null }); + const before = folder.updatedAt.getTime(); + await new Promise((r) => setTimeout(r, 10)); + const renamed = await renameFolder(portId, folder.id, 'New'); + expect(renamed.name).toBe('New'); + expect(renamed.updatedAt.getTime()).toBeGreaterThan(before); + }); + + it('rejects rename to an existing sibling name', async () => { + await createFolder(portId, TEST_USER_ID, { name: 'Existing', parentId: null }); + const folder = await createFolder(portId, TEST_USER_ID, { name: 'Mine', parentId: null }); + await expect(renameFolder(portId, folder.id, 'Existing')).rejects.toThrow(/already exists/i); + }); + + it('throws NotFound when the folder belongs to another port', async () => { + const otherPort = await setupTestPort(); + const folder = await createFolder(otherPort, TEST_USER_ID, { name: 'X', parentId: null }); + await expect(renameFolder(portId, folder.id, 'Y')).rejects.toThrow(/not found/i); + }); +}); +``` + +- [ ] **Step 2: Run the failing test** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts -t renameFolder` +Expected: import error or fail. + +- [ ] **Step 3: Implement `renameFolder`** + +Append to `src/lib/services/document-folders.service.ts`: + +```typescript +export async function renameFolder( + portId: string, + folderId: string, + newName: string, +): Promise { + const trimmed = newName.trim(); + if (!trimmed) throw new ValidationError('Folder name cannot be empty'); + if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars'); + + const existing = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), + }); + if (!existing) throw new NotFoundError('Folder'); + + try { + const [updated] = await db + .update(documentFolders) + .set({ name: trimmed, updatedAt: new Date() }) + .where(eq(documentFolders.id, folderId)) + .returning(); + if (!updated) throw new NotFoundError('Folder'); + return updated; + } catch (err) { + if (err instanceof Error && err.message.includes('uniq_document_folders_sibling_name')) { + throw new ConflictError(`A folder named "${trimmed}" already exists here`); + } + throw err; + } +} +``` + +- [ ] **Step 4: Run rename tests — should be 3/3** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts -t renameFolder` +Expected: 3/3 pass. + +- [ ] **Step 5: Write failing test for move (with cycle prevention)** + +Append to the same test file: + +```typescript +import { moveFolder } from '@/lib/services/document-folders.service'; + +describe('document-folders service · moveFolder', () => { + let portId: string; + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders); + }); + + it('moves a folder under a new parent', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); + const orphan = await createFolder(portId, TEST_USER_ID, { name: 'Orphan', parentId: null }); + const moved = await moveFolder(portId, orphan.id, root.id); + expect(moved.parentId).toBe(root.id); + }); + + it('moves a folder back to root with parentId=null', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); + const child = await createFolder(portId, TEST_USER_ID, { name: 'Child', parentId: root.id }); + const moved = await moveFolder(portId, child.id, null); + expect(moved.parentId).toBeNull(); + }); + + it('rejects a move that would create a cycle', async () => { + const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null }); + const b = await createFolder(portId, TEST_USER_ID, { name: 'B', parentId: a.id }); + const c = await createFolder(portId, TEST_USER_ID, { name: 'C', parentId: b.id }); + // moving A under C would create A → B → C → A + await expect(moveFolder(portId, a.id, c.id)).rejects.toThrow(/cycle/i); + }); + + it('rejects moving a folder under itself', async () => { + const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null }); + await expect(moveFolder(portId, a.id, a.id)).rejects.toThrow(/cycle/i); + }); +}); +``` + +- [ ] **Step 6: Run the failing test** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts -t moveFolder` +Expected: import error. + +- [ ] **Step 7: Implement `moveFolder` with cycle check** + +Append to `src/lib/services/document-folders.service.ts`: + +```typescript +export async function moveFolder( + portId: string, + folderId: string, + newParentId: string | null, +): Promise { + if (newParentId === folderId) { + throw new ValidationError('Cannot move a folder under itself (cycle)'); + } + + const folder = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), + }); + if (!folder) throw new NotFoundError('Folder'); + + if (newParentId !== null) { + const newParent = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, newParentId), eq(documentFolders.portId, portId)), + }); + if (!newParent) throw new ValidationError('Parent folder not found in this port'); + + // Cycle check: walk newParent's ancestor chain. If we hit folderId, + // newParent is a descendant of the folder being moved → cycle. + let cursor: string | null = newParent.parentId; + const seen = new Set([newParent.id]); + while (cursor) { + if (cursor === folderId) { + throw new ValidationError('Cannot move a folder under one of its descendants (cycle)'); + } + if (seen.has(cursor)) break; // defensive — pre-existing cycle, bail + seen.add(cursor); + const next: { parentId: string | null } | undefined = await db.query.documentFolders.findFirst({ + where: eq(documentFolders.id, cursor), + columns: { parentId: true }, + }); + cursor = next?.parentId ?? null; + } + } + + try { + const [updated] = await db + .update(documentFolders) + .set({ parentId: newParentId, updatedAt: new Date() }) + .where(eq(documentFolders.id, folderId)) + .returning(); + if (!updated) throw new NotFoundError('Folder'); + return updated; + } catch (err) { + if (err instanceof Error && err.message.includes('uniq_document_folders_sibling_name')) { + throw new ConflictError(`A folder with that name already exists in the destination`); + } + throw err; + } +} +``` + +- [ ] **Step 8: Run move tests — should be 4/4** + +Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts -t moveFolder` +Expected: 4/4 pass. + +- [ ] **Step 9: Write failing test for soft-rescue delete** + +Create `tests/integration/document-folders-soft-delete.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documentFolders, documents } from '@/lib/db/schema/documents'; +import { + createFolder, + deleteFolderSoftRescue, +} from '@/lib/services/document-folders.service'; +import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; + +describe('document-folders · deleteFolderSoftRescue', () => { + let portId: string; + + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documentFolders); + }); + + it('moves child subfolders up to the deleted folder’s parent', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); + const middle = await createFolder(portId, TEST_USER_ID, { name: 'Middle', parentId: root.id }); + const leaf = await createFolder(portId, TEST_USER_ID, { name: 'Leaf', parentId: middle.id }); + + await deleteFolderSoftRescue(portId, middle.id, TEST_USER_ID); + + const survivor = await db.query.documentFolders.findFirst({ + where: eq(documentFolders.id, leaf.id), + }); + expect(survivor?.parentId).toBe(root.id); + }); + + it('moves child documents to the deleted folder’s parent', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); + const child = await createFolder(portId, TEST_USER_ID, { name: 'Child', parentId: root.id }); + + // Insert a document directly. The fixture port has at least one + // entity to attach to — adapt to whatever your test helpers expose. + const [doc] = await db + .insert(documents) + .values({ + portId, + documentType: 'other', + title: 'Orphan-rescue test', + createdBy: TEST_USER_ID, + folderId: child.id, + }) + .returning(); + + await deleteFolderSoftRescue(portId, child.id, TEST_USER_ID); + + const updatedDoc = await db.query.documents.findFirst({ + where: eq(documents.id, doc!.id), + }); + expect(updatedDoc?.folderId).toBe(root.id); + }); + + it('moves root-folder children to root (folderId=null) when the deleted folder is at root', async () => { + const folder = await createFolder(portId, TEST_USER_ID, { name: 'TopLevel', parentId: null }); + const child = await createFolder(portId, TEST_USER_ID, { + name: 'Survivor', + parentId: folder.id, + }); + await deleteFolderSoftRescue(portId, folder.id, TEST_USER_ID); + const survivor = await db.query.documentFolders.findFirst({ + where: eq(documentFolders.id, child.id), + }); + expect(survivor?.parentId).toBeNull(); + }); + + it('throws NotFound for a folder in another port', async () => { + const otherPort = await setupTestPort(); + const folder = await createFolder(otherPort, TEST_USER_ID, { name: 'X', parentId: null }); + await expect(deleteFolderSoftRescue(portId, folder.id, TEST_USER_ID)).rejects.toThrow( + /not found/i, + ); + }); +}); +``` + +- [ ] **Step 10: Run failing tests** + +Run: `pnpm exec vitest run tests/integration/document-folders-soft-delete.test.ts` +Expected: import error. + +- [ ] **Step 11: Implement `deleteFolderSoftRescue`** + +Append to `src/lib/services/document-folders.service.ts`: + +```typescript +import { documents } from '@/lib/db/schema/documents'; +import { createAuditLog } from '@/lib/audit'; + +/** + * Soft-rescue delete: re-parent every child folder + every linked + * document to the deleted folder's parent (or to root if the deleted + * folder is at root). Audit-logged. Wrapped in a transaction so + * partial failures don't leave dangling rows. + */ +export async function deleteFolderSoftRescue( + portId: string, + folderId: string, + userId: string, +): Promise { + await db.transaction(async (tx) => { + const folder = await tx.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), + }); + if (!folder) throw new NotFoundError('Folder'); + + const newParent = folder.parentId; // null = re-parent to root + + // Re-parent child folders. + await tx + .update(documentFolders) + .set({ parentId: newParent, updatedAt: new Date() }) + .where( + and( + eq(documentFolders.parentId, folderId), + eq(documentFolders.portId, portId), + ), + ); + + // Re-parent child documents. + await tx + .update(documents) + .set({ folderId: newParent, updatedAt: new Date() }) + .where(and(eq(documents.folderId, folderId), eq(documents.portId, portId))); + + // Now safe to delete — the FK constraints are clear. + await tx.delete(documentFolders).where(eq(documentFolders.id, folderId)); + + void createAuditLog({ + userId, + portId, + action: 'delete', + entityType: 'document_folder', + entityId: folderId, + oldValue: { name: folder.name, parentId: folder.parentId }, + metadata: { rescuedTo: newParent }, + }); + }); +} +``` + +If `isNull` is unused after this paste, the linter will flag it — remove it from the imports. + +- [ ] **Step 12: Run soft-delete tests — should be 4/4** + +Run: `pnpm exec vitest run tests/integration/document-folders-soft-delete.test.ts` +Expected: 4/4 pass. + +- [ ] **Step 13: Commit** + +```bash +git add src/lib/services/document-folders.service.ts \ + tests/integration/document-folders-crud.test.ts \ + tests/integration/document-folders-soft-delete.test.ts +git commit -m "$(cat <<'EOF' +feat(documents): folder service · rename + move + soft-rescue delete + +renameFolder + moveFolder enforce sibling-name uniqueness via the +shared DB index and reject cross-port leakage at the service +boundary. moveFolder walks the destination's ancestor chain to refuse +cycles before the write. + +deleteFolderSoftRescue re-parents every child folder and document up +to the deleted folder's parent (or to root) inside a transaction, then +drops the folder row. Children never disappear silently — a wrong +click moves work up the tree, never deletes it. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Folder validators + +**Files:** +- Create: `src/lib/validators/document-folders.ts` +- Create: `tests/unit/document-folders-validators.test.ts` + +- [ ] **Step 1: Write failing validator tests** + +Create `tests/unit/document-folders-validators.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { + createFolderSchema, + renameFolderSchema, + moveFolderSchema, +} from '@/lib/validators/document-folders'; + +describe('document-folder validators', () => { + it('accepts a valid create payload', () => { + expect(createFolderSchema.safeParse({ name: 'Deals', parentId: null }).success).toBe(true); + expect( + createFolderSchema.safeParse({ name: 'Q1', parentId: 'abc-123' }).success, + ).toBe(true); + }); + + it('rejects empty + over-long names', () => { + expect(createFolderSchema.safeParse({ name: '', parentId: null }).success).toBe(false); + expect( + createFolderSchema.safeParse({ name: 'x'.repeat(201), parentId: null }).success, + ).toBe(false); + }); + + it('rejects whitespace-only names', () => { + expect(createFolderSchema.safeParse({ name: ' ', parentId: null }).success).toBe(false); + }); + + it('rename schema requires only name', () => { + expect(renameFolderSchema.safeParse({ name: 'New' }).success).toBe(true); + expect(renameFolderSchema.safeParse({ name: '' }).success).toBe(false); + }); + + it('move schema accepts null parentId', () => { + expect(moveFolderSchema.safeParse({ parentId: null }).success).toBe(true); + expect(moveFolderSchema.safeParse({ parentId: 'abc' }).success).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: `pnpm exec vitest run tests/unit/document-folders-validators.test.ts` +Expected: import error. + +- [ ] **Step 3: Implement validators** + +Create `src/lib/validators/document-folders.ts`: + +```typescript +import { z } from 'zod'; + +const folderName = z + .string() + .min(1, 'Folder name is required') + .max(200, 'Folder name cannot exceed 200 characters') + .refine((s) => s.trim().length > 0, 'Folder name cannot be only whitespace'); + +export const createFolderSchema = z.object({ + name: folderName, + parentId: z.string().nullable(), +}); +export type CreateFolderInput = z.infer; + +export const renameFolderSchema = z.object({ + name: folderName, +}); +export type RenameFolderInput = z.infer; + +export const moveFolderSchema = z.object({ + parentId: z.string().nullable(), +}); +export type MoveFolderInput = z.infer; + +export const moveDocumentToFolderSchema = z.object({ + folderId: z.string().nullable(), +}); +export type MoveDocumentToFolderInput = z.infer; +``` + +- [ ] **Step 4: Run tests — should be 5/5** + +Run: `pnpm exec vitest run tests/unit/document-folders-validators.test.ts` +Expected: 5/5 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/validators/document-folders.ts tests/unit/document-folders-validators.test.ts +git commit -m "$(cat <<'EOF' +feat(documents): zod validators for folder CRUD + +createFolderSchema, renameFolderSchema, moveFolderSchema, +moveDocumentToFolderSchema. Names: 1–200 chars, non-whitespace. +parentId/folderId nullable to allow root. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Folder API routes + +**Files:** +- Create: `src/app/api/v1/document-folders/route.ts` +- Create: `src/app/api/v1/document-folders/[id]/route.ts` + +- [ ] **Step 1: Create the collection route (GET tree, POST create)** + +Create `src/app/api/v1/document-folders/route.ts`: + +```typescript +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createFolderSchema } from '@/lib/validators/document-folders'; +import { listTree, createFolder } from '@/lib/services/document-folders.service'; + +/** + * GET /api/v1/document-folders + * + * Returns the entire folder tree for the caller's port. Roots come + * back at the top level with `children` nested. Cached on the client + * for 30s via TanStack — folders change rarely; the manager mutations + * invalidate the query. + * + * Permission: documents.view (read-only; everyone in the port can + * browse the tree even if they can't manage it). + */ +export const GET = withAuth( + withPermission('documents', 'view', async (_req, ctx) => { + try { + const tree = await listTree(ctx.portId); + return NextResponse.json({ data: tree }); + } catch (error) { + return errorResponse(error); + } + }), +); + +/** + * POST /api/v1/document-folders + * Body: { name, parentId } + * + * Permission: documents.manage_folders. + */ +export const POST = withAuth( + withPermission('documents', 'manage_folders', async (req, ctx) => { + try { + const body = await parseBody(req, createFolderSchema); + const folder = await createFolder(ctx.portId, ctx.userId, body); + return NextResponse.json({ data: folder }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); +``` + +- [ ] **Step 2: Create the per-folder route (PATCH rename/move, DELETE soft-rescue)** + +Create `src/app/api/v1/document-folders/[id]/route.ts`: + +```typescript +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { + renameFolderSchema, + moveFolderSchema, +} from '@/lib/validators/document-folders'; +import { + renameFolder, + moveFolder, + deleteFolderSoftRescue, +} from '@/lib/services/document-folders.service'; + +// PATCH supports either { name } (rename) or { parentId } (move). +// Refuses both in the same body so the rep doesn't accidentally do +// two unrelated changes in one click. +const patchBodySchema = z.union([renameFolderSchema, moveFolderSchema]); + +export const PATCH = withAuth( + withPermission('documents', 'manage_folders', async (req, ctx, params) => { + try { + const folderId = params.id; + if (!folderId) throw new NotFoundError('Folder'); + const body = await parseBody(req, patchBodySchema); + let updated; + if ('name' in body) { + updated = await renameFolder(ctx.portId, folderId, body.name); + } else { + updated = await moveFolder(ctx.portId, folderId, body.parentId); + } + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('documents', 'manage_folders', async (_req, ctx, params) => { + try { + const folderId = params.id; + if (!folderId) throw new NotFoundError('Folder'); + await deleteFolderSoftRescue(ctx.portId, folderId, ctx.userId); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); +``` + +- [ ] **Step 3: Smoke-test the routes via curl (optional but reassuring)** + +Start `pnpm dev` if it isn't running. Log in as the seeded super-admin. Then: + +```bash +# Replace with the value from your browser's pn-crm.session_token. +curl -s 'http://localhost:3000/api/v1/document-folders' \ + -H "Cookie: pn-crm.session_token=" | jq '.data | length' +``` + +Expected: `0` (empty tree on a fresh port). + +- [ ] **Step 4: Run all integration tests to confirm no regression** + +Run: `pnpm exec vitest run tests/integration/` +Expected: all pass (the new tests + everything else). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/v1/document-folders/ +git commit -m "$(cat <<'EOF' +feat(documents): folder CRUD API routes + +GET /api/v1/document-folders → full tree (documents.view). +POST /api/v1/document-folders → create (documents.manage_folders). +PATCH /api/v1/document-folders/[id] → rename OR move (union schema — +refuses both in one body). +DELETE /api/v1/document-folders/[id] → soft-rescue delete; returns 204. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Move-document-to-folder API route + service plumbing + +**Files:** +- Modify: `src/lib/services/documents.service.ts` +- Modify: `src/lib/validators/documents.ts` +- Create: `src/app/api/v1/documents/[id]/folder/route.ts` +- Create: `tests/integration/documents-list-folder-filter.test.ts` + +- [ ] **Step 1: Extend `listDocuments` to accept `folderId` filter** + +In `src/lib/validators/documents.ts`, find `listDocumentsSchema` (search for it). Add: + +```typescript +folderId: z.string().nullable().optional(), +includeDescendants: z.coerce.boolean().optional().default(false), +``` + +(Add them inside the `.object({...})` block alongside the existing optional filters.) + +- [ ] **Step 2: Implement the filter inside `listDocuments`** + +In `src/lib/services/documents.service.ts`, inside the `listDocuments` function, after the existing `if (status)` filter line, add: + +```typescript +if (query.folderId !== undefined) { + if (query.folderId === null) { + filters.push(isNull(documents.folderId)); + } else if (query.includeDescendants) { + // Recursive descendants — small folder trees, fine to do in JS. + const tree = await listTree(portId); + const ids = collectDescendantIds(tree, query.folderId); + filters.push(inArray(documents.folderId, [query.folderId, ...ids])); + } else { + filters.push(eq(documents.folderId, query.folderId)); + } +} +``` + +You'll need to: +1. Add `isNull, inArray` to the existing drizzle imports if missing. +2. Import `listTree` from `'@/lib/services/document-folders.service'`. +3. Add a helper `collectDescendantIds` to that same service file: + +```typescript +// in document-folders.service.ts, exported +export function collectDescendantIds(tree: FolderNode[], rootId: string): string[] { + const out: string[] = []; + function visit(nodes: FolderNode[], inside: boolean) { + for (const n of nodes) { + if (inside || n.id === rootId) { + if (n.id !== rootId) out.push(n.id); + visit(n.children, true); + } else { + visit(n.children, false); + } + } + } + visit(tree, false); + return out; +} +``` + +- [ ] **Step 3: Add `folderId` to `createDocument`** + +In `src/lib/validators/documents.ts`, find `createDocumentSchema` and add `folderId: z.string().nullable().optional()` alongside the other polymorphic FKs. + +In `src/lib/services/documents.service.ts`, in the function that inserts a new document (search for `db.insert(documents)`), include `folderId: data.folderId ?? null` in the values block. + +- [ ] **Step 4: Write the failing list-by-folder integration test** + +Create `tests/integration/documents-list-folder-filter.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { db } from '@/lib/db'; +import { documents, documentFolders } from '@/lib/db/schema/documents'; +import { createFolder } from '@/lib/services/document-folders.service'; +import { listDocuments } from '@/lib/services/documents.service'; +import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; + +describe('documents.listDocuments folder filtering', () => { + let portId: string; + beforeEach(async () => { + portId = await setupTestPort(); + await db.delete(documents); + await db.delete(documentFolders); + }); + + it('filters by folderId (direct children only by default)', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); + const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id }); + await db.insert(documents).values([ + { portId, documentType: 'other', title: 'In Root', createdBy: TEST_USER_ID, folderId: root.id }, + { portId, documentType: 'other', title: 'In Sub', createdBy: TEST_USER_ID, folderId: sub.id }, + { portId, documentType: 'other', title: 'At Root (no folder)', createdBy: TEST_USER_ID, folderId: null }, + ]); + + const res = await listDocuments(portId, { page: 1, limit: 50, folderId: root.id }); + expect(res.data.map((d) => d.title)).toContain('In Root'); + expect(res.data.map((d) => d.title)).not.toContain('In Sub'); + }); + + it('includeDescendants=true pulls in children of children', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); + const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id }); + await db.insert(documents).values([ + { portId, documentType: 'other', title: 'In Root', createdBy: TEST_USER_ID, folderId: root.id }, + { portId, documentType: 'other', title: 'In Sub', createdBy: TEST_USER_ID, folderId: sub.id }, + ]); + + const res = await listDocuments(portId, { + page: 1, + limit: 50, + folderId: root.id, + includeDescendants: true, + }); + const titles = res.data.map((d) => d.title); + expect(titles).toContain('In Root'); + expect(titles).toContain('In Sub'); + }); + + it('folderId=null returns only docs at root', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); + await db.insert(documents).values([ + { portId, documentType: 'other', title: 'In Root', createdBy: TEST_USER_ID, folderId: root.id }, + { portId, documentType: 'other', title: 'At Root', createdBy: TEST_USER_ID, folderId: null }, + ]); + + const res = await listDocuments(portId, { page: 1, limit: 50, folderId: null }); + expect(res.data.map((d) => d.title)).toEqual(['At Root']); + }); +}); +``` + +- [ ] **Step 5: Run the test — should pass** + +Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts` +Expected: 3/3 pass. + +- [ ] **Step 6: Add the per-document move endpoint** + +Create `src/app/api/v1/documents/[id]/folder/route.ts`: + +```typescript +import { NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { documents, documentFolders } from '@/lib/db/schema/documents'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { moveDocumentToFolderSchema } from '@/lib/validators/document-folders'; +import { createAuditLog } from '@/lib/audit'; + +export const PATCH = withAuth( + withPermission('documents', 'manage_folders', async (req, ctx, params) => { + try { + const docId = params.id; + if (!docId) throw new NotFoundError('Document'); + const body = await parseBody(req, moveDocumentToFolderSchema); + + const existing = await db.query.documents.findFirst({ + where: and(eq(documents.id, docId), eq(documents.portId, ctx.portId)), + }); + if (!existing) throw new NotFoundError('Document'); + + if (body.folderId !== null) { + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.id, body.folderId), + eq(documentFolders.portId, ctx.portId), + ), + }); + if (!folder) throw new ValidationError('Folder not found in this port'); + } + + const [updated] = await db + .update(documents) + .set({ folderId: body.folderId, updatedAt: new Date() }) + .where(eq(documents.id, docId)) + .returning(); + + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'document', + entityId: docId, + oldValue: { folderId: existing.folderId }, + newValue: { folderId: body.folderId }, + metadata: { type: 'folder_move' }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } + }), +); +``` + +- [ ] **Step 7: Run all tests + tsc** + +Run: `pnpm exec tsc --noEmit && pnpm exec vitest run` +Expected: tsc clean; vitest all pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/services/documents.service.ts \ + src/lib/services/document-folders.service.ts \ + src/lib/validators/documents.ts \ + src/app/api/v1/documents/[id]/folder/route.ts \ + tests/integration/documents-list-folder-filter.test.ts +git commit -m "$(cat <<'EOF' +feat(documents): folder filter on list + per-doc move endpoint + +listDocuments accepts folderId (string|null|undefined) and +includeDescendants. folderId=null returns only docs at root; +includeDescendants=true expands the subtree via the +collectDescendantIds helper (in-memory walk over the cached tree). + +PATCH /api/v1/documents/[id]/folder moves a single document under +documents.manage_folders. Audit-logged with folder_move metadata. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: `useDocumentFolders` hook + +**Files:** +- Create: `src/hooks/use-document-folders.ts` + +- [ ] **Step 1: Implement the hook** + +Create `src/hooks/use-document-folders.ts`: + +```typescript +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiFetch } from '@/lib/api/client'; +import type { DocumentFolder } from '@/lib/db/schema/documents'; + +export interface FolderNode extends DocumentFolder { + children: FolderNode[]; +} + +const FOLDERS_KEY = ['document-folders'] as const; + +export function useDocumentFolders() { + return useQuery({ + queryKey: FOLDERS_KEY, + queryFn: () => + apiFetch<{ data: FolderNode[] }>('/api/v1/document-folders').then((r) => r.data), + staleTime: 30_000, + }); +} + +export function useCreateFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { name: string; parentId: string | null }) => + apiFetch('/api/v1/document-folders', { method: 'POST', body: input }), + onSuccess: () => qc.invalidateQueries({ queryKey: FOLDERS_KEY }), + }); +} + +export function useRenameFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + apiFetch(`/api/v1/document-folders/${id}`, { method: 'PATCH', body: { name } }), + onSuccess: () => qc.invalidateQueries({ queryKey: FOLDERS_KEY }), + }); +} + +export function useMoveFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, parentId }: { id: string; parentId: string | null }) => + apiFetch(`/api/v1/document-folders/${id}`, { method: 'PATCH', body: { parentId } }), + onSuccess: () => qc.invalidateQueries({ queryKey: FOLDERS_KEY }), + }); +} + +export function useDeleteFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/document-folders/${id}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: FOLDERS_KEY }); + qc.invalidateQueries({ queryKey: ['documents'] }); + }, + }); +} + +export function useMoveDocument() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ docId, folderId }: { docId: string; folderId: string | null }) => + apiFetch(`/api/v1/documents/${docId}/folder`, { + method: 'PATCH', + body: { folderId }, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['documents'] }), + }); +} + +/** Walk the tree → produce flat path strings like "Deals 2026 / Q1". */ +export function buildFolderPaths(tree: FolderNode[]): Array<{ id: string; path: string }> { + const out: Array<{ id: string; path: string }> = []; + function walk(nodes: FolderNode[], prefix: string) { + for (const n of nodes) { + const path = prefix ? `${prefix} / ${n.name}` : n.name; + out.push({ id: n.id, path }); + walk(n.children, path); + } + } + walk(tree, ''); + return out; +} +``` + +- [ ] **Step 2: Verify TS** + +Run: `pnpm exec tsc --noEmit` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/hooks/use-document-folders.ts +git commit -m "$(cat <<'EOF' +feat(documents): useDocumentFolders hook + mutations + +Wraps the folder tree fetch in TanStack with a 30s staleTime, and +provides create / rename / move / delete / move-document mutations +that invalidate the relevant query keys. buildFolderPaths flattens +the tree into ' / '-separated path strings for picker dropdowns. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: FolderTreeSidebar component + +**Files:** +- Create: `src/components/documents/folder-tree-sidebar.tsx` + +- [ ] **Step 1: Implement the sidebar tree** + +Create `src/components/documents/folder-tree-sidebar.tsx`: + +```typescript +'use client'; + +import { useState } from 'react'; +import { ChevronRight, Folder, FolderOpen, Inbox } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders'; + +interface FolderTreeSidebarProps { + /** Currently-selected folder id, or `null` for root, or `undefined` + * for "All documents" (no folder filter). */ + selectedFolderId: string | null | undefined; + onSelect: (folderId: string | null | undefined) => void; + /** Slot below the tree for a "New folder" affordance from the parent. */ + footer?: React.ReactNode; +} + +/** + * Collapsed-by-default tree. Each row shows a chevron that toggles its + * children; clicking the row label selects the folder. The "All + * documents" + "Root" pseudo-rows at the top let reps filter to the + * full set or to docs without a folder. + * + * Designed for unlimited depth — only the top level renders by default + * so deep trees don't blow out the page; reps drill in by expanding. + */ +export function FolderTreeSidebar({ + selectedFolderId, + onSelect, + footer, +}: FolderTreeSidebarProps) { + const { data: tree = [], isLoading } = useDocumentFolders(); + + return ( + + ); +} + +function PseudoRow({ + label, + icon: Icon, + active, + onClick, +}: { + label: string; + icon: typeof Inbox; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function FolderRow({ + node, + depth, + selectedFolderId, + onSelect, +}: { + node: FolderNode; + depth: number; + selectedFolderId: string | null | undefined; + onSelect: (folderId: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const hasChildren = node.children.length > 0; + const isActive = selectedFolderId === node.id; + + return ( + <> +
+ + +
+ {open + ? node.children.map((child) => ( + + )) + : null} + + ); +} +``` + +- [ ] **Step 2: Verify TS** + +Run: `pnpm exec tsc --noEmit` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/documents/folder-tree-sidebar.tsx +git commit -m "$(cat <<'EOF' +feat(documents): FolderTreeSidebar (collapsed-by-default tree) + +Persistent left rail with "All documents" + "Root" pseudo-rows above +the tree. Each tree row has a chevron toggle (expand/collapse) and a +clickable label (select). Renders unlimited depth without blowing out +the page — children only mount when their parent is expanded. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: FolderBreadcrumb component + +**Files:** +- Create: `src/components/documents/folder-breadcrumb.tsx` + +- [ ] **Step 1: Implement the breadcrumb** + +Create `src/components/documents/folder-breadcrumb.tsx`: + +```typescript +'use client'; + +import { ChevronRight, Home } from 'lucide-react'; + +import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders'; + +interface FolderBreadcrumbProps { + selectedFolderId: string | null | undefined; + onSelect: (folderId: string | null | undefined) => void; +} + +function findPath(tree: FolderNode[], id: string): FolderNode[] | null { + for (const node of tree) { + if (node.id === id) return [node]; + const inChild = findPath(node.children, id); + if (inChild) return [node, ...inChild]; + } + return null; +} + +export function FolderBreadcrumb({ selectedFolderId, onSelect }: FolderBreadcrumbProps) { + const { data: tree = [] } = useDocumentFolders(); + + let label: string; + let path: FolderNode[] = []; + if (selectedFolderId === undefined) { + label = 'All documents'; + } else if (selectedFolderId === null) { + label = 'Root'; + } else { + path = findPath(tree, selectedFolderId) ?? []; + label = path.at(-1)?.name ?? 'Folder'; + } + + return ( + + ); +} +``` + +- [ ] **Step 2: Verify TS** + +Run: `pnpm exec tsc --noEmit` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/documents/folder-breadcrumb.tsx +git commit -m "$(cat <<'EOF' +feat(documents): FolderBreadcrumb header crumb trail + +Renders the current folder's path as a clickable breadcrumb with a +Home affordance back to "All documents". Each ancestor is clickable +to navigate up; the last segment is the current folder (non-clickable, +foreground colour). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: FolderActionsMenu (create / rename / delete dialogs) + +**Files:** +- Create: `src/components/documents/folder-actions-menu.tsx` + +- [ ] **Step 1: Implement the actions menu** + +Create `src/components/documents/folder-actions-menu.tsx`: + +```typescript +'use client'; + +import { useState } from 'react'; +import { FolderPlus, Pencil, Trash2, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; +import { toastError } from '@/lib/api/toast-error'; +import { + useCreateFolder, + useDeleteFolder, + useRenameFolder, + useDocumentFolders, +} from '@/hooks/use-document-folders'; + +interface FolderActionsMenuProps { + /** The folder these actions apply to. `null` means root → only the + * Create-new-folder action is available. */ + selectedFolderId: string | null | undefined; + /** Callback after delete so parent can reset selection. */ + onAfterDelete?: () => void; +} + +export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderActionsMenuProps) { + const [createOpen, setCreateOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); + const [name, setName] = useState(''); + + const createMutation = useCreateFolder(); + const renameMutation = useRenameFolder(); + const deleteMutation = useDeleteFolder(); + const { data: tree = [] } = useDocumentFolders(); + + const isFolderSelected = typeof selectedFolderId === 'string'; + const currentName = (() => { + if (!isFolderSelected) return ''; + function find(nodes: typeof tree): string | null { + for (const n of nodes) { + if (n.id === selectedFolderId) return n.name; + const inChild = find(n.children); + if (inChild) return inChild; + } + return null; + } + return find(tree) ?? ''; + })(); + + return ( + <> + + + + + + { + setName(''); + setCreateOpen(true); + }} + > + + New folder {isFolderSelected ? 'inside this' : 'at root'} + + {isFolderSelected ? ( + <> + { + setName(currentName); + setRenameOpen(true); + }} + > + + Rename + + e.preventDefault()} + className="text-destructive" + > + + Delete + + } + title="Delete folder?" + description="Subfolders and documents inside will move up to the parent. The folder itself is removed." + confirmLabel="Delete folder" + onConfirm={async () => { + try { + await deleteMutation.mutateAsync(selectedFolderId as string); + toast.success('Folder deleted; contents moved to parent.'); + onAfterDelete?.(); + } catch (err) { + toastError(err); + } + }} + /> + + ) : null} + + + + + + + + New folder {isFolderSelected ? 'inside the current folder' : 'at root'} + + +
+ + setName(e.target.value)} + autoFocus + maxLength={200} + /> +
+ + + + +
+
+ + + + + Rename folder + +
+ + setName(e.target.value)} + autoFocus + maxLength={200} + /> +
+ + + + +
+
+ + ); +} +``` + +- [ ] **Step 2: Verify TS** + +Run: `pnpm exec tsc --noEmit` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/documents/folder-actions-menu.tsx +git commit -m "$(cat <<'EOF' +feat(documents): FolderActionsMenu (create / rename / delete dialogs) + +DropdownMenu trigger with three actions: New folder (works at root or +inside the selected folder), Rename, Delete (confirm-then-soft-rescue). +Delete copy explicitly tells reps the contents move to the parent so +nothing dies silently. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: MoveToFolderDialog (per-document picker) + +**Files:** +- Create: `src/components/documents/move-to-folder-dialog.tsx` + +- [ ] **Step 1: Implement the move dialog** + +Create `src/components/documents/move-to-folder-dialog.tsx`: + +```typescript +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, FolderInput } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { toastError } from '@/lib/api/toast-error'; +import { + buildFolderPaths, + useDocumentFolders, + useMoveDocument, +} from '@/hooks/use-document-folders'; + +interface MoveToFolderDialogProps { + documentId: string; + documentTitle: string; + currentFolderId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function MoveToFolderDialog({ + documentId, + documentTitle, + currentFolderId, + open, + onOpenChange, +}: MoveToFolderDialogProps) { + const { data: tree = [] } = useDocumentFolders(); + const move = useMoveDocument(); + const [pickedId, setPickedId] = useState(currentFolderId); + + const paths = useMemo(() => buildFolderPaths(tree), [tree]); + + return ( + + + + Move “{documentTitle}” + + + + + No folders match. + + setPickedId(null)} + className="flex items-center gap-2" + > + + Root (no folder) + + + {paths.length > 0 ? ( + + {paths.map((p) => ( + setPickedId(p.id)} + className="flex items-center gap-2" + > + + {p.path} + + ))} + + ) : null} + + + + + + + + + ); +} +``` + +- [ ] **Step 2: Verify TS** + +Run: `pnpm exec tsc --noEmit` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/documents/move-to-folder-dialog.tsx +git commit -m "$(cat <<'EOF' +feat(documents): MoveToFolderDialog single-doc move picker + +cmdk Combobox dialog showing all folder paths flat (' / '-separated), +plus a "Root (no folder)" pseudo-option. Move button disabled when the +picked folder matches the document's current folder. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: Wire DocumentsHub — sidebar + breadcrumb + drop signature pill + In-progress tab + +**Files:** +- Modify: `src/components/documents/documents-hub.tsx` + +This task is bigger than the others. Read the current file first, then make targeted edits. + +- [ ] **Step 1: Read the existing hub** + +Run: `wc -l src/components/documents/documents-hub.tsx` and open the file in your editor. Note: where state lives, where the type-filter dropdown renders, where `signatureOnly` is wired, where the tab list is defined. + +- [ ] **Step 2: Add folder state + sidebar layout** + +At the top of the component (with the other `useState` hooks), add: + +```typescript +// undefined = "All documents" (no folder filter), null = root only, +// string = a specific folder id. +const [selectedFolderId, setSelectedFolderId] = useState(undefined); +``` + +Wrap the existing return content in a flex layout that puts the sidebar to the left: + +```tsx +return ( +
+ + setSelectedFolderId(undefined)} + /> + + } + /> +
+ + {/* …existing content (tabs, filters, table, etc.) goes here… */} +
+
+); +``` + +Add the imports at the top: + +```typescript +import { FolderTreeSidebar } from './folder-tree-sidebar'; +import { FolderBreadcrumb } from './folder-breadcrumb'; +import { FolderActionsMenu } from './folder-actions-menu'; +import { PermissionGate } from '@/components/shared/permission-gate'; +``` + +- [ ] **Step 3: Push the folder filter into the documents query** + +Find the existing `useQuery` for documents (search for `'documents'` in the queryKey or the `apiFetch('/api/v1/documents'…)` call). Add `selectedFolderId` to the queryKey and pass it as a query-string param: + +```typescript +const docsQuery = useQuery({ + queryKey: ['documents', /* existing keys */, selectedFolderId], + queryFn: () => { + const params = new URLSearchParams(); + // …existing params… + if (selectedFolderId !== undefined) { + // null → folderId=null; string → folderId= + params.set('folderId', selectedFolderId ?? ''); + } + return apiFetch(`/api/v1/documents?${params.toString()}`); + }, +}); +``` + +(Adjust to match the existing query-building pattern in the file.) + +- [ ] **Step 4: Drop the `signatureOnly` toggle** + +Search for `signatureOnly` and remove: +- The state (`useState`). +- The toggle UI (likely a Switch or Pill). +- The query parameter wiring. + +Leave any default behaviour as "show all". Keep `NON_SIGNATURE_TYPES` in the service — that's used for the EOI Queue filter, which is a different concern. + +- [ ] **Step 5: Add the "In progress" tab** + +Find the tab definition (likely an array like `['all', 'eoi_queue', ...]` or `…`). Insert a new tab `in_progress` between `all` and `eoi_queue`: + +```typescript +{ value: 'in_progress', label: 'In progress' }, +``` + +Then in the service-side `buildHubTabFilters` (`src/lib/services/documents.service.ts`), add a case for it (next to the existing tab cases): + +```typescript +case 'in_progress': + return [ + sql`${documents.status} IN ('draft', 'sent', 'partially_signed') AND ${documents.status} != 'expired'`, + ]; +``` + +Also extend the `tab` enum in `listDocumentsSchema` (validators) to include `'in_progress'`. + +- [ ] **Step 6: Run tsc + relevant tests** + +```bash +pnpm exec tsc --noEmit +pnpm exec vitest run tests/integration/documents- +``` + +Expected: tsc clean; existing documents tests still pass (the tab additions are backward-compatible). + +- [ ] **Step 7: Smoke check via the browser** + +Run `pnpm dev` (restart if it was running before the schema migration). Navigate to `/{portSlug}/documents`. Verify: +- Sidebar renders with "All documents" + "Root" + (empty tree initially). +- Breadcrumb shows "All". +- Tabs show: All / In progress / EOI queue / Awaiting them / Awaiting me / Completed / Expired. +- No "Signature-based only" toggle visible. + +If the sidebar overflows mobile, the `flex-col sm:flex-row` on the outer div handles the stack. Verify on a narrow viewport. + +- [ ] **Step 8: Commit** + +```bash +git add src/components/documents/documents-hub.tsx \ + src/lib/services/documents.service.ts \ + src/lib/validators/documents.ts +git commit -m "$(cat <<'EOF' +feat(documents): wire folder sidebar + breadcrumb + In-progress tab + +Documents hub now opens with the folder tree on the left and a +breadcrumb on top. Folder selection is its own state — undefined = +"All", null = "Root only", string = specific folder. Filter pushes +through to /api/v1/documents via folderId query param. + +Drops the "Signature-based only" pill — it defaulted to true and +silently hid informational documents, which confused new reps. With +folders the rep organises by location, not by signature-vs-not. + +Adds an "In progress" hub tab covering status IN (draft, sent, +partially_signed) for the everyday "what's in flight" view. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 14: Dynamic type-filter chips + "Move to folder" row action + +**Files:** +- Modify: `src/components/documents/documents-hub.tsx` +- Modify: `src/components/documents/document-list.tsx` (or wherever the per-row action menu lives) + +- [ ] **Step 1: Replace the static type filter dropdown with chips over actual types in use** + +In the hub, replace the existing type Select dropdown with a chip cloud sourced from the documents query response. The simplest path: derive the set of distinct `documentType` values from the current page of results, then render chips: + +```tsx +{(() => { + const seenTypes = Array.from( + new Set((docsQuery.data?.data ?? []).map((d) => d.documentType)), + ).sort(); + if (seenTypes.length === 0) return null; + return ( +
+ + {seenTypes.map((t) => ( + + ))} +
+ ); +})()} +``` + +Replace the existing `typeFilter` state's type from a constrained enum to `string | undefined` so any documentType seen in the response is acceptable. + +- [ ] **Step 2: Add a "Move to folder" item to the per-row action menu** + +In `src/components/documents/document-list.tsx` (or whichever file renders the per-doc dropdown), add the import: + +```typescript +import { MoveToFolderDialog } from './move-to-folder-dialog'; +``` + +Add a state in the row: + +```typescript +const [moveOpen, setMoveOpen] = useState(false); +``` + +Add a menu item alongside the existing Send / Delete entries: + +```tsx + + setMoveOpen(true)}> + + Move to folder… + + +``` + +And render the dialog: + +```tsx + +``` + +Make sure the document row data includes `folderId` (extend the local interface if needed). + +- [ ] **Step 3: Run tsc + smoke** + +```bash +pnpm exec tsc --noEmit +``` + +Manually click "Move to folder…" on a document and confirm the dialog appears with the folder list. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/documents/documents-hub.tsx src/components/documents/document-list.tsx +git commit -m "$(cat <<'EOF' +feat(documents): dynamic type-filter chips + move-to-folder row action + +Type-filter chip cloud sourced from the documentTypes seen in the +current result set, replacing the static dropdown over the whole +DOCUMENT_TYPES enum. New "Move to folder…" entry on the per-row +action menu (gated on documents.manage_folders) opens the +MoveToFolderDialog Combobox. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 15: Admin-configurable Expired tab + +**Files:** +- Modify: `src/components/admin/settings/settings-manager.tsx` +- Modify: `src/components/documents/documents-hub.tsx` +- Modify: `src/lib/services/settings.service.ts` (if a typed reader doesn't already exist) + +- [ ] **Step 1: Register the new flag in the settings catalog** + +In `src/components/admin/settings/settings-manager.tsx`, find the `KNOWN_SETTINGS` array and add: + +```typescript +{ + key: 'documents_show_expired_tab', + label: 'Documents — show Expired tab', + description: + 'When off, the Expired tab on the documents hub is hidden. Use this when expired EOIs are noise that distracts reps from active deals.', + type: 'boolean', + defaultValue: true, +}, +``` + +- [ ] **Step 2: Read the setting in the hub** + +In `src/components/documents/documents-hub.tsx`, fetch it via the existing settings hook (likely there's a `useSystemSetting` or you can hit `/api/v1/admin/settings` directly — but that's gated on manage_settings, which reps don't have). + +The simpler path: use the `useVocabulary`-style pattern but for booleans. Add a new public-read endpoint OR use a lightweight `/api/v1/system-settings/public` that exposes a curated allow-list including `documents_show_expired_tab`. For this task, do the minimum: add the key to the existing public read endpoint surface. + +If no public reader exists yet: + +Create `src/app/api/v1/documents/feature-flags/route.ts`: + +```typescript +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { getSetting } from '@/lib/services/settings.service'; + +export const GET = withAuth( + withPermission('documents', 'view', async (_req, ctx) => { + try { + const showExpired = await getSetting('documents_show_expired_tab', ctx.portId); + return NextResponse.json({ + data: { + showExpiredTab: showExpired?.value !== false, // default true + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); +``` + +In the hub, fetch this: + +```typescript +const flags = useQuery<{ data: { showExpiredTab: boolean } }>({ + queryKey: ['documents', 'feature-flags'], + queryFn: () => apiFetch('/api/v1/documents/feature-flags'), + staleTime: 5 * 60 * 1000, +}); +const showExpiredTab = flags.data?.data.showExpiredTab ?? true; +``` + +Then conditionally render the Expired tab: + +```typescript +const tabs = [ + { value: 'all', label: 'All' }, + { value: 'in_progress', label: 'In progress' }, + { value: 'eoi_queue', label: 'EOI queue' }, + { value: 'awaiting_them', label: 'Awaiting them' }, + { value: 'awaiting_me', label: 'Awaiting me' }, + { value: 'completed', label: 'Completed' }, + ...(showExpiredTab ? [{ value: 'expired', label: 'Expired' }] : []), +]; +``` + +- [ ] **Step 3: Run tsc + smoke** + +```bash +pnpm exec tsc --noEmit +``` + +In a browser, toggle `documents_show_expired_tab` off in admin settings, hard-refresh the documents page, confirm the Expired tab disappears. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/admin/settings/settings-manager.tsx \ + src/components/documents/documents-hub.tsx \ + src/app/api/v1/documents/feature-flags/route.ts +git commit -m "$(cat <<'EOF' +feat(documents): admin-configurable Expired tab visibility + +New documents_show_expired_tab system setting (default true). Public +read via GET /api/v1/documents/feature-flags (gated on documents.view +so reps can read it without holding manage_settings). When off, the +Expired tab is hidden from the documents hub — useful when expired +EOIs are noise that distracts reps from active deals. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 16: Playwright smoke test + +**Files:** +- Modify: `tests/e2e/smoke/04-documents.spec.ts` + +- [ ] **Step 1: Add a folder smoke flow** + +Append to `tests/e2e/smoke/04-documents.spec.ts`: + +```typescript +test('admin can create a folder, move a document, and the breadcrumb updates', async ({ + page, + loggedInAdmin: _, // adapt to whatever fixture name your suite uses +}) => { + await page.goto('/port-nimara/documents'); + + // Create a folder via the actions menu. + await page.getByRole('button', { name: /folder actions/i }).click(); + await page.getByRole('menuitem', { name: /new folder at root/i }).click(); + const folderName = `Smoke ${Date.now()}`; + await page.getByLabel('Name').fill(folderName); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByRole('button', { name: folderName })).toBeVisible(); + + // Click into the folder; breadcrumb updates. + await page.getByRole('button', { name: folderName }).click(); + await expect(page.getByRole('navigation', { name: /breadcrumb/i })).toContainText(folderName); +}); +``` + +(Adjust the selectors and fixture names to match your suite's conventions — open another file under `tests/e2e/smoke/` for reference.) + +- [ ] **Step 2: Run the smoke test** + +```bash +pnpm exec playwright test --project=smoke --grep "create a folder" +``` + +Expected: pass (~30s including auth). + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/smoke/04-documents.spec.ts +git commit -m "$(cat <<'EOF' +test(e2e): smoke — create folder + breadcrumb update on documents hub + +Covers the happy-path admin flow: open hub, open Folder Actions menu, +create a root folder, click into it, breadcrumb updates. Doesn't yet +cover delete (soft-rescue) or move-to-folder — separate spec when +needed. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 17: CLAUDE.md update + final verification + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add a Documents folders subsection to CLAUDE.md** + +Find the Conventions block (search for "**Inline editing pattern:**" — folders sit nearby in similar shape). Add: + +```markdown +- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents carry a nullable `folder_id` (null = root). Sibling-name uniqueness enforced 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 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. All folder ops gated on `documents.manage_folders` (read access piggybacks on `documents.view`). +``` + +- [ ] **Step 2: Final test sweep** + +```bash +pnpm exec tsc --noEmit +pnpm exec vitest run +pnpm exec playwright test --project=smoke +``` + +Expected: tsc clean; vitest all pass; smoke passes. + +- [ ] **Step 3: Manual UAT checklist** + +Walk through each in a browser at `/port-nimara/documents`: + +- [ ] Sidebar renders with "All documents" + "Root" + tree. +- [ ] Create a folder at root via Folder Actions menu. +- [ ] Create a subfolder via the same menu (after selecting the parent). +- [ ] Rename a folder; refresh — name persists. +- [ ] Click "Move to folder…" on a document; pick a folder; verify it disappears from the previous location and shows up in the new folder. +- [ ] Delete a folder that has children; verify children + documents bubble up to the parent. +- [ ] Toggle off the Expired tab in admin settings; refresh; tab disappears. +- [ ] Switch through tabs (All / In progress / EOI queue / etc.) while a folder is selected; folder filter persists across tabs. +- [ ] Click "All documents" → folder filter clears, all docs visible. +- [ ] On a phone-width viewport, sidebar stacks above the doc list; navigation still works. + +- [ ] **Step 4: Commit + push** + +```bash +git add CLAUDE.md +git commit -m "$(cat <<'EOF' +docs(claude-md): document folders model + soft-rescue delete semantics + +Documents the new document_folders self-FK tree, the sibling-name +uniqueness invariant, and the soft-rescue delete behaviour so future +sessions don't try to wire CASCADE. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +If you want to push the branch, do so (the user will say if not). + +--- + +## Self-review + +**Spec coverage:** +- Folders (create / delete / nested) — ✅ Tasks 1, 3, 4, 11. +- Sort + filter (date, type, owner) — partial: type-filter chips done in Task 14; date sort already exists in `listDocuments`; owner sort isn't explicitly added — flag as a follow-up if reps want it. +- Wider file-type allowlist — n/a (no enforced allowlist exists today; out of scope per plan header). +- "Documents in progress" filter — ✅ Task 13. +- Drop the "Signature-based only" pill — ✅ Task 13. +- "Expired" tab admin-configurable — ✅ Task 15. +- Type-filter dropdown reflects actual types in use — ✅ Task 14. +- Unlimited nesting + careful UI — ✅ Tasks 1, 9 (collapsed-by-default tree). + +**Placeholder scan:** none — every step has concrete code or a precise instruction with the exact file path and line context. + +**Type consistency:** +- `FolderNode` defined identically in service (Task 3) and hook (Task 8). +- `selectedFolderId: string | null | undefined` consistent across sidebar, breadcrumb, hub, actions menu. +- `folderId: string | null` consistent across validators, service, document moves. +- `moveDocumentToFolderSchema` defined in Task 5, used in Task 7. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-09-documents-folders.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration. Best when you want to keep moving. +2. **Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach?