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>
3152 lines
112 KiB
Markdown
3152 lines
112 KiB
Markdown
# Documents Folders Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
---
|
||
|
||
## Progress snapshot — 2026-05-09 (mid-execution pause)
|
||
|
||
Working on branch `feat/documents-folders` (off `main`). Subagent-driven execution: every task gets implementer → spec reviewer → code-quality reviewer → fix loop if needed.
|
||
|
||
| Task | Topic | Status | Commit(s) |
|
||
| ---- | ------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| 1 | Schema + migration (`document_folders` + `folder_id` on documents) | ✅ Done | `5bed62d` + `4a50bab` (fix: Drizzle `.references()` + relations) |
|
||
| 2 | `documents.manage_folders` permission | ✅ Done | `e6cf50f` |
|
||
| 3 | Service: `listTree` + `createFolder` (TDD) | ✅ Done | `4b31f01` + `5c5ab49` (fix: port-scope test cleanup + tighten message) |
|
||
| 4 | Service: rename + move (cycle prevention) + soft-rescue delete | ✅ Done | `e9251a3` + `4ec0004` (fix: audit-log out of tx + portId on ancestor walk + drop misleading updatedAt + userId for rename/move audit) |
|
||
| 5 | Zod validators | ✅ Done | `830ac39` |
|
||
| 6 | Folder API routes (GET tree / POST / PATCH rename-or-move / DELETE) | ✅ Done | `1082b80` + `e9d5df6` (fix: `.strict()` on union members so `{name, parentId}` together is a 400 not silent drop) |
|
||
| 7 | listDocuments folder filter + per-doc move route | ✅ Done | `a0ffa1b` |
|
||
| 8 | `useDocumentFolders` hook | 🔴 Not started | — |
|
||
| 9 | `FolderTreeSidebar` component | 🔴 Not started | — |
|
||
| 10 | `FolderBreadcrumb` component | 🔴 Not started | — |
|
||
| 11 | `FolderActionsMenu` (create/rename/delete dialogs) | 🔴 Not started | — |
|
||
| 12 | `MoveToFolderDialog` (per-doc picker) | 🔴 Not started | — |
|
||
| 13 | Wire `DocumentsHub`: sidebar + breadcrumb, drop signature pill, In-progress tab | 🔴 Not started | — |
|
||
| 14 | Dynamic type-filter chips + per-row Move action | 🔴 Not started | — |
|
||
| 15 | Admin-configurable Expired tab | 🔴 Not started | — |
|
||
| 16 | Playwright smoke test | 🔴 Not started | — |
|
||
| 17 | CLAUDE.md update + final verification | 🔴 Not started | — |
|
||
| 18 | **NEW** — path-style download URLs (hybrid storage decision) | 🔴 Not started | — |
|
||
| 19 | **NEW** — importer from organized S3/filesystem bucket | 🔴 Not started | — |
|
||
|
||
**Test posture at pause:** `pnpm exec tsc --noEmit` clean; full vitest suite **1213/1213 passing** (108 test files). 11 commits on the branch ahead of `main`.
|
||
|
||
**Backend complete; UI + storage-strategy work remains.** Tasks 1–7 ship the entire DB + service + API layer for folders. Reps can already create / rename / move / delete folders and move documents between them via direct API calls — only the UI and the path-style URL polish are missing.
|
||
|
||
### Decision log so far (recorded mid-execution, locking the design)
|
||
|
||
- **Storage strategy:** Hybrid — UUID-flat storage paths preserved for parity with the established pattern across brochures, berth PDFs, invoices, reports, templates, expense receipts; the `migrate-storage` byte-verbatim copy keeps working. Documents will gain a `downloadUrl` field whose URL embeds the folder path + filename for browser-tab / shared-link readability, validated for truth on the server (Task 18). The legacy-bucket importer (Task 19) is the migration tool for any organised MinIO tree the team brings over.
|
||
- **Permission split:** `documents.manage_folders` is the new perm; `documents.edit` no longer covers folder reorganisation. Admin + sales_manager + director get the new perm by default; sales_agent / viewer / residential_partner do not.
|
||
- **Soft-rescue delete:** `deleteFolderSoftRescue` re-parents subfolders + documents to the deleted folder's parent (or root) inside a transaction; never CASCADE. Audit-logged with `metadata.rescuedTo`.
|
||
- **Cycle prevention:** `moveFolder` walks the destination's ancestor chain in JS before writing, with both `seen`-set defense and a `portId` filter on the walk so a corrupted parentId pointing at another port can't be silently traversed.
|
||
- **PATCH body exclusivity:** Folder PATCH refuses bodies that carry both `name` and `parentId` via `.strict()` on each union member, so a rename request can't silently swallow a move attempt.
|
||
- **`updatedAt` semantics on bulk vs per-doc moves:** Bulk soft-rescue does NOT bump per-document `updatedAt` (admin storage op shouldn't surface every doc as "recently modified"). Per-doc move via the `[id]/folder` PATCH DOES bump `updatedAt` (deliberate user action on that doc).
|
||
|
||
### What's next when execution resumes
|
||
|
||
1. **Task 8** (useDocumentFolders hook) — small TanStack wrapper. ~30 min.
|
||
2. **Tasks 9–12** (4 UI components: sidebar tree, breadcrumb, actions menu, move dialog) — each ~30–60 min. Independent of each other.
|
||
3. **Task 13** (DocumentsHub wiring) — the integration point. Drops `signatureOnly` pill, adds In-progress tab, threads `folderId` through queries. ~60 min.
|
||
4. **Task 14** (dynamic type chips + per-row Move) — ~45 min.
|
||
5. **Task 15** (admin-configurable Expired tab) — ~30 min.
|
||
6. **Task 16** (Playwright smoke) — ~30 min.
|
||
7. **Task 18** (path-style download URLs) — ~60 min, can land independently of UI tasks.
|
||
8. **Task 19** (organized-bucket importer) — script-only, ~60–90 min, deferrable.
|
||
9. **Task 17** (CLAUDE.md + final verification) — last.
|
||
|
||
To resume from a fresh session, paste:
|
||
|
||
```
|
||
I'm resuming the documents-folders plan execution. We're on branch
|
||
feat/documents-folders. Tasks 1-7 are complete (commits 5bed62d → a0ffa1b).
|
||
Use the superpowers:subagent-driven-development skill to continue with
|
||
Task 8 (useDocumentFolders hook). Plan:
|
||
docs/superpowers/plans/2026-05-09-documents-folders.md.
|
||
Tests at last checkpoint: 1213/1213. Branch off main.
|
||
```
|
||
|
||
---
|
||
|
||
**Goal:** Add a port-wide nestable folder tree to documents, plus quality-of-life polish on the documents hub (drop the confusing "Signature-based only" pill, add an "In progress" tab, surface dynamic type-filter chips, gate the "Expired" tab on a per-port setting).
|
||
|
||
**Architecture:** New `document_folders` table with a self-referencing `parent_id` (unlimited nesting via recursive CTE for path resolution). Add a nullable `folder_id` column to `documents`; null = root. Folder UI is a collapsed-by-default left sidebar tree plus a breadcrumb header on the documents hub. Folder delete moves children to the parent (soft rescue); audit-logged. Folder ops gated on a new `documents.manage_folders` permission, mirroring the existing `files.manage_folders`. All API routes follow the established `withAuth(withPermission(...))` + `parseBody` + `errorResponse` envelope.
|
||
|
||
**Tech Stack:** Next.js 15 App Router (typedRoutes), TypeScript strict (noUncheckedIndexedAccess), Drizzle ORM on PostgreSQL, TanStack React Query, shadcn/ui (Radix Popover, Command, Dialog), Vitest (unit + integration), Playwright (smoke).
|
||
|
||
**Decisions locked (from 2026-05-09 review):**
|
||
|
||
- **Folder scope:** port-wide (one tree per port).
|
||
- **Hub tabs:** stay flat across the port; folder is an orthogonal filter.
|
||
- **Signature-based only pill:** **drop entirely**.
|
||
- **Move permission:** new `documents.manage_folders` (mirrors `files.manage_folders`).
|
||
- **Folder UI:** collapsed sidebar tree + breadcrumb header.
|
||
- **Delete semantics:** move children to parent; audit-logged. Cascade NEVER.
|
||
- **In-progress filter:** `status IN (draft, sent, partially_signed) AND status != 'expired'`.
|
||
- **Folder watchers:** **out of scope** for this plan; doc-level watchers only.
|
||
|
||
**Out of scope (separate work):**
|
||
|
||
- Folder watchers / subscriptions.
|
||
- Wider file-type allowlist (the upload route already accepts any MIME — no enforcement to widen).
|
||
- Bulk multi-select move (single-doc move only in v1).
|
||
- Folder color tags / icons (boring grey folders are fine for v1).
|
||
|
||
**Conventions to honour (from `CLAUDE.md`):**
|
||
|
||
- Strict TypeScript, no `any`. Unused vars prefixed `_`.
|
||
- Prettier: single quotes, semicolons, trailing commas, 100-char width.
|
||
- Body parsing: ALWAYS use `parseBody(req, schema)` from `@/lib/api/route-helpers`.
|
||
- Response envelope: `{ data: <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 folder’s parent', async () => {
|
||
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
|
||
const middle = await createFolder(portId, TEST_USER_ID, { name: 'Middle', parentId: root.id });
|
||
const leaf = await createFolder(portId, TEST_USER_ID, { name: 'Leaf', parentId: middle.id });
|
||
|
||
await deleteFolderSoftRescue(portId, middle.id, TEST_USER_ID);
|
||
|
||
const survivor = await db.query.documentFolders.findFirst({
|
||
where: eq(documentFolders.id, leaf.id),
|
||
});
|
||
expect(survivor?.parentId).toBe(root.id);
|
||
});
|
||
|
||
it('moves child documents to the deleted folder’s parent', async () => {
|
||
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
|
||
const child = await createFolder(portId, TEST_USER_ID, { name: 'Child', parentId: root.id });
|
||
|
||
// Insert a document directly. The fixture port has at least one
|
||
// entity to attach to — adapt to whatever your test helpers expose.
|
||
const [doc] = await db
|
||
.insert(documents)
|
||
.values({
|
||
portId,
|
||
documentType: 'other',
|
||
title: 'Orphan-rescue test',
|
||
createdBy: TEST_USER_ID,
|
||
folderId: child.id,
|
||
})
|
||
.returning();
|
||
|
||
await deleteFolderSoftRescue(portId, child.id, TEST_USER_ID);
|
||
|
||
const updatedDoc = await db.query.documents.findFirst({
|
||
where: eq(documents.id, doc!.id),
|
||
});
|
||
expect(updatedDoc?.folderId).toBe(root.id);
|
||
});
|
||
|
||
it('moves root-folder children to root (folderId=null) when the deleted folder is at root', async () => {
|
||
const folder = await createFolder(portId, TEST_USER_ID, { name: 'TopLevel', parentId: null });
|
||
const child = await createFolder(portId, TEST_USER_ID, {
|
||
name: 'Survivor',
|
||
parentId: folder.id,
|
||
});
|
||
await deleteFolderSoftRescue(portId, folder.id, TEST_USER_ID);
|
||
const survivor = await db.query.documentFolders.findFirst({
|
||
where: eq(documentFolders.id, child.id),
|
||
});
|
||
expect(survivor?.parentId).toBeNull();
|
||
});
|
||
|
||
it('throws NotFound for a folder in another port', async () => {
|
||
const otherPort = await setupTestPort();
|
||
const folder = await createFolder(otherPort, TEST_USER_ID, { name: 'X', parentId: null });
|
||
await expect(deleteFolderSoftRescue(portId, folder.id, TEST_USER_ID)).rejects.toThrow(
|
||
/not found/i,
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 10: Run failing tests**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/document-folders-soft-delete.test.ts`
|
||
Expected: import error.
|
||
|
||
- [ ] **Step 11: Implement `deleteFolderSoftRescue`**
|
||
|
||
Append to `src/lib/services/document-folders.service.ts`:
|
||
|
||
```typescript
|
||
import { documents } from '@/lib/db/schema/documents';
|
||
import { createAuditLog } from '@/lib/audit';
|
||
|
||
/**
|
||
* Soft-rescue delete: re-parent every child folder + every linked
|
||
* document to the deleted folder's parent (or to root if the deleted
|
||
* folder is at root). Audit-logged. Wrapped in a transaction so
|
||
* partial failures don't leave dangling rows.
|
||
*/
|
||
export async function deleteFolderSoftRescue(
|
||
portId: string,
|
||
folderId: string,
|
||
userId: string,
|
||
): Promise<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: 1–200 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 “{documentTitle}”</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?
|