chore: prettier formatter drift across recent commits
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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) |
|
| Task | Topic | Status | Commit(s) |
|
||||||
|------|-------|--------|-----------|
|
| ---- | ------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | Schema + migration (`document_folders` + `folder_id` on documents) | ✅ Done | `5bed62d` + `4a50bab` (fix: Drizzle `.references()` + relations) |
|
| 1 | Schema + migration (`document_folders` + `folder_id` on documents) | ✅ Done | `5bed62d` + `4a50bab` (fix: Drizzle `.references()` + relations) |
|
||||||
| 2 | `documents.manage_folders` permission | ✅ Done | `e6cf50f` |
|
| 2 | `documents.manage_folders` permission | ✅ Done | `e6cf50f` |
|
||||||
| 3 | Service: `listTree` + `createFolder` (TDD) | ✅ Done | `4b31f01` + `5c5ab49` (fix: port-scope test cleanup + tighten message) |
|
| 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) |
|
| 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` |
|
| 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) |
|
| 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` |
|
| 7 | listDocuments folder filter + per-doc move route | ✅ Done | `a0ffa1b` |
|
||||||
| 8 | `useDocumentFolders` hook | 🔴 Not started | — |
|
| 8 | `useDocumentFolders` hook | 🔴 Not started | — |
|
||||||
| 9 | `FolderTreeSidebar` component | 🔴 Not started | — |
|
| 9 | `FolderTreeSidebar` component | 🔴 Not started | — |
|
||||||
| 10 | `FolderBreadcrumb` component | 🔴 Not started | — |
|
| 10 | `FolderBreadcrumb` component | 🔴 Not started | — |
|
||||||
| 11 | `FolderActionsMenu` (create/rename/delete dialogs) | 🔴 Not started | — |
|
| 11 | `FolderActionsMenu` (create/rename/delete dialogs) | 🔴 Not started | — |
|
||||||
| 12 | `MoveToFolderDialog` (per-doc picker) | 🔴 Not started | — |
|
| 12 | `MoveToFolderDialog` (per-doc picker) | 🔴 Not started | — |
|
||||||
| 13 | Wire `DocumentsHub`: sidebar + breadcrumb, drop signature pill, In-progress tab | 🔴 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 | — |
|
| 14 | Dynamic type-filter chips + per-row Move action | 🔴 Not started | — |
|
||||||
| 15 | Admin-configurable Expired tab | 🔴 Not started | — |
|
| 15 | Admin-configurable Expired tab | 🔴 Not started | — |
|
||||||
| 16 | Playwright smoke test | 🔴 Not started | — |
|
| 16 | Playwright smoke test | 🔴 Not started | — |
|
||||||
| 17 | CLAUDE.md update + final verification | 🔴 Not started | — |
|
| 17 | CLAUDE.md update + final verification | 🔴 Not started | — |
|
||||||
| 18 | **NEW** — path-style download URLs (hybrid storage decision) | 🔴 Not started | — |
|
| 18 | **NEW** — path-style download URLs (hybrid storage decision) | 🔴 Not started | — |
|
||||||
| 19 | **NEW** — importer from organized S3/filesystem bucket | 🔴 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`.
|
**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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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.
|
- **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.
|
- **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.
|
`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
|
- Bulk file actions (multi-select move, multi-select download zip) — separate work
|
||||||
- Tagging or labels on files — separate work
|
- Tagging or labels on files — separate work
|
||||||
- Trash / restore for hard-deleted files (current behavior preserved)
|
- 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-port admin override for aggregation symmetry (rejected as needless setting at E11)
|
||||||
- Per-user feature flag rollout — hard cutover (E rollout decision)
|
- Per-user feature flag rollout — hard cutover (E rollout decision)
|
||||||
- Native PDF preview rebuild — existing `FilePreviewDialog` reused
|
- 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:
|
Subfolder naming:
|
||||||
|
|
||||||
- Default name = entity display name (client `firstName lastName` / company `name` / yacht `name`).
|
- 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.
|
- 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 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).
|
- 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 clients via `company_memberships`
|
||||||
- linked companies via `company_memberships` and via yacht ownership
|
- linked companies via `company_memberships` and via yacht ownership
|
||||||
- linked yachts via current ownership (`yachts.current_owner_type` + `current_owner_id`)
|
- 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 <path>` caption per row.
|
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 <path>` caption per row.
|
||||||
|
|
||||||
@@ -227,26 +227,26 @@ Today's signing-status tabs (`in_progress` / `eoi_queue` / `awaiting_them` / `aw
|
|||||||
|
|
||||||
## Edge cases — decisions
|
## Edge cases — decisions
|
||||||
|
|
||||||
| ID | Edge case | Decision |
|
| ID | Edge case | Decision |
|
||||||
|----|-----------|----------|
|
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| E1 | Entity renamed | System folder name auto-syncs in the same transaction. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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`. |
|
| 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. |
|
| 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). |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Rollout | Feature flag or hard cutover? | Hard cutover. Migration backfills data; new hub replaces old hub on merge. |
|
||||||
|
|
||||||
## Schema deltas
|
## Schema deltas
|
||||||
|
|
||||||
@@ -361,13 +361,13 @@ Script: `pnpm tsx scripts/backfill-document-folders.ts`. Wraps in `pg_advisory_x
|
|||||||
|
|
||||||
## Risks and mitigations
|
## Risks and mitigations
|
||||||
|
|
||||||
| Risk | Mitigation |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Open questions deferred to plan
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,9 @@ async function main(): Promise<void> {
|
|||||||
console.log(`✓ Imported ${entry.key}`);
|
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(
|
async function ensureFolderChain(
|
||||||
|
|||||||
@@ -33,10 +33,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('Invalid folder');
|
if (!folder) throw new ValidationError('Invalid folder');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,9 +182,7 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={!name.trim() || name.trim() === currentName || renameMutation.isPending}
|
||||||
!name.trim() || name.trim() === currentName || renameMutation.isPending
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await renameMutation.mutateAsync({
|
await renameMutation.mutateAsync({
|
||||||
|
|||||||
@@ -369,9 +369,7 @@ export class FilesystemBackend implements StorageBackend {
|
|||||||
* caller doesn't have to special-case empty trees.
|
* caller doesn't have to special-case empty trees.
|
||||||
*/
|
*/
|
||||||
async listByPrefix(prefix: string): Promise<string[]> {
|
async listByPrefix(prefix: string): Promise<string[]> {
|
||||||
const startAbs = prefix
|
const startAbs = prefix ? this.resolveKey(prefix.replace(/\/+$/, '')) : this.rootResolved;
|
||||||
? this.resolveKey(prefix.replace(/\/+$/, ''))
|
|
||||||
: this.rootResolved;
|
|
||||||
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
async function walk(dir: string): Promise<void> {
|
async function walk(dir: string): Promise<void> {
|
||||||
@@ -393,9 +391,7 @@ export class FilesystemBackend implements StorageBackend {
|
|||||||
}
|
}
|
||||||
await walk(startAbs);
|
await walk(startAbs);
|
||||||
|
|
||||||
return out
|
return out.map((abs) => path.relative(this.rootResolved, abs).split(path.sep).join('/')).sort();
|
||||||
.map((abs) => path.relative(this.rootResolved, abs).split(path.sep).join('/'))
|
|
||||||
.sort();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Used by the proxy route — returns the validated absolute path. */
|
/** Used by the proxy route — returns the validated absolute path. */
|
||||||
|
|||||||
@@ -124,13 +124,17 @@ describe('document-folders service · renameFolder', () => {
|
|||||||
it('rejects rename to an existing sibling name', async () => {
|
it('rejects rename to an existing sibling name', async () => {
|
||||||
await createFolder(portId, TEST_USER_ID, { name: 'Existing', parentId: null });
|
await createFolder(portId, TEST_USER_ID, { name: 'Existing', parentId: null });
|
||||||
const folder = await createFolder(portId, TEST_USER_ID, { name: 'Mine', parentId: null });
|
const folder = await createFolder(portId, TEST_USER_ID, { name: 'Mine', parentId: null });
|
||||||
await expect(renameFolder(portId, folder.id, 'Existing', TEST_USER_ID)).rejects.toThrow(/already exists/i);
|
await expect(renameFolder(portId, folder.id, 'Existing', TEST_USER_ID)).rejects.toThrow(
|
||||||
|
/already exists/i,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFound when the folder belongs to another port', async () => {
|
it('throws NotFound when the folder belongs to another port', async () => {
|
||||||
const otherPort = await makePort();
|
const otherPort = await makePort();
|
||||||
const folder = await createFolder(otherPort.id, TEST_USER_ID, { name: 'X', parentId: null });
|
const folder = await createFolder(otherPort.id, TEST_USER_ID, { name: 'X', parentId: null });
|
||||||
await expect(renameFolder(portId, folder.id, 'Y', TEST_USER_ID)).rejects.toThrow(/couldn't find/i);
|
await expect(renameFolder(portId, folder.id, 'Y', TEST_USER_ID)).rejects.toThrow(
|
||||||
|
/couldn't find/i,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,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 { user } from '@/lib/db/schema/users';
|
import { user } from '@/lib/db/schema/users';
|
||||||
import {
|
import { createFolder, deleteFolderSoftRescue } from '@/lib/services/document-folders.service';
|
||||||
createFolder,
|
|
||||||
deleteFolderSoftRescue,
|
|
||||||
} from '@/lib/services/document-folders.service';
|
|
||||||
import { makePort } from '../helpers/factories';
|
import { makePort } from '../helpers/factories';
|
||||||
|
|
||||||
describe('document-folders · deleteFolderSoftRescue', () => {
|
describe('document-folders · deleteFolderSoftRescue', () => {
|
||||||
@@ -25,7 +22,7 @@ describe('document-folders · deleteFolderSoftRescue', () => {
|
|||||||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves child subfolders up to the deleted folder\'s parent', async () => {
|
it("moves child subfolders up to the deleted folder's parent", async () => {
|
||||||
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
|
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
|
||||||
const middle = await createFolder(portId, testUserId, { name: 'Middle', parentId: root.id });
|
const middle = await createFolder(portId, testUserId, { name: 'Middle', parentId: root.id });
|
||||||
const leaf = await createFolder(portId, testUserId, { name: 'Leaf', parentId: middle.id });
|
const leaf = await createFolder(portId, testUserId, { name: 'Leaf', parentId: middle.id });
|
||||||
@@ -38,7 +35,7 @@ describe('document-folders · deleteFolderSoftRescue', () => {
|
|||||||
expect(survivor?.parentId).toBe(root.id);
|
expect(survivor?.parentId).toBe(root.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves child documents to the deleted folder\'s parent', async () => {
|
it("moves child documents to the deleted folder's parent", async () => {
|
||||||
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
|
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
|
||||||
const child = await createFolder(portId, testUserId, { name: 'Child', parentId: root.id });
|
const child = await createFolder(portId, testUserId, { name: 'Child', parentId: root.id });
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
|||||||
storagePath: 'test/contract.pdf',
|
storagePath: 'test/contract.pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { downloadHandler } = await import(
|
const { downloadHandler } =
|
||||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||||
);
|
|
||||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||||
const req = new Request('http://localhost/api/v1/documents/x/download/whatever') as never;
|
const req = new Request('http://localhost/api/v1/documents/x/download/whatever') as never;
|
||||||
const res = await downloadHandler(req, ctx, {
|
const res = await downloadHandler(req, ctx, {
|
||||||
@@ -124,9 +123,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
|||||||
storagePath: 'test/spec.pdf',
|
storagePath: 'test/spec.pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { downloadHandler } = await import(
|
const { downloadHandler } =
|
||||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||||
);
|
|
||||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||||
const req = new Request('http://localhost/x') as never;
|
const req = new Request('http://localhost/x') as never;
|
||||||
const res = await downloadHandler(req, ctx, {
|
const res = await downloadHandler(req, ctx, {
|
||||||
@@ -147,9 +145,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
|||||||
storagePath: 'test/real.pdf',
|
storagePath: 'test/real.pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { downloadHandler } = await import(
|
const { downloadHandler } =
|
||||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||||
);
|
|
||||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||||
const req = new Request('http://localhost/x') as never;
|
const req = new Request('http://localhost/x') as never;
|
||||||
const res = await downloadHandler(req, ctx, {
|
const res = await downloadHandler(req, ctx, {
|
||||||
@@ -172,9 +169,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const { downloadHandler } = await import(
|
const { downloadHandler } =
|
||||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||||
);
|
|
||||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||||
const req = new Request('http://localhost/x') as never;
|
const req = new Request('http://localhost/x') as never;
|
||||||
const res = await downloadHandler(req, ctx, {
|
const res = await downloadHandler(req, ctx, {
|
||||||
@@ -199,9 +195,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
|||||||
storagePath: 'test/a.pdf',
|
storagePath: 'test/a.pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { downloadHandler } = await import(
|
const { downloadHandler } =
|
||||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||||
);
|
|
||||||
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
|
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
|
||||||
const req = new Request('http://localhost/x') as never;
|
const req = new Request('http://localhost/x') as never;
|
||||||
const res = await downloadHandler(req, ctx, {
|
const res = await downloadHandler(req, ctx, {
|
||||||
|
|||||||
@@ -9,16 +9,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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user