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).
|
||||
|
||||
**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<FolderNode[]>({
|
||||
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 (
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 p-4 space-y-4">
|
||||
<FolderBreadcrumb
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={setSelectedFolderId}
|
||||
/>
|
||||
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={setSelectedFolderId} />
|
||||
{/* …existing content (tabs, filters, table, etc.) goes here… */}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-xs',
|
||||
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => setTypeFilter(undefined)}
|
||||
>
|
||||
All types
|
||||
</button>
|
||||
{seenTypes.map((t) => (
|
||||
{
|
||||
(() => {
|
||||
const seenTypes = Array.from(
|
||||
new Set((docsQuery.data?.data ?? []).map((d) => d.documentType)),
|
||||
).sort();
|
||||
if (seenTypes.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
key={t}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-xs',
|
||||
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
|
||||
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
onClick={() => setTypeFilter(undefined)}
|
||||
>
|
||||
{t}
|
||||
All types
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{seenTypes.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-xs',
|
||||
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
}
|
||||
```
|
||||
|
||||
Replace the existing `typeFilter` state's type from a constrained enum to `string | undefined` so any documentType seen in the response is acceptable.
|
||||
@@ -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<ReturnType<typeof listTree>>,
|
||||
): string {
|
||||
const segments: string[] = [];
|
||||
if (folderId) {
|
||||
function walk(nodes: typeof tree): boolean {
|
||||
for (const n of nodes) {
|
||||
if (n.id === folderId) {
|
||||
segments.push(n.name);
|
||||
return true;
|
||||
}
|
||||
if (walk(n.children)) {
|
||||
segments.unshift(n.name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
walk(tree);
|
||||
}
|
||||
segments.push(filename);
|
||||
return segments.join('/');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test the truth-check + happy path**
|
||||
|
||||
Create `tests/integration/document-path-style-download.test.ts` with cases:
|
||||
- Happy path: download a document via correct URL works (returns the file body).
|
||||
- Wrong-folder-path slug: returns 404.
|
||||
- Wrong-filename slug: returns 404.
|
||||
- Missing file (orphaned doc with null fileId): returns 404.
|
||||
- Cross-port doc (correct slug but different port): returns 404.
|
||||
|
||||
- [ ] **Step 5: Update document-list responses to surface downloadUrl**
|
||||
|
||||
The list endpoint shape gains a `downloadUrl` field per row; the detail endpoint adds the same. UI consumers pick this up automatically when they request /api/v1/documents/...
|
||||
|
||||
- [ ] **Step 6: tsc + vitest**
|
||||
|
||||
```bash
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm exec vitest run
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
feat(documents): path-style download URLs for rep-facing readability
|
||||
|
||||
Storage stays UUID-flat per the established pattern; the new
|
||||
catch-all /api/v1/documents/[id]/download/[...slug] route serves
|
||||
files keyed on doc id but validates that the slug matches the
|
||||
current folder path + filename. URLs in shared links / browser
|
||||
tabs read like 'Deals 2026/Q1/contract.pdf' even though storage
|
||||
keys remain UUIDs. listDocuments + detail responses now include
|
||||
a downloadUrl field so UI consumers don't reconstruct paths.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 19: Importer from organized S3/filesystem bucket
|
||||
|
||||
**Context:** When the team migrates from a legacy MinIO bucket whose folder structure represents real organisation (`s3://old-data/Deals 2026/Q1/contract.pdf`), the importer walks that tree, builds matching `document_folders` rows in the CRM, and inserts `documents` rows pointing at the existing storage keys without rewriting them. One-shot script, idempotent.
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/import-organized-documents.ts`
|
||||
- Optional: `tests/unit/import-organized-documents.test.ts` (testing the path-parser pure function).
|
||||
|
||||
- [ ] **Step 1: CLI surface + dry-run output**
|
||||
|
||||
```bash
|
||||
pnpm tsx scripts/import-organized-documents.ts \
|
||||
--port-slug port-nimara \
|
||||
--bucket-prefix "legacy-imports/" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Output: a tree of "would create" rows.
|
||||
|
||||
- [ ] **Step 2: Apply mode**
|
||||
|
||||
```bash
|
||||
pnpm tsx scripts/import-organized-documents.ts \
|
||||
--port-slug port-nimara \
|
||||
--bucket-prefix "legacy-imports/" \
|
||||
--apply
|
||||
```
|
||||
|
||||
Idempotent: re-runs are no-ops via:
|
||||
- `document_folders` sibling-uniqueness index (re-create attempts hit ConflictError → caught + skipped).
|
||||
- `documents` rows checked by `(portId, fileStoragePath)` before insert.
|
||||
|
||||
- [ ] **Step 3: Pure path-parser**
|
||||
|
||||
Extract a pure function `parseImportPath(prefix, key)` → `{ folderSegments: string[], filename: string }`. Unit-test it with edge cases (trailing slashes, special chars in folder names, empty intermediate segments).
|
||||
|
||||
- [ ] **Step 4: Walker + DB writer**
|
||||
|
||||
Use `getStorageBackend().listByPrefix(...)` if it exists; if not, augment the storage backend interface with a list method (S3: `listObjectsV2`; filesystem: recursive readdir).
|
||||
|
||||
Walk in alphabetical order so `documents` rows in the import log appear deterministically.
|
||||
|
||||
- [ ] **Step 5: Audit log per imported doc**
|
||||
|
||||
Each created `documents` row gets a `createAuditLog({ action: 'create', metadata: { source: 'organized-bucket-importer' } })`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(documents): importer for organized S3/filesystem buckets
|
||||
|
||||
One-shot script that walks an existing organized bucket tree,
|
||||
creates matching document_folders rows + documents rows pointing
|
||||
at the storage keys verbatim (no path rewrite). Idempotent via
|
||||
the sibling-uniqueness index + (portId, fileStoragePath) check.
|
||||
Use when migrating from a legacy MinIO bucket whose folder
|
||||
structure already represents real organisation.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- Folders (create / delete / nested) — ✅ Tasks 1, 3, 4, 11.
|
||||
- Sort + filter (date, type, owner) — partial: type-filter chips done in Task 14; date sort already exists in `listDocuments`; owner sort isn't explicitly added — flag as a follow-up if reps want it.
|
||||
- Wider file-type allowlist — n/a (no enforced allowlist exists today; out of scope per plan header).
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user