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

3152 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Documents 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.ts``listTree`, `createFolder`, `renameFolder`, `moveFolder`, `deleteFolderSoftRescue`, `resolvePath`.
- Modify: `src/lib/services/documents.service.ts` — accept `folderId` in `createDocument`; filter on `folderId` in `listDocuments`.
**API routes (3 created, 1 modified):**
- Create: `src/app/api/v1/document-folders/route.ts``GET` (whole tree), `POST` (create).
- Create: `src/app/api/v1/document-folders/[id]/route.ts``PATCH` (rename + move), `DELETE` (soft-rescue).
- Create: `src/app/api/v1/documents/[id]/folder/route.ts``PATCH` (move single document). Co-locates with the doc.
- Modify: `src/app/api/v1/documents/route.ts` — surface `folderId` filter in `GET` query parsing.
**Roles seeding (1 modified):**
- Modify: `src/lib/db/seed-data/role-permissions.ts` (or wherever the `RolePermissions` defaults live — discover by `grep`) — set `documents.manage_folders: true` on `admin` + `sales_manager`, `false` on `sales_rep`. Adjust to match existing role granularity.
**Hooks (1 created):**
- Create: `src/hooks/use-document-folders.ts` — TanStack Query wrapper for tree fetch + invalidation helpers.
**UI components (4 created, 1 modified):**
- Create: `src/components/documents/folder-tree-sidebar.tsx` — collapsed-by-default left rail tree.
- Create: `src/components/documents/folder-breadcrumb.tsx` — header crumb trail with "Move up" / context menu.
- Create: `src/components/documents/folder-actions-menu.tsx` — Create / Rename / Delete dialogs.
- Create: `src/components/documents/move-to-folder-dialog.tsx` — per-document move picker (Combobox of folder paths).
- Modify: `src/components/documents/documents-hub.tsx` — wire sidebar + breadcrumb, drop `signatureOnly` toggle, swap type-filter to dynamic chip group, add "In progress" tab, gate "Expired" tab on a system_setting.
**Admin settings (1 modified):**
- Modify: `src/components/admin/settings/settings-manager.tsx` — add `documents_show_expired_tab` boolean (default `true`) to the Feature Flags card.
**Tests (4 created, 1 modified):**
- Create: `tests/unit/document-folders-validators.test.ts` — Zod validation edge cases.
- Create: `tests/integration/document-folders-crud.test.ts` — folder CRUD, port isolation, parent-cycle prevention.
- Create: `tests/integration/document-folders-soft-delete.test.ts` — children bubble up to parent on delete.
- Create: `tests/integration/documents-list-folder-filter.test.ts``listDocuments` with `folderId` and `includeDescendants` flags.
- Modify: `tests/e2e/smoke/04-documents.spec.ts` — add a folder smoke test (create folder, move doc, navigate).
**Docs (1 modified):**
- Modify: `CLAUDE.md` — Add a "Documents folders" subsection under the Conventions block describing the folder model + the `documents.manage_folders` perm.
---
## Task 1: Schema — `document_folders` table + `folder_id` on documents
**Files:**
- Modify: `src/lib/db/schema/documents.ts`
- Create: `src/lib/db/migrations/0050_document_folders.sql`
- [ ] **Step 1: Append the `documentFolders` table to `documents.ts`**
Add at the bottom of `src/lib/db/schema/documents.ts` (before the type exports at end of file):
```typescript
/**
* Per-port folder tree for organising documents. Self-referencing
* via parent_id; null parent = root. Unlimited depth — the UI is the
* gate (collapsed sidebar tree + breadcrumb header). Cycle prevention
* happens in the service layer (parent_id chain walk on insert/move).
*
* On folder delete: children (both subfolders and documents) bubble
* up to the deleted folder's parent. Never CASCADE.
*/
export const documentFolders = pgTable(
'document_folders',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
parentId: text('parent_id'),
name: text('name').notNull(),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_document_folders_port').on(table.portId),
index('idx_document_folders_parent').on(table.parentId),
// Sibling-uniqueness: can't have two folders with the same name in
// the same parent (or two roots with the same name in a port).
// COALESCE makes NULL parents share a single bucket per port.
uniqueIndex('uniq_document_folders_sibling_name').on(
table.portId,
sql`COALESCE(${table.parentId}, '__root__')`,
sql`LOWER(${table.name})`,
),
],
);
export type DocumentFolder = typeof documentFolders.$inferSelect;
export type NewDocumentFolder = typeof documentFolders.$inferInsert;
```
Add `folderId` column to the existing `documents` table definition (in the same file, inside the `pgTable('documents', { ... })` columns block — keep it next to the polymorphic FKs):
```typescript
folderId: text('folder_id'),
```
And add an index for it inside the same table's `(table) => [...]` list:
```typescript
index('idx_docs_folder').on(table.folderId),
```
You will also need to add `uniqueIndex` to the imports at the top of the file if it isn't already imported (it likely is — `documents.ts` already uses `index` and `uniqueIndex` is in the drizzle pg-core).
- [ ] **Step 2: Verify the schema compiles**
Run: `pnpm exec tsc --noEmit`
Expected: clean exit (no output).
If TS complains about `sql` being unused, the import is already present (`documents.ts` uses it for SQL fragments).
- [ ] **Step 3: Write the migration SQL**
Create `src/lib/db/migrations/0050_document_folders.sql`:
```sql
-- Document folders: per-port, unlimited-depth tree. parent_id references
-- another document_folders row; null = root. Sibling-name uniqueness is
-- enforced via a partial-uniqueness on (port_id, COALESCE(parent_id,
-- '__root__'), LOWER(name)) so two folders can't share a name inside
-- the same parent. The CRM checks parent_id chain for cycles in the
-- service layer; no DB-side cycle guard.
CREATE TABLE IF NOT EXISTS "document_folders" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL REFERENCES "ports" ("id"),
"parent_id" text,
"name" text NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
-- Self-FK with ON DELETE NO ACTION (the service implements
-- soft-rescue; bubbling children up to the parent in a single
-- transaction). Letting the DB cascade would silently destroy data.
ALTER TABLE "document_folders"
ADD CONSTRAINT "document_folders_parent_fk"
FOREIGN KEY ("parent_id") REFERENCES "document_folders" ("id")
ON DELETE NO ACTION;
CREATE INDEX IF NOT EXISTS "idx_document_folders_port"
ON "document_folders" ("port_id");
CREATE INDEX IF NOT EXISTS "idx_document_folders_parent"
ON "document_folders" ("parent_id");
CREATE UNIQUE INDEX IF NOT EXISTS "uniq_document_folders_sibling_name"
ON "document_folders" ("port_id", COALESCE("parent_id", '__root__'), LOWER("name"));
-- Add folder_id to documents. Nullable; null = root. ON DELETE SET NULL
-- so a botched folder delete (or a rare DB-direct delete) can't take
-- documents with it.
ALTER TABLE "documents"
ADD COLUMN IF NOT EXISTS "folder_id" text REFERENCES "document_folders" ("id") ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS "idx_docs_folder"
ON "documents" ("folder_id");
```
- [ ] **Step 4: Apply the migration to the dev database**
Run:
```bash
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
-f src/lib/db/migrations/0050_document_folders.sql
```
Expected: `CREATE TABLE`, `ALTER TABLE`, `CREATE INDEX` (×4), `ALTER TABLE`, `CREATE INDEX`. No errors.
If `next dev` is running, **restart it** (per `CLAUDE.md` — postgres.js prepared statements cache stale column lists otherwise).
- [ ] **Step 5: Verify with a sanity SELECT**
Run:
```bash
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
-c "\\d document_folders"
```
Expected: shows the new table with the 7 columns and the parent-FK.
- [ ] **Step 6: Commit**
```bash
git add src/lib/db/schema/documents.ts src/lib/db/migrations/0050_document_folders.sql
git commit -m "$(cat <<'EOF'
feat(documents): document_folders schema + folder_id on documents
Adds a per-port folder tree (self-FK on parent_id, unlimited depth)
plus a nullable folder_id on documents (null = root). Sibling-name
uniqueness enforced via a unique index on (port_id, COALESCE(parent_id,
'__root__'), LOWER(name)) so two folders can't share a name inside
the same parent. ON DELETE SET NULL on documents.folder_id and ON
DELETE NO ACTION on the parent self-FK so a botched delete never
silently destroys data — the service layer implements soft-rescue
(bubble children up to parent) instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <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:
```typescript
documents: {
view: boolean;
create: boolean;
edit: boolean;
send_for_signing: boolean;
upload_signed: boolean;
delete: boolean;
manage_folders: boolean;
};
```
- [ ] **Step 3: Backfill defaults in the role seed file**
In whichever role-seed file you found, set `documents.manage_folders` for each role:
- `admin`: `true`
- `sales_manager`: `true`
- `sales_rep`: `false`
- `viewer`: `false`
- (Any other roles: `false` unless the role's intent is admin-equivalent.)
Each role's `documents` block will need the new key. Keep the existing keys.
- [ ] **Step 4: Verify TypeScript still compiles**
Run: `pnpm exec tsc --noEmit`
Expected: clean. If TS complains about a role missing `manage_folders`, you missed one — the type now requires it.
- [ ] **Step 5: Re-seed the dev database with the updated role permissions**
The seed script re-writes role rows on every run. Run:
```bash
pnpm db:seed
```
Expected: completes without error. (If the seed script aborts on existing data, look for a `--reset` or equivalent flag in `package.json`.)
- [ ] **Step 6: Commit**
```bash
git add src/lib/db/schema/users.ts src/lib/db/seed-data/roles.ts # adjust path
git commit -m "$(cat <<'EOF'
feat(perms): add documents.manage_folders permission
Mirrors files.manage_folders. Gates create / rename / move / delete of
document folders, plus moving documents between folders. Reps with
documents.edit but not manage_folders can rename docs in place but
can't reorganise the tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '@/lib/db';
import { documentFolders } from '@/lib/db/schema/documents';
import { listTree, createFolder } from '@/lib/services/document-folders.service';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
describe('document-folders service · listTree', () => {
let portId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders);
});
it('returns an empty array when no folders exist', async () => {
const tree = await listTree(portId);
expect(tree).toEqual([]);
});
it('returns root folders with children nested under them', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null });
const child = await createFolder(portId, TEST_USER_ID, {
name: 'Q1',
parentId: root.id,
});
const tree = await listTree(portId);
expect(tree).toHaveLength(1);
expect(tree[0]?.id).toBe(root.id);
expect(tree[0]?.children).toHaveLength(1);
expect(tree[0]?.children[0]?.id).toBe(child.id);
});
it('only returns folders for the requested port', async () => {
const otherPort = await setupTestPort();
await createFolder(otherPort, TEST_USER_ID, { name: 'Other Port', parentId: null });
const tree = await listTree(portId);
expect(tree).toEqual([]);
});
});
```
(Discover the actual helper imports by reading any existing integration test under `tests/integration/`, e.g. `documents-hub-eoi-queue.test.ts`. The helper names above — `setupTestPort`, `TEST_USER_ID` — are the typical convention; adjust if your codebase uses different ones.)
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts`
Expected: import error — service module doesn't exist yet.
- [ ] **Step 3: Create the service skeleton with `listTree` + `createFolder`**
Create `src/lib/services/document-folders.service.ts`:
```typescript
import { and, asc, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders, type DocumentFolder } from '@/lib/db/schema/documents';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
export interface FolderNode extends DocumentFolder {
children: FolderNode[];
}
/**
* Returns the entire folder tree for a port, nested under their
* parents. Roots come back at the top level. Order is alphabetical
* (case-insensitive) within each parent — matches the sibling-uniqueness
* index ordering and gives reps a stable browsing experience.
*
* Uses a single SELECT + JS nesting rather than a recursive CTE; the
* folder tree is small (UI gates depth; thousands of folders would be
* a misuse) so the in-memory build is cheaper than a CTE round-trip.
*/
export async function listTree(portId: string): Promise<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`:
```typescript
describe('document-folders service · createFolder unique-sibling guard', () => {
let portId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders);
});
it('rejects a duplicate sibling name (case-insensitive)', async () => {
await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null });
await expect(
createFolder(portId, TEST_USER_ID, { name: 'deals 2026', parentId: null }),
).rejects.toThrow(/already exists/i);
});
it('allows the same name under different parents', async () => {
const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null });
const b = await createFolder(portId, TEST_USER_ID, { name: 'B', parentId: null });
await createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: a.id });
await expect(
createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: b.id }),
).resolves.toBeDefined();
});
it('rejects a parentId from another port', async () => {
const otherPort = await setupTestPort();
const otherFolder = await createFolder(otherPort, TEST_USER_ID, {
name: 'Other',
parentId: null,
});
await expect(
createFolder(portId, TEST_USER_ID, { name: 'Should fail', parentId: otherFolder.id }),
).rejects.toThrow(/not found in this port/i);
});
});
```
- [ ] **Step 6: Run all folder tests — should be 6/6**
Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts`
Expected: 6/6 pass.
- [ ] **Step 7: Commit**
```bash
git add src/lib/services/document-folders.service.ts tests/integration/document-folders-crud.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): folder service · listTree + createFolder
In-memory tree build (single SELECT + JS nesting); the folder tree is
small enough that a recursive CTE buys nothing. Sibling-name conflict
maps the Postgres unique-index 23505 to a typed ConflictError so the
UI can render a clean toast. Cross-port parentId rejected at the
service boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { renameFolder } from '@/lib/services/document-folders.service';
describe('document-folders service · renameFolder', () => {
let portId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders);
});
it('renames a folder and bumps updatedAt', async () => {
const folder = await createFolder(portId, TEST_USER_ID, { name: 'Old', parentId: null });
const before = folder.updatedAt.getTime();
await new Promise((r) => setTimeout(r, 10));
const renamed = await renameFolder(portId, folder.id, 'New');
expect(renamed.name).toBe('New');
expect(renamed.updatedAt.getTime()).toBeGreaterThan(before);
});
it('rejects rename to an existing sibling name', async () => {
await createFolder(portId, TEST_USER_ID, { name: 'Existing', parentId: null });
const folder = await createFolder(portId, TEST_USER_ID, { name: 'Mine', parentId: null });
await expect(renameFolder(portId, folder.id, 'Existing')).rejects.toThrow(/already exists/i);
});
it('throws NotFound when the folder belongs to another port', async () => {
const otherPort = await setupTestPort();
const folder = await createFolder(otherPort, TEST_USER_ID, { name: 'X', parentId: null });
await expect(renameFolder(portId, folder.id, 'Y')).rejects.toThrow(/not found/i);
});
});
```
- [ ] **Step 2: Run the failing test**
Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts -t renameFolder`
Expected: import error or fail.
- [ ] **Step 3: Implement `renameFolder`**
Append to `src/lib/services/document-folders.service.ts`:
```typescript
export async function renameFolder(
portId: string,
folderId: string,
newName: string,
): Promise<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:
```typescript
import { moveFolder } from '@/lib/services/document-folders.service';
describe('document-folders service · moveFolder', () => {
let portId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders);
});
it('moves a folder under a new parent', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const orphan = await createFolder(portId, TEST_USER_ID, { name: 'Orphan', parentId: null });
const moved = await moveFolder(portId, orphan.id, root.id);
expect(moved.parentId).toBe(root.id);
});
it('moves a folder back to root with parentId=null', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const child = await createFolder(portId, TEST_USER_ID, { name: 'Child', parentId: root.id });
const moved = await moveFolder(portId, child.id, null);
expect(moved.parentId).toBeNull();
});
it('rejects a move that would create a cycle', async () => {
const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null });
const b = await createFolder(portId, TEST_USER_ID, { name: 'B', parentId: a.id });
const c = await createFolder(portId, TEST_USER_ID, { name: 'C', parentId: b.id });
// moving A under C would create A → B → C → A
await expect(moveFolder(portId, a.id, c.id)).rejects.toThrow(/cycle/i);
});
it('rejects moving a folder under itself', async () => {
const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null });
await expect(moveFolder(portId, a.id, a.id)).rejects.toThrow(/cycle/i);
});
});
```
- [ ] **Step 6: Run the failing test**
Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts -t moveFolder`
Expected: import error.
- [ ] **Step 7: Implement `moveFolder` with cycle check**
Append to `src/lib/services/document-folders.service.ts`:
```typescript
export async function moveFolder(
portId: string,
folderId: string,
newParentId: string | null,
): Promise<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`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders, documents } from '@/lib/db/schema/documents';
import { createFolder, deleteFolderSoftRescue } from '@/lib/services/document-folders.service';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
describe('document-folders · deleteFolderSoftRescue', () => {
let portId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documentFolders);
});
it('moves child subfolders up to the deleted 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`:
```typescript
import { documents } from '@/lib/db/schema/documents';
import { createAuditLog } from '@/lib/audit';
/**
* Soft-rescue delete: re-parent every child folder + every linked
* document to the deleted folder's parent (or to root if the deleted
* folder is at root). Audit-logged. Wrapped in a transaction so
* partial failures don't leave dangling rows.
*/
export async function deleteFolderSoftRescue(
portId: string,
folderId: string,
userId: string,
): Promise<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**
```bash
git add src/lib/services/document-folders.service.ts \
tests/integration/document-folders-crud.test.ts \
tests/integration/document-folders-soft-delete.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): folder service · rename + move + soft-rescue delete
renameFolder + moveFolder enforce sibling-name uniqueness via the
shared DB index and reject cross-port leakage at the service
boundary. moveFolder walks the destination's ancestor chain to refuse
cycles before the write.
deleteFolderSoftRescue re-parents every child folder and document up
to the deleted folder's parent (or to root) inside a transaction, then
drops the folder row. Children never disappear silently — a wrong
click moves work up the tree, never deletes it.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
import { describe, it, expect } from 'vitest';
import {
createFolderSchema,
renameFolderSchema,
moveFolderSchema,
} from '@/lib/validators/document-folders';
describe('document-folder validators', () => {
it('accepts a valid create payload', () => {
expect(createFolderSchema.safeParse({ name: 'Deals', parentId: null }).success).toBe(true);
expect(createFolderSchema.safeParse({ name: 'Q1', parentId: 'abc-123' }).success).toBe(true);
});
it('rejects empty + over-long names', () => {
expect(createFolderSchema.safeParse({ name: '', parentId: null }).success).toBe(false);
expect(createFolderSchema.safeParse({ name: 'x'.repeat(201), parentId: null }).success).toBe(
false,
);
});
it('rejects whitespace-only names', () => {
expect(createFolderSchema.safeParse({ name: ' ', parentId: null }).success).toBe(false);
});
it('rename schema requires only name', () => {
expect(renameFolderSchema.safeParse({ name: 'New' }).success).toBe(true);
expect(renameFolderSchema.safeParse({ name: '' }).success).toBe(false);
});
it('move schema accepts null parentId', () => {
expect(moveFolderSchema.safeParse({ parentId: null }).success).toBe(true);
expect(moveFolderSchema.safeParse({ parentId: 'abc' }).success).toBe(true);
});
});
```
- [ ] **Step 2: Run failing tests**
Run: `pnpm exec vitest run tests/unit/document-folders-validators.test.ts`
Expected: import error.
- [ ] **Step 3: Implement validators**
Create `src/lib/validators/document-folders.ts`:
```typescript
import { z } from 'zod';
const folderName = z
.string()
.min(1, 'Folder name is required')
.max(200, 'Folder name cannot exceed 200 characters')
.refine((s) => s.trim().length > 0, 'Folder name cannot be only whitespace');
export const createFolderSchema = z.object({
name: folderName,
parentId: z.string().nullable(),
});
export type CreateFolderInput = z.infer<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**
```bash
git add src/lib/validators/document-folders.ts tests/unit/document-folders-validators.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): zod validators for folder CRUD
createFolderSchema, renameFolderSchema, moveFolderSchema,
moveDocumentToFolderSchema. Names: 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`:
```typescript
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createFolderSchema } from '@/lib/validators/document-folders';
import { listTree, createFolder } from '@/lib/services/document-folders.service';
/**
* GET /api/v1/document-folders
*
* Returns the entire folder tree for the caller's port. Roots come
* back at the top level with `children` nested. Cached on the client
* for 30s via TanStack — folders change rarely; the manager mutations
* invalidate the query.
*
* Permission: documents.view (read-only; everyone in the port can
* browse the tree even if they can't manage it).
*/
export const GET = withAuth(
withPermission('documents', 'view', async (_req, ctx) => {
try {
const tree = await listTree(ctx.portId);
return NextResponse.json({ data: tree });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* POST /api/v1/document-folders
* Body: { name, parentId }
*
* Permission: documents.manage_folders.
*/
export const POST = withAuth(
withPermission('documents', 'manage_folders', async (req, ctx) => {
try {
const body = await parseBody(req, createFolderSchema);
const folder = await createFolder(ctx.portId, ctx.userId, body);
return NextResponse.json({ data: folder }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);
```
- [ ] **Step 2: Create the per-folder route (PATCH rename/move, DELETE soft-rescue)**
Create `src/app/api/v1/document-folders/[id]/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { renameFolderSchema, moveFolderSchema } from '@/lib/validators/document-folders';
import {
renameFolder,
moveFolder,
deleteFolderSoftRescue,
} from '@/lib/services/document-folders.service';
// PATCH supports either { name } (rename) or { parentId } (move).
// Refuses both in the same body so the rep doesn't accidentally do
// two unrelated changes in one click.
const patchBodySchema = z.union([renameFolderSchema, moveFolderSchema]);
export const PATCH = withAuth(
withPermission('documents', 'manage_folders', async (req, ctx, params) => {
try {
const folderId = params.id;
if (!folderId) throw new NotFoundError('Folder');
const body = await parseBody(req, patchBodySchema);
let updated;
if ('name' in body) {
updated = await renameFolder(ctx.portId, folderId, body.name);
} else {
updated = await moveFolder(ctx.portId, folderId, body.parentId);
}
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('documents', 'manage_folders', async (_req, ctx, params) => {
try {
const folderId = params.id;
if (!folderId) throw new NotFoundError('Folder');
await deleteFolderSoftRescue(ctx.portId, folderId, ctx.userId);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);
```
- [ ] **Step 3: Smoke-test the routes via curl (optional but reassuring)**
Start `pnpm dev` if it isn't running. Log in as the seeded super-admin. Then:
```bash
# Replace <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**
```bash
git add src/app/api/v1/document-folders/
git commit -m "$(cat <<'EOF'
feat(documents): folder CRUD API routes
GET /api/v1/document-folders → full tree (documents.view).
POST /api/v1/document-folders → create (documents.manage_folders).
PATCH /api/v1/document-folders/[id] → rename OR move (union schema —
refuses both in one body).
DELETE /api/v1/document-folders/[id] → soft-rescue delete; returns 204.
Co-Authored-By: Claude Opus 4.7 (1M context) <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:
```typescript
folderId: z.string().nullable().optional(),
includeDescendants: z.coerce.boolean().optional().default(false),
```
(Add them inside the `.object({...})` block alongside the existing optional filters.)
- [ ] **Step 2: Implement the filter inside `listDocuments`**
In `src/lib/services/documents.service.ts`, inside the `listDocuments` function, after the existing `if (status)` filter line, add:
```typescript
if (query.folderId !== undefined) {
if (query.folderId === null) {
filters.push(isNull(documents.folderId));
} else if (query.includeDescendants) {
// Recursive descendants — small folder trees, fine to do in JS.
const tree = await listTree(portId);
const ids = collectDescendantIds(tree, query.folderId);
filters.push(inArray(documents.folderId, [query.folderId, ...ids]));
} else {
filters.push(eq(documents.folderId, query.folderId));
}
}
```
You'll need to:
1. Add `isNull, inArray` to the existing drizzle imports if missing.
2. Import `listTree` from `'@/lib/services/document-folders.service'`.
3. Add a helper `collectDescendantIds` to that same service file:
```typescript
// in document-folders.service.ts, exported
export function collectDescendantIds(tree: FolderNode[], rootId: string): string[] {
const out: string[] = [];
function visit(nodes: FolderNode[], inside: boolean) {
for (const n of nodes) {
if (inside || n.id === rootId) {
if (n.id !== rootId) out.push(n.id);
visit(n.children, true);
} else {
visit(n.children, false);
}
}
}
visit(tree, false);
return out;
}
```
- [ ] **Step 3: Add `folderId` to `createDocument`**
In `src/lib/validators/documents.ts`, find `createDocumentSchema` and add `folderId: z.string().nullable().optional()` alongside the other polymorphic FKs.
In `src/lib/services/documents.service.ts`, in the function that inserts a new document (search for `db.insert(documents)`), include `folderId: data.folderId ?? null` in the values block.
- [ ] **Step 4: Write the failing list-by-folder integration test**
Create `tests/integration/documents-list-folder-filter.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '@/lib/db';
import { documents, documentFolders } from '@/lib/db/schema/documents';
import { createFolder } from '@/lib/services/document-folders.service';
import { listDocuments } from '@/lib/services/documents.service';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
describe('documents.listDocuments folder filtering', () => {
let portId: string;
beforeEach(async () => {
portId = await setupTestPort();
await db.delete(documents);
await db.delete(documentFolders);
});
it('filters by folderId (direct children only by default)', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id });
await db.insert(documents).values([
{
portId,
documentType: 'other',
title: 'In Root',
createdBy: TEST_USER_ID,
folderId: root.id,
},
{ portId, documentType: 'other', title: 'In Sub', createdBy: TEST_USER_ID, folderId: sub.id },
{
portId,
documentType: 'other',
title: 'At Root (no folder)',
createdBy: TEST_USER_ID,
folderId: null,
},
]);
const res = await listDocuments(portId, { page: 1, limit: 50, folderId: root.id });
expect(res.data.map((d) => d.title)).toContain('In Root');
expect(res.data.map((d) => d.title)).not.toContain('In Sub');
});
it('includeDescendants=true pulls in children of children', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id });
await db.insert(documents).values([
{
portId,
documentType: 'other',
title: 'In Root',
createdBy: TEST_USER_ID,
folderId: root.id,
},
{ portId, documentType: 'other', title: 'In Sub', createdBy: TEST_USER_ID, folderId: sub.id },
]);
const res = await listDocuments(portId, {
page: 1,
limit: 50,
folderId: root.id,
includeDescendants: true,
});
const titles = res.data.map((d) => d.title);
expect(titles).toContain('In Root');
expect(titles).toContain('In Sub');
});
it('folderId=null returns only docs at root', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
await db.insert(documents).values([
{
portId,
documentType: 'other',
title: 'In Root',
createdBy: TEST_USER_ID,
folderId: root.id,
},
{ portId, documentType: 'other', title: 'At Root', createdBy: TEST_USER_ID, folderId: null },
]);
const res = await listDocuments(portId, { page: 1, limit: 50, folderId: null });
expect(res.data.map((d) => d.title)).toEqual(['At Root']);
});
});
```
- [ ] **Step 5: Run the test — should pass**
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts`
Expected: 3/3 pass.
- [ ] **Step 6: Add the per-document move endpoint**
Create `src/app/api/v1/documents/[id]/folder/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { documents, documentFolders } from '@/lib/db/schema/documents';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { moveDocumentToFolderSchema } from '@/lib/validators/document-folders';
import { createAuditLog } from '@/lib/audit';
export const PATCH = withAuth(
withPermission('documents', 'manage_folders', async (req, ctx, params) => {
try {
const docId = params.id;
if (!docId) throw new NotFoundError('Document');
const body = await parseBody(req, moveDocumentToFolderSchema);
const existing = await db.query.documents.findFirst({
where: and(eq(documents.id, docId), eq(documents.portId, ctx.portId)),
});
if (!existing) throw new NotFoundError('Document');
if (body.folderId !== null) {
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, body.folderId), eq(documentFolders.portId, ctx.portId)),
});
if (!folder) throw new ValidationError('Folder not found in this port');
}
const [updated] = await db
.update(documents)
.set({ folderId: body.folderId, updatedAt: new Date() })
.where(eq(documents.id, docId))
.returning();
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'document',
entityId: docId,
oldValue: { folderId: existing.folderId },
newValue: { folderId: body.folderId },
metadata: { type: 'folder_move' },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);
```
- [ ] **Step 7: Run all tests + tsc**
Run: `pnpm exec tsc --noEmit && pnpm exec vitest run`
Expected: tsc clean; vitest all pass.
- [ ] **Step 8: Commit**
```bash
git add src/lib/services/documents.service.ts \
src/lib/services/document-folders.service.ts \
src/lib/validators/documents.ts \
src/app/api/v1/documents/[id]/folder/route.ts \
tests/integration/documents-list-folder-filter.test.ts
git commit -m "$(cat <<'EOF'
feat(documents): folder filter on list + per-doc move endpoint
listDocuments accepts folderId (string|null|undefined) and
includeDescendants. folderId=null returns only docs at root;
includeDescendants=true expands the subtree via the
collectDescendantIds helper (in-memory walk over the cached tree).
PATCH /api/v1/documents/[id]/folder moves a single document under
documents.manage_folders. Audit-logged with folder_move metadata.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type { DocumentFolder } from '@/lib/db/schema/documents';
export interface FolderNode extends DocumentFolder {
children: FolderNode[];
}
const FOLDERS_KEY = ['document-folders'] as const;
export function useDocumentFolders() {
return useQuery<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**
```bash
git add src/hooks/use-document-folders.ts
git commit -m "$(cat <<'EOF'
feat(documents): useDocumentFolders hook + mutations
Wraps the folder tree fetch in TanStack with a 30s staleTime, and
provides create / rename / move / delete / move-document mutations
that invalidate the relevant query keys. buildFolderPaths flattens
the tree into ' / '-separated path strings for picker dropdowns.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
'use client';
import { useState } from 'react';
import { ChevronRight, Folder, FolderOpen, Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
interface FolderTreeSidebarProps {
/** Currently-selected folder id, or `null` for root, or `undefined`
* for "All documents" (no folder filter). */
selectedFolderId: string | null | undefined;
onSelect: (folderId: string | null | undefined) => void;
/** Slot below the tree for a "New folder" affordance from the parent. */
footer?: React.ReactNode;
}
/**
* Collapsed-by-default tree. Each row shows a chevron that toggles its
* children; clicking the row label selects the folder. The "All
* documents" + "Root" pseudo-rows at the top let reps filter to the
* full set or to docs without a folder.
*
* Designed for unlimited depth — only the top level renders by default
* so deep trees don't blow out the page; reps drill in by expanding.
*/
export function FolderTreeSidebar({
selectedFolderId,
onSelect,
footer,
}: FolderTreeSidebarProps) {
const { data: tree = [], isLoading } = useDocumentFolders();
return (
<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**
```bash
git add src/components/documents/folder-tree-sidebar.tsx
git commit -m "$(cat <<'EOF'
feat(documents): FolderTreeSidebar (collapsed-by-default tree)
Persistent left rail with "All documents" + "Root" pseudo-rows above
the tree. Each tree row has a chevron toggle (expand/collapse) and a
clickable label (select). Renders unlimited depth without blowing out
the page — children only mount when their parent is expanded.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
'use client';
import { ChevronRight, Home } from 'lucide-react';
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
interface FolderBreadcrumbProps {
selectedFolderId: string | null | undefined;
onSelect: (folderId: string | null | undefined) => void;
}
function findPath(tree: FolderNode[], id: string): FolderNode[] | null {
for (const node of tree) {
if (node.id === id) return [node];
const inChild = findPath(node.children, id);
if (inChild) return [node, ...inChild];
}
return null;
}
export function FolderBreadcrumb({ selectedFolderId, onSelect }: FolderBreadcrumbProps) {
const { data: tree = [] } = useDocumentFolders();
let label: string;
let path: FolderNode[] = [];
if (selectedFolderId === undefined) {
label = 'All documents';
} else if (selectedFolderId === null) {
label = 'Root';
} else {
path = findPath(tree, selectedFolderId) ?? [];
label = path.at(-1)?.name ?? 'Folder';
}
return (
<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**
```bash
git add src/components/documents/folder-breadcrumb.tsx
git commit -m "$(cat <<'EOF'
feat(documents): FolderBreadcrumb header crumb trail
Renders the current folder's path as a clickable breadcrumb with a
Home affordance back to "All documents". Each ancestor is clickable
to navigate up; the last segment is the current folder (non-clickable,
foreground colour).
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
'use client';
import { useState } from 'react';
import { FolderPlus, Pencil, Trash2, MoreHorizontal } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { toastError } from '@/lib/api/toast-error';
import {
useCreateFolder,
useDeleteFolder,
useRenameFolder,
useDocumentFolders,
} from '@/hooks/use-document-folders';
interface FolderActionsMenuProps {
/** The folder these actions apply to. `null` means root → only the
* Create-new-folder action is available. */
selectedFolderId: string | null | undefined;
/** Callback after delete so parent can reset selection. */
onAfterDelete?: () => void;
}
export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderActionsMenuProps) {
const [createOpen, setCreateOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [name, setName] = useState('');
const createMutation = useCreateFolder();
const renameMutation = useRenameFolder();
const deleteMutation = useDeleteFolder();
const { data: tree = [] } = useDocumentFolders();
const isFolderSelected = typeof selectedFolderId === 'string';
const currentName = (() => {
if (!isFolderSelected) return '';
function find(nodes: typeof tree): string | null {
for (const n of nodes) {
if (n.id === selectedFolderId) return n.name;
const inChild = find(n.children);
if (inChild) return inChild;
}
return null;
}
return find(tree) ?? '';
})();
return (
<>
<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**
```bash
git add src/components/documents/folder-actions-menu.tsx
git commit -m "$(cat <<'EOF'
feat(documents): FolderActionsMenu (create / rename / delete dialogs)
DropdownMenu trigger with three actions: New folder (works at root or
inside the selected folder), Rename, Delete (confirm-then-soft-rescue).
Delete copy explicitly tells reps the contents move to the parent so
nothing dies silently.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
'use client';
import { useMemo, useState } from 'react';
import { Check, FolderInput } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { toastError } from '@/lib/api/toast-error';
import {
buildFolderPaths,
useDocumentFolders,
useMoveDocument,
} from '@/hooks/use-document-folders';
interface MoveToFolderDialogProps {
documentId: string;
documentTitle: string;
currentFolderId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MoveToFolderDialog({
documentId,
documentTitle,
currentFolderId,
open,
onOpenChange,
}: MoveToFolderDialogProps) {
const { data: tree = [] } = useDocumentFolders();
const move = useMoveDocument();
const [pickedId, setPickedId] = useState<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**
```bash
git add src/components/documents/move-to-folder-dialog.tsx
git commit -m "$(cat <<'EOF'
feat(documents): MoveToFolderDialog single-doc move picker
cmdk Combobox dialog showing all folder paths flat (' / '-separated),
plus a "Root (no folder)" pseudo-option. Move button disabled when the
picked folder matches the document's current folder.
Co-Authored-By: Claude Opus 4.7 (1M context) <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:
```typescript
// 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:
```tsx
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:
```typescript
import { FolderTreeSidebar } from './folder-tree-sidebar';
import { FolderBreadcrumb } from './folder-breadcrumb';
import { FolderActionsMenu } from './folder-actions-menu';
import { PermissionGate } from '@/components/shared/permission-gate';
```
- [ ] **Step 3: Push the folder filter into the documents query**
Find the existing `useQuery` for documents (search for `'documents'` in the queryKey or the `apiFetch('/api/v1/documents'…)` call). Add `selectedFolderId` to the queryKey and pass it as a query-string param:
```typescript
const docsQuery = useQuery({
queryKey: ['documents' /* existing keys */, , selectedFolderId],
queryFn: () => {
const params = new URLSearchParams();
// …existing params…
if (selectedFolderId !== undefined) {
// null → folderId=null; string → folderId=<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`:
```typescript
{ value: 'in_progress', label: 'In progress' },
```
Then in the service-side `buildHubTabFilters` (`src/lib/services/documents.service.ts`), add a case for it (next to the existing tab cases):
```typescript
case 'in_progress':
return [
sql`${documents.status} IN ('draft', 'sent', 'partially_signed') AND ${documents.status} != 'expired'`,
];
```
Also extend the `tab` enum in `listDocumentsSchema` (validators) to include `'in_progress'`.
- [ ] **Step 6: Run tsc + relevant tests**
```bash
pnpm exec tsc --noEmit
pnpm exec vitest run tests/integration/documents-
```
Expected: tsc clean; existing documents tests still pass (the tab additions are backward-compatible).
- [ ] **Step 7: Smoke check via the browser**
Run `pnpm dev` (restart if it was running before the schema migration). Navigate to `/{portSlug}/documents`. Verify:
- Sidebar renders with "All documents" + "Root" + (empty tree initially).
- Breadcrumb shows "All".
- Tabs show: All / In progress / EOI queue / Awaiting them / Awaiting me / Completed / Expired.
- No "Signature-based only" toggle visible.
If the sidebar overflows mobile, the `flex-col sm:flex-row` on the outer div handles the stack. Verify on a narrow viewport.
- [ ] **Step 8: Commit**
```bash
git add src/components/documents/documents-hub.tsx \
src/lib/services/documents.service.ts \
src/lib/validators/documents.ts
git commit -m "$(cat <<'EOF'
feat(documents): wire folder sidebar + breadcrumb + In-progress tab
Documents hub now opens with the folder tree on the left and a
breadcrumb on top. Folder selection is its own state — undefined =
"All", null = "Root only", string = specific folder. Filter pushes
through to /api/v1/documents via folderId query param.
Drops the "Signature-based only" pill — it defaulted to true and
silently hid informational documents, which confused new reps. With
folders the rep organises by location, not by signature-vs-not.
Adds an "In progress" hub tab covering status IN (draft, sent,
partially_signed) for the everyday "what's in flight" view.
Co-Authored-By: Claude Opus 4.7 (1M context) <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:
```tsx
{
(() => {
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:
```typescript
import { MoveToFolderDialog } from './move-to-folder-dialog';
```
Add a state in the row:
```typescript
const [moveOpen, setMoveOpen] = useState(false);
```
Add a menu item alongside the existing Send / Delete entries:
```tsx
<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:
```tsx
<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**
```bash
pnpm exec tsc --noEmit
```
Manually click "Move to folder…" on a document and confirm the dialog appears with the folder list.
- [ ] **Step 4: Commit**
```bash
git add src/components/documents/documents-hub.tsx src/components/documents/document-list.tsx
git commit -m "$(cat <<'EOF'
feat(documents): dynamic type-filter chips + move-to-folder row action
Type-filter chip cloud sourced from the documentTypes seen in the
current result set, replacing the static dropdown over the whole
DOCUMENT_TYPES enum. New "Move to folder…" entry on the per-row
action menu (gated on documents.manage_folders) opens the
MoveToFolderDialog Combobox.
Co-Authored-By: Claude Opus 4.7 (1M context) <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:
```typescript
{
key: 'documents_show_expired_tab',
label: 'Documents — show Expired tab',
description:
'When off, the Expired tab on the documents hub is hidden. Use this when expired EOIs are noise that distracts reps from active deals.',
type: 'boolean',
defaultValue: true,
},
```
- [ ] **Step 2: Read the setting in the hub**
In `src/components/documents/documents-hub.tsx`, fetch it via the existing settings hook (likely there's a `useSystemSetting` or you can hit `/api/v1/admin/settings` directly — but that's gated on manage_settings, which reps don't have).
The simpler path: use the `useVocabulary`-style pattern but for booleans. Add a new public-read endpoint OR use a lightweight `/api/v1/system-settings/public` that exposes a curated allow-list including `documents_show_expired_tab`. For this task, do the minimum: add the key to the existing public read endpoint surface.
If no public reader exists yet:
Create `src/app/api/v1/documents/feature-flags/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getSetting } from '@/lib/services/settings.service';
export const GET = withAuth(
withPermission('documents', 'view', async (_req, ctx) => {
try {
const showExpired = await getSetting('documents_show_expired_tab', ctx.portId);
return NextResponse.json({
data: {
showExpiredTab: showExpired?.value !== false, // default true
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
```
In the hub, fetch this:
```typescript
const flags = useQuery<{ data: { showExpiredTab: boolean } }>({
queryKey: ['documents', 'feature-flags'],
queryFn: () => apiFetch('/api/v1/documents/feature-flags'),
staleTime: 5 * 60 * 1000,
});
const showExpiredTab = flags.data?.data.showExpiredTab ?? true;
```
Then conditionally render the Expired tab:
```typescript
const tabs = [
{ value: 'all', label: 'All' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'eoi_queue', label: 'EOI queue' },
{ value: 'awaiting_them', label: 'Awaiting them' },
{ value: 'awaiting_me', label: 'Awaiting me' },
{ value: 'completed', label: 'Completed' },
...(showExpiredTab ? [{ value: 'expired', label: 'Expired' }] : []),
];
```
- [ ] **Step 3: Run tsc + smoke**
```bash
pnpm exec tsc --noEmit
```
In a browser, toggle `documents_show_expired_tab` off in admin settings, hard-refresh the documents page, confirm the Expired tab disappears.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/settings/settings-manager.tsx \
src/components/documents/documents-hub.tsx \
src/app/api/v1/documents/feature-flags/route.ts
git commit -m "$(cat <<'EOF'
feat(documents): admin-configurable Expired tab visibility
New documents_show_expired_tab system setting (default true). Public
read via GET /api/v1/documents/feature-flags (gated on documents.view
so reps can read it without holding manage_settings). When off, the
Expired tab is hidden from the documents hub — useful when expired
EOIs are noise that distracts reps from active deals.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```typescript
test('admin can create a folder, move a document, and the breadcrumb updates', async ({
page,
loggedInAdmin: _, // adapt to whatever fixture name your suite uses
}) => {
await page.goto('/port-nimara/documents');
// Create a folder via the actions menu.
await page.getByRole('button', { name: /folder actions/i }).click();
await page.getByRole('menuitem', { name: /new folder at root/i }).click();
const folderName = `Smoke ${Date.now()}`;
await page.getByLabel('Name').fill(folderName);
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('button', { name: folderName })).toBeVisible();
// Click into the folder; breadcrumb updates.
await page.getByRole('button', { name: folderName }).click();
await expect(page.getByRole('navigation', { name: /breadcrumb/i })).toContainText(folderName);
});
```
(Adjust the selectors and fixture names to match your suite's conventions — open another file under `tests/e2e/smoke/` for reference.)
- [ ] **Step 2: Run the smoke test**
```bash
pnpm exec playwright test --project=smoke --grep "create a folder"
```
Expected: pass (~30s including auth).
- [ ] **Step 3: Commit**
```bash
git add tests/e2e/smoke/04-documents.spec.ts
git commit -m "$(cat <<'EOF'
test(e2e): smoke — create folder + breadcrumb update on documents hub
Covers the happy-path admin flow: open hub, open Folder Actions menu,
create a root folder, click into it, breadcrumb updates. Doesn't yet
cover delete (soft-rescue) or move-to-folder — separate spec when
needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <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:
```markdown
- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents carry a nullable `folder_id` (null = root). Sibling-name uniqueness enforced via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain. All folder ops gated on `documents.manage_folders` (read access piggybacks on `documents.view`).
```
- [ ] **Step 2: Final test sweep**
```bash
pnpm exec tsc --noEmit
pnpm exec vitest run
pnpm exec playwright test --project=smoke
```
Expected: tsc clean; vitest all pass; smoke passes.
- [ ] **Step 3: Manual UAT checklist**
Walk through each in a browser at `/port-nimara/documents`:
- [ ] Sidebar renders with "All documents" + "Root" + tree.
- [ ] Create a folder at root via Folder Actions menu.
- [ ] Create a subfolder via the same menu (after selecting the parent).
- [ ] Rename a folder; refresh — name persists.
- [ ] Click "Move to folder…" on a document; pick a folder; verify it disappears from the previous location and shows up in the new folder.
- [ ] Delete a folder that has children; verify children + documents bubble up to the parent.
- [ ] Toggle off the Expired tab in admin settings; refresh; tab disappears.
- [ ] Switch through tabs (All / In progress / EOI queue / etc.) while a folder is selected; folder filter persists across tabs.
- [ ] Click "All documents" → folder filter clears, all docs visible.
- [ ] On a phone-width viewport, sidebar stacks above the doc list; navigation still works.
- [ ] **Step 4: Commit + push**
```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(claude-md): document folders model + soft-rescue delete semantics
Documents the new document_folders self-FK tree, the sibling-name
uniqueness invariant, and the soft-rescue delete behaviour so future
sessions don't try to wire CASCADE.
Co-Authored-By: Claude Opus 4.7 (1M context) <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.ts``listDocuments` and the single-doc detail returns now include a `downloadUrl` field.
- [ ] **Step 1: Add the URL builder helper**
In `src/lib/services/documents.service.ts`, near the existing helpers, add:
```typescript
import type { FolderNode } from '@/lib/services/document-folders.service';
/**
* Resolve the rep-facing download URL for a document. The URL embeds
* the folder path + filename for browser-tab / shared-link readability,
* but the route handler keys lookup off the doc id and validates the
* slug for truth — so a hand-edited URL with a wrong path still 404s
* instead of silently serving the wrong file.
*
* Usage: pass the resolved folder tree once per request and call this
* for each doc in the result set so we don't refetch the tree per row.
*/
export function buildDocumentDownloadUrl(
doc: { id: string; folderId: string | null; filename: string | null },
folderTree: readonly FolderNode[],
): string {
const segments: string[] = [];
if (doc.folderId) {
const path = findFolderPath(folderTree, doc.folderId);
for (const node of path) segments.push(encodeURIComponent(node.name));
}
segments.push(encodeURIComponent(doc.filename ?? doc.id));
return `/api/v1/documents/${doc.id}/download/${segments.join('/')}`;
}
function findFolderPath(tree: readonly FolderNode[], id: string): FolderNode[] {
for (const node of tree) {
if (node.id === id) return [node];
const inChild = findFolderPath(node.children, id);
if (inChild.length > 0) return [node, ...inChild];
}
return [];
}
```
The doc passed in needs a `filename` field; it lives on the linked `files` row, so the existing list query needs to join (or the service helper that hydrates documents already does — check).
- [ ] **Step 2: Inject downloadUrl into the listDocuments response**
In `listDocuments`, after the rows are fetched, fetch the folder tree once via the existing `listTree` import and map each row to include `downloadUrl: buildDocumentDownloadUrl(row, tree)`.
- [ ] **Step 3: Create the catch-all download route**
Create `src/app/api/v1/documents/[id]/download/[...slug]/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { documents, files } from '@/lib/db/schema/documents';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { getStorageBackend } from '@/lib/storage';
import { listTree } from '@/lib/services/document-folders.service';
interface RouteParams {
id: string;
slug?: string[];
}
export const GET = withAuth(
withPermission('documents', 'view', async (_req, ctx, params: RouteParams) => {
try {
const docId = params.id;
if (!docId) throw new NotFoundError('Document');
const doc = await db
.select({
id: documents.id,
folderId: documents.folderId,
fileId: documents.fileId,
fileStoragePath: files.storagePath,
fileMimeType: files.mimeType,
fileFilename: files.filename,
})
.from(documents)
.leftJoin(files, eq(files.id, documents.fileId))
.where(and(eq(documents.id, docId), eq(documents.portId, ctx.portId)))
.limit(1)
.then((r) => r[0]);
if (!doc?.fileStoragePath) throw new NotFoundError('Document file');
// Slug truth-check: rebuild what the URL SHOULD be from current
// state and 404 if the supplied slug doesn't match. Stops a
// hand-edited URL from rendering a stale or wrong filename in a
// forwarded link.
const tree = await listTree(ctx.portId);
const expected = buildExpectedSlug(doc.folderId, doc.fileFilename ?? doc.id, tree);
const supplied = (params.slug ?? []).map(decodeURIComponent).join('/');
if (supplied !== expected) throw new NotFoundError('Document at this path');
const backend = await getStorageBackend();
const stream = await backend.get(doc.fileStoragePath);
return new NextResponse(stream as unknown as ReadableStream, {
status: 200,
headers: {
'content-type': doc.fileMimeType ?? 'application/octet-stream',
'content-disposition': `inline; filename="${doc.fileFilename ?? doc.id}"`,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
function buildExpectedSlug(
folderId: string | null,
filename: string,
tree: Awaited<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**
```bash
pnpm exec tsc --noEmit
pnpm exec vitest run
```
- [ ] **Step 7: Commit**
```
feat(documents): path-style download URLs for rep-facing readability
Storage stays UUID-flat per the established pattern; the new
catch-all /api/v1/documents/[id]/download/[...slug] route serves
files keyed on doc id but validates that the slug matches the
current folder path + filename. URLs in shared links / browser
tabs read like 'Deals 2026/Q1/contract.pdf' even though storage
keys remain UUIDs. listDocuments + detail responses now include
a downloadUrl field so UI consumers don't reconstruct paths.
```
---
## Task 19: Importer from organized S3/filesystem bucket
**Context:** When the team migrates from a legacy MinIO bucket whose folder structure represents real organisation (`s3://old-data/Deals 2026/Q1/contract.pdf`), the importer walks that tree, builds matching `document_folders` rows in the CRM, and inserts `documents` rows pointing at the existing storage keys without rewriting them. One-shot script, idempotent.
**Files:**
- Create: `scripts/import-organized-documents.ts`
- Optional: `tests/unit/import-organized-documents.test.ts` (testing the path-parser pure function).
- [ ] **Step 1: CLI surface + dry-run output**
```bash
pnpm tsx scripts/import-organized-documents.ts \
--port-slug port-nimara \
--bucket-prefix "legacy-imports/" \
--dry-run
```
Output: a tree of "would create" rows.
- [ ] **Step 2: Apply mode**
```bash
pnpm tsx scripts/import-organized-documents.ts \
--port-slug port-nimara \
--bucket-prefix "legacy-imports/" \
--apply
```
Idempotent: re-runs are no-ops via:
- `document_folders` sibling-uniqueness index (re-create attempts hit ConflictError → caught + skipped).
- `documents` rows checked by `(portId, fileStoragePath)` before insert.
- [ ] **Step 3: Pure path-parser**
Extract a pure function `parseImportPath(prefix, key)``{ folderSegments: string[], filename: string }`. Unit-test it with edge cases (trailing slashes, special chars in folder names, empty intermediate segments).
- [ ] **Step 4: Walker + DB writer**
Use `getStorageBackend().listByPrefix(...)` if it exists; if not, augment the storage backend interface with a list method (S3: `listObjectsV2`; filesystem: recursive readdir).
Walk in alphabetical order so `documents` rows in the import log appear deterministically.
- [ ] **Step 5: Audit log per imported doc**
Each created `documents` row gets a `createAuditLog({ action: 'create', metadata: { source: 'organized-bucket-importer' } })`.
- [ ] **Step 6: Commit**
```
feat(documents): importer for organized S3/filesystem buckets
One-shot script that walks an existing organized bucket tree,
creates matching document_folders rows + documents rows pointing
at the storage keys verbatim (no path rewrite). Idempotent via
the sibling-uniqueness index + (portId, fileStoragePath) check.
Use when migrating from a legacy MinIO bucket whose folder
structure already represents real organisation.
```
---
## Self-review
**Spec coverage:**
- Folders (create / delete / nested) — ✅ Tasks 1, 3, 4, 11.
- Sort + filter (date, type, owner) — partial: type-filter chips done in Task 14; date sort already exists in `listDocuments`; owner sort isn't explicitly added — flag as a follow-up if reps want it.
- Wider file-type allowlist — n/a (no enforced allowlist exists today; out of scope per plan header).
- "Documents in progress" filter — ✅ Task 13.
- Drop the "Signature-based only" pill — ✅ Task 13.
- "Expired" tab admin-configurable — ✅ Task 15.
- Type-filter dropdown reflects actual types in use — ✅ Task 14.
- Unlimited nesting + careful UI — ✅ Tasks 1, 9 (collapsed-by-default tree).
**Placeholder scan:** none — every step has concrete code or a precise instruction with the exact file path and line context.
**Type consistency:**
- `FolderNode` defined identically in service (Task 3) and hook (Task 8).
- `selectedFolderId: string | null | undefined` consistent across sidebar, breadcrumb, hub, actions menu.
- `folderId: string | null` consistent across validators, service, document moves.
- `moveDocumentToFolderSchema` defined in Task 5, used in Task 7.
---
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-05-09-documents-folders.md`. Two execution options:
1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration. Best when you want to keep moving.
2. **Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?