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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:50:28 +02:00
parent e9251a399a
commit 9f3e739c76

View File

@@ -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). **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):** **Decisions locked (from 2026-05-09 review):**
- **Folder scope:** port-wide (one tree per port). - **Folder scope:** port-wide (one tree per port).
- **Hub tabs:** stay flat across the port; folder is an orthogonal filter. - **Hub tabs:** stay flat across the port; folder is an orthogonal filter.
- **Signature-based only pill:** **drop entirely**. - **Signature-based only pill:** **drop entirely**.
@@ -19,12 +20,14 @@
- **Folder watchers:** **out of scope** for this plan; doc-level watchers only. - **Folder watchers:** **out of scope** for this plan; doc-level watchers only.
**Out of scope (separate work):** **Out of scope (separate work):**
- Folder watchers / subscriptions. - Folder watchers / subscriptions.
- Wider file-type allowlist (the upload route already accepts any MIME — no enforcement to widen). - 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). - Bulk multi-select move (single-doc move only in v1).
- Folder color tags / icons (boring grey folders are fine for v1). - Folder color tags / icons (boring grey folders are fine for v1).
**Conventions to honour (from `CLAUDE.md`):** **Conventions to honour (from `CLAUDE.md`):**
- Strict TypeScript, no `any`. Unused vars prefixed `_`. - Strict TypeScript, no `any`. Unused vars prefixed `_`.
- Prettier: single quotes, semicolons, trailing commas, 100-char width. - Prettier: single quotes, semicolons, trailing commas, 100-char width.
- Body parsing: ALWAYS use `parseBody(req, schema)` from `@/lib/api/route-helpers`. - Body parsing: ALWAYS use `parseBody(req, schema)` from `@/lib/api/route-helpers`.
@@ -38,31 +41,38 @@
## File Structure ## File Structure
**Schema (1 file modified, 1 migration created):** **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/documents.ts` — add `documentFolders` table; add `folderId` column to `documents`.
- Modify: `src/lib/db/schema/users.ts` — add `documents.manage_folders` to `RolePermissions['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. - Create: `src/lib/db/migrations/0050_document_folders.sql` — manual migration; backfill notes.
**Validators (1 created, 1 modified):** **Validators (1 created, 1 modified):**
- Create: `src/lib/validators/document-folders.ts` — Zod schemas for create / rename / move. - 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`. - Modify: `src/lib/validators/documents.ts` — add `folderId` to `createDocumentSchema` and `listDocumentsSchema`.
**Service (1 created, 1 modified):** **Service (1 created, 1 modified):**
- Create: `src/lib/services/document-folders.service.ts``listTree`, `createFolder`, `renameFolder`, `moveFolder`, `deleteFolderSoftRescue`, `resolvePath`. - 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`. - Modify: `src/lib/services/documents.service.ts` — accept `folderId` in `createDocument`; filter on `folderId` in `listDocuments`.
**API routes (3 created, 1 modified):** **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/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/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. - 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. - Modify: `src/app/api/v1/documents/route.ts` — surface `folderId` filter in `GET` query parsing.
**Roles seeding (1 modified):** **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. - 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):** **Hooks (1 created):**
- Create: `src/hooks/use-document-folders.ts` — TanStack Query wrapper for tree fetch + invalidation helpers. - Create: `src/hooks/use-document-folders.ts` — TanStack Query wrapper for tree fetch + invalidation helpers.
**UI components (4 created, 1 modified):** **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-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-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/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. - 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):** **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. - 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):** **Tests (4 created, 1 modified):**
- Create: `tests/unit/document-folders-validators.test.ts` — Zod validation edge cases. - 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-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/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). - Modify: `tests/e2e/smoke/04-documents.spec.ts` — add a folder smoke test (create folder, move doc, navigate).
**Docs (1 modified):** **Docs (1 modified):**
- Modify: `CLAUDE.md` — Add a "Documents folders" subsection under the Conventions block describing the folder model + the `documents.manage_folders` perm. - 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 ## Task 1: Schema — `document_folders` table + `folder_id` on documents
**Files:** **Files:**
- Modify: `src/lib/db/schema/documents.ts` - Modify: `src/lib/db/schema/documents.ts`
- Create: `src/lib/db/migrations/0050_document_folders.sql` - Create: `src/lib/db/migrations/0050_document_folders.sql`
@@ -254,6 +268,7 @@ EOF
## Task 2: Add `documents.manage_folders` permission ## Task 2: Add `documents.manage_folders` permission
**Files:** **Files:**
- Modify: `src/lib/db/schema/users.ts` - 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-data/role-permissions.ts` (path may differ — `grep -rn 'manage_folders' src/lib` to discover)
- Modify: `src/lib/db/seed.ts` (re-seed roles) - Modify: `src/lib/db/seed.ts` (re-seed roles)
@@ -283,6 +298,7 @@ documents: {
- [ ] **Step 3: Backfill defaults in the role seed file** - [ ] **Step 3: Backfill defaults in the role seed file**
In whichever role-seed file you found, set `documents.manage_folders` for each role: In whichever role-seed file you found, set `documents.manage_folders` for each role:
- `admin`: `true` - `admin`: `true`
- `sales_manager`: `true` - `sales_manager`: `true`
- `sales_rep`: `false` - `sales_rep`: `false`
@@ -328,6 +344,7 @@ EOF
## Task 3: Folder service — types + listTree ## Task 3: Folder service — types + listTree
**Files:** **Files:**
- Create: `src/lib/services/document-folders.service.ts` - Create: `src/lib/services/document-folders.service.ts`
- Test: `tests/integration/document-folders-crud.test.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 ## Task 4: Folder service — rename, move (cycle prevention), soft-rescue delete
**Files:** **Files:**
- Modify: `src/lib/services/document-folders.service.ts` - Modify: `src/lib/services/document-folders.service.ts`
- Test: `tests/integration/document-folders-crud.test.ts` (extend) - Test: `tests/integration/document-folders-crud.test.ts` (extend)
- Create: `tests/integration/document-folders-soft-delete.test.ts` - Create: `tests/integration/document-folders-soft-delete.test.ts`
@@ -727,7 +745,8 @@ export async function moveFolder(
} }
if (seen.has(cursor)) break; // defensive — pre-existing cycle, bail if (seen.has(cursor)) break; // defensive — pre-existing cycle, bail
seen.add(cursor); seen.add(cursor);
const next: { parentId: string | null } | undefined = await db.query.documentFolders.findFirst({ const next: { parentId: string | null } | undefined =
await db.query.documentFolders.findFirst({
where: eq(documentFolders.id, cursor), where: eq(documentFolders.id, cursor),
columns: { parentId: true }, columns: { parentId: true },
}); });
@@ -767,10 +786,7 @@ import { eq } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { documentFolders, documents } from '@/lib/db/schema/documents'; import { documentFolders, documents } from '@/lib/db/schema/documents';
import { import { createFolder, deleteFolderSoftRescue } from '@/lib/services/document-folders.service';
createFolder,
deleteFolderSoftRescue,
} from '@/lib/services/document-folders.service';
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures'; import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
describe('document-folders · deleteFolderSoftRescue', () => { describe('document-folders · deleteFolderSoftRescue', () => {
@@ -878,12 +894,7 @@ export async function deleteFolderSoftRescue(
await tx await tx
.update(documentFolders) .update(documentFolders)
.set({ parentId: newParent, updatedAt: new Date() }) .set({ parentId: newParent, updatedAt: new Date() })
.where( .where(and(eq(documentFolders.parentId, folderId), eq(documentFolders.portId, portId)));
and(
eq(documentFolders.parentId, folderId),
eq(documentFolders.portId, portId),
),
);
// Re-parent child documents. // Re-parent child documents.
await tx await tx
@@ -943,6 +954,7 @@ EOF
## Task 5: Folder validators ## Task 5: Folder validators
**Files:** **Files:**
- Create: `src/lib/validators/document-folders.ts` - Create: `src/lib/validators/document-folders.ts`
- Create: `tests/unit/document-folders-validators.test.ts` - Create: `tests/unit/document-folders-validators.test.ts`
@@ -961,16 +973,14 @@ import {
describe('document-folder validators', () => { describe('document-folder validators', () => {
it('accepts a valid create payload', () => { it('accepts a valid create payload', () => {
expect(createFolderSchema.safeParse({ name: 'Deals', parentId: null }).success).toBe(true); expect(createFolderSchema.safeParse({ name: 'Deals', parentId: null }).success).toBe(true);
expect( expect(createFolderSchema.safeParse({ name: 'Q1', parentId: 'abc-123' }).success).toBe(true);
createFolderSchema.safeParse({ name: 'Q1', parentId: 'abc-123' }).success,
).toBe(true);
}); });
it('rejects empty + over-long names', () => { it('rejects empty + over-long names', () => {
expect(createFolderSchema.safeParse({ name: '', parentId: null }).success).toBe(false); expect(createFolderSchema.safeParse({ name: '', parentId: null }).success).toBe(false);
expect( expect(createFolderSchema.safeParse({ name: 'x'.repeat(201), parentId: null }).success).toBe(
createFolderSchema.safeParse({ name: 'x'.repeat(201), parentId: null }).success, false,
).toBe(false); );
}); });
it('rejects whitespace-only names', () => { it('rejects whitespace-only names', () => {
@@ -1055,6 +1065,7 @@ EOF
## Task 6: Folder API routes ## Task 6: Folder API routes
**Files:** **Files:**
- Create: `src/app/api/v1/document-folders/route.ts` - Create: `src/app/api/v1/document-folders/route.ts`
- Create: `src/app/api/v1/document-folders/[id]/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 { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, NotFoundError } from '@/lib/errors'; import { errorResponse, NotFoundError } from '@/lib/errors';
import { import { renameFolderSchema, moveFolderSchema } from '@/lib/validators/document-folders';
renameFolderSchema,
moveFolderSchema,
} from '@/lib/validators/document-folders';
import { import {
renameFolder, renameFolder,
moveFolder, moveFolder,
@@ -1211,6 +1219,7 @@ EOF
## Task 7: Move-document-to-folder API route + service plumbing ## Task 7: Move-document-to-folder API route + service plumbing
**Files:** **Files:**
- Modify: `src/lib/services/documents.service.ts` - Modify: `src/lib/services/documents.service.ts`
- Modify: `src/lib/validators/documents.ts` - Modify: `src/lib/validators/documents.ts`
- Create: `src/app/api/v1/documents/[id]/folder/route.ts` - Create: `src/app/api/v1/documents/[id]/folder/route.ts`
@@ -1247,6 +1256,7 @@ if (query.folderId !== undefined) {
``` ```
You'll need to: You'll need to:
1. Add `isNull, inArray` to the existing drizzle imports if missing. 1. Add `isNull, inArray` to the existing drizzle imports if missing.
2. Import `listTree` from `'@/lib/services/document-folders.service'`. 2. Import `listTree` from `'@/lib/services/document-folders.service'`.
3. Add a helper `collectDescendantIds` to that same service file: 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 root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id }); const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id });
await db.insert(documents).values([ 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: '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 }); 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 root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id }); const sub = await createFolder(portId, TEST_USER_ID, { name: 'Sub', parentId: root.id });
await db.insert(documents).values([ 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: '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 () => { it('folderId=null returns only docs at root', async () => {
const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null }); const root = await createFolder(portId, TEST_USER_ID, { name: 'Root', parentId: null });
await db.insert(documents).values([ 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 }, { portId, documentType: 'other', title: 'At Root', createdBy: TEST_USER_ID, folderId: null },
]); ]);
@@ -1377,10 +1411,7 @@ export const PATCH = withAuth(
if (body.folderId !== null) { if (body.folderId !== null) {
const folder = await db.query.documentFolders.findFirst({ const folder = await db.query.documentFolders.findFirst({
where: and( where: and(eq(documentFolders.id, body.folderId), eq(documentFolders.portId, ctx.portId)),
eq(documentFolders.id, body.folderId),
eq(documentFolders.portId, ctx.portId),
),
}); });
if (!folder) throw new ValidationError('Folder not found in this port'); if (!folder) throw new ValidationError('Folder not found in this port');
} }
@@ -1446,6 +1477,7 @@ EOF
## Task 8: `useDocumentFolders` hook ## Task 8: `useDocumentFolders` hook
**Files:** **Files:**
- Create: `src/hooks/use-document-folders.ts` - Create: `src/hooks/use-document-folders.ts`
- [ ] **Step 1: Implement the hook** - [ ] **Step 1: Implement the hook**
@@ -1469,8 +1501,7 @@ const FOLDERS_KEY = ['document-folders'] as const;
export function useDocumentFolders() { export function useDocumentFolders() {
return useQuery<FolderNode[]>({ return useQuery<FolderNode[]>({
queryKey: FOLDERS_KEY, queryKey: FOLDERS_KEY,
queryFn: () => queryFn: () => apiFetch<{ data: FolderNode[] }>('/api/v1/document-folders').then((r) => r.data),
apiFetch<{ data: FolderNode[] }>('/api/v1/document-folders').then((r) => r.data),
staleTime: 30_000, staleTime: 30_000,
}); });
} }
@@ -1505,8 +1536,7 @@ export function useMoveFolder() {
export function useDeleteFolder() { export function useDeleteFolder() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/document-folders/${id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/document-folders/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: FOLDERS_KEY }); qc.invalidateQueries({ queryKey: FOLDERS_KEY });
qc.invalidateQueries({ queryKey: ['documents'] }); qc.invalidateQueries({ queryKey: ['documents'] });
@@ -1568,6 +1598,7 @@ EOF
## Task 9: FolderTreeSidebar component ## Task 9: FolderTreeSidebar component
**Files:** **Files:**
- Create: `src/components/documents/folder-tree-sidebar.tsx` - Create: `src/components/documents/folder-tree-sidebar.tsx`
- [ ] **Step 1: Implement the sidebar tree** - [ ] **Step 1: Implement the sidebar tree**
@@ -1765,6 +1796,7 @@ EOF
## Task 10: FolderBreadcrumb component ## Task 10: FolderBreadcrumb component
**Files:** **Files:**
- Create: `src/components/documents/folder-breadcrumb.tsx` - Create: `src/components/documents/folder-breadcrumb.tsx`
- [ ] **Step 1: Implement the breadcrumb** - [ ] **Step 1: Implement the breadcrumb**
@@ -1874,6 +1906,7 @@ EOF
## Task 11: FolderActionsMenu (create / rename / delete dialogs) ## Task 11: FolderActionsMenu (create / rename / delete dialogs)
**Files:** **Files:**
- Create: `src/components/documents/folder-actions-menu.tsx` - Create: `src/components/documents/folder-actions-menu.tsx`
- [ ] **Step 1: Implement the actions menu** - [ ] **Step 1: Implement the actions menu**
@@ -2115,6 +2148,7 @@ EOF
## Task 12: MoveToFolderDialog (per-document picker) ## Task 12: MoveToFolderDialog (per-document picker)
**Files:** **Files:**
- Create: `src/components/documents/move-to-folder-dialog.tsx` - Create: `src/components/documents/move-to-folder-dialog.tsx`
- [ ] **Step 1: Implement the move dialog** - [ ] **Step 1: Implement the move dialog**
@@ -2265,6 +2299,7 @@ EOF
## Task 13: Wire DocumentsHub — sidebar + breadcrumb + drop signature pill + In-progress tab ## Task 13: Wire DocumentsHub — sidebar + breadcrumb + drop signature pill + In-progress tab
**Files:** **Files:**
- Modify: `src/components/documents/documents-hub.tsx` - Modify: `src/components/documents/documents-hub.tsx`
This task is bigger than the others. Read the current file first, then make targeted edits. This task is bigger than the others. Read the current file first, then make targeted edits.
@@ -2301,10 +2336,7 @@ return (
} }
/> />
<div className="flex-1 min-w-0 p-4 space-y-4"> <div className="flex-1 min-w-0 p-4 space-y-4">
<FolderBreadcrumb <FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={setSelectedFolderId} />
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
/>
{/* …existing content (tabs, filters, table, etc.) goes here… */} {/* …existing content (tabs, filters, table, etc.) goes here… */}
</div> </div>
</div> </div>
@@ -2326,7 +2358,7 @@ Find the existing `useQuery` for documents (search for `'documents'` in the quer
```typescript ```typescript
const docsQuery = useQuery({ const docsQuery = useQuery({
queryKey: ['documents', /* existing keys */, selectedFolderId], queryKey: ['documents' /* existing keys */, , selectedFolderId],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
// …existing params… // …existing params…
@@ -2344,6 +2376,7 @@ const docsQuery = useQuery({
- [ ] **Step 4: Drop the `signatureOnly` toggle** - [ ] **Step 4: Drop the `signatureOnly` toggle**
Search for `signatureOnly` and remove: Search for `signatureOnly` and remove:
- The state (`useState`). - The state (`useState`).
- The toggle UI (likely a Switch or Pill). - The toggle UI (likely a Switch or Pill).
- The query parameter wiring. - 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** - [ ] **Step 7: Smoke check via the browser**
Run `pnpm dev` (restart if it was running before the schema migration). Navigate to `/{portSlug}/documents`. Verify: 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). - Sidebar renders with "All documents" + "Root" + (empty tree initially).
- Breadcrumb shows "All". - Breadcrumb shows "All".
- Tabs show: All / In progress / EOI queue / Awaiting them / Awaiting me / Completed / Expired. - 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 ## Task 14: Dynamic type-filter chips + "Move to folder" row action
**Files:** **Files:**
- Modify: `src/components/documents/documents-hub.tsx` - Modify: `src/components/documents/documents-hub.tsx`
- Modify: `src/components/documents/document-list.tsx` (or wherever the per-row action menu lives) - Modify: `src/components/documents/document-list.tsx` (or wherever the per-row action menu lives)
@@ -2427,7 +2462,8 @@ 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: 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 ```tsx
{(() => { {
(() => {
const seenTypes = Array.from( const seenTypes = Array.from(
new Set((docsQuery.data?.data ?? []).map((d) => d.documentType)), new Set((docsQuery.data?.data ?? []).map((d) => d.documentType)),
).sort(); ).sort();
@@ -2459,7 +2495,8 @@ In the hub, replace the existing type Select dropdown with a chip cloud sourced
))} ))}
</div> </div>
); );
})()} })();
}
``` ```
Replace the existing `typeFilter` state's type from a constrained enum to `string | undefined` so any documentType seen in the response is acceptable. 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 ## Task 15: Admin-configurable Expired tab
**Files:** **Files:**
- Modify: `src/components/admin/settings/settings-manager.tsx` - Modify: `src/components/admin/settings/settings-manager.tsx`
- Modify: `src/components/documents/documents-hub.tsx` - Modify: `src/components/documents/documents-hub.tsx`
- Modify: `src/lib/services/settings.service.ts` (if a typed reader doesn't already exist) - 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 ## Task 16: Playwright smoke test
**Files:** **Files:**
- Modify: `tests/e2e/smoke/04-documents.spec.ts` - Modify: `tests/e2e/smoke/04-documents.spec.ts`
- [ ] **Step 1: Add a folder smoke flow** - [ ] **Step 1: Add a folder smoke flow**
@@ -2703,6 +2742,7 @@ EOF
## Task 17: CLAUDE.md update + final verification ## Task 17: CLAUDE.md update + final verification
**Files:** **Files:**
- Modify: `CLAUDE.md` - Modify: `CLAUDE.md`
- [ ] **Step 1: Add a Documents folders subsection to 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<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 ## Self-review
**Spec coverage:** **Spec coverage:**
- Folders (create / delete / nested) — ✅ Tasks 1, 3, 4, 11. - 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. - 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). - 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. **Placeholder scan:** none — every step has concrete code or a precise instruction with the exact file path and line context.
**Type consistency:** **Type consistency:**
- `FolderNode` defined identically in service (Task 3) and hook (Task 8). - `FolderNode` defined identically in service (Task 3) and hook (Task 8).
- `selectedFolderId: string | null | undefined` consistent across sidebar, breadcrumb, hub, actions menu. - `selectedFolderId: string | null | undefined` consistent across sidebar, breadcrumb, hub, actions menu.
- `folderId: string | null` consistent across validators, service, document moves. - `folderId: string | null` consistent across validators, service, document moves.