Files
pn-new-crm/docs/superpowers/plans/2026-05-09-documents-folders.md
Matt 5422f11747 chore: prettier formatter drift across recent commits
Prettier reformatting on files touched in the wave 11.B sequence —
markdown italics _underscore-style_, single-line conditionals, minor
whitespace fixes. No semantic changes. .env.example reformatting left
unstaged (blocked by pre-commit hook).

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

112 KiB
Raw Blame History

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 17 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 912 (4 UI components: sidebar tree, breadcrumb, actions menu, move dialog) — each ~3060 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, ~6090 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: <T> } 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.tslistTree, 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.tsGET (whole tree), POST (create).
  • Create: src/app/api/v1/document-folders/[id]/route.tsPATCH (rename + move), DELETE (soft-rescue).
  • Create: src/app/api/v1/documents/[id]/folder/route.tsPATCH (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.tslistDocuments 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):

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

folderId: text('folder_id'),

And add an index for it inside the same table's (table) => [...] list:

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:

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

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:

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

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:

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

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:

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<FolderNode[]> {
  const rows = await db
    .select()
    .from(documentFolders)
    .where(eq(documentFolders.portId, portId))
    .orderBy(asc(documentFolders.name));

  const byId = new Map<string, FolderNode>();
  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<DocumentFolder> {
  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:

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

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:

export async function renameFolder(
  portId: string,
  folderId: string,
  newName: string,
): Promise<DocumentFolder> {
  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:

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:

export async function moveFolder(
  portId: string,
  folderId: string,
  newParentId: string | null,
): Promise<DocumentFolder> {
  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<string>([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:

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

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

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:

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<typeof createFolderSchema>;

export const renameFolderSchema = z.object({
  name: folderName,
});
export type RenameFolderInput = z.infer<typeof renameFolderSchema>;

export const moveFolderSchema = z.object({
  parentId: z.string().nullable(),
});
export type MoveFolderInput = z.infer<typeof moveFolderSchema>;

export const moveDocumentToFolderSchema = z.object({
  folderId: z.string().nullable(),
});
export type MoveDocumentToFolderInput = z.infer<typeof moveDocumentToFolderSchema>;
  • 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
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: 1200 chars, non-whitespace.
parentId/folderId nullable to allow root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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:

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:

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:

# Replace <SESSION_COOKIE> 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=<SESSION_COOKIE>" | 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
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) <noreply@anthropic.com>
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:

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:

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

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:

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

'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<FolderNode[]>({
    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
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) <noreply@anthropic.com>
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:

'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 (
    <aside className="w-full sm:w-60 shrink-0 border-r bg-muted/40 p-2">
      <div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
        Folders
      </div>
      <div className="space-y-0.5">
        <PseudoRow
          label="All documents"
          icon={Inbox}
          active={selectedFolderId === undefined}
          onClick={() => onSelect(undefined)}
        />
        <PseudoRow
          label="Root (no folder)"
          icon={Folder}
          active={selectedFolderId === null}
          onClick={() => onSelect(null)}
        />
      </div>
      <div className="mt-3 space-y-0.5">
        {isLoading ? (
          <p className="px-2 text-xs text-muted-foreground">Loading</p>
        ) : tree.length === 0 ? (
          <p className="px-2 text-xs text-muted-foreground">No folders yet.</p>
        ) : (
          tree.map((node) => (
            <FolderRow
              key={node.id}
              node={node}
              depth={0}
              selectedFolderId={selectedFolderId}
              onSelect={onSelect}
            />
          ))
        )}
      </div>
      {footer ? <div className="mt-4 border-t pt-3">{footer}</div> : null}
    </aside>
  );
}

function PseudoRow({
  label,
  icon: Icon,
  active,
  onClick,
}: {
  label: string;
  icon: typeof Inbox;
  active: boolean;
  onClick: () => void;
}) {
  return (
    <Button
      variant="ghost"
      size="sm"
      className={cn('w-full justify-start font-normal', active && 'bg-accent text-foreground')}
      onClick={onClick}
    >
      <Icon className="mr-2 h-4 w-4" />
      {label}
    </Button>
  );
}

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 (
    <>
      <div
        className={cn(
          'group flex items-center gap-0.5 rounded-md px-1 py-0.5 text-sm',
          isActive && 'bg-accent text-foreground',
        )}
        style={{ paddingLeft: `${depth * 12 + 4}px` }}
      >
        <button
          type="button"
          aria-label={open ? 'Collapse' : 'Expand'}
          onClick={() => setOpen((o) => !o)}
          className={cn(
            'flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground',
            !hasChildren && 'invisible',
          )}
        >
          <ChevronRight className={cn('h-3.5 w-3.5 transition-transform', open && 'rotate-90')} />
        </button>
        <button
          type="button"
          onClick={() => onSelect(node.id)}
          className="flex flex-1 items-center gap-1.5 truncate text-left"
        >
          {open && hasChildren ? (
            <FolderOpen className="h-4 w-4 shrink-0" />
          ) : (
            <Folder className="h-4 w-4 shrink-0" />
          )}
          <span className="truncate">{node.name}</span>
        </button>
      </div>
      {open
        ? node.children.map((child) => (
            <FolderRow
              key={child.id}
              node={child}
              depth={depth + 1}
              selectedFolderId={selectedFolderId}
              onSelect={onSelect}
            />
          ))
        : null}
    </>
  );
}
  • Step 2: Verify TS

Run: pnpm exec tsc --noEmit Expected: clean.

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

'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 (
    <nav
      aria-label="Folder breadcrumb"
      className="flex items-center gap-1 text-sm text-muted-foreground"
    >
      <button
        type="button"
        onClick={() => onSelect(undefined)}
        className="flex items-center gap-1 hover:text-foreground"
      >
        <Home className="h-3.5 w-3.5" />
        <span>All</span>
      </button>
      {path.length === 0 && selectedFolderId === null ? (
        <>
          <ChevronRight className="h-3.5 w-3.5" />
          <span className="text-foreground">Root</span>
        </>
      ) : null}
      {path.map((node, i) => (
        <span key={node.id} className="flex items-center gap-1">
          <ChevronRight className="h-3.5 w-3.5" />
          {i === path.length - 1 ? (
            <span className="text-foreground">{node.name}</span>
          ) : (
            <button
              type="button"
              onClick={() => onSelect(node.id)}
              className="hover:text-foreground"
            >
              {node.name}
            </button>
          )}
        </span>
      ))}
      <span className="sr-only">Current location: {label}</span>
    </nav>
  );
}
  • Step 2: Verify TS

Run: pnpm exec tsc --noEmit Expected: clean.

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

'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 (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" size="icon" className="h-7 w-7">
            <MoreHorizontal className="h-4 w-4" />
            <span className="sr-only">Folder actions</span>
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem
            onClick={() => {
              setName('');
              setCreateOpen(true);
            }}
          >
            <FolderPlus className="mr-2 h-4 w-4" />
            New folder {isFolderSelected ? 'inside this' : 'at root'}
          </DropdownMenuItem>
          {isFolderSelected ? (
            <>
              <DropdownMenuItem
                onClick={() => {
                  setName(currentName);
                  setRenameOpen(true);
                }}
              >
                <Pencil className="mr-2 h-4 w-4" />
                Rename
              </DropdownMenuItem>
              <ConfirmationDialog
                trigger={
                  <DropdownMenuItem
                    onSelect={(e) => e.preventDefault()}
                    className="text-destructive"
                  >
                    <Trash2 className="mr-2 h-4 w-4" />
                    Delete
                  </DropdownMenuItem>
                }
                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}
        </DropdownMenuContent>
      </DropdownMenu>

      <Dialog open={createOpen} onOpenChange={setCreateOpen}>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>
              New folder {isFolderSelected ? 'inside the current folder' : 'at root'}
            </DialogTitle>
          </DialogHeader>
          <div className="space-y-2">
            <Label htmlFor="folder-name">Name</Label>
            <Input
              id="folder-name"
              value={name}
              onChange={(e) => setName(e.target.value)}
              autoFocus
              maxLength={200}
            />
          </div>
          <DialogFooter>
            <Button variant="outline" onClick={() => setCreateOpen(false)}>
              Cancel
            </Button>
            <Button
              disabled={!name.trim() || createMutation.isPending}
              onClick={async () => {
                try {
                  await createMutation.mutateAsync({
                    name: name.trim(),
                    parentId: isFolderSelected ? (selectedFolderId as string) : null,
                  });
                  toast.success('Folder created');
                  setCreateOpen(false);
                } catch (err) {
                  toastError(err);
                }
              }}
            >
              Create
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>

      <Dialog open={renameOpen} onOpenChange={setRenameOpen}>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>Rename folder</DialogTitle>
          </DialogHeader>
          <div className="space-y-2">
            <Label htmlFor="folder-rename">New name</Label>
            <Input
              id="folder-rename"
              value={name}
              onChange={(e) => setName(e.target.value)}
              autoFocus
              maxLength={200}
            />
          </div>
          <DialogFooter>
            <Button variant="outline" onClick={() => setRenameOpen(false)}>
              Cancel
            </Button>
            <Button
              disabled={!name.trim() || renameMutation.isPending}
              onClick={async () => {
                try {
                  await renameMutation.mutateAsync({
                    id: selectedFolderId as string,
                    name: name.trim(),
                  });
                  toast.success('Folder renamed');
                  setRenameOpen(false);
                } catch (err) {
                  toastError(err);
                }
              }}
            >
              Save
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}
  • Step 2: Verify TS

Run: pnpm exec tsc --noEmit Expected: clean.

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

'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<string | null>(currentFolderId);

  const paths = useMemo(() => buildFolderPaths(tree), [tree]);

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Move &ldquo;{documentTitle}&rdquo;</DialogTitle>
        </DialogHeader>
        <Command>
          <CommandInput placeholder="Search folders…" />
          <CommandList>
            <CommandEmpty>No folders match.</CommandEmpty>
            <CommandGroup heading="Special">
              <CommandItem
                value="__root__"
                onSelect={() => setPickedId(null)}
                className="flex items-center gap-2"
              >
                <Check
                  className={pickedId === null ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
                />
                Root (no folder)
              </CommandItem>
            </CommandGroup>
            {paths.length > 0 ? (
              <CommandGroup heading="Folders">
                {paths.map((p) => (
                  <CommandItem
                    key={p.id}
                    value={p.path}
                    onSelect={() => setPickedId(p.id)}
                    className="flex items-center gap-2"
                  >
                    <Check
                      className={pickedId === p.id ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
                    />
                    <span className="truncate">{p.path}</span>
                  </CommandItem>
                ))}
              </CommandGroup>
            ) : null}
          </CommandList>
        </Command>
        <DialogFooter>
          <Button variant="outline" onClick={() => onOpenChange(false)}>
            Cancel
          </Button>
          <Button
            disabled={pickedId === currentFolderId || move.isPending}
            onClick={async () => {
              try {
                await move.mutateAsync({ docId: documentId, folderId: pickedId });
                toast.success('Document moved');
                onOpenChange(false);
              } catch (err) {
                toastError(err);
              }
            }}
          >
            <FolderInput className="mr-1.5 h-4 w-4" />
            Move
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
  • Step 2: Verify TS

Run: pnpm exec tsc --noEmit Expected: clean.

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

// undefined = "All documents" (no folder filter), null = root only,
// string = a specific folder id.
const [selectedFolderId, setSelectedFolderId] = useState<string | null | undefined>(undefined);

Wrap the existing return content in a flex layout that puts the sidebar to the left:

return (
  <div className="flex flex-col sm:flex-row h-full">
    <FolderTreeSidebar
      selectedFolderId={selectedFolderId}
      onSelect={setSelectedFolderId}
      footer={
        <PermissionGate resource="documents" action="manage_folders">
          <FolderActionsMenu
            selectedFolderId={selectedFolderId}
            onAfterDelete={() => setSelectedFolderId(undefined)}
          />
        </PermissionGate>
      }
    />
    <div className="flex-1 min-w-0 p-4 space-y-4">
      <FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={setSelectedFolderId} />
      {/* …existing content (tabs, filters, table, etc.) goes here… */}
    </div>
  </div>
);

Add the imports at the top:

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:

const docsQuery = useQuery({
  queryKey: ['documents' /* existing keys */, , selectedFolderId],
  queryFn: () => {
    const params = new URLSearchParams();
    // …existing params…
    if (selectedFolderId !== undefined) {
      // null → folderId=null; string → folderId=<id>
      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 <TabsList><TabsTrigger>…). Insert a new tab in_progress between all and eoi_queue:

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

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

{
  (() => {
    const seenTypes = Array.from(
      new Set((docsQuery.data?.data ?? []).map((d) => d.documentType)),
    ).sort();
    if (seenTypes.length === 0) return null;
    return (
      <div className="flex flex-wrap gap-1.5">
        <button
          type="button"
          className={cn(
            'rounded-full border px-2.5 py-0.5 text-xs',
            typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
          )}
          onClick={() => setTypeFilter(undefined)}
        >
          All types
        </button>
        {seenTypes.map((t) => (
          <button
            type="button"
            key={t}
            className={cn(
              'rounded-full border px-2.5 py-0.5 text-xs',
              typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
            )}
            onClick={() => setTypeFilter(t)}
          >
            {t}
          </button>
        ))}
      </div>
    );
  })();
}

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:

import { MoveToFolderDialog } from './move-to-folder-dialog';

Add a state in the row:

const [moveOpen, setMoveOpen] = useState(false);

Add a menu item alongside the existing Send / Delete entries:

<PermissionGate resource="documents" action="manage_folders">
  <DropdownMenuItem onSelect={() => setMoveOpen(true)}>
    <FolderInput className="mr-2 h-4 w-4" />
    Move to folder
  </DropdownMenuItem>
</PermissionGate>

And render the dialog:

<MoveToFolderDialog
  documentId={doc.id}
  documentTitle={doc.title}
  currentFolderId={doc.folderId ?? null}
  open={moveOpen}
  onOpenChange={setMoveOpen}
/>

Make sure the document row data includes folderId (extend the local interface if needed).

  • Step 3: Run tsc + smoke
pnpm exec tsc --noEmit

Manually click "Move to folder…" on a document and confirm the dialog appears with the folder list.

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

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

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:

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:

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

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
pnpm exec playwright test --project=smoke --grep "create a folder"

Expected: pass (~30s including auth).

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

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

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

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:

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<ReturnType<typeof listTree>>,
): 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
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

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