From 9f3e739c76be728293383837cf7e9e5182c5d7a5 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 19:50:28 +0200 Subject: [PATCH] docs(plan): add Tasks 18-19 (path-style URLs + organized-bucket importer) User chose the hybrid storage strategy after reviewing the cost analysis: storage paths stay UUID-flat (preserves the established pattern across brochures, berth PDFs, invoices, reports, templates, expense receipts, and the migrate-storage byte-verbatim copy), but documents gain a path-style download URL so reps see meaningful paths in shared links and browser tabs. Task 18 wires the new /api/v1/documents/[id]/download/[...slug] catch-all route + a downloadUrl field on list/detail responses. The slug is validated for truth so a hand-edited URL with a stale path 404s instead of silently serving the wrong file. Task 19 is the importer the user mentioned: a one-shot script that walks an organized legacy bucket, creates matching folder tree + document rows pointing at existing storage keys verbatim. Idempotent via the sibling-uniqueness index. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-09-documents-folders.md | 425 +++++++++++++++--- 1 file changed, 358 insertions(+), 67 deletions(-) diff --git a/docs/superpowers/plans/2026-05-09-documents-folders.md b/docs/superpowers/plans/2026-05-09-documents-folders.md index 682016f0..a37cef65 100644 --- a/docs/superpowers/plans/2026-05-09-documents-folders.md +++ b/docs/superpowers/plans/2026-05-09-documents-folders.md @@ -9,6 +9,7 @@ **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**. @@ -19,12 +20,14 @@ - **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`. @@ -38,31 +41,38 @@ ## 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. @@ -70,9 +80,11 @@ - 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. @@ -80,6 +92,7 @@ - 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. --- @@ -87,6 +100,7 @@ ## 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` @@ -254,6 +268,7 @@ 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) @@ -283,6 +298,7 @@ documents: { - [ ] **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` @@ -328,6 +344,7 @@ EOF ## Task 3: Folder service — types + listTree **Files:** + - Create: `src/lib/services/document-folders.service.ts` - Test: `tests/integration/document-folders-crud.test.ts` @@ -557,6 +574,7 @@ 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` @@ -727,10 +745,11 @@ export async function moveFolder( } 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 }, - }); + const next: { parentId: string | null } | undefined = + await db.query.documentFolders.findFirst({ + where: eq(documentFolders.id, cursor), + columns: { parentId: true }, + }); cursor = next?.parentId ?? null; } } @@ -767,10 +786,7 @@ 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 { createFolder, deleteFolderSoftRescue } from '@/lib/services/document-folders.service'; import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; describe('document-folders · deleteFolderSoftRescue', () => { @@ -878,12 +894,7 @@ export async function deleteFolderSoftRescue( await tx .update(documentFolders) .set({ parentId: newParent, updatedAt: new Date() }) - .where( - and( - eq(documentFolders.parentId, folderId), - eq(documentFolders.portId, portId), - ), - ); + .where(and(eq(documentFolders.parentId, folderId), eq(documentFolders.portId, portId))); // Re-parent child documents. await tx @@ -943,6 +954,7 @@ EOF ## Task 5: Folder validators **Files:** + - Create: `src/lib/validators/document-folders.ts` - Create: `tests/unit/document-folders-validators.test.ts` @@ -961,16 +973,14 @@ import { 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); + 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); + expect(createFolderSchema.safeParse({ name: 'x'.repeat(201), parentId: null }).success).toBe( + false, + ); }); it('rejects whitespace-only names', () => { @@ -1055,6 +1065,7 @@ 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` @@ -1123,10 +1134,7 @@ 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 { renameFolderSchema, moveFolderSchema } from '@/lib/validators/document-folders'; import { renameFolder, moveFolder, @@ -1211,6 +1219,7 @@ 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` @@ -1247,6 +1256,7 @@ if (query.folderId !== undefined) { ``` 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: @@ -1300,9 +1310,21 @@ describe('documents.listDocuments folder filtering', () => { 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 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 }, + { + 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 }); @@ -1314,7 +1336,13 @@ describe('documents.listDocuments folder filtering', () => { 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 Root', + createdBy: TEST_USER_ID, + folderId: root.id, + }, { portId, documentType: 'other', title: 'In Sub', createdBy: TEST_USER_ID, folderId: sub.id }, ]); @@ -1332,7 +1360,13 @@ describe('documents.listDocuments folder filtering', () => { 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: 'In Root', + createdBy: TEST_USER_ID, + folderId: root.id, + }, { portId, documentType: 'other', title: 'At Root', createdBy: TEST_USER_ID, folderId: null }, ]); @@ -1377,10 +1411,7 @@ export const PATCH = withAuth( if (body.folderId !== null) { const folder = await db.query.documentFolders.findFirst({ - where: and( - eq(documentFolders.id, body.folderId), - eq(documentFolders.portId, ctx.portId), - ), + where: and(eq(documentFolders.id, body.folderId), eq(documentFolders.portId, ctx.portId)), }); if (!folder) throw new ValidationError('Folder not found in this port'); } @@ -1446,6 +1477,7 @@ EOF ## Task 8: `useDocumentFolders` hook **Files:** + - Create: `src/hooks/use-document-folders.ts` - [ ] **Step 1: Implement the hook** @@ -1469,8 +1501,7 @@ const FOLDERS_KEY = ['document-folders'] as const; export function useDocumentFolders() { return useQuery({ queryKey: FOLDERS_KEY, - queryFn: () => - apiFetch<{ data: FolderNode[] }>('/api/v1/document-folders').then((r) => r.data), + queryFn: () => apiFetch<{ data: FolderNode[] }>('/api/v1/document-folders').then((r) => r.data), staleTime: 30_000, }); } @@ -1505,8 +1536,7 @@ export function useMoveFolder() { export function useDeleteFolder() { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: string) => - apiFetch(`/api/v1/document-folders/${id}`, { method: 'DELETE' }), + mutationFn: (id: string) => apiFetch(`/api/v1/document-folders/${id}`, { method: 'DELETE' }), onSuccess: () => { qc.invalidateQueries({ queryKey: FOLDERS_KEY }); qc.invalidateQueries({ queryKey: ['documents'] }); @@ -1568,6 +1598,7 @@ EOF ## Task 9: FolderTreeSidebar component **Files:** + - Create: `src/components/documents/folder-tree-sidebar.tsx` - [ ] **Step 1: Implement the sidebar tree** @@ -1765,6 +1796,7 @@ EOF ## Task 10: FolderBreadcrumb component **Files:** + - Create: `src/components/documents/folder-breadcrumb.tsx` - [ ] **Step 1: Implement the breadcrumb** @@ -1874,6 +1906,7 @@ EOF ## Task 11: FolderActionsMenu (create / rename / delete dialogs) **Files:** + - Create: `src/components/documents/folder-actions-menu.tsx` - [ ] **Step 1: Implement the actions menu** @@ -2115,6 +2148,7 @@ EOF ## Task 12: MoveToFolderDialog (per-document picker) **Files:** + - Create: `src/components/documents/move-to-folder-dialog.tsx` - [ ] **Step 1: Implement the move dialog** @@ -2265,6 +2299,7 @@ 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. @@ -2301,10 +2336,7 @@ return ( } />
- + {/* …existing content (tabs, filters, table, etc.) goes here… */}
@@ -2326,7 +2358,7 @@ Find the existing `useQuery` for documents (search for `'documents'` in the quer ```typescript const docsQuery = useQuery({ - queryKey: ['documents', /* existing keys */, selectedFolderId], + queryKey: ['documents' /* existing keys */, , selectedFolderId], queryFn: () => { const params = new URLSearchParams(); // …existing params… @@ -2344,6 +2376,7 @@ const docsQuery = useQuery({ - [ ] **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. @@ -2381,6 +2414,7 @@ Expected: tsc clean; existing documents tests still pass (the tab additions are - [ ] **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. @@ -2419,6 +2453,7 @@ 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) @@ -2427,39 +2462,41 @@ EOF In the hub, replace the existing type Select dropdown with a chip cloud sourced from the documents query response. The simplest path: derive the set of distinct `documentType` values from the current page of results, then render chips: ```tsx -{(() => { - const seenTypes = Array.from( - new Set((docsQuery.data?.data ?? []).map((d) => d.documentType)), - ).sort(); - if (seenTypes.length === 0) return null; - return ( -
- - {seenTypes.map((t) => ( +{ + (() => { + const seenTypes = Array.from( + new Set((docsQuery.data?.data ?? []).map((d) => d.documentType)), + ).sort(); + if (seenTypes.length === 0) return null; + return ( +
- ))} -
- ); -})()} + {seenTypes.map((t) => ( + + ))} +
+ ); + })(); +} ``` Replace the existing `typeFilter` state's type from a constrained enum to `string | undefined` so any documentType seen in the response is acceptable. @@ -2534,6 +2571,7 @@ 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) @@ -2644,6 +2682,7 @@ EOF ## Task 16: Playwright smoke test **Files:** + - Modify: `tests/e2e/smoke/04-documents.spec.ts` - [ ] **Step 1: Add a folder smoke flow** @@ -2703,6 +2742,7 @@ EOF ## Task 17: CLAUDE.md update + final verification **Files:** + - Modify: `CLAUDE.md` - [ ] **Step 1: Add a Documents folders subsection to CLAUDE.md** @@ -2758,9 +2798,259 @@ If you want to push the branch, do so (the user will say if not). --- +## Task 18: Path-style download URLs (storage strategy decision) + +**Context:** Storage paths stay UUID-flat (`{portSlug}/{entity}/{entityId}/{fileUuid}.{ext}`) per the established pattern across all six other content types (brochures, berth PDFs, invoices, reports, templates, expense receipts). The `migrate-storage` script preserves bytes verbatim — a switchover between filesystem and S3/MinIO never rewrites paths. + +The trade-off — that an admin browsing MinIO directly sees UUID gibberish instead of meaningful folder names — is mitigated for the rep-facing UX by serving documents via a **path-style** URL whose user-visible path mirrors the folder tree. The actual file lookup is keyed on the document's `id`; the path segments are decorative + validated for truth. + +**Files:** +- Create: `src/app/api/v1/documents/[id]/download/[...slug]/route.ts` +- Modify: `src/lib/services/documents.service.ts` — add `buildDocumentDownloadUrl(doc, folderTree)` helper that resolves the doc's folder path + filename and emits the URL string. +- Modify: `src/lib/services/documents.service.ts` — `listDocuments` and the single-doc detail returns now include a `downloadUrl` field. + +- [ ] **Step 1: Add the URL builder helper** + +In `src/lib/services/documents.service.ts`, near the existing helpers, add: + +```typescript +import type { FolderNode } from '@/lib/services/document-folders.service'; + +/** + * Resolve the rep-facing download URL for a document. The URL embeds + * the folder path + filename for browser-tab / shared-link readability, + * but the route handler keys lookup off the doc id and validates the + * slug for truth — so a hand-edited URL with a wrong path still 404s + * instead of silently serving the wrong file. + * + * Usage: pass the resolved folder tree once per request and call this + * for each doc in the result set so we don't refetch the tree per row. + */ +export function buildDocumentDownloadUrl( + doc: { id: string; folderId: string | null; filename: string | null }, + folderTree: readonly FolderNode[], +): string { + const segments: string[] = []; + if (doc.folderId) { + const path = findFolderPath(folderTree, doc.folderId); + for (const node of path) segments.push(encodeURIComponent(node.name)); + } + segments.push(encodeURIComponent(doc.filename ?? doc.id)); + return `/api/v1/documents/${doc.id}/download/${segments.join('/')}`; +} + +function findFolderPath(tree: readonly FolderNode[], id: string): FolderNode[] { + for (const node of tree) { + if (node.id === id) return [node]; + const inChild = findFolderPath(node.children, id); + if (inChild.length > 0) return [node, ...inChild]; + } + return []; +} +``` + +The doc passed in needs a `filename` field; it lives on the linked `files` row, so the existing list query needs to join (or the service helper that hydrates documents already does — check). + +- [ ] **Step 2: Inject downloadUrl into the listDocuments response** + +In `listDocuments`, after the rows are fetched, fetch the folder tree once via the existing `listTree` import and map each row to include `downloadUrl: buildDocumentDownloadUrl(row, tree)`. + +- [ ] **Step 3: Create the catch-all download route** + +Create `src/app/api/v1/documents/[id]/download/[...slug]/route.ts`: + +```typescript +import { NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { documents, files } from '@/lib/db/schema/documents'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { getStorageBackend } from '@/lib/storage'; +import { listTree } from '@/lib/services/document-folders.service'; + +interface RouteParams { + id: string; + slug?: string[]; +} + +export const GET = withAuth( + withPermission('documents', 'view', async (_req, ctx, params: RouteParams) => { + try { + const docId = params.id; + if (!docId) throw new NotFoundError('Document'); + + const doc = await db + .select({ + id: documents.id, + folderId: documents.folderId, + fileId: documents.fileId, + fileStoragePath: files.storagePath, + fileMimeType: files.mimeType, + fileFilename: files.filename, + }) + .from(documents) + .leftJoin(files, eq(files.id, documents.fileId)) + .where(and(eq(documents.id, docId), eq(documents.portId, ctx.portId))) + .limit(1) + .then((r) => r[0]); + + if (!doc?.fileStoragePath) throw new NotFoundError('Document file'); + + // Slug truth-check: rebuild what the URL SHOULD be from current + // state and 404 if the supplied slug doesn't match. Stops a + // hand-edited URL from rendering a stale or wrong filename in a + // forwarded link. + const tree = await listTree(ctx.portId); + const expected = buildExpectedSlug(doc.folderId, doc.fileFilename ?? doc.id, tree); + const supplied = (params.slug ?? []).map(decodeURIComponent).join('/'); + if (supplied !== expected) throw new NotFoundError('Document at this path'); + + const backend = await getStorageBackend(); + const stream = await backend.get(doc.fileStoragePath); + + return new NextResponse(stream as unknown as ReadableStream, { + status: 200, + headers: { + 'content-type': doc.fileMimeType ?? 'application/octet-stream', + 'content-disposition': `inline; filename="${doc.fileFilename ?? doc.id}"`, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +function buildExpectedSlug( + folderId: string | null, + filename: string, + tree: Awaited>, +): string { + const segments: string[] = []; + if (folderId) { + function walk(nodes: typeof tree): boolean { + for (const n of nodes) { + if (n.id === folderId) { + segments.push(n.name); + return true; + } + if (walk(n.children)) { + segments.unshift(n.name); + return true; + } + } + return false; + } + walk(tree); + } + segments.push(filename); + return segments.join('/'); +} +``` + +- [ ] **Step 4: Test the truth-check + happy path** + +Create `tests/integration/document-path-style-download.test.ts` with cases: +- Happy path: download a document via correct URL works (returns the file body). +- Wrong-folder-path slug: returns 404. +- Wrong-filename slug: returns 404. +- Missing file (orphaned doc with null fileId): returns 404. +- Cross-port doc (correct slug but different port): returns 404. + +- [ ] **Step 5: Update document-list responses to surface downloadUrl** + +The list endpoint shape gains a `downloadUrl` field per row; the detail endpoint adds the same. UI consumers pick this up automatically when they request /api/v1/documents/... + +- [ ] **Step 6: tsc + vitest** + +```bash +pnpm exec tsc --noEmit +pnpm exec vitest run +``` + +- [ ] **Step 7: Commit** + +``` +feat(documents): path-style download URLs for rep-facing readability + +Storage stays UUID-flat per the established pattern; the new +catch-all /api/v1/documents/[id]/download/[...slug] route serves +files keyed on doc id but validates that the slug matches the +current folder path + filename. URLs in shared links / browser +tabs read like 'Deals 2026/Q1/contract.pdf' even though storage +keys remain UUIDs. listDocuments + detail responses now include +a downloadUrl field so UI consumers don't reconstruct paths. +``` + +--- + +## Task 19: Importer from organized S3/filesystem bucket + +**Context:** When the team migrates from a legacy MinIO bucket whose folder structure represents real organisation (`s3://old-data/Deals 2026/Q1/contract.pdf`), the importer walks that tree, builds matching `document_folders` rows in the CRM, and inserts `documents` rows pointing at the existing storage keys without rewriting them. One-shot script, idempotent. + +**Files:** +- Create: `scripts/import-organized-documents.ts` +- Optional: `tests/unit/import-organized-documents.test.ts` (testing the path-parser pure function). + +- [ ] **Step 1: CLI surface + dry-run output** + +```bash +pnpm tsx scripts/import-organized-documents.ts \ + --port-slug port-nimara \ + --bucket-prefix "legacy-imports/" \ + --dry-run +``` + +Output: a tree of "would create" rows. + +- [ ] **Step 2: Apply mode** + +```bash +pnpm tsx scripts/import-organized-documents.ts \ + --port-slug port-nimara \ + --bucket-prefix "legacy-imports/" \ + --apply +``` + +Idempotent: re-runs are no-ops via: +- `document_folders` sibling-uniqueness index (re-create attempts hit ConflictError → caught + skipped). +- `documents` rows checked by `(portId, fileStoragePath)` before insert. + +- [ ] **Step 3: Pure path-parser** + +Extract a pure function `parseImportPath(prefix, key)` → `{ folderSegments: string[], filename: string }`. Unit-test it with edge cases (trailing slashes, special chars in folder names, empty intermediate segments). + +- [ ] **Step 4: Walker + DB writer** + +Use `getStorageBackend().listByPrefix(...)` if it exists; if not, augment the storage backend interface with a list method (S3: `listObjectsV2`; filesystem: recursive readdir). + +Walk in alphabetical order so `documents` rows in the import log appear deterministically. + +- [ ] **Step 5: Audit log per imported doc** + +Each created `documents` row gets a `createAuditLog({ action: 'create', metadata: { source: 'organized-bucket-importer' } })`. + +- [ ] **Step 6: Commit** + +``` +feat(documents): importer for organized S3/filesystem buckets + +One-shot script that walks an existing organized bucket tree, +creates matching document_folders rows + documents rows pointing +at the storage keys verbatim (no path rewrite). Idempotent via +the sibling-uniqueness index + (portId, fileStoragePath) check. +Use when migrating from a legacy MinIO bucket whose folder +structure already represents real organisation. +``` + +--- + ## Self-review **Spec coverage:** + - Folders (create / delete / nested) — ✅ Tasks 1, 3, 4, 11. - Sort + filter (date, type, owner) — partial: type-filter chips done in Task 14; date sort already exists in `listDocuments`; owner sort isn't explicitly added — flag as a follow-up if reps want it. - Wider file-type allowlist — n/a (no enforced allowlist exists today; out of scope per plan header). @@ -2773,6 +3063,7 @@ If you want to push the branch, do so (the user will say if not). **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.