# 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. --- ## Progress snapshot — 2026-05-09 (mid-execution pause) Working on branch `feat/documents-folders` (off `main`). Subagent-driven execution: every task gets implementer → spec reviewer → code-quality reviewer → fix loop if needed. | Task | Topic | Status | Commit(s) | | ---- | ------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | 1 | Schema + migration (`document_folders` + `folder_id` on documents) | ✅ Done | `5bed62d` + `4a50bab` (fix: Drizzle `.references()` + relations) | | 2 | `documents.manage_folders` permission | ✅ Done | `e6cf50f` | | 3 | Service: `listTree` + `createFolder` (TDD) | ✅ Done | `4b31f01` + `5c5ab49` (fix: port-scope test cleanup + tighten message) | | 4 | Service: rename + move (cycle prevention) + soft-rescue delete | ✅ Done | `e9251a3` + `4ec0004` (fix: audit-log out of tx + portId on ancestor walk + drop misleading updatedAt + userId for rename/move audit) | | 5 | Zod validators | ✅ Done | `830ac39` | | 6 | Folder API routes (GET tree / POST / PATCH rename-or-move / DELETE) | ✅ Done | `1082b80` + `e9d5df6` (fix: `.strict()` on union members so `{name, parentId}` together is a 400 not silent drop) | | 7 | listDocuments folder filter + per-doc move route | ✅ Done | `a0ffa1b` | | 8 | `useDocumentFolders` hook | 🔴 Not started | — | | 9 | `FolderTreeSidebar` component | 🔴 Not started | — | | 10 | `FolderBreadcrumb` component | 🔴 Not started | — | | 11 | `FolderActionsMenu` (create/rename/delete dialogs) | 🔴 Not started | — | | 12 | `MoveToFolderDialog` (per-doc picker) | 🔴 Not started | — | | 13 | Wire `DocumentsHub`: sidebar + breadcrumb, drop signature pill, In-progress tab | 🔴 Not started | — | | 14 | Dynamic type-filter chips + per-row Move action | 🔴 Not started | — | | 15 | Admin-configurable Expired tab | 🔴 Not started | — | | 16 | Playwright smoke test | 🔴 Not started | — | | 17 | CLAUDE.md update + final verification | 🔴 Not started | — | | 18 | **NEW** — path-style download URLs (hybrid storage decision) | 🔴 Not started | — | | 19 | **NEW** — importer from organized S3/filesystem bucket | 🔴 Not started | — | **Test posture at pause:** `pnpm exec tsc --noEmit` clean; full vitest suite **1213/1213 passing** (108 test files). 11 commits on the branch ahead of `main`. **Backend complete; UI + storage-strategy work remains.** Tasks 1–7 ship the entire DB + service + API layer for folders. Reps can already create / rename / move / delete folders and move documents between them via direct API calls — only the UI and the path-style URL polish are missing. ### Decision log so far (recorded mid-execution, locking the design) - **Storage strategy:** Hybrid — UUID-flat storage paths preserved for parity with the established pattern across brochures, berth PDFs, invoices, reports, templates, expense receipts; the `migrate-storage` byte-verbatim copy keeps working. Documents will gain a `downloadUrl` field whose URL embeds the folder path + filename for browser-tab / shared-link readability, validated for truth on the server (Task 18). The legacy-bucket importer (Task 19) is the migration tool for any organised MinIO tree the team brings over. - **Permission split:** `documents.manage_folders` is the new perm; `documents.edit` no longer covers folder reorganisation. Admin + sales_manager + director get the new perm by default; sales_agent / viewer / residential_partner do not. - **Soft-rescue delete:** `deleteFolderSoftRescue` re-parents subfolders + documents to the deleted folder's parent (or root) inside a transaction; never CASCADE. Audit-logged with `metadata.rescuedTo`. - **Cycle prevention:** `moveFolder` walks the destination's ancestor chain in JS before writing, with both `seen`-set defense and a `portId` filter on the walk so a corrupted parentId pointing at another port can't be silently traversed. - **PATCH body exclusivity:** Folder PATCH refuses bodies that carry both `name` and `parentId` via `.strict()` on each union member, so a rename request can't silently swallow a move attempt. - **`updatedAt` semantics on bulk vs per-doc moves:** Bulk soft-rescue does NOT bump per-document `updatedAt` (admin storage op shouldn't surface every doc as "recently modified"). Per-doc move via the `[id]/folder` PATCH DOES bump `updatedAt` (deliberate user action on that doc). ### What's next when execution resumes 1. **Task 8** (useDocumentFolders hook) — small TanStack wrapper. ~30 min. 2. **Tasks 9–12** (4 UI components: sidebar tree, breadcrumb, actions menu, move dialog) — each ~30–60 min. Independent of each other. 3. **Task 13** (DocumentsHub wiring) — the integration point. Drops `signatureOnly` pill, adds In-progress tab, threads `folderId` through queries. ~60 min. 4. **Task 14** (dynamic type chips + per-row Move) — ~45 min. 5. **Task 15** (admin-configurable Expired tab) — ~30 min. 6. **Task 16** (Playwright smoke) — ~30 min. 7. **Task 18** (path-style download URLs) — ~60 min, can land independently of UI tasks. 8. **Task 19** (organized-bucket importer) — script-only, ~60–90 min, deferrable. 9. **Task 17** (CLAUDE.md + final verification) — last. To resume from a fresh session, paste: ``` I'm resuming the documents-folders plan execution. We're on branch feat/documents-folders. Tasks 1-7 are complete (commits 5bed62d → a0ffa1b). Use the superpowers:subagent-driven-development skill to continue with Task 8 (useDocumentFolders hook). Plan: docs/superpowers/plans/2026-05-09-documents-folders.md. Tests at last checkpoint: 1213/1213. Branch off main. ``` --- **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). --- ## Task 18: Path-style download URLs (storage strategy decision) **Context:** Storage paths stay UUID-flat (`{portSlug}/{entity}/{entityId}/{fileUuid}.{ext}`) per the established pattern across all six other content types (brochures, berth PDFs, invoices, reports, templates, expense receipts). The `migrate-storage` script preserves bytes verbatim — a switchover between filesystem and S3/MinIO never rewrites paths. The trade-off — that an admin browsing MinIO directly sees UUID gibberish instead of meaningful folder names — is mitigated for the rep-facing UX by serving documents via a **path-style** URL whose user-visible path mirrors the folder tree. The actual file lookup is keyed on the document's `id`; the path segments are decorative + validated for truth. **Files:** - Create: `src/app/api/v1/documents/[id]/download/[...slug]/route.ts` - Modify: `src/lib/services/documents.service.ts` — add `buildDocumentDownloadUrl(doc, folderTree)` helper that resolves the doc's folder path + filename and emits the URL string. - Modify: `src/lib/services/documents.service.ts` — `listDocuments` and the single-doc detail returns now include a `downloadUrl` field. - [ ] **Step 1: Add the URL builder helper** In `src/lib/services/documents.service.ts`, near the existing helpers, add: ```typescript import type { FolderNode } from '@/lib/services/document-folders.service'; /** * Resolve the rep-facing download URL for a document. The URL embeds * the folder path + filename for browser-tab / shared-link readability, * but the route handler keys lookup off the doc id and validates the * slug for truth — so a hand-edited URL with a wrong path still 404s * instead of silently serving the wrong file. * * Usage: pass the resolved folder tree once per request and call this * for each doc in the result set so we don't refetch the tree per row. */ export function buildDocumentDownloadUrl( doc: { id: string; folderId: string | null; filename: string | null }, folderTree: readonly FolderNode[], ): string { const segments: string[] = []; if (doc.folderId) { const path = findFolderPath(folderTree, doc.folderId); for (const node of path) segments.push(encodeURIComponent(node.name)); } segments.push(encodeURIComponent(doc.filename ?? doc.id)); return `/api/v1/documents/${doc.id}/download/${segments.join('/')}`; } function findFolderPath(tree: readonly FolderNode[], id: string): FolderNode[] { for (const node of tree) { if (node.id === id) return [node]; const inChild = findFolderPath(node.children, id); if (inChild.length > 0) return [node, ...inChild]; } return []; } ``` The doc passed in needs a `filename` field; it lives on the linked `files` row, so the existing list query needs to join (or the service helper that hydrates documents already does — check). - [ ] **Step 2: Inject downloadUrl into the listDocuments response** In `listDocuments`, after the rows are fetched, fetch the folder tree once via the existing `listTree` import and map each row to include `downloadUrl: buildDocumentDownloadUrl(row, tree)`. - [ ] **Step 3: Create the catch-all download route** Create `src/app/api/v1/documents/[id]/download/[...slug]/route.ts`: ```typescript import { NextResponse } from 'next/server'; import { and, eq } from 'drizzle-orm'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { documents, files } from '@/lib/db/schema/documents'; import { errorResponse, NotFoundError } from '@/lib/errors'; import { getStorageBackend } from '@/lib/storage'; import { listTree } from '@/lib/services/document-folders.service'; interface RouteParams { id: string; slug?: string[]; } export const GET = withAuth( withPermission('documents', 'view', async (_req, ctx, params: RouteParams) => { try { const docId = params.id; if (!docId) throw new NotFoundError('Document'); const doc = await db .select({ id: documents.id, folderId: documents.folderId, fileId: documents.fileId, fileStoragePath: files.storagePath, fileMimeType: files.mimeType, fileFilename: files.filename, }) .from(documents) .leftJoin(files, eq(files.id, documents.fileId)) .where(and(eq(documents.id, docId), eq(documents.portId, ctx.portId))) .limit(1) .then((r) => r[0]); if (!doc?.fileStoragePath) throw new NotFoundError('Document file'); // Slug truth-check: rebuild what the URL SHOULD be from current // state and 404 if the supplied slug doesn't match. Stops a // hand-edited URL from rendering a stale or wrong filename in a // forwarded link. const tree = await listTree(ctx.portId); const expected = buildExpectedSlug(doc.folderId, doc.fileFilename ?? doc.id, tree); const supplied = (params.slug ?? []).map(decodeURIComponent).join('/'); if (supplied !== expected) throw new NotFoundError('Document at this path'); const backend = await getStorageBackend(); const stream = await backend.get(doc.fileStoragePath); return new NextResponse(stream as unknown as ReadableStream, { status: 200, headers: { 'content-type': doc.fileMimeType ?? 'application/octet-stream', 'content-disposition': `inline; filename="${doc.fileFilename ?? doc.id}"`, }, }); } catch (error) { return errorResponse(error); } }), ); function buildExpectedSlug( folderId: string | null, filename: string, tree: Awaited>, ): string { const segments: string[] = []; if (folderId) { function walk(nodes: typeof tree): boolean { for (const n of nodes) { if (n.id === folderId) { segments.push(n.name); return true; } if (walk(n.children)) { segments.unshift(n.name); return true; } } return false; } walk(tree); } segments.push(filename); return segments.join('/'); } ``` - [ ] **Step 4: Test the truth-check + happy path** Create `tests/integration/document-path-style-download.test.ts` with cases: - Happy path: download a document via correct URL works (returns the file body). - Wrong-folder-path slug: returns 404. - Wrong-filename slug: returns 404. - Missing file (orphaned doc with null fileId): returns 404. - Cross-port doc (correct slug but different port): returns 404. - [ ] **Step 5: Update document-list responses to surface downloadUrl** The list endpoint shape gains a `downloadUrl` field per row; the detail endpoint adds the same. UI consumers pick this up automatically when they request /api/v1/documents/... - [ ] **Step 6: tsc + vitest** ```bash pnpm exec tsc --noEmit pnpm exec vitest run ``` - [ ] **Step 7: Commit** ``` feat(documents): path-style download URLs for rep-facing readability Storage stays UUID-flat per the established pattern; the new catch-all /api/v1/documents/[id]/download/[...slug] route serves files keyed on doc id but validates that the slug matches the current folder path + filename. URLs in shared links / browser tabs read like 'Deals 2026/Q1/contract.pdf' even though storage keys remain UUIDs. listDocuments + detail responses now include a downloadUrl field so UI consumers don't reconstruct paths. ``` --- ## Task 19: Importer from organized S3/filesystem bucket **Context:** When the team migrates from a legacy MinIO bucket whose folder structure represents real organisation (`s3://old-data/Deals 2026/Q1/contract.pdf`), the importer walks that tree, builds matching `document_folders` rows in the CRM, and inserts `documents` rows pointing at the existing storage keys without rewriting them. One-shot script, idempotent. **Files:** - Create: `scripts/import-organized-documents.ts` - Optional: `tests/unit/import-organized-documents.test.ts` (testing the path-parser pure function). - [ ] **Step 1: CLI surface + dry-run output** ```bash pnpm tsx scripts/import-organized-documents.ts \ --port-slug port-nimara \ --bucket-prefix "legacy-imports/" \ --dry-run ``` Output: a tree of "would create" rows. - [ ] **Step 2: Apply mode** ```bash pnpm tsx scripts/import-organized-documents.ts \ --port-slug port-nimara \ --bucket-prefix "legacy-imports/" \ --apply ``` Idempotent: re-runs are no-ops via: - `document_folders` sibling-uniqueness index (re-create attempts hit ConflictError → caught + skipped). - `documents` rows checked by `(portId, fileStoragePath)` before insert. - [ ] **Step 3: Pure path-parser** Extract a pure function `parseImportPath(prefix, key)` → `{ folderSegments: string[], filename: string }`. Unit-test it with edge cases (trailing slashes, special chars in folder names, empty intermediate segments). - [ ] **Step 4: Walker + DB writer** Use `getStorageBackend().listByPrefix(...)` if it exists; if not, augment the storage backend interface with a list method (S3: `listObjectsV2`; filesystem: recursive readdir). Walk in alphabetical order so `documents` rows in the import log appear deterministically. - [ ] **Step 5: Audit log per imported doc** Each created `documents` row gets a `createAuditLog({ action: 'create', metadata: { source: 'organized-bucket-importer' } })`. - [ ] **Step 6: Commit** ``` feat(documents): importer for organized S3/filesystem buckets One-shot script that walks an existing organized bucket tree, creates matching document_folders rows + documents rows pointing at the storage keys verbatim (no path rewrite). Idempotent via the sibling-uniqueness index + (portId, fileStoragePath) check. Use when migrating from a legacy MinIO bucket whose folder structure already represents real organisation. ``` --- ## 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?