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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user