From 5422f11747ba1fa32ffc8df9964c0c494dc8d12b Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 May 2026 10:57:37 +0200 Subject: [PATCH] chore: prettier formatter drift across recent commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prettier reformatting on files touched in the wave 11.B sequence — markdown italics _underscore-style_, single-line conditionals, minor whitespace fixes. No semantic changes. .env.example reformatting left unstaged (blocked by pre-commit hook). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-09-documents-folders.md | 42 ++++++------- ...nts-hub-split-and-client-folders-design.md | 62 +++++++++---------- scripts/import-organized-documents.ts | 4 +- src/app/api/v1/documents/[id]/folder/route.ts | 5 +- .../documents/folder-actions-menu.tsx | 4 +- src/lib/storage/filesystem.ts | 8 +-- .../integration/document-folders-crud.test.ts | 8 ++- .../document-folders-soft-delete.test.ts | 9 +-- .../document-path-style-download.test.ts | 25 +++----- .../unit/document-folders-validators.test.ts | 10 ++- 10 files changed, 82 insertions(+), 95 deletions(-) diff --git a/docs/superpowers/plans/2026-05-09-documents-folders.md b/docs/superpowers/plans/2026-05-09-documents-folders.md index 50e47b32..0c0b1a51 100644 --- a/docs/superpowers/plans/2026-05-09-documents-folders.md +++ b/docs/superpowers/plans/2026-05-09-documents-folders.md @@ -8,27 +8,27 @@ Working on branch `feat/documents-folders` (off `main`). Subagent-driven execution: every task gets implementer → spec reviewer → code-quality reviewer → fix loop if needed. -| Task | Topic | Status | Commit(s) | -|------|-------|--------|-----------| -| 1 | Schema + migration (`document_folders` + `folder_id` on documents) | ✅ Done | `5bed62d` + `4a50bab` (fix: Drizzle `.references()` + relations) | -| 2 | `documents.manage_folders` permission | ✅ Done | `e6cf50f` | -| 3 | Service: `listTree` + `createFolder` (TDD) | ✅ Done | `4b31f01` + `5c5ab49` (fix: port-scope test cleanup + tighten message) | -| 4 | Service: rename + move (cycle prevention) + soft-rescue delete | ✅ Done | `e9251a3` + `4ec0004` (fix: audit-log out of tx + portId on ancestor walk + drop misleading updatedAt + userId for rename/move audit) | -| 5 | Zod validators | ✅ Done | `830ac39` | -| 6 | Folder API routes (GET tree / POST / PATCH rename-or-move / DELETE) | ✅ Done | `1082b80` + `e9d5df6` (fix: `.strict()` on union members so `{name, parentId}` together is a 400 not silent drop) | -| 7 | listDocuments folder filter + per-doc move route | ✅ Done | `a0ffa1b` | -| 8 | `useDocumentFolders` hook | 🔴 Not started | — | -| 9 | `FolderTreeSidebar` component | 🔴 Not started | — | -| 10 | `FolderBreadcrumb` component | 🔴 Not started | — | -| 11 | `FolderActionsMenu` (create/rename/delete dialogs) | 🔴 Not started | — | -| 12 | `MoveToFolderDialog` (per-doc picker) | 🔴 Not started | — | -| 13 | Wire `DocumentsHub`: sidebar + breadcrumb, drop signature pill, In-progress tab | 🔴 Not started | — | -| 14 | Dynamic type-filter chips + per-row Move action | 🔴 Not started | — | -| 15 | Admin-configurable Expired tab | 🔴 Not started | — | -| 16 | Playwright smoke test | 🔴 Not started | — | -| 17 | CLAUDE.md update + final verification | 🔴 Not started | — | -| 18 | **NEW** — path-style download URLs (hybrid storage decision) | 🔴 Not started | — | -| 19 | **NEW** — importer from organized S3/filesystem bucket | 🔴 Not started | — | +| Task | Topic | Status | Commit(s) | +| ---- | ------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Schema + migration (`document_folders` + `folder_id` on documents) | ✅ Done | `5bed62d` + `4a50bab` (fix: Drizzle `.references()` + relations) | +| 2 | `documents.manage_folders` permission | ✅ Done | `e6cf50f` | +| 3 | Service: `listTree` + `createFolder` (TDD) | ✅ Done | `4b31f01` + `5c5ab49` (fix: port-scope test cleanup + tighten message) | +| 4 | Service: rename + move (cycle prevention) + soft-rescue delete | ✅ Done | `e9251a3` + `4ec0004` (fix: audit-log out of tx + portId on ancestor walk + drop misleading updatedAt + userId for rename/move audit) | +| 5 | Zod validators | ✅ Done | `830ac39` | +| 6 | Folder API routes (GET tree / POST / PATCH rename-or-move / DELETE) | ✅ Done | `1082b80` + `e9d5df6` (fix: `.strict()` on union members so `{name, parentId}` together is a 400 not silent drop) | +| 7 | listDocuments folder filter + per-doc move route | ✅ Done | `a0ffa1b` | +| 8 | `useDocumentFolders` hook | 🔴 Not started | — | +| 9 | `FolderTreeSidebar` component | 🔴 Not started | — | +| 10 | `FolderBreadcrumb` component | 🔴 Not started | — | +| 11 | `FolderActionsMenu` (create/rename/delete dialogs) | 🔴 Not started | — | +| 12 | `MoveToFolderDialog` (per-doc picker) | 🔴 Not started | — | +| 13 | Wire `DocumentsHub`: sidebar + breadcrumb, drop signature pill, In-progress tab | 🔴 Not started | — | +| 14 | Dynamic type-filter chips + per-row Move action | 🔴 Not started | — | +| 15 | Admin-configurable Expired tab | 🔴 Not started | — | +| 16 | Playwright smoke test | 🔴 Not started | — | +| 17 | CLAUDE.md update + final verification | 🔴 Not started | — | +| 18 | **NEW** — path-style download URLs (hybrid storage decision) | 🔴 Not started | — | +| 19 | **NEW** — importer from organized S3/filesystem bucket | 🔴 Not started | — | **Test posture at pause:** `pnpm exec tsc --noEmit` clean; full vitest suite **1213/1213 passing** (108 test files). 11 commits on the branch ahead of `main`. diff --git a/docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md b/docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md index 661ac35d..b1590aea 100644 --- a/docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md +++ b/docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md @@ -20,7 +20,7 @@ This spec unifies both surfaces under a single hub with a stacked **Signing in p Three first-class concepts after this spec ships: - **File** (`files` row) — a stored binary artifact (PDF/image/etc.) with one `folder_id` and entity FKs (`client_id` / `company_id` / `yacht_id`). The canonical "document" reps file and find. Produced by either direct upload or as the output of a completed signing workflow. -- **Signing workflow** (`documents` row) — the *process* of getting a PDF signed via Documenso. Lifecycle `draft` → `sent` → `partially_signed` → `completed`. Surfaces in the hub's Signing section while in-flight. On completion, produces a signed-PDF file; the workflow row itself becomes audit history accessed via a "view signing details" link on the resulting file. Stops appearing in user-facing folder views. +- **Signing workflow** (`documents` row) — the _process_ of getting a PDF signed via Documenso. Lifecycle `draft` → `sent` → `partially_signed` → `completed`. Surfaces in the hub's Signing section while in-flight. On completion, produces a signed-PDF file; the workflow row itself becomes audit history accessed via a "view signing details" link on the resulting file. Stops appearing in user-facing folder views. - **Folder** (`document_folders` row) — per-port nestable tree (existing). Extended to hold both files and in-flight workflows. Gains three system-managed roots and per-entity auto-subfolders. `documents.folder_id` stays meaningful for in-flight workflows (rep can file by deal/project). Becomes irrelevant on completion — the rendering layer hides completed workflows from folder views entirely. @@ -56,7 +56,7 @@ Three first-class concepts after this spec ships: - Bulk file actions (multi-select move, multi-select download zip) — separate work - Tagging or labels on files — separate work - Trash / restore for hard-deleted files (current behavior preserved) -- Search across file *content* (full-text PDF search) — current behavior preserved (search is title/filename only) +- Search across file _content_ (full-text PDF search) — current behavior preserved (search is title/filename only) - Per-port admin override for aggregation symmetry (rejected as needless setting at E11) - Per-user feature flag rollout — hard cutover (E rollout decision) - Native PDF preview rebuild — existing `FilePreviewDialog` reused @@ -78,7 +78,7 @@ Per-entity subfolders are created **lazily on first need** — when a workflow c Subfolder naming: - Default name = entity display name (client `firstName lastName` / company `name` / yacht `name`). -- Numeric collision suffix: `Smith, John (2)`, `Smith, John (3)`, etc. Suffix appended to the *new* (later-created) folder; existing folder names never change due to collision. +- Numeric collision suffix: `Smith, John (2)`, `Smith, John (3)`, etc. Suffix appended to the _new_ (later-created) folder; existing folder names never change due to collision. - Auto-rename on entity rename — runs in the same DB transaction as the entity update. - Entity archive: `(archived)` suffix appended, folder shown muted in tree, auto-deposit blocked until restored. - Entity hard-delete: `(deleted)` suffix appended, `system_managed` flipped to `false` (folder demoted to a regular user folder; rep can rename/move/delete normally). @@ -152,7 +152,7 @@ Aggregation is **symmetric** (E aggregation reach decision). Walking from any en - linked clients via `company_memberships` - linked companies via `company_memberships` and via yacht ownership - linked yachts via current ownership (`yachts.current_owner_type` + `current_owner_id`) -- + any second-degree links (e.g., `Clients/Smith` shows files of `Smith Marine LLC`'s yachts via the chain Smith → Smith Marine LLC → owned yachts) +- - any second-degree links (e.g., `Clients/Smith` shows files of `Smith Marine LLC`'s yachts via the chain Smith → Smith Marine LLC → owned yachts) Each result group is rendered with a labelled header: `DIRECTLY ATTACHED · 3`, `FROM COMPANY — SMITH MARINE LLC · 1`, `FROM YACHT — MV SERENITY · 2`, etc. Files lived where they were physically filed (e.g., `Yachts/MV Serenity/`); the aggregation only borrows them for display, with a `lives in ` caption per row. @@ -227,26 +227,26 @@ Today's signing-status tabs (`in_progress` / `eoi_queue` / `awaiting_them` / `aw ## Edge cases — decisions -| ID | Edge case | Decision | -|----|-----------|----------| -| E1 | Entity renamed | System folder name auto-syncs in the same transaction. | -| E2 | Two entities collide on folder name (e.g., both "Smith, John") | Append numeric suffix `(2)`, `(3)` to the **new** colliding folder. Existing folders never change. | -| E3 | Entity archived | Folder stays with `(archived)` suffix, muted style. Auto-deposit halts. | -| E4 | Entity hard-deleted | Folder gets `(deleted)` suffix, `system_managed` flips to `false` (rep can clean up). Files retain orphaned data. | -| E5 | Yacht ownership transferred | Files snapshot their entity FKs at upload time. Old client folder retains historical files; new client gets a new folder for files created after transfer. Both coexist. | -| E6 | Workflow's owner FK changes mid-signing | Resolve owner at completion time. Signed PDF lands in current owner's folder. | -| E7 | Rep moves a file out of a system folder | Allowed. `folder_id` changes; entity FK is unchanged so aggregation still surfaces it via FK. The "lives in …" caption updates. | -| E8 | Rep manually uploads into an entity folder | Auto-set the file's matching entity FK from the destination folder's `entity_type` + `entity_id`. Custom folders → no auto-mapping. | -| E9 | Workflow has no entity at all | Signed PDF lands at root with `folder_id = null`. Surfaces in root-view Files section only. | -| E10 | File/workflow attached to interest only, interest has no resolved owner | Same as E9 — root, null folder. Manual move or future backfill resolves later. | -| E11 | Aggregated view returns 1000+ files | Top 20 per owner-source group, `Show all (N)` drilldown into flat paginated list per source. | -| E12 | Hub root view (no folder selected) | Port-wide Signing + recent Files, both paginated. | -| E13 | Concurrent completions race for the same entity folder | `INSERT … ON CONFLICT DO NOTHING RETURNING id`, then re-`SELECT` if needed. Uses the new partial unique index `uniq_document_folders_entity`. | -| E14 | Cross-port aggregation leak | `port_id = $p` filter at every join in aggregation SQL. Defense-in-depth. | -| Lazy folder creation | When are system root + per-entity folders created? | Roots: on port init. Subfolders: lazy on first need (auto-deposit, manual upload, or "Open folder" button on entity page). | -| Aggregation reach | Symmetric or owner-down only? | Symmetric — walk relationships in both directions. `Clients/Smith/`, `Companies/Smith Marine LLC/`, `Yachts/MV Serenity/` all show the full graph from their vantage point. | -| Search scope | Where does the search box look? | Current folder + descendants. Empty/root selection → port-wide. Includes both Signing and Files results. | -| Rollout | Feature flag or hard cutover? | Hard cutover. Migration backfills data; new hub replaces old hub on merge. | +| ID | Edge case | Decision | +| -------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| E1 | Entity renamed | System folder name auto-syncs in the same transaction. | +| E2 | Two entities collide on folder name (e.g., both "Smith, John") | Append numeric suffix `(2)`, `(3)` to the **new** colliding folder. Existing folders never change. | +| E3 | Entity archived | Folder stays with `(archived)` suffix, muted style. Auto-deposit halts. | +| E4 | Entity hard-deleted | Folder gets `(deleted)` suffix, `system_managed` flips to `false` (rep can clean up). Files retain orphaned data. | +| E5 | Yacht ownership transferred | Files snapshot their entity FKs at upload time. Old client folder retains historical files; new client gets a new folder for files created after transfer. Both coexist. | +| E6 | Workflow's owner FK changes mid-signing | Resolve owner at completion time. Signed PDF lands in current owner's folder. | +| E7 | Rep moves a file out of a system folder | Allowed. `folder_id` changes; entity FK is unchanged so aggregation still surfaces it via FK. The "lives in …" caption updates. | +| E8 | Rep manually uploads into an entity folder | Auto-set the file's matching entity FK from the destination folder's `entity_type` + `entity_id`. Custom folders → no auto-mapping. | +| E9 | Workflow has no entity at all | Signed PDF lands at root with `folder_id = null`. Surfaces in root-view Files section only. | +| E10 | File/workflow attached to interest only, interest has no resolved owner | Same as E9 — root, null folder. Manual move or future backfill resolves later. | +| E11 | Aggregated view returns 1000+ files | Top 20 per owner-source group, `Show all (N)` drilldown into flat paginated list per source. | +| E12 | Hub root view (no folder selected) | Port-wide Signing + recent Files, both paginated. | +| E13 | Concurrent completions race for the same entity folder | `INSERT … ON CONFLICT DO NOTHING RETURNING id`, then re-`SELECT` if needed. Uses the new partial unique index `uniq_document_folders_entity`. | +| E14 | Cross-port aggregation leak | `port_id = $p` filter at every join in aggregation SQL. Defense-in-depth. | +| Lazy folder creation | When are system root + per-entity folders created? | Roots: on port init. Subfolders: lazy on first need (auto-deposit, manual upload, or "Open folder" button on entity page). | +| Aggregation reach | Symmetric or owner-down only? | Symmetric — walk relationships in both directions. `Clients/Smith/`, `Companies/Smith Marine LLC/`, `Yachts/MV Serenity/` all show the full graph from their vantage point. | +| Search scope | Where does the search box look? | Current folder + descendants. Empty/root selection → port-wide. Includes both Signing and Files results. | +| Rollout | Feature flag or hard cutover? | Hard cutover. Migration backfills data; new hub replaces old hub on merge. | ## Schema deltas @@ -361,13 +361,13 @@ Script: `pnpm tsx scripts/backfill-document-folders.ts`. Wraps in `pg_advisory_x ## Risks and mitigations -| Risk | Mitigation | -|------|------------| -| Aggregation queries slow on large portfolios (5k+ files per client) | Per-group pagination caps render cost; supporting indexes on `files(port_id, client_id)`, `files(port_id, company_id)`, `files(port_id, yacht_id)` already exist; new `files(folder_id)` and `files(port_id, folder_id)` cover folder filtering | -| Backfill migration locks production for too long | Per-port advisory lock; backfill batched in chunks of 1000 file rows per transaction; safe to re-run if interrupted | -| System-folder protection bypass via direct DB write | Application-level enforcement; we accept that direct DB writes can bypass (no DB constraint enforces "you can't update system_managed=true rows"). Audit log entries on folder ops surface anomalies | -| Hard cutover means broken hub if backfill fails | Backfill is idempotent and runs *before* code rollout; if backfill fails, the new hub still renders (just with sparse folders); rollback = revert the migration + redeploy old hub binary | -| Rep confused by "view signing details" link disappearing for non-Documenso signed files (e.g., manually uploaded "already signed" PDFs via /upload-signed) | The link shows only when `signed_file_id` traces to a `documents` row; manually-uploaded "signed" PDFs that bypass the workflow won't have the link, which is correct — there's nothing to show | +| Risk | Mitigation | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Aggregation queries slow on large portfolios (5k+ files per client) | Per-group pagination caps render cost; supporting indexes on `files(port_id, client_id)`, `files(port_id, company_id)`, `files(port_id, yacht_id)` already exist; new `files(folder_id)` and `files(port_id, folder_id)` cover folder filtering | +| Backfill migration locks production for too long | Per-port advisory lock; backfill batched in chunks of 1000 file rows per transaction; safe to re-run if interrupted | +| System-folder protection bypass via direct DB write | Application-level enforcement; we accept that direct DB writes can bypass (no DB constraint enforces "you can't update system_managed=true rows"). Audit log entries on folder ops surface anomalies | +| Hard cutover means broken hub if backfill fails | Backfill is idempotent and runs _before_ code rollout; if backfill fails, the new hub still renders (just with sparse folders); rollback = revert the migration + redeploy old hub binary | +| Rep confused by "view signing details" link disappearing for non-Documenso signed files (e.g., manually uploaded "already signed" PDFs via /upload-signed) | The link shows only when `signed_file_id` traces to a `documents` row; manually-uploaded "signed" PDFs that bypass the workflow won't have the link, which is correct — there's nothing to show | ## Open questions deferred to plan diff --git a/scripts/import-organized-documents.ts b/scripts/import-organized-documents.ts index 9db230c0..6817163b 100644 --- a/scripts/import-organized-documents.ts +++ b/scripts/import-organized-documents.ts @@ -237,7 +237,9 @@ async function main(): Promise { console.log(`✓ Imported ${entry.key}`); } - console.log(`\nDone. Created ${createdCount} documents, skipped ${skippedCount} (already imported).`); + console.log( + `\nDone. Created ${createdCount} documents, skipped ${skippedCount} (already imported).`, + ); } async function ensureFolderChain( diff --git a/src/app/api/v1/documents/[id]/folder/route.ts b/src/app/api/v1/documents/[id]/folder/route.ts index 343e4ff0..0dd8a6de 100644 --- a/src/app/api/v1/documents/[id]/folder/route.ts +++ b/src/app/api/v1/documents/[id]/folder/route.ts @@ -33,10 +33,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('Invalid folder'); } diff --git a/src/components/documents/folder-actions-menu.tsx b/src/components/documents/folder-actions-menu.tsx index 0f537448..643f4805 100644 --- a/src/components/documents/folder-actions-menu.tsx +++ b/src/components/documents/folder-actions-menu.tsx @@ -182,9 +182,7 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct Cancel