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:
@@ -9,7 +9,7 @@
|
||||
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) |
|
||||
|
||||
@@ -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 <path>` caption per row.
|
||||
|
||||
@@ -228,7 +228,7 @@ 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. |
|
||||
@@ -362,11 +362,11 @@ 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 |
|
||||
| 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
|
||||
|
||||
@@ -237,7 +237,9 @@ async function main(): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -182,9 +182,7 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!name.trim() || name.trim() === currentName || renameMutation.isPending
|
||||
}
|
||||
disabled={!name.trim() || name.trim() === currentName || renameMutation.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await renameMutation.mutateAsync({
|
||||
|
||||
@@ -369,9 +369,7 @@ export class FilesystemBackend implements StorageBackend {
|
||||
* caller doesn't have to special-case empty trees.
|
||||
*/
|
||||
async listByPrefix(prefix: string): Promise<string[]> {
|
||||
const startAbs = prefix
|
||||
? this.resolveKey(prefix.replace(/\/+$/, ''))
|
||||
: this.rootResolved;
|
||||
const startAbs = prefix ? this.resolveKey(prefix.replace(/\/+$/, '')) : this.rootResolved;
|
||||
|
||||
const out: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
@@ -393,9 +391,7 @@ export class FilesystemBackend implements StorageBackend {
|
||||
}
|
||||
await walk(startAbs);
|
||||
|
||||
return out
|
||||
.map((abs) => path.relative(this.rootResolved, abs).split(path.sep).join('/'))
|
||||
.sort();
|
||||
return out.map((abs) => path.relative(this.rootResolved, abs).split(path.sep).join('/')).sort();
|
||||
}
|
||||
|
||||
/** 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 () => {
|
||||
await createFolder(portId, TEST_USER_ID, { name: 'Existing', 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 () => {
|
||||
const otherPort = await makePort();
|
||||
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 { documentFolders, documents } from '@/lib/db/schema/documents';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import {
|
||||
createFolder,
|
||||
deleteFolderSoftRescue,
|
||||
} from '@/lib/services/document-folders.service';
|
||||
import { createFolder, deleteFolderSoftRescue } from '@/lib/services/document-folders.service';
|
||||
import { makePort } from '../helpers/factories';
|
||||
|
||||
describe('document-folders · deleteFolderSoftRescue', () => {
|
||||
@@ -25,7 +22,7 @@ describe('document-folders · deleteFolderSoftRescue', () => {
|
||||
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 middle = await createFolder(portId, testUserId, { name: 'Middle', parentId: root.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);
|
||||
});
|
||||
|
||||
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 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',
|
||||
});
|
||||
|
||||
const { downloadHandler } = await import(
|
||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
||||
);
|
||||
const { downloadHandler } =
|
||||
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const req = new Request('http://localhost/api/v1/documents/x/download/whatever') as never;
|
||||
const res = await downloadHandler(req, ctx, {
|
||||
@@ -124,9 +123,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
||||
storagePath: 'test/spec.pdf',
|
||||
});
|
||||
|
||||
const { downloadHandler } = await import(
|
||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
||||
);
|
||||
const { downloadHandler } =
|
||||
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const req = new Request('http://localhost/x') as never;
|
||||
const res = await downloadHandler(req, ctx, {
|
||||
@@ -147,9 +145,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
||||
storagePath: 'test/real.pdf',
|
||||
});
|
||||
|
||||
const { downloadHandler } = await import(
|
||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
||||
);
|
||||
const { downloadHandler } =
|
||||
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const req = new Request('http://localhost/x') as never;
|
||||
const res = await downloadHandler(req, ctx, {
|
||||
@@ -172,9 +169,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
||||
})
|
||||
.returning();
|
||||
|
||||
const { downloadHandler } = await import(
|
||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
||||
);
|
||||
const { downloadHandler } =
|
||||
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const req = new Request('http://localhost/x') as never;
|
||||
const res = await downloadHandler(req, ctx, {
|
||||
@@ -199,9 +195,8 @@ describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
||||
storagePath: 'test/a.pdf',
|
||||
});
|
||||
|
||||
const { downloadHandler } = await import(
|
||||
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
||||
);
|
||||
const { downloadHandler } =
|
||||
await import('@/app/api/v1/documents/[id]/download/[...slug]/handlers');
|
||||
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
|
||||
const req = new Request('http://localhost/x') as never;
|
||||
const res = await downloadHandler(req, ctx, {
|
||||
|
||||
@@ -9,16 +9,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', () => {
|
||||
|
||||
Reference in New Issue
Block a user