Auto-format all files modified during the documents-hub-split feature branch that were not yet aligned with the project's Prettier config (single quotes, semicolons, trailing commas). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4502 lines
167 KiB
Markdown
4502 lines
167 KiB
Markdown
# Documents Hub Split + Auto-Filed Client Folders Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the parallel `/[port]/documents` (Documenso signing rows) and `/[port]/documents/files` (bare uploads) surfaces with a single unified hub anchored by a per-port folder tree that has three system-managed roots (`Clients/` / `Companies/` / `Yachts/`), auto-creates per-entity subfolders, auto-deposits Documenso-signed PDFs into the owner's folder, and renders entity folders as an owner-aggregated projection (DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT groups, each paginated).
|
||
|
||
**Architecture:** Build on top of Wave 11.B's `document_folders` table (per-port nestable tree, soft-rescue delete, sibling-name uniqueness). Add `system_managed` / `entity_type` / `entity_id` / `archived_at` columns to `document_folders`; add `folder_id` to `files`. New service helpers (`ensureSystemRoots`, `ensureEntityFolder`, `syncEntityFolderName`, `applyEntityArchivedSuffix`, `demoteSystemFolderOnEntityDelete`, `listFilesAggregatedByEntity`, `listInflightWorkflowsAggregatedByEntity`) drive the auto-deposit + projection logic. `handleDocumentCompleted` extends to resolve the owner and ensure the entity folder before assigning `signed_file_id`. Hub UI rebuilds around a stacked Signing-in-progress / Files layout; legacy `/files` route 301-redirects; storagePath-prefix folder tree is deleted. Hard cutover — backfill runs as part of the deploy migration, no feature flag.
|
||
|
||
**Tech Stack:** Next.js 15 App Router (typedRoutes), TypeScript strict (noUncheckedIndexedAccess), Drizzle ORM on PostgreSQL, TanStack React Query, shadcn/ui (Radix Dialog, Command, Popover, Collapsible), Vitest (unit + integration), Playwright (smoke + visual).
|
||
|
||
**Source design:** `docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md` (commit `286eb51`). Read end-to-end before starting — the spec captures every locked decision (edge cases E1–E14, aggregation reach, rollout strategy, governance).
|
||
|
||
**Builds on:** Wave 11.B (branch `feat/documents-folders`, already merged into current branch). Tasks 1–19 in `docs/superpowers/plans/2026-05-09-documents-folders.md` are **done**; this plan continues on the same branch without altering Wave 11.B's commits.
|
||
|
||
**Decisions locked (from the spec):**
|
||
|
||
- **Rollout:** hard cutover, no feature flag, backfill runs in the migration.
|
||
- **Aggregation reach:** symmetric (Client ↔ Company ↔ Yacht walk in both directions).
|
||
- **Source of truth for aggregation:** snapshotted file FKs (`files.client_id` / `files.company_id` / `files.yacht_id`), not the linked entity's current relationships.
|
||
- **Per-group pagination:** top 20 by `created_at desc`, `Show all (N)` drilldown into a flat paginated list scoped to the source.
|
||
- **System folder governance:** rename/move/delete blocked at API + UI when `system_managed = true`; UI shows 🔒 marker.
|
||
- **Entity rename:** syncs system folder name in the same transaction.
|
||
- **Entity archive:** `(archived)` suffix, muted style, auto-deposit halts.
|
||
- **Entity hard-delete:** `(deleted)` suffix + `system_managed = false` (demoted to user folder).
|
||
- **Concurrency for entity folders:** `INSERT … ON CONFLICT (port_id, entity_type, entity_id) DO NOTHING RETURNING id` + re-`SELECT` on conflict, backed by partial unique index `uniq_document_folders_entity`.
|
||
- **Cross-port leakage:** defense-in-depth `port_id` filter at every join in aggregation SQL.
|
||
- **Search scope:** current folder + descendants; empty/root selection → port-wide; spans both Signing and Files.
|
||
- **Completed workflows in folder views:** hidden — only the signed-PDF file appears, with a "view signing details" link to the workflow audit trail.
|
||
|
||
**Out of scope (explicit, from spec):**
|
||
|
||
- Permission changes beyond existing `documents.view` + `documents.manage_folders`.
|
||
- Bulk file actions (multi-select move, zip download).
|
||
- File tagging / labels.
|
||
- Trash / restore for hard-deleted files (current behavior preserved).
|
||
- Full-text PDF content search (title/filename only, as today).
|
||
- Per-port admin override for aggregation symmetry.
|
||
- Native PDF preview rebuild (existing `FilePreviewDialog` reused).
|
||
|
||
**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`.
|
||
- Response envelope: `{ data: <T> }` for content; `204 No Content` for no-body mutations. Errors go through `errorResponse(error)` from `@/lib/errors`.
|
||
- Schema migrations during dev: after `db:push` or `psql -f migrations/...`, restart `next dev` to flush stale prepared-statement column lists.
|
||
- Service-tested handlers go in sibling `handlers.ts` when integration tests need to bypass middleware.
|
||
- Defense-in-depth `port_id` filter at every join (per CLAUDE.md + recommender precedent).
|
||
- Pre-commit hook blocks `.env*` files; never `--no-verify`.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Schema (1 file modified, 1 migration created):**
|
||
|
||
- Modify: `src/lib/db/schema/documents.ts` — add `systemManaged`, `entityType`, `entityId`, `archivedAt` columns to `documentFolders`; add `folderId` column to `files`; partial unique index `uniq_document_folders_entity`; CHECK constraint `chk_system_folder_shape`.
|
||
- Create: `src/lib/db/migrations/0051_documents_hub_split.sql` — column adds, partial unique index, CHECK constraint, supporting indexes on `files.folder_id`, backfill DML.
|
||
|
||
**Service layer (3 files modified, 0 created):**
|
||
|
||
- Modify: `src/lib/services/document-folders.service.ts` — add `ensureSystemRoots`, `ensureEntityFolder`, `syncEntityFolderName`, `applyEntityArchivedSuffix`, `applyEntityRestoredSuffix`, `demoteSystemFolderOnEntityDelete`. Extend `renameFolder` / `moveFolder` / `deleteFolderSoftRescue` to reject when `system_managed = true`.
|
||
- Modify: `src/lib/services/files.ts` — add `listFilesInFolder`, `listFilesAggregatedByEntity`, `applyEntityFkFromFolder` (E8 auto-mapping). Extend `uploadFile` to call `applyEntityFkFromFolder` when `folderId` is set.
|
||
- Modify: `src/lib/services/documents.service.ts` — extend `handleDocumentCompleted` with owner-resolve + ensure-folder + entity-FK-copy steps (3a–3c). Add `listInflightWorkflowsAggregatedByEntity`. Hide `status='completed'` workflows from `listDocuments` when `folderId` is set.
|
||
- Modify: `src/lib/services/ports.service.ts` — call `ensureSystemRoots(port.id, port.id /* system user */)` after `createPort` insert.
|
||
- Modify: `src/lib/services/clients.service.ts` — call `syncEntityFolderName` on rename in `updateClient`; `applyEntityArchivedSuffix` in `archiveClient`; `applyEntityRestoredSuffix` in `restoreClient`.
|
||
- Modify: `src/lib/services/companies.service.ts` — same hooks in `updateCompany` / `archiveCompany` / restore.
|
||
- Modify: `src/lib/services/yachts.service.ts` — same hooks in `updateYacht` / `archiveYacht`.
|
||
|
||
**Validators (2 files modified):**
|
||
|
||
- Modify: `src/lib/validators/documents.ts` — extend `listDocumentsSchema` with `entityType` + `entityId` query params (mutually exclusive with `folderId`).
|
||
- Modify: `src/lib/validators/files.ts` — extend list/upload schemas with `folderId` + `entityType` + `entityId` query params.
|
||
|
||
**API routes (3 files modified, 1 created):**
|
||
|
||
- Modify: `src/app/api/v1/documents/route.ts` — accept `entityType + entityId` query params; route to aggregated projection or flat list.
|
||
- Modify: `src/app/api/v1/files/route.ts` — same.
|
||
- Modify: `src/app/api/v1/document-folders/[id]/route.ts` — return `400 ConflictError` when caller tries to rename / move / delete a `system_managed = true` folder (handled in the service; route just needs to pass `userId` through).
|
||
- Create: `src/app/api/v1/documents/[id]/signing-details/route.ts` — `GET` returns `{ workflow, signers, events }` for the signing-details dialog. Wraps `getDocumentDetail` from `documents.service.ts`.
|
||
|
||
**Webhook handler (1 file modified):**
|
||
|
||
- Modify: `src/lib/services/documents.service.ts:handleDocumentCompleted` — extend with steps 3a (resolve owner via the Owner-wins chain), 3b (ensure entity folder), 3c (set `files.folder_id` + copy entity FKs onto the signed file). The same handler is called by `src/app/api/webhooks/documenso/route.ts:187` and `src/jobs/processors/documenso-poll.ts:59` — no changes needed at those call sites.
|
||
|
||
**UI components (5 files created, 4 files modified, 2 files deleted):**
|
||
|
||
- Create: `src/components/documents/aggregated-section.tsx` — renders a Signing or Files section grouped by owner-source with per-group pagination + per-row "lives in <path>" caption.
|
||
- Create: `src/components/documents/signing-details-dialog.tsx` — modal showing workflow + signers + events for a signed-PDF file row.
|
||
- Create: `src/hooks/use-aggregated-listing.ts` — TanStack Query wrapper for the aggregated projection endpoint (Signing + Files).
|
||
- Create: `src/components/documents/hub-root-view.tsx` — port-wide root landing (no folder selected): recent Signing + recent Files sections, both paginated.
|
||
- Create: `src/components/documents/entity-folder-view.tsx` — composes `AggregatedSection × 2` (one for Signing, one for Files) when the selected folder is a system-managed entity subfolder.
|
||
- Modify: `src/components/documents/documents-hub.tsx` — major rebuild: stacked Signing/Files sections, drop signing-status tabs and `documentsHubTabs` enum, branch on `selectedFolder.entityType` to render `EntityFolderView` vs the plain folder listing vs `HubRootView`.
|
||
- Modify: `src/components/documents/folder-tree-sidebar.tsx` — render 🔒 marker for `system_managed`; show muted style for `archived_at != null`.
|
||
- Modify: `src/components/documents/folder-actions-menu.tsx` — disable rename / move / delete buttons when the selected folder is `system_managed = true`; show a tooltip explaining why.
|
||
- Modify: `src/components/documents/document-list.tsx` (or wherever the per-row Move action lives) — add the "view signing details" link on rows that represent signed-PDF files.
|
||
- Delete: `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` — replaced by a 301 redirect in `next.config.mjs`.
|
||
- Delete: `src/components/files/folder-tree.tsx` — legacy `storagePath`-prefix folder rendering, no longer used.
|
||
|
||
**Stores (1 modified):**
|
||
|
||
- Modify: `src/stores/file-browser-store.ts` — drop the `storagePath`-keyed `currentFolder` state (still used by `/files` page today). Repurpose as: `selectedFolderId` (the `document_folders.id` ref or `null` / `undefined`).
|
||
|
||
**Backfill / migration support (1 created):**
|
||
|
||
- Create: `scripts/backfill-document-folders.ts` — one-time idempotent script (also invoked from the migration). Per port: ensure 3 system roots; ensure subfolders for every entity with attached files or completed workflows; set `files.folder_id` from entity FKs; copy entity FKs from completed workflows onto signed `files` rows. Wraps in `pg_advisory_xact_lock(<portIdHash>)` per port.
|
||
|
||
**Routing (1 modified):**
|
||
|
||
- Modify: `next.config.mjs` — add a permanent redirect `/[portSlug]/documents/files` → `/[portSlug]/documents`.
|
||
|
||
**Tests (8 created, 3 modified):**
|
||
|
||
- Create: `tests/unit/document-folders-system-folders.test.ts` — `ensureEntityFolder` idempotency, `syncEntityFolderName` collision (numeric suffix), `applyEntityArchivedSuffix` round-trip, `demoteSystemFolderOnEntityDelete` flips `system_managed`, system-folder rename/move/delete rejected.
|
||
- Create: `tests/unit/aggregated-projection.test.ts` — `listFilesAggregatedByEntity` symmetric walk, per-group pagination, file-FK-as-source-of-truth (yacht-transfer scenario).
|
||
- Create: `tests/integration/documents-completion-auto-deposit.test.ts` — `handleDocumentCompleted` with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner) — verifies the signed file row gets `folder_id` set + entity FKs copied.
|
||
- Create: `tests/integration/documents-hub-system-folders.test.ts` — API-level: aggregated listing, system folder protection (rename/move/delete return 4xx), entity rename round-trips folder name, archive/restore lifecycle.
|
||
- Create: `tests/integration/files-folder-aggregation.test.ts` — `GET /api/v1/files?entityType=client&entityId=…` returns the owner-aggregated payload with correct group counts.
|
||
- Create: `tests/integration/backfill-document-folders.test.ts` — backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows.
|
||
- Create: `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts` — open Clients/Smith/, see grouped Signing + Files, click "view signing details" → dialog opens.
|
||
- Create: `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts` — upload PDF into Clients/Smith/, verify `client_id` auto-set and file appears in the entity folder.
|
||
- Modify: `tests/e2e/visual/snapshots.spec.ts` — add `hub-root` and `hub-entity-folder` snapshots; regenerate baselines after intentional UI changes.
|
||
- Modify: `tests/integration/documents-list-folder-filter.test.ts` — assert completed workflows are hidden when `folderId` is set.
|
||
- Modify: `tests/e2e/realapi/eoi-documenso-completion.spec.ts` (or whichever realapi spec covers the round-trip) — assert the signed PDF lands in the owner's entity folder.
|
||
|
||
**Docs (1 modified):**
|
||
|
||
- Modify: `CLAUDE.md` — extend the existing "Document folders" subsection with: system-managed roots + entity subfolders, owner-aggregation projection, file-FK-as-source-of-truth invariant for aggregation, defense-in-depth `port_id` filter in aggregation SQL.
|
||
|
||
---
|
||
|
||
## Execution order
|
||
|
||
Tasks are ordered so each one ships a self-contained increment. Hard prerequisite chains (schema → service → API → UI) are respected, but inside each layer tasks are independent and can be parallelised by `subagent-driven-development`.
|
||
|
||
1. **Task 1** — Schema: column adds + partial unique index + CHECK constraint (migration `0051`).
|
||
2. **Task 2** — Service: `ensureSystemRoots` + port-init wiring.
|
||
3. **Task 3** — Service: `ensureEntityFolder` (concurrent-safe).
|
||
4. **Task 4** — Service: system-folder protection (extend `renameFolder` / `moveFolder` / `deleteFolderSoftRescue`).
|
||
5. **Task 5** — Service: `syncEntityFolderName` + collision suffixing + wire into clients / companies / yachts services.
|
||
6. **Task 6** — Service: archive / restore / hard-delete suffix helpers + wire into entity services.
|
||
7. **Task 7** — Webhook: extend `handleDocumentCompleted` with owner-resolve + ensure-folder + entity-FK-copy steps.
|
||
8. **Task 8** — Service: aggregated projection (`listFilesAggregatedByEntity` + `listInflightWorkflowsAggregatedByEntity`).
|
||
9. **Task 9** — API: `files` + `documents` routes accept `entityType + entityId` query params; new `signing-details` route.
|
||
10. **Task 10** — API: hide completed workflows from `listDocuments` when `folderId` is set.
|
||
11. **Task 11** — Backfill script + idempotency tests.
|
||
12. **Task 12** — UI: `AggregatedSection` component + `useAggregatedListing` hook.
|
||
13. **Task 13** — UI: `SigningDetailsDialog` + per-row "view signing details" link.
|
||
14. **Task 14** — UI: `FolderTreeSidebar` + `FolderActionsMenu` system-folder awareness (🔒 marker, archived muted, action suppression).
|
||
15. **Task 15** — UI: `HubRootView` + `EntityFolderView` + rebuild `DocumentsHub` to compose them.
|
||
16. **Task 16** — Files page removal + 301 redirect + legacy `folder-tree.tsx` deletion.
|
||
17. **Task 17** — Backfill on deploy: run the script from the migration (or as a step in deploy).
|
||
18. **Task 18** — E2E: smoke + visual snapshots.
|
||
19. **Task 19** — CLAUDE.md update + final verification (`pnpm exec tsc --noEmit` + full vitest + playwright smoke).
|
||
|
||
---
|
||
|
||
## Task 1: Schema — `document_folders` system columns + `files.folder_id`
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/db/schema/documents.ts`
|
||
- Create: `src/lib/db/migrations/0051_documents_hub_split.sql`
|
||
|
||
- [ ] **Step 1: Extend `documentFolders` table definition**
|
||
|
||
In `src/lib/db/schema/documents.ts`, replace the existing `documentFolders` declaration (the block beginning `export const documentFolders = pgTable(...)`) with one that adds four columns and one partial unique index:
|
||
|
||
```typescript
|
||
export const documentFolders = pgTable(
|
||
'document_folders',
|
||
{
|
||
id: text('id')
|
||
.primaryKey()
|
||
.$defaultFn(() => crypto.randomUUID()),
|
||
portId: text('port_id')
|
||
.notNull()
|
||
.references(() => ports.id),
|
||
parentId: text('parent_id'),
|
||
name: text('name').notNull(),
|
||
/** True = folder is managed by the system (one of the three roots
|
||
* Clients/Companies/Yachts, or an auto-created entity subfolder).
|
||
* System folders reject rename/move/delete at the API layer. Demoted
|
||
* to false when the owning entity is hard-deleted. */
|
||
systemManaged: boolean('system_managed').notNull().default(false),
|
||
/** null | 'root' | 'client' | 'company' | 'yacht'. 'root' is the
|
||
* three system roots; the entity values mark per-entity subfolders. */
|
||
entityType: text('entity_type'),
|
||
/** Null when entityType is null or 'root'; the entity's id otherwise.
|
||
* Combined with entityType to dedupe entity folders per port. */
|
||
entityId: text('entity_id'),
|
||
/** Mirrors the entity's archive state. Non-null = folder muted in UI
|
||
* and auto-deposit halted. Cleared on entity restore. */
|
||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||
createdBy: text('created_by').notNull(),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(table) => [
|
||
index('idx_document_folders_port').on(table.portId),
|
||
index('idx_document_folders_parent').on(table.parentId),
|
||
uniqueIndex('uniq_document_folders_sibling_name').on(
|
||
table.portId,
|
||
sql`COALESCE(${table.parentId}, '__root__')`,
|
||
sql`LOWER(${table.name})`,
|
||
),
|
||
// One subfolder per entity per port. Excludes 'root' folders (the
|
||
// three system roots are deduped by sibling-name uniqueness).
|
||
uniqueIndex('uniq_document_folders_entity')
|
||
.on(table.portId, table.entityType, table.entityId)
|
||
.where(sql`${table.entityId} IS NOT NULL`),
|
||
],
|
||
);
|
||
```
|
||
|
||
Make sure `boolean` is in the import list at the top of the file — it should already be imported (the `files` table uses it elsewhere). If not, add `boolean` to the `drizzle-orm/pg-core` import.
|
||
|
||
- [ ] **Step 2: Add `folderId` column to `files` table definition**
|
||
|
||
In the same file, find the `files` table declaration (~line 21) and add `folderId` next to the existing entity-FK columns. Also add an index on `(port_id, folder_id)` for the aggregated lookup:
|
||
|
||
```typescript
|
||
clientId: text('client_id').references(() => clients.id),
|
||
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
|
||
companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }),
|
||
folderId: text('folder_id').references((): AnyPgColumn => documentFolders.id, {
|
||
onDelete: 'set null',
|
||
}),
|
||
```
|
||
|
||
And inside the `(table) => [...]` index list:
|
||
|
||
```typescript
|
||
index('idx_files_folder').on(table.folderId),
|
||
index('idx_files_port_folder').on(table.portId, table.folderId),
|
||
```
|
||
|
||
`AnyPgColumn` is already imported at the top of the file (`documents.folderId` uses it).
|
||
|
||
- [ ] **Step 3: Verify TypeScript compiles**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: clean exit (no output).
|
||
|
||
- [ ] **Step 4: Write the migration SQL**
|
||
|
||
Create `src/lib/db/migrations/0051_documents_hub_split.sql`:
|
||
|
||
```sql
|
||
-- Wave 11.B+: documents hub split + auto-filed client folders.
|
||
-- Adds system-managed folder lifecycle columns to document_folders
|
||
-- (Clients/Companies/Yachts roots + per-entity subfolders), adds the
|
||
-- folder_id pointer to files, and backfills the structure for every
|
||
-- existing port + file. Idempotent — safe to re-run.
|
||
|
||
-- ─── document_folders: lifecycle columns ──────────────────────────────────
|
||
ALTER TABLE "document_folders"
|
||
ADD COLUMN IF NOT EXISTS "system_managed" boolean NOT NULL DEFAULT false,
|
||
ADD COLUMN IF NOT EXISTS "entity_type" text,
|
||
ADD COLUMN IF NOT EXISTS "entity_id" text,
|
||
ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;
|
||
|
||
-- Shape guard: system_managed=true implies a known shape. Either a root
|
||
-- (entity_type='root', entity_id null) or a per-entity subfolder
|
||
-- (entity_type in {client,company,yacht} AND entity_id NOT NULL).
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_system_folder_shape'
|
||
) THEN
|
||
ALTER TABLE "document_folders"
|
||
ADD CONSTRAINT "chk_system_folder_shape" CHECK (
|
||
NOT system_managed
|
||
OR entity_type = 'root'
|
||
OR (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL)
|
||
);
|
||
END IF;
|
||
END $$;
|
||
|
||
-- Partial unique index: one subfolder per (port, entity_type, entity_id).
|
||
-- Excludes root folders (entity_id IS NULL) — those are deduped by the
|
||
-- existing sibling-name uniqueness index.
|
||
CREATE UNIQUE INDEX IF NOT EXISTS "uniq_document_folders_entity"
|
||
ON "document_folders" ("port_id", "entity_type", "entity_id")
|
||
WHERE "entity_id" IS NOT NULL;
|
||
|
||
-- ─── files: folder pointer ────────────────────────────────────────────────
|
||
ALTER TABLE "files"
|
||
ADD COLUMN IF NOT EXISTS "folder_id" text REFERENCES "document_folders" ("id")
|
||
ON DELETE SET NULL;
|
||
|
||
CREATE INDEX IF NOT EXISTS "idx_files_folder" ON "files" ("folder_id");
|
||
CREATE INDEX IF NOT EXISTS "idx_files_port_folder" ON "files" ("port_id", "folder_id");
|
||
```
|
||
|
||
The migration intentionally does NOT include the data backfill — the backfill is in a separate script (`scripts/backfill-document-folders.ts`, Task 11) so the schema change can deploy first and the backfill can be re-run idempotently after.
|
||
|
||
- [ ] **Step 5: Apply the migration to the dev database**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
|
||
-f src/lib/db/migrations/0051_documents_hub_split.sql
|
||
```
|
||
|
||
Expected output: `ALTER TABLE`, `DO`, `CREATE UNIQUE INDEX`, `ALTER TABLE`, `CREATE INDEX` × 2. No errors.
|
||
|
||
If `next dev` is running, restart it (per CLAUDE.md — postgres.js prepared statement cache).
|
||
|
||
- [ ] **Step 6: Sanity check the schema**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
|
||
-c "\d document_folders" \
|
||
-c "\d files"
|
||
```
|
||
|
||
Expected: `document_folders` shows `system_managed`, `entity_type`, `entity_id`, `archived_at` columns plus the new partial unique index and the check constraint. `files` shows `folder_id` plus the two new indexes.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/lib/db/schema/documents.ts src/lib/db/migrations/0051_documents_hub_split.sql
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): schema for hub split + entity-folder lifecycle
|
||
|
||
Adds system_managed / entity_type / entity_id / archived_at to
|
||
document_folders for the three system roots (Clients/Companies/
|
||
Yachts) + per-entity auto-subfolders. Adds files.folder_id so a
|
||
file's home is a first-class field (not derived from storagePath
|
||
prefix). Partial unique index uniq_document_folders_entity dedupes
|
||
entity subfolders per port; chk_system_folder_shape pins the shape
|
||
of system rows. Migration is idempotent and ships without backfill —
|
||
the backfill script runs as a separate deploy step.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Service — `ensureSystemRoots` + wire into port creation
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/document-folders.service.ts`
|
||
- Modify: `src/lib/services/ports.service.ts`
|
||
- Test: `tests/unit/document-folders-system-folders.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test for `ensureSystemRoots`**
|
||
|
||
Create `tests/unit/document-folders-system-folders.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { and, eq } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { documentFolders } from '@/lib/db/schema/documents';
|
||
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
|
||
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
|
||
|
||
describe('document-folders service · ensureSystemRoots', () => {
|
||
let portId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
});
|
||
|
||
it('creates Clients, Companies, and Yachts root folders with system_managed=true', async () => {
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const rows = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
|
||
expect(rows.map((r) => r.name).sort()).toEqual(['Clients', 'Companies', 'Yachts']);
|
||
for (const r of rows) {
|
||
expect(r.systemManaged).toBe(true);
|
||
expect(r.parentId).toBeNull();
|
||
expect(r.entityId).toBeNull();
|
||
}
|
||
});
|
||
|
||
it('is idempotent — second call does not create duplicates', async () => {
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const rows = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
|
||
expect(rows).toHaveLength(3);
|
||
});
|
||
|
||
it('returns the three root rows in a stable order (Clients, Companies, Yachts)', async () => {
|
||
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
|
||
expect(roots.map((r) => r.name)).toEqual(['Clients', 'Companies', 'Yachts']);
|
||
});
|
||
});
|
||
```
|
||
|
||
Adjust the import path for `setupTestPort` / `TEST_USER_ID` to match whatever helper layout the integration tests use — read `tests/integration/document-folders-crud.test.ts` for the convention (Wave 11.B uses this same fixture file).
|
||
|
||
- [ ] **Step 2: Run the test — expect import failure**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
|
||
Expected: `ensureSystemRoots` is not exported by `document-folders.service.ts` — module-level error.
|
||
|
||
- [ ] **Step 3: Implement `ensureSystemRoots`**
|
||
|
||
Append to `src/lib/services/document-folders.service.ts`:
|
||
|
||
```typescript
|
||
const SYSTEM_ROOT_NAMES = ['Clients', 'Companies', 'Yachts'] as const;
|
||
type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number];
|
||
|
||
/**
|
||
* Idempotently create the three system root folders for a port
|
||
* (`Clients/`, `Companies/`, `Yachts/`). Returns the rows in stable
|
||
* order. Safe to call on every port-init and on every backfill run.
|
||
*
|
||
* Uses INSERT … ON CONFLICT … DO NOTHING via the sibling-name unique
|
||
* index (`uniq_document_folders_sibling_name`) so a concurrent caller
|
||
* can't race two inserts of the same root. Re-SELECTs on conflict so
|
||
* the return shape is always populated.
|
||
*/
|
||
export async function ensureSystemRoots(portId: string, userId: string): Promise<DocumentFolder[]> {
|
||
// Try to insert all three; collect existing ids on conflict.
|
||
const values = SYSTEM_ROOT_NAMES.map((name) => ({
|
||
portId,
|
||
parentId: null,
|
||
name,
|
||
systemManaged: true,
|
||
entityType: 'root' as const,
|
||
entityId: null,
|
||
createdBy: userId,
|
||
}));
|
||
|
||
await db
|
||
.insert(documentFolders)
|
||
.values(values)
|
||
.onConflictDoNothing({
|
||
target: [
|
||
documentFolders.portId,
|
||
sql`COALESCE(${documentFolders.parentId}, '__root__')`,
|
||
sql`LOWER(${documentFolders.name})`,
|
||
],
|
||
});
|
||
|
||
// Re-SELECT — the rows that already existed are not in `.returning()`
|
||
// when ON CONFLICT DO NOTHING is used. SELECT is the authoritative
|
||
// post-write state.
|
||
const rows = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
|
||
|
||
// Preserve SYSTEM_ROOT_NAMES order for callers (stable test assertions
|
||
// and a stable UI render order).
|
||
return SYSTEM_ROOT_NAMES.map((name) => {
|
||
const row = rows.find((r) => r.name === name);
|
||
if (!row) throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
|
||
return row;
|
||
});
|
||
}
|
||
```
|
||
|
||
You'll also need to add `sql` to the imports at the top of the file (it's not currently imported). Update the import line:
|
||
|
||
```typescript
|
||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
|
||
Expected: 3/3 pass.
|
||
|
||
- [ ] **Step 5: Wire `ensureSystemRoots` into `createPort`**
|
||
|
||
Open `src/lib/services/ports.service.ts`. Find `createPort` (~line 23). After the `.returning()` insert, before the audit log call (or before the function returns), call `ensureSystemRoots`:
|
||
|
||
```typescript
|
||
// After: const [row] = await db.insert(ports).values(...).returning();
|
||
// Before: createAuditLog(...) / return row;
|
||
|
||
await ensureSystemRoots(row.id, meta.userId);
|
||
```
|
||
|
||
Add the import at the top:
|
||
|
||
```typescript
|
||
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
|
||
```
|
||
|
||
Read the existing file first to get the exact structure — line 23 is `createPort`'s signature, but the insert + audit-log block sits inside it. Place the call after the row insert succeeds and before any return.
|
||
|
||
- [ ] **Step 6: Verify the wiring with a smoke test**
|
||
|
||
Run: `pnpm exec tsc --noEmit && pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts`
|
||
Expected: clean tsc + 3/3 pass.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/document-folders.service.ts src/lib/services/ports.service.ts tests/unit/document-folders-system-folders.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): ensureSystemRoots + wire into createPort
|
||
|
||
Adds idempotent root-folder bootstrap (Clients/Companies/Yachts)
|
||
called on every port-init. ON CONFLICT DO NOTHING on the sibling-name
|
||
unique index prevents racing inserts; the re-SELECT returns the stable
|
||
row set in SYSTEM_ROOT_NAMES order. Same helper is invoked by the
|
||
backfill script in a later task.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Service — `ensureEntityFolder` (concurrent-safe)
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/document-folders.service.ts`
|
||
- Test: `tests/unit/document-folders-system-folders.test.ts` (append)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `tests/unit/document-folders-system-folders.test.ts`:
|
||
|
||
```typescript
|
||
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
|
||
import { clients } from '@/lib/db/schema/clients';
|
||
|
||
describe('document-folders service · ensureEntityFolder', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
let rootId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
|
||
rootId = roots.find((r) => r.name === 'Clients')!.id;
|
||
|
||
const [client] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = client!.id;
|
||
});
|
||
|
||
it('creates a subfolder under the matching system root with system_managed=true', async () => {
|
||
const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||
expect(folder.systemManaged).toBe(true);
|
||
expect(folder.entityType).toBe('client');
|
||
expect(folder.entityId).toBe(clientId);
|
||
expect(folder.parentId).toBe(rootId);
|
||
expect(folder.name).toBe('Smith, John'); // lastName, firstName per the entity-display convention
|
||
});
|
||
|
||
it('is idempotent — returns the same row on second call', async () => {
|
||
const a = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||
const b = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||
expect(a.id).toBe(b.id);
|
||
const all = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)));
|
||
expect(all).toHaveLength(1);
|
||
});
|
||
|
||
it('appends a numeric suffix on name collision with an existing folder', async () => {
|
||
// Pre-seed a folder with the same name (e.g., a second client called John Smith)
|
||
const [collidingClient] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||
const second = await ensureEntityFolder(portId, 'client', collidingClient!.id, TEST_USER_ID);
|
||
expect(second.name).toBe('Smith, John (2)');
|
||
});
|
||
|
||
it('rejects unknown entity types', async () => {
|
||
await expect(
|
||
// @ts-expect-error -- runtime check
|
||
ensureEntityFolder(portId, 'boat', clientId, TEST_USER_ID),
|
||
).rejects.toThrow(/entity type/i);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test — expect failure**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t ensureEntityFolder`
|
||
Expected: `ensureEntityFolder` is not exported.
|
||
|
||
- [ ] **Step 3: Implement `ensureEntityFolder`**
|
||
|
||
Append to `src/lib/services/document-folders.service.ts`:
|
||
|
||
```typescript
|
||
import { clients } from '@/lib/db/schema/clients';
|
||
import { companies } from '@/lib/db/schema/companies';
|
||
import { yachts } from '@/lib/db/schema/yachts';
|
||
|
||
export type EntityType = 'client' | 'company' | 'yacht';
|
||
const ENTITY_TYPES = new Set<EntityType>(['client', 'company', 'yacht']);
|
||
|
||
/**
|
||
* Returns the display name for an entity, in the form used by the
|
||
* Clients/Companies/Yachts subfolders. Clients render as "LastName,
|
||
* FirstName" (matches the rep-facing list views); companies + yachts
|
||
* use their `name` column verbatim.
|
||
*/
|
||
async function resolveEntityDisplayName(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
): Promise<string> {
|
||
if (entityType === 'client') {
|
||
const c = await db.query.clients.findFirst({
|
||
where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
|
||
columns: { firstName: true, lastName: true },
|
||
});
|
||
if (!c) throw new NotFoundError('Client');
|
||
return `${c.lastName ?? ''}, ${c.firstName ?? ''}`.trim().replace(/^,\s*|,\s*$/, '');
|
||
}
|
||
if (entityType === 'company') {
|
||
const co = await db.query.companies.findFirst({
|
||
where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
|
||
columns: { name: true },
|
||
});
|
||
if (!co) throw new NotFoundError('Company');
|
||
return co.name;
|
||
}
|
||
// yacht
|
||
const y = await db.query.yachts.findFirst({
|
||
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
|
||
columns: { name: true },
|
||
});
|
||
if (!y) throw new NotFoundError('Yacht');
|
||
return y.name;
|
||
}
|
||
|
||
/**
|
||
* Idempotently create the per-entity subfolder under the matching
|
||
* system root (`Clients/` / `Companies/` / `Yachts/`). Returns the
|
||
* folder row regardless of whether it was newly created or already
|
||
* existed. Concurrent callers race safely via the partial unique
|
||
* index `uniq_document_folders_entity` — the loser INSERT does
|
||
* nothing and the re-SELECT returns the winner's row.
|
||
*
|
||
* On sibling-name collision (two entities want the same name), appends
|
||
* a numeric suffix `(2)`, `(3)`, …, until the insert succeeds. The
|
||
* `system_managed` flag stays true on the suffixed folder.
|
||
*/
|
||
export async function ensureEntityFolder(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
userId: string,
|
||
): Promise<DocumentFolder> {
|
||
if (!ENTITY_TYPES.has(entityType)) {
|
||
throw new ValidationError(`Unknown entity type: ${entityType}`);
|
||
}
|
||
|
||
// Fast path: row already exists.
|
||
const existing = await db.query.documentFolders.findFirst({
|
||
where: and(
|
||
eq(documentFolders.portId, portId),
|
||
eq(documentFolders.entityType, entityType),
|
||
eq(documentFolders.entityId, entityId),
|
||
),
|
||
});
|
||
if (existing) return existing;
|
||
|
||
// Locate the system root for this entity type.
|
||
const rootName: SystemRootName =
|
||
entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts';
|
||
const root = await db.query.documentFolders.findFirst({
|
||
where: and(
|
||
eq(documentFolders.portId, portId),
|
||
eq(documentFolders.entityType, 'root'),
|
||
eq(documentFolders.name, rootName),
|
||
),
|
||
});
|
||
if (!root) {
|
||
// Self-heal: the port-init hook may have been skipped (legacy port).
|
||
await ensureSystemRoots(portId, userId);
|
||
return ensureEntityFolder(portId, entityType, entityId, userId);
|
||
}
|
||
|
||
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
|
||
|
||
// Try the base name first; on sibling-name collision, append (2), (3)...
|
||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||
const candidate = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`;
|
||
try {
|
||
const [row] = await db
|
||
.insert(documentFolders)
|
||
.values({
|
||
portId,
|
||
parentId: root.id,
|
||
name: candidate,
|
||
systemManaged: true,
|
||
entityType,
|
||
entityId,
|
||
createdBy: userId,
|
||
})
|
||
.returning();
|
||
if (!row) throw new Error('ensureEntityFolder: insert returned no row');
|
||
return row;
|
||
} catch (err) {
|
||
// If another caller won the entity-id race, re-SELECT and return their row.
|
||
if (isEntityFolderConflict(err)) {
|
||
const winner = await db.query.documentFolders.findFirst({
|
||
where: and(
|
||
eq(documentFolders.portId, portId),
|
||
eq(documentFolders.entityType, entityType),
|
||
eq(documentFolders.entityId, entityId),
|
||
),
|
||
});
|
||
if (winner) return winner;
|
||
}
|
||
// Sibling-name collision (different entity, same name) → bump suffix and retry.
|
||
if (isSiblingNameConflict(err)) continue;
|
||
throw err;
|
||
}
|
||
}
|
||
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
||
}
|
||
|
||
function isEntityFolderConflict(err: unknown): boolean {
|
||
if (!err || typeof err !== 'object') return false;
|
||
const e = err as { code?: unknown; constraint_name?: unknown; constraint?: unknown };
|
||
if (e.code !== '23505') return false;
|
||
return (e.constraint_name ?? e.constraint) === 'uniq_document_folders_entity';
|
||
}
|
||
```
|
||
|
||
The helper imports (`clients`, `companies`, `yachts` schemas) and the existing `isSiblingNameConflict` already in the file are reused. Make sure `NotFoundError` is in the imports at the top — it is.
|
||
|
||
- [ ] **Step 4: Run the test — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t ensureEntityFolder`
|
||
Expected: 4/4 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/document-folders.service.ts tests/unit/document-folders-system-folders.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): ensureEntityFolder (concurrent-safe + suffix on collision)
|
||
|
||
Idempotent per-entity subfolder creation under the matching system
|
||
root. Fast-path SELECT short-circuits the common case. Inserts race
|
||
safely via uniq_document_folders_entity (partial unique on
|
||
port_id+entity_type+entity_id) — the loser re-SELECTs the winner's
|
||
row. Sibling-name collisions between two entities with the same
|
||
display name append (2), (3), … to the new folder; existing folders
|
||
never rename.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Service — system-folder protection on rename / move / delete
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/document-folders.service.ts`
|
||
- Test: `tests/unit/document-folders-system-folders.test.ts` (append)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `tests/unit/document-folders-system-folders.test.ts`:
|
||
|
||
```typescript
|
||
import {
|
||
deleteFolderSoftRescue,
|
||
moveFolder,
|
||
renameFolder,
|
||
} from '@/lib/services/document-folders.service';
|
||
|
||
describe('document-folders service · system folder protection', () => {
|
||
let portId: string;
|
||
let rootId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
const roots = await ensureSystemRoots(portId, TEST_USER_ID);
|
||
rootId = roots.find((r) => r.name === 'Clients')!.id;
|
||
});
|
||
|
||
it('rejects rename of a system-managed root', async () => {
|
||
await expect(renameFolder(portId, rootId, 'Customers', TEST_USER_ID)).rejects.toThrow(
|
||
/system folder/i,
|
||
);
|
||
});
|
||
|
||
it('rejects move of a system-managed root', async () => {
|
||
const other = await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const companies = other.find((r) => r.name === 'Companies')!;
|
||
await expect(moveFolder(portId, rootId, companies.id, TEST_USER_ID)).rejects.toThrow(
|
||
/system folder/i,
|
||
);
|
||
});
|
||
|
||
it('rejects delete of a system-managed root', async () => {
|
||
await expect(deleteFolderSoftRescue(portId, rootId, TEST_USER_ID)).rejects.toThrow(
|
||
/system folder/i,
|
||
);
|
||
});
|
||
|
||
it('allows rename/delete of a user folder under a system root', async () => {
|
||
// Create a normal subfolder under Clients/ (user-managed).
|
||
const user = await db
|
||
.insert(documentFolders)
|
||
.values({
|
||
portId,
|
||
parentId: rootId,
|
||
name: 'Templates',
|
||
systemManaged: false,
|
||
createdBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
await expect(
|
||
renameFolder(portId, user[0]!.id, 'My Templates', TEST_USER_ID),
|
||
).resolves.toBeDefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests — expect 3 failures**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'system folder protection'`
|
||
Expected: the rename/move/delete protection tests fail (they currently succeed because there's no guard).
|
||
|
||
- [ ] **Step 3: Add the protection guard helper**
|
||
|
||
In `src/lib/services/document-folders.service.ts`, add this internal helper near the top of the file (after `isSiblingNameConflict`):
|
||
|
||
```typescript
|
||
/**
|
||
* Throws ConflictError if the folder is system-managed. Centralises the
|
||
* rejection so rename/move/delete all surface identical error shapes.
|
||
*/
|
||
async function assertNotSystemManaged(
|
||
portId: string,
|
||
folderId: string,
|
||
action: 'rename' | 'move' | 'delete',
|
||
): Promise<DocumentFolder> {
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
|
||
});
|
||
if (!folder) throw new NotFoundError('Folder');
|
||
if (folder.systemManaged) {
|
||
const verb = action === 'rename' ? 'renamed' : action === 'move' ? 'moved' : 'deleted';
|
||
throw new ConflictError(`System folders can't be ${verb}`);
|
||
}
|
||
return folder;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire the guard into `renameFolder`, `moveFolder`, `deleteFolderSoftRescue`**
|
||
|
||
Replace the existing existence check at the start of each function with a call to `assertNotSystemManaged`. For `renameFolder` (current line ~120), replace:
|
||
|
||
```typescript
|
||
const existing = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
|
||
});
|
||
if (!existing) throw new NotFoundError('Folder');
|
||
```
|
||
|
||
with:
|
||
|
||
```typescript
|
||
const existing = await assertNotSystemManaged(portId, folderId, 'rename');
|
||
```
|
||
|
||
For `moveFolder` (current line ~168), replace:
|
||
|
||
```typescript
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
|
||
});
|
||
if (!folder) throw new NotFoundError('Folder');
|
||
```
|
||
|
||
with:
|
||
|
||
```typescript
|
||
const folder = await assertNotSystemManaged(portId, folderId, 'move');
|
||
```
|
||
|
||
For `deleteFolderSoftRescue` (current line ~242), replace:
|
||
|
||
```typescript
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
|
||
});
|
||
if (!folder) throw new NotFoundError('Folder');
|
||
```
|
||
|
||
with:
|
||
|
||
```typescript
|
||
const folder = await assertNotSystemManaged(portId, folderId, 'delete');
|
||
```
|
||
|
||
- [ ] **Step 5: Run the tests — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'system folder protection'`
|
||
Expected: 4/4 pass. Also run the full file to verify the other tests still pass:
|
||
|
||
```bash
|
||
pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 6: Run the wider folder test suite to catch regressions**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/document-folders-crud.test.ts tests/integration/document-folders-soft-delete.test.ts`
|
||
Expected: all Wave 11.B tests still pass (the new guard is fail-closed but doesn't alter the user-folder paths).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/document-folders.service.ts tests/unit/document-folders-system-folders.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): block rename/move/delete on system folders
|
||
|
||
assertNotSystemManaged centralises the guard so the three mutation
|
||
paths surface identical ConflictError shapes. System roots and per-
|
||
entity subfolders are immutable through the rep-facing API; the only
|
||
way for system_managed to flip back to false is the entity-hard-
|
||
delete demotion path (next task).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Service — `syncEntityFolderName` + wire into entity rename
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/document-folders.service.ts`
|
||
- Modify: `src/lib/services/clients.service.ts`
|
||
- Modify: `src/lib/services/companies.service.ts`
|
||
- Modify: `src/lib/services/yachts.service.ts`
|
||
- Test: `tests/unit/document-folders-system-folders.test.ts` (append)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `tests/unit/document-folders-system-folders.test.ts`:
|
||
|
||
```typescript
|
||
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
|
||
|
||
describe('document-folders service · syncEntityFolderName', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const [client] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = client!.id;
|
||
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||
});
|
||
|
||
it('renames the entity subfolder when the entity is renamed', async () => {
|
||
await db.update(clients).set({ firstName: 'Jonathan' }).where(eq(clients.id, clientId));
|
||
await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID);
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||
});
|
||
expect(folder?.name).toBe('Smith, Jonathan');
|
||
});
|
||
|
||
it('is a no-op when the folder does not exist (lazy creation)', async () => {
|
||
const otherPort = await setupTestPort();
|
||
await ensureSystemRoots(otherPort, TEST_USER_ID);
|
||
const [otherClient] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId: otherPort,
|
||
firstName: 'Jane',
|
||
lastName: 'Doe',
|
||
email: `jane-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
// No folder created. Sync should not throw.
|
||
await expect(
|
||
syncEntityFolderName(otherPort, 'client', otherClient!.id, TEST_USER_ID),
|
||
).resolves.toBeUndefined();
|
||
});
|
||
|
||
it('appends numeric suffix on rename collision (target name already taken)', async () => {
|
||
// Create a second client called Smith, Jane and give them a folder.
|
||
const [collider] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'Jane',
|
||
lastName: 'Smith',
|
||
email: `jane-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
await ensureEntityFolder(portId, 'client', collider!.id, TEST_USER_ID);
|
||
|
||
// Rename John → Jane (collision with the other Smith, Jane).
|
||
await db.update(clients).set({ firstName: 'Jane' }).where(eq(clients.id, clientId));
|
||
await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID);
|
||
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||
});
|
||
expect(folder?.name).toBe('Smith, Jane (2)');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests — expect failure**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t syncEntityFolderName`
|
||
Expected: `syncEntityFolderName` not exported.
|
||
|
||
- [ ] **Step 3: Implement `syncEntityFolderName`**
|
||
|
||
Append to `src/lib/services/document-folders.service.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Rename the per-entity subfolder to match the entity's current display
|
||
* name. Called from the entity rename services (`updateClient`,
|
||
* `updateCompany`, `updateYacht`). No-op when the folder does not exist
|
||
* (lazy creation — entities without a folder skip the sync entirely).
|
||
*
|
||
* Sibling-name collision is resolved by suffix bump (matches
|
||
* `ensureEntityFolder` semantics).
|
||
*
|
||
* Intentionally does NOT call `assertNotSystemManaged` — this helper
|
||
* is the legitimate path for renaming a system folder.
|
||
*/
|
||
export async function syncEntityFolderName(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
_userId: string,
|
||
): Promise<void> {
|
||
if (!ENTITY_TYPES.has(entityType)) return;
|
||
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(
|
||
eq(documentFolders.portId, portId),
|
||
eq(documentFolders.entityType, entityType),
|
||
eq(documentFolders.entityId, entityId),
|
||
),
|
||
});
|
||
if (!folder) return; // Lazy creation — nothing to sync yet.
|
||
|
||
// Preserve archived suffix if present.
|
||
const isArchived = folder.name.endsWith(' (archived)');
|
||
const isDeleted = folder.name.endsWith(' (deleted)');
|
||
if (isDeleted) return; // Demoted; rep owns the name now.
|
||
|
||
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
|
||
const targetSuffix = isArchived ? ' (archived)' : '';
|
||
|
||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||
const candidate =
|
||
attempt === 0 ? `${baseName}${targetSuffix}` : `${baseName} (${attempt + 1})${targetSuffix}`;
|
||
if (candidate === folder.name) return; // No-op rename.
|
||
try {
|
||
const [updated] = await db
|
||
.update(documentFolders)
|
||
.set({ name: candidate, updatedAt: new Date() })
|
||
.where(eq(documentFolders.id, folder.id))
|
||
.returning();
|
||
if (updated) return;
|
||
} catch (err) {
|
||
if (isSiblingNameConflict(err)) continue;
|
||
throw err;
|
||
}
|
||
}
|
||
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t syncEntityFolderName`
|
||
Expected: 3/3 pass.
|
||
|
||
- [ ] **Step 5: Wire into `clients.service.ts:updateClient`**
|
||
|
||
Open `src/lib/services/clients.service.ts` and find `updateClient` (~line 489). The function takes a partial update object. After the `db.update(clients).set(...).returning()` succeeds, but **only** when `firstName` or `lastName` is in the update payload, call:
|
||
|
||
```typescript
|
||
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
|
||
|
||
// Inside updateClient, after the .returning() call:
|
||
if (data.firstName !== undefined || data.lastName !== undefined) {
|
||
// Best-effort — folder sync must not fail the entity update.
|
||
await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => {
|
||
logger.error({ err, clientId: id }, 'Failed to sync client folder name');
|
||
});
|
||
}
|
||
```
|
||
|
||
Read the actual `updateClient` signature first — `meta.userId` and `logger` are the conventions in the codebase; adjust if the file uses a different name. `logger` is imported at the top of most service files; if it isn't here, add: `import { logger } from '@/lib/logger';`.
|
||
|
||
- [ ] **Step 6: Wire into `companies.service.ts:updateCompany`**
|
||
|
||
Same pattern in `src/lib/services/companies.service.ts` (~line 133). The trigger condition is `data.name !== undefined`:
|
||
|
||
```typescript
|
||
if (data.name !== undefined) {
|
||
await syncEntityFolderName(portId, 'company', id, meta.userId).catch((err) => {
|
||
logger.error({ err, companyId: id }, 'Failed to sync company folder name');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Wire into `yachts.service.ts:updateYacht`**
|
||
|
||
Same pattern in `src/lib/services/yachts.service.ts` (~line 113):
|
||
|
||
```typescript
|
||
if (data.name !== undefined) {
|
||
await syncEntityFolderName(portId, 'yacht', id, meta.userId).catch((err) => {
|
||
logger.error({ err, yachtId: id }, 'Failed to sync yacht folder name');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Verify TypeScript compiles**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: clean exit.
|
||
|
||
- [ ] **Step 9: Run the full folder test suite + entity service tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts tests/integration/document-folders-crud.test.ts tests/integration/clients tests/integration/companies tests/integration/yachts
|
||
```
|
||
|
||
Expected: all pass. (The `.catch` swallows folder errors so failing sync paths don't break entity updates.)
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/document-folders.service.ts src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts tests/unit/document-folders-system-folders.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): syncEntityFolderName + entity-rename hooks
|
||
|
||
Per-entity subfolder names mirror the entity's current display string.
|
||
Wired into updateClient / updateCompany / updateYacht; runs only when
|
||
the name fields change. Best-effort (logged + swallowed) so a folder-
|
||
sync error never fails an entity update. Preserves the (archived)
|
||
suffix when present; skips entirely when the folder has been demoted
|
||
to (deleted) — the rep owns the name at that point.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Service — archive / restore / hard-delete suffix helpers
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/document-folders.service.ts`
|
||
- Modify: `src/lib/services/clients.service.ts` (archive/restore/delete hooks)
|
||
- Modify: `src/lib/services/companies.service.ts` (same)
|
||
- Modify: `src/lib/services/yachts.service.ts` (same)
|
||
- Test: `tests/unit/document-folders-system-folders.test.ts` (append)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `tests/unit/document-folders-system-folders.test.ts`:
|
||
|
||
```typescript
|
||
import {
|
||
applyEntityArchivedSuffix,
|
||
applyEntityRestoredSuffix,
|
||
demoteSystemFolderOnEntityDelete,
|
||
} from '@/lib/services/document-folders.service';
|
||
|
||
describe('document-folders service · archive lifecycle', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const [client] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = client!.id;
|
||
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||
});
|
||
|
||
it('appends (archived) suffix and stamps archived_at on archive', async () => {
|
||
await applyEntityArchivedSuffix(portId, 'client', clientId);
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||
});
|
||
expect(folder?.name).toBe('Smith, John (archived)');
|
||
expect(folder?.archivedAt).toBeInstanceOf(Date);
|
||
expect(folder?.systemManaged).toBe(true); // still system-managed
|
||
});
|
||
|
||
it('removes (archived) suffix and clears archived_at on restore', async () => {
|
||
await applyEntityArchivedSuffix(portId, 'client', clientId);
|
||
await applyEntityRestoredSuffix(portId, 'client', clientId);
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||
});
|
||
expect(folder?.name).toBe('Smith, John');
|
||
expect(folder?.archivedAt).toBeNull();
|
||
});
|
||
|
||
it('appends (deleted) and flips system_managed=false on entity hard-delete', async () => {
|
||
await demoteSystemFolderOnEntityDelete(portId, 'client', clientId);
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||
});
|
||
expect(folder?.name).toBe('Smith, John (deleted)');
|
||
expect(folder?.systemManaged).toBe(false);
|
||
});
|
||
|
||
it('is a no-op when the folder does not exist', async () => {
|
||
const otherPort = await setupTestPort();
|
||
await ensureSystemRoots(otherPort, TEST_USER_ID);
|
||
const [other] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId: otherPort,
|
||
firstName: 'Lone',
|
||
lastName: 'Wolf',
|
||
email: `lone-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
// No folder created; archive should not throw.
|
||
await expect(
|
||
applyEntityArchivedSuffix(otherPort, 'client', other!.id),
|
||
).resolves.toBeUndefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests — expect failure**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'archive lifecycle'`
|
||
Expected: helpers not exported.
|
||
|
||
- [ ] **Step 3: Implement the three helpers**
|
||
|
||
Append to `src/lib/services/document-folders.service.ts`:
|
||
|
||
```typescript
|
||
const ARCHIVED_SUFFIX = ' (archived)';
|
||
const DELETED_SUFFIX = ' (deleted)';
|
||
|
||
/**
|
||
* Stamp an entity's subfolder as archived: append " (archived)" to the
|
||
* name (idempotent — won't double-append) and set archived_at. No-op
|
||
* when the folder does not exist (lazy creation). Used by the entity
|
||
* archive paths in clients / companies / yachts services.
|
||
*/
|
||
export async function applyEntityArchivedSuffix(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
): Promise<void> {
|
||
if (!ENTITY_TYPES.has(entityType)) return;
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(
|
||
eq(documentFolders.portId, portId),
|
||
eq(documentFolders.entityType, entityType),
|
||
eq(documentFolders.entityId, entityId),
|
||
),
|
||
});
|
||
if (!folder) return;
|
||
const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
|
||
? folder.name
|
||
: `${folder.name}${ARCHIVED_SUFFIX}`;
|
||
await db
|
||
.update(documentFolders)
|
||
.set({ name: newName, archivedAt: new Date(), updatedAt: new Date() })
|
||
.where(eq(documentFolders.id, folder.id));
|
||
}
|
||
|
||
/**
|
||
* Inverse of `applyEntityArchivedSuffix` — strip " (archived)" from
|
||
* the name and clear archived_at. No-op when the folder does not
|
||
* exist or wasn't archived. Used by the entity restore paths.
|
||
*/
|
||
export async function applyEntityRestoredSuffix(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
): Promise<void> {
|
||
if (!ENTITY_TYPES.has(entityType)) return;
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(
|
||
eq(documentFolders.portId, portId),
|
||
eq(documentFolders.entityType, entityType),
|
||
eq(documentFolders.entityId, entityId),
|
||
),
|
||
});
|
||
if (!folder) return;
|
||
const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
|
||
? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
|
||
: folder.name;
|
||
await db
|
||
.update(documentFolders)
|
||
.set({ name: newName, archivedAt: null, updatedAt: new Date() })
|
||
.where(eq(documentFolders.id, folder.id));
|
||
}
|
||
|
||
/**
|
||
* Entity has been hard-deleted: demote the folder to a regular user
|
||
* folder by clearing `system_managed`, appending " (deleted)" to the
|
||
* name, and dropping the entity FK so the partial unique index no
|
||
* longer constrains it. Files still inside the folder retain their
|
||
* snapshotted entity FKs (orphaned — they appear in the root-view
|
||
* Files section once the rep cleans up).
|
||
*
|
||
* Idempotent: re-demoting an already-demoted folder is a no-op.
|
||
*/
|
||
export async function demoteSystemFolderOnEntityDelete(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
): Promise<void> {
|
||
if (!ENTITY_TYPES.has(entityType)) return;
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(
|
||
eq(documentFolders.portId, portId),
|
||
eq(documentFolders.entityType, entityType),
|
||
eq(documentFolders.entityId, entityId),
|
||
),
|
||
});
|
||
if (!folder) return;
|
||
const stripped = folder.name.endsWith(ARCHIVED_SUFFIX)
|
||
? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
|
||
: folder.name;
|
||
const newName = stripped.endsWith(DELETED_SUFFIX) ? stripped : `${stripped}${DELETED_SUFFIX}`;
|
||
await db
|
||
.update(documentFolders)
|
||
.set({
|
||
name: newName,
|
||
systemManaged: false,
|
||
entityType: null,
|
||
entityId: null,
|
||
archivedAt: null,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(eq(documentFolders.id, folder.id));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts -t 'archive lifecycle'`
|
||
Expected: 4/4 pass.
|
||
|
||
- [ ] **Step 5: Wire into `clients.service.ts:archiveClient` and `restoreClient`**
|
||
|
||
In `src/lib/services/clients.service.ts`, add the import:
|
||
|
||
```typescript
|
||
import {
|
||
applyEntityArchivedSuffix,
|
||
applyEntityRestoredSuffix,
|
||
} from '@/lib/services/document-folders.service';
|
||
```
|
||
|
||
Inside `archiveClient` (~line 537), after the entity update succeeds:
|
||
|
||
```typescript
|
||
await applyEntityArchivedSuffix(portId, 'client', id).catch((err) => {
|
||
logger.error({ err, clientId: id }, 'Failed to apply archived suffix to client folder');
|
||
});
|
||
```
|
||
|
||
Inside `restoreClient` (~line 565), after the entity update succeeds:
|
||
|
||
```typescript
|
||
await applyEntityRestoredSuffix(portId, 'client', id).catch((err) => {
|
||
logger.error({ err, clientId: id }, 'Failed to clear archived suffix on client folder');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Wire archive into companies + yachts services**
|
||
|
||
In `companies.service.ts:archiveCompany` (~line 189):
|
||
|
||
```typescript
|
||
import {
|
||
applyEntityArchivedSuffix,
|
||
applyEntityRestoredSuffix,
|
||
} from '@/lib/services/document-folders.service';
|
||
|
||
// After the entity update:
|
||
await applyEntityArchivedSuffix(portId, 'company', id).catch((err) => {
|
||
logger.error({ err, companyId: id }, 'Failed to apply archived suffix to company folder');
|
||
});
|
||
```
|
||
|
||
If `restoreCompany` exists, wire it too with `applyEntityRestoredSuffix`. If it doesn't exist (grep first: `grep -n 'restoreCompany\|export async function restore' src/lib/services/companies.service.ts`), skip restore for companies — note the gap in the commit message.
|
||
|
||
Same for `yachts.service.ts:archiveYacht` (~line 167) — wire archive with `applyEntityArchivedSuffix('yacht', …)`. Grep for `restoreYacht`; if absent, skip.
|
||
|
||
- [ ] **Step 7: Wire hard-delete demote into delete paths**
|
||
|
||
Grep for the delete service functions:
|
||
|
||
```bash
|
||
grep -n 'export async function delete' src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts
|
||
```
|
||
|
||
If a hard-delete function exists (e.g., `deleteClient` outside the archive path), add `demoteSystemFolderOnEntityDelete(portId, '<type>', id)` before the entity row is removed. If only soft-delete exists (the codebase prefers archiveX), skip Task 6 Step 7 — the suffix helper is still ready for whenever hard-delete lands.
|
||
|
||
- [ ] **Step 8: Verify TypeScript compiles + run the full folder suite**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm exec tsc --noEmit && pnpm exec vitest run tests/unit/document-folders-system-folders.test.ts tests/integration/document-folders-crud.test.ts tests/integration/document-folders-soft-delete.test.ts
|
||
```
|
||
|
||
Expected: clean tsc + all pass.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/document-folders.service.ts src/lib/services/clients.service.ts src/lib/services/companies.service.ts src/lib/services/yachts.service.ts tests/unit/document-folders-system-folders.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): entity-folder archive / restore / demote helpers
|
||
|
||
applyEntityArchivedSuffix stamps " (archived)" + archived_at on the
|
||
entity subfolder so the UI mutes it and auto-deposit halts. Restore is
|
||
the inverse. demoteSystemFolderOnEntityDelete flips system_managed=
|
||
false, appends " (deleted)", and clears the entity FK so the partial
|
||
unique index releases the slot — orphaned files retain their entity
|
||
FK snapshots and surface in the rep's clean-up view.
|
||
|
||
All three helpers are best-effort from the entity-side hooks; folder
|
||
errors are logged but do not fail the entity-update operation.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Webhook — extend `handleDocumentCompleted` with auto-deposit
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/documents.service.ts`
|
||
- Test: `tests/integration/documents-completion-auto-deposit.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing integration tests**
|
||
|
||
Create `tests/integration/documents-completion-auto-deposit.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { and, eq } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { documents, files, documentFolders } from '@/lib/db/schema/documents';
|
||
import { clients } from '@/lib/db/schema/clients';
|
||
import { interests } from '@/lib/db/schema/interests';
|
||
import { handleDocumentCompleted } from '@/lib/services/documents.service';
|
||
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
|
||
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
|
||
|
||
// Stub the Documenso download so the handler doesn't hit the network.
|
||
vi.mock('@/lib/services/documenso-client', async (orig) => {
|
||
const real = await orig<typeof import('@/lib/services/documenso-client')>();
|
||
return {
|
||
...real,
|
||
downloadSignedPdf: vi.fn(async () => Buffer.from('%PDF-1.4 stub\n')),
|
||
};
|
||
});
|
||
|
||
describe('handleDocumentCompleted · auto-deposit', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
|
||
const [client] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = client!.id;
|
||
});
|
||
|
||
it('client-direct: signed PDF lands in the client subfolder', async () => {
|
||
const [doc] = await db
|
||
.insert(documents)
|
||
.values({
|
||
portId,
|
||
clientId,
|
||
documentType: 'eoi',
|
||
title: 'EOI · John Smith',
|
||
documensoId: `doc-${crypto.randomUUID()}`,
|
||
status: 'partially_signed',
|
||
createdBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
|
||
await handleDocumentCompleted({ documentId: doc!.documensoId!, portId });
|
||
|
||
const updatedDoc = await db.query.documents.findFirst({ where: eq(documents.id, doc!.id) });
|
||
expect(updatedDoc?.status).toBe('completed');
|
||
expect(updatedDoc?.signedFileId).not.toBeNull();
|
||
|
||
const signedFile = await db.query.files.findFirst({
|
||
where: eq(files.id, updatedDoc!.signedFileId!),
|
||
});
|
||
const clientFolder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||
});
|
||
expect(clientFolder).toBeDefined();
|
||
expect(signedFile?.folderId).toBe(clientFolder!.id);
|
||
expect(signedFile?.clientId).toBe(clientId);
|
||
});
|
||
|
||
it('no owner: signed PDF lands at root with folder_id=null', async () => {
|
||
const [doc] = await db
|
||
.insert(documents)
|
||
.values({
|
||
portId,
|
||
documentType: 'other',
|
||
title: 'Untargeted contract',
|
||
documensoId: `doc-${crypto.randomUUID()}`,
|
||
status: 'partially_signed',
|
||
createdBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
|
||
await handleDocumentCompleted({ documentId: doc!.documensoId!, portId });
|
||
|
||
const updatedDoc = await db.query.documents.findFirst({ where: eq(documents.id, doc!.id) });
|
||
expect(updatedDoc?.signedFileId).not.toBeNull();
|
||
const signedFile = await db.query.files.findFirst({
|
||
where: eq(files.id, updatedDoc!.signedFileId!),
|
||
});
|
||
expect(signedFile?.folderId).toBeNull();
|
||
expect(signedFile?.clientId).toBeNull();
|
||
});
|
||
|
||
it('via interest: resolves owner through interest.primaryClientId', async () => {
|
||
const [interest] = await db
|
||
.insert(interests)
|
||
.values({
|
||
portId,
|
||
primaryClientId: clientId,
|
||
pipelineStage: 'eoi_sent',
|
||
clientReadyToSign: true,
|
||
})
|
||
.returning();
|
||
|
||
const [doc] = await db
|
||
.insert(documents)
|
||
.values({
|
||
portId,
|
||
interestId: interest!.id,
|
||
documentType: 'eoi',
|
||
title: 'EOI · via interest',
|
||
documensoId: `doc-${crypto.randomUUID()}`,
|
||
status: 'partially_signed',
|
||
createdBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
|
||
await handleDocumentCompleted({ documentId: doc!.documensoId!, portId });
|
||
|
||
const updatedDoc = await db.query.documents.findFirst({ where: eq(documents.id, doc!.id) });
|
||
const signedFile = await db.query.files.findFirst({
|
||
where: eq(files.id, updatedDoc!.signedFileId!),
|
||
});
|
||
expect(signedFile?.clientId).toBe(clientId);
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||
});
|
||
expect(signedFile?.folderId).toBe(folder!.id);
|
||
});
|
||
});
|
||
```
|
||
|
||
Read `tests/integration/document-folders-crud.test.ts` first to copy the exact `setupTestPort` import path. Check `tests/helpers/` for the `getStorageBackend` mock — most integration tests stub it to write to a temp filesystem. If they don't, you'll need to mock it inline.
|
||
|
||
The `interest.primaryClientId` field name may differ — confirm by reading `src/lib/db/schema/interests.ts`. The spec calls it that; the schema may name it `primary_client_id` (snake case) which Drizzle exposes as `primaryClientId`.
|
||
|
||
- [ ] **Step 2: Run the tests — expect failure**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/documents-completion-auto-deposit.test.ts`
|
||
Expected: tests fail because the handler doesn't set `folder_id` yet.
|
||
|
||
- [ ] **Step 3: Add an owner-resolution helper to `documents.service.ts`**
|
||
|
||
In `src/lib/services/documents.service.ts`, add this internal helper near the top of the file (after the imports). It encapsulates the Owner-wins chain from the spec:
|
||
|
||
```typescript
|
||
import { ensureEntityFolder, type EntityType } from '@/lib/services/document-folders.service';
|
||
|
||
interface ResolvedOwner {
|
||
entityType: EntityType;
|
||
entityId: string;
|
||
}
|
||
|
||
/**
|
||
* Owner-wins owner resolution chain — see spec §"Routing on workflow
|
||
* completion" §3a. Returns the first non-null candidate in priority
|
||
* order: direct client/company/yacht FK on the document, then the
|
||
* linked interest's primary entity. Returns null when no owner is
|
||
* resolvable (signed PDF will land at root).
|
||
*/
|
||
async function resolveDocumentOwner(doc: {
|
||
clientId: string | null;
|
||
companyId: string | null;
|
||
yachtId: string | null;
|
||
interestId: string | null;
|
||
}): Promise<ResolvedOwner | null> {
|
||
if (doc.clientId) return { entityType: 'client', entityId: doc.clientId };
|
||
if (doc.companyId) return { entityType: 'company', entityId: doc.companyId };
|
||
if (doc.yachtId) return { entityType: 'yacht', entityId: doc.yachtId };
|
||
|
||
if (doc.interestId) {
|
||
const interest = await db.query.interests.findFirst({
|
||
where: eq(interests.id, doc.interestId),
|
||
columns: { primaryClientId: true, primaryCompanyId: true, primaryYachtId: true },
|
||
});
|
||
if (interest?.primaryClientId) {
|
||
return { entityType: 'client', entityId: interest.primaryClientId };
|
||
}
|
||
if (interest?.primaryCompanyId) {
|
||
return { entityType: 'company', entityId: interest.primaryCompanyId };
|
||
}
|
||
if (interest?.primaryYachtId) {
|
||
return { entityType: 'yacht', entityId: interest.primaryYachtId };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
```
|
||
|
||
Verify the actual column names on `interests` first. Read `src/lib/db/schema/interests.ts`. If the columns are named differently (e.g., `clientId` instead of `primaryClientId`), adjust the projection columns and the field reads.
|
||
|
||
- [ ] **Step 4: Modify `handleDocumentCompleted` to set folder_id + copy entity FKs**
|
||
|
||
In the same file, find `handleDocumentCompleted` (~line 1065). Locate the block that inserts the signed file row (~line 1088):
|
||
|
||
```typescript
|
||
const [fileRecord] = await db
|
||
.insert(files)
|
||
.values({
|
||
portId: doc.portId,
|
||
clientId: doc.clientId ?? null,
|
||
filename: `signed-${doc.id}.pdf`,
|
||
// ...
|
||
})
|
||
.returning();
|
||
```
|
||
|
||
Replace it with a version that resolves the owner and ensures the entity folder before inserting:
|
||
|
||
```typescript
|
||
// Resolve owner via the Owner-wins chain. The signed PDF lands in
|
||
// this owner's auto-created entity subfolder (or at root if no owner).
|
||
const owner = await resolveDocumentOwner(doc);
|
||
|
||
let entityFolderId: string | null = null;
|
||
if (owner) {
|
||
try {
|
||
const folder = await ensureEntityFolder(doc.portId, owner.entityType, owner.entityId, 'system');
|
||
entityFolderId = folder.id;
|
||
} catch (err) {
|
||
// Folder creation is best-effort — signed file still lands, just
|
||
// at root. Log so we can clean up post-deploy.
|
||
logger.error(
|
||
{ err, documentId: doc.id, owner },
|
||
'ensureEntityFolder failed during document completion',
|
||
);
|
||
}
|
||
}
|
||
|
||
const [fileRecord] = await db
|
||
.insert(files)
|
||
.values({
|
||
portId: doc.portId,
|
||
clientId: owner?.entityType === 'client' ? owner.entityId : (doc.clientId ?? null),
|
||
companyId: owner?.entityType === 'company' ? owner.entityId : (doc.companyId ?? null),
|
||
yachtId: owner?.entityType === 'yacht' ? owner.entityId : (doc.yachtId ?? null),
|
||
folderId: entityFolderId,
|
||
filename: `signed-${doc.id}.pdf`,
|
||
originalName: `signed-${doc.id}.pdf`,
|
||
mimeType: 'application/pdf',
|
||
sizeBytes: String(signedPdfBuffer.length),
|
||
storagePath,
|
||
storageBucket: env.MINIO_BUCKET,
|
||
category: 'eoi',
|
||
uploadedBy: 'system',
|
||
})
|
||
.returning();
|
||
```
|
||
|
||
Make sure the existing `companyId` / `yachtId` propagation is preserved — the helper writes the resolved owner's id onto the matching FK, while leaving non-resolved FKs at whatever the workflow had. The `doc` object already carries `clientId / companyId / yachtId / interestId` from `resolveWebhookDocument`.
|
||
|
||
- [ ] **Step 5: Run the tests — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/documents-completion-auto-deposit.test.ts`
|
||
Expected: 3/3 pass.
|
||
|
||
- [ ] **Step 6: Run the wider documents suite to catch regressions**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/documents tests/unit/documents`
|
||
Expected: all pre-existing tests still pass.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/documents.service.ts tests/integration/documents-completion-auto-deposit.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): auto-deposit signed PDFs into entity folders
|
||
|
||
handleDocumentCompleted resolves the workflow owner via the Owner-wins
|
||
chain (client → company → yacht → interest.primaryClientId →
|
||
.primaryCompanyId → .primaryYachtId), ensures the matching entity
|
||
subfolder exists, and sets files.folder_id + the matching entity FK
|
||
on the signed file row. Falls back to root (folder_id=null) when no
|
||
owner is resolvable. ensureEntityFolder failures are logged but never
|
||
fail the completion — the signed PDF always lands.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Service — aggregated projection (`listFilesAggregatedByEntity` + workflows)
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/files.ts`
|
||
- Modify: `src/lib/services/documents.service.ts`
|
||
- Test: `tests/unit/aggregated-projection.test.ts`
|
||
|
||
This is the killer-feature task. Owner-aggregated projection walks the relationship graph (symmetric reach per spec) and returns results grouped by owner-source: DIRECTLY ATTACHED, FROM COMPANY — X, FROM YACHT — Y, FROM CLIENT — Z. Each group caps at 20 rows with a `Show all (N)` drill-through.
|
||
|
||
- [ ] **Step 1: Write failing tests for `listFilesAggregatedByEntity`**
|
||
|
||
Create `tests/unit/aggregated-projection.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { eq } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { documentFolders, files } from '@/lib/db/schema/documents';
|
||
import { clients } from '@/lib/db/schema/clients';
|
||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||
import { yachts } from '@/lib/db/schema/yachts';
|
||
import { listFilesAggregatedByEntity } from '@/lib/services/files';
|
||
import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service';
|
||
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
|
||
|
||
describe('files service · listFilesAggregatedByEntity', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
let companyId: string;
|
||
let yachtId: string;
|
||
let clientFolderId: string;
|
||
let companyFolderId: string;
|
||
let yachtFolderId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
|
||
const [c] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = c!.id;
|
||
|
||
const [co] = await db
|
||
.insert(companies)
|
||
.values({
|
||
portId,
|
||
name: 'Smith Marine LLC',
|
||
})
|
||
.returning();
|
||
companyId = co!.id;
|
||
|
||
const [y] = await db
|
||
.insert(yachts)
|
||
.values({
|
||
portId,
|
||
name: 'MV Serenity',
|
||
currentOwnerType: 'client',
|
||
currentOwnerId: clientId,
|
||
})
|
||
.returning();
|
||
yachtId = y!.id;
|
||
|
||
await db.insert(companyMemberships).values({
|
||
companyId,
|
||
clientId,
|
||
});
|
||
|
||
clientFolderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id;
|
||
companyFolderId = (await ensureEntityFolder(portId, 'company', companyId, TEST_USER_ID)).id;
|
||
yachtFolderId = (await ensureEntityFolder(portId, 'yacht', yachtId, TEST_USER_ID)).id;
|
||
});
|
||
|
||
async function insertFile(opts: {
|
||
clientId?: string | null;
|
||
companyId?: string | null;
|
||
yachtId?: string | null;
|
||
folderId: string;
|
||
filename: string;
|
||
}) {
|
||
const [row] = await db
|
||
.insert(files)
|
||
.values({
|
||
portId,
|
||
clientId: opts.clientId ?? null,
|
||
companyId: opts.companyId ?? null,
|
||
yachtId: opts.yachtId ?? null,
|
||
folderId: opts.folderId,
|
||
filename: opts.filename,
|
||
originalName: opts.filename,
|
||
mimeType: 'application/pdf',
|
||
storagePath: `test/${crypto.randomUUID()}`,
|
||
storageBucket: 'test',
|
||
uploadedBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
return row!;
|
||
}
|
||
|
||
it('groups DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT for a client view', async () => {
|
||
await insertFile({ clientId, folderId: clientFolderId, filename: 'Passport.pdf' });
|
||
await insertFile({ companyId, folderId: companyFolderId, filename: 'Articles.pdf' });
|
||
await insertFile({ yachtId, folderId: yachtFolderId, filename: 'Survey.pdf' });
|
||
|
||
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||
const groupNames = result.groups.map((g) => g.label);
|
||
expect(groupNames).toContain('DIRECTLY ATTACHED');
|
||
expect(groupNames.some((n) => n.startsWith('FROM COMPANY'))).toBe(true);
|
||
expect(groupNames.some((n) => n.startsWith('FROM YACHT'))).toBe(true);
|
||
|
||
const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||
expect(direct?.files.map((f) => f.filename)).toContain('Passport.pdf');
|
||
|
||
const company = result.groups.find((g) => g.label.startsWith('FROM COMPANY'));
|
||
expect(company?.files.map((f) => f.filename)).toContain('Articles.pdf');
|
||
|
||
const yacht = result.groups.find((g) => g.label.startsWith('FROM YACHT'));
|
||
expect(yacht?.files.map((f) => f.filename)).toContain('Survey.pdf');
|
||
});
|
||
|
||
it('caps each group at 20 rows and surfaces total for Show all', async () => {
|
||
for (let i = 0; i < 25; i += 1) {
|
||
await insertFile({ clientId, folderId: clientFolderId, filename: `direct-${i}.pdf` });
|
||
}
|
||
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||
const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||
expect(direct?.files).toHaveLength(20);
|
||
expect(direct?.total).toBe(25);
|
||
});
|
||
|
||
it('snapshots are file-FK-based — yacht transfer does not move historical files', async () => {
|
||
const file = await insertFile({
|
||
yachtId,
|
||
clientId,
|
||
folderId: yachtFolderId,
|
||
filename: 'Historic.pdf',
|
||
});
|
||
// Transfer the yacht to a different owner.
|
||
const [mary] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'Mary',
|
||
lastName: 'Brown',
|
||
email: `mary-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
await db
|
||
.update(yachts)
|
||
.set({ currentOwnerType: 'client', currentOwnerId: mary!.id })
|
||
.where(eq(yachts.id, yachtId));
|
||
|
||
// John's view still shows the file (via DIRECTLY ATTACHED or FROM YACHT).
|
||
const johnView = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||
const allFiles = johnView.groups.flatMap((g) => g.files.map((f) => f.id));
|
||
expect(allFiles).toContain(file.id);
|
||
|
||
// Mary's view does NOT show the file (it was never linked to her).
|
||
const maryView = await listFilesAggregatedByEntity(portId, 'client', mary!.id);
|
||
const maryFiles = maryView.groups.flatMap((g) => g.files.map((f) => f.id));
|
||
expect(maryFiles).not.toContain(file.id);
|
||
});
|
||
|
||
it('rejects cross-port leakage with defense-in-depth port filter', async () => {
|
||
const otherPort = await setupTestPort();
|
||
const [otherClient] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId: otherPort,
|
||
firstName: 'Other',
|
||
lastName: 'Port',
|
||
email: `other-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
// Try to query the other port's client from our port — should return empty.
|
||
const result = await listFilesAggregatedByEntity(portId, 'client', otherClient!.id);
|
||
expect(result.groups.flatMap((g) => g.files)).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
Verify `clients` / `companies` / `companyMemberships` / `yachts` insert shapes by reading the schema files first. Required NOT NULL columns may need filler values in the test fixtures.
|
||
|
||
- [ ] **Step 2: Run the test — expect failure**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts`
|
||
Expected: `listFilesAggregatedByEntity` not exported.
|
||
|
||
- [ ] **Step 3: Implement the projection helper**
|
||
|
||
Append to `src/lib/services/files.ts`:
|
||
|
||
```typescript
|
||
import { documentFolders } from '@/lib/db/schema/documents';
|
||
import { clients } from '@/lib/db/schema/clients';
|
||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||
import { yachts } from '@/lib/db/schema/yachts';
|
||
import type { EntityType } from '@/lib/services/document-folders.service';
|
||
import { inArray } from 'drizzle-orm';
|
||
import { desc } from 'drizzle-orm';
|
||
|
||
export interface AggregatedFileGroup {
|
||
/** Label used by the UI header (e.g. "DIRECTLY ATTACHED" or "FROM YACHT — MV SERENITY"). */
|
||
label: string;
|
||
/** Stable key for de-duplication when the same file appears via multiple paths. */
|
||
source: 'direct' | 'client' | 'company' | 'yacht';
|
||
/** Up to 20 most-recent files in this group. */
|
||
files: Array<typeof files.$inferSelect & { livesInFolderPath?: string }>;
|
||
/** Total count in this source — used to surface `Show all (N)`. */
|
||
total: number;
|
||
}
|
||
|
||
interface AggregatedFilesResult {
|
||
groups: AggregatedFileGroup[];
|
||
}
|
||
|
||
const GROUP_LIMIT = 20;
|
||
|
||
/**
|
||
* Walk the relationship graph from the requested entity and return
|
||
* files grouped by source. Symmetric reach (per spec):
|
||
* - DIRECTLY ATTACHED: files whose FK matches the entity directly
|
||
* - FROM COMPANY: for client → linked companies; for yacht → owning company
|
||
* - FROM YACHT: for client → yachts they own (currentOwnerType+currentOwnerId)
|
||
* + yachts owned by companies they're a member of
|
||
* - FROM CLIENT: for company → member clients; for yacht → owning client
|
||
*
|
||
* Defense-in-depth: port_id filter on every entity / membership / yacht /
|
||
* file join (per CLAUDE.md + recommender precedent). The entry-point
|
||
* filter is insufficient — a corrupted FK pointing at another port
|
||
* must not surface in this port's aggregation.
|
||
*
|
||
* Source of truth: each file's snapshotted entity FKs (files.client_id /
|
||
* .company_id / .yacht_id), NOT the entity's current relationships.
|
||
* Historical files stay where they were filed.
|
||
*/
|
||
export async function listFilesAggregatedByEntity(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
): Promise<AggregatedFilesResult> {
|
||
// Verify the entity belongs to this port (defense-in-depth — refuses
|
||
// cross-port reads before any aggregation runs).
|
||
const entityExists = await assertEntityInPort(portId, entityType, entityId);
|
||
if (!entityExists) return { groups: [] };
|
||
|
||
// Step 1: build the set of related entity ids for each source bucket.
|
||
const related = await collectRelatedEntities(portId, entityType, entityId);
|
||
|
||
// Step 2: emit one group per source, each capped at GROUP_LIMIT.
|
||
const groups: AggregatedFileGroup[] = [];
|
||
|
||
// DIRECTLY ATTACHED — files whose own FK matches the requested entity.
|
||
const directColumn =
|
||
entityType === 'client'
|
||
? files.clientId
|
||
: entityType === 'company'
|
||
? files.companyId
|
||
: files.yachtId;
|
||
const direct = await fetchGroupRows(portId, eq(directColumn, entityId), GROUP_LIMIT);
|
||
if (direct.rows.length > 0) {
|
||
groups.push({
|
||
label: 'DIRECTLY ATTACHED',
|
||
source: 'direct',
|
||
files: direct.rows,
|
||
total: direct.total,
|
||
});
|
||
}
|
||
|
||
// FROM COMPANY — for each linked company, surface its files.
|
||
for (const { id, name } of related.companies) {
|
||
const g = await fetchGroupRows(portId, eq(files.companyId, id), GROUP_LIMIT);
|
||
if (g.rows.length === 0) continue;
|
||
groups.push({
|
||
label: `FROM COMPANY — ${name.toUpperCase()}`,
|
||
source: 'company',
|
||
files: g.rows,
|
||
total: g.total,
|
||
});
|
||
}
|
||
|
||
// FROM YACHT — for each linked yacht, surface its files.
|
||
for (const { id, name } of related.yachts) {
|
||
const g = await fetchGroupRows(portId, eq(files.yachtId, id), GROUP_LIMIT);
|
||
if (g.rows.length === 0) continue;
|
||
groups.push({
|
||
label: `FROM YACHT — ${name.toUpperCase()}`,
|
||
source: 'yacht',
|
||
files: g.rows,
|
||
total: g.total,
|
||
});
|
||
}
|
||
|
||
// FROM CLIENT — for each linked client (e.g., when viewing a company), surface theirs.
|
||
for (const { id, name } of related.clients) {
|
||
const g = await fetchGroupRows(portId, eq(files.clientId, id), GROUP_LIMIT);
|
||
if (g.rows.length === 0) continue;
|
||
groups.push({
|
||
label: `FROM CLIENT — ${name.toUpperCase()}`,
|
||
source: 'client',
|
||
files: g.rows,
|
||
total: g.total,
|
||
});
|
||
}
|
||
|
||
return { groups };
|
||
}
|
||
|
||
async function assertEntityInPort(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
): Promise<boolean> {
|
||
if (entityType === 'client') {
|
||
const c = await db.query.clients.findFirst({
|
||
where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
|
||
columns: { id: true },
|
||
});
|
||
return Boolean(c);
|
||
}
|
||
if (entityType === 'company') {
|
||
const c = await db.query.companies.findFirst({
|
||
where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
|
||
columns: { id: true },
|
||
});
|
||
return Boolean(c);
|
||
}
|
||
const y = await db.query.yachts.findFirst({
|
||
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
|
||
columns: { id: true },
|
||
});
|
||
return Boolean(y);
|
||
}
|
||
|
||
interface RelatedEntities {
|
||
clients: Array<{ id: string; name: string }>;
|
||
companies: Array<{ id: string; name: string }>;
|
||
yachts: Array<{ id: string; name: string }>;
|
||
}
|
||
|
||
/**
|
||
* Walk the relationship graph and collect related entity ids for each
|
||
* source bucket. Symmetric reach: walking from a client surfaces their
|
||
* companies + their yachts + (second-degree) yachts owned by their
|
||
* companies. Every join carries port_id = $portId (defense-in-depth).
|
||
*/
|
||
async function collectRelatedEntities(
|
||
portId: string,
|
||
entityType: EntityType,
|
||
entityId: string,
|
||
): Promise<RelatedEntities> {
|
||
if (entityType === 'client') {
|
||
// Companies the client is a member of.
|
||
const memberCompanies = await db
|
||
.select({ id: companies.id, name: companies.name })
|
||
.from(companyMemberships)
|
||
.innerJoin(
|
||
companies,
|
||
and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)),
|
||
)
|
||
.where(eq(companyMemberships.clientId, entityId));
|
||
|
||
// Yachts owned directly by the client.
|
||
const directYachts = await db
|
||
.select({ id: yachts.id, name: yachts.name })
|
||
.from(yachts)
|
||
.where(
|
||
and(
|
||
eq(yachts.portId, portId),
|
||
eq(yachts.currentOwnerType, 'client'),
|
||
eq(yachts.currentOwnerId, entityId),
|
||
),
|
||
);
|
||
|
||
// Yachts owned by the client's companies (second-degree).
|
||
let companyYachts: Array<{ id: string; name: string }> = [];
|
||
if (memberCompanies.length > 0) {
|
||
companyYachts = await db
|
||
.select({ id: yachts.id, name: yachts.name })
|
||
.from(yachts)
|
||
.where(
|
||
and(
|
||
eq(yachts.portId, portId),
|
||
eq(yachts.currentOwnerType, 'company'),
|
||
inArray(
|
||
yachts.currentOwnerId,
|
||
memberCompanies.map((c) => c.id),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return {
|
||
clients: [],
|
||
companies: memberCompanies,
|
||
yachts: dedupeBy([...directYachts, ...companyYachts], (y) => y.id),
|
||
};
|
||
}
|
||
|
||
if (entityType === 'company') {
|
||
const memberClients = await db
|
||
.select({ id: clients.id, firstName: clients.firstName, lastName: clients.lastName })
|
||
.from(companyMemberships)
|
||
.innerJoin(
|
||
clients,
|
||
and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)),
|
||
)
|
||
.where(eq(companyMemberships.companyId, entityId));
|
||
|
||
const ownedYachts = await db
|
||
.select({ id: yachts.id, name: yachts.name })
|
||
.from(yachts)
|
||
.where(
|
||
and(
|
||
eq(yachts.portId, portId),
|
||
eq(yachts.currentOwnerType, 'company'),
|
||
eq(yachts.currentOwnerId, entityId),
|
||
),
|
||
);
|
||
|
||
return {
|
||
clients: memberClients.map((c) => ({
|
||
id: c.id,
|
||
name: `${c.lastName ?? ''}, ${c.firstName ?? ''}`.replace(/^,\s*|,\s*$/, ''),
|
||
})),
|
||
companies: [],
|
||
yachts: ownedYachts,
|
||
};
|
||
}
|
||
|
||
// yacht
|
||
const yacht = await db.query.yachts.findFirst({
|
||
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
|
||
});
|
||
if (!yacht) return { clients: [], companies: [], yachts: [] };
|
||
|
||
if (yacht.currentOwnerType === 'client') {
|
||
const owner = await db.query.clients.findFirst({
|
||
where: and(eq(clients.id, yacht.currentOwnerId), eq(clients.portId, portId)),
|
||
columns: { id: true, firstName: true, lastName: true },
|
||
});
|
||
return {
|
||
clients: owner
|
||
? [
|
||
{
|
||
id: owner.id,
|
||
name: `${owner.lastName ?? ''}, ${owner.firstName ?? ''}`.replace(/^,\s*|,\s*$/, ''),
|
||
},
|
||
]
|
||
: [],
|
||
companies: [],
|
||
yachts: [],
|
||
};
|
||
}
|
||
// currentOwnerType === 'company'
|
||
const owner = await db.query.companies.findFirst({
|
||
where: and(eq(companies.id, yacht.currentOwnerId), eq(companies.portId, portId)),
|
||
columns: { id: true, name: true },
|
||
});
|
||
return {
|
||
clients: [],
|
||
companies: owner ? [{ id: owner.id, name: owner.name }] : [],
|
||
yachts: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fetch up to `limit` files matching `predicate` (plus a COUNT for the
|
||
* "Show all (N)" CTA). Always carries port_id = $portId.
|
||
*/
|
||
async function fetchGroupRows(
|
||
portId: string,
|
||
predicate: ReturnType<typeof eq>,
|
||
limit: number,
|
||
): Promise<{
|
||
rows: Array<typeof files.$inferSelect>;
|
||
total: number;
|
||
}> {
|
||
const rows = await db
|
||
.select()
|
||
.from(files)
|
||
.where(and(eq(files.portId, portId), predicate))
|
||
.orderBy(desc(files.createdAt))
|
||
.limit(limit);
|
||
|
||
const [{ count }] = await db
|
||
.select({ count: sql<number>`count(*)::int` })
|
||
.from(files)
|
||
.where(and(eq(files.portId, portId), predicate));
|
||
|
||
return { rows, total: Number(count ?? 0) };
|
||
}
|
||
|
||
function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {
|
||
const seen = new Set<K>();
|
||
const out: T[] = [];
|
||
for (const item of items) {
|
||
const k = key(item);
|
||
if (seen.has(k)) continue;
|
||
seen.add(k);
|
||
out.push(item);
|
||
}
|
||
return out;
|
||
}
|
||
```
|
||
|
||
You'll need to add `sql` to the imports if it isn't already imported. Read the current top of `files.ts` first.
|
||
|
||
- [ ] **Step 4: Run the tests — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts`
|
||
Expected: 4/4 pass.
|
||
|
||
- [ ] **Step 5: Add `listInflightWorkflowsAggregatedByEntity` to documents service**
|
||
|
||
Append to `src/lib/services/documents.service.ts` — reuses the same `collectRelatedEntities` walk by exporting it from `files.ts`. Export it:
|
||
|
||
```typescript
|
||
// In src/lib/services/files.ts, change `async function collectRelatedEntities` →
|
||
// `export async function collectRelatedEntities`. Same for the type alias if needed.
|
||
```
|
||
|
||
Then append to `src/lib/services/documents.service.ts`:
|
||
|
||
```typescript
|
||
import { collectRelatedEntities, type AggregatedFileGroup } from '@/lib/services/files';
|
||
|
||
export interface AggregatedWorkflowGroup {
|
||
label: string;
|
||
source: 'direct' | 'client' | 'company' | 'yacht';
|
||
workflows: Array<typeof documents.$inferSelect>;
|
||
total: number;
|
||
}
|
||
|
||
const WORKFLOW_GROUP_LIMIT = 20;
|
||
const INFLIGHT_STATUSES = ['draft', 'sent', 'partially_signed'] as const;
|
||
|
||
/**
|
||
* Same projection shape as listFilesAggregatedByEntity but for in-flight
|
||
* signing workflows. Completed workflows are intentionally hidden — they
|
||
* surface via their resulting signed-PDF file row instead.
|
||
*/
|
||
export async function listInflightWorkflowsAggregatedByEntity(
|
||
portId: string,
|
||
entityType: 'client' | 'company' | 'yacht',
|
||
entityId: string,
|
||
): Promise<{ groups: AggregatedWorkflowGroup[] }> {
|
||
const related = await collectRelatedEntities(portId, entityType, entityId);
|
||
const groups: AggregatedWorkflowGroup[] = [];
|
||
|
||
const directColumn =
|
||
entityType === 'client'
|
||
? documents.clientId
|
||
: entityType === 'company'
|
||
? documents.companyId
|
||
: documents.yachtId;
|
||
|
||
const direct = await fetchWorkflowGroupRows(portId, eq(directColumn, entityId));
|
||
if (direct.rows.length > 0) {
|
||
groups.push({
|
||
label: 'DIRECTLY ATTACHED',
|
||
source: 'direct',
|
||
workflows: direct.rows,
|
||
total: direct.total,
|
||
});
|
||
}
|
||
for (const { id, name } of related.companies) {
|
||
const g = await fetchWorkflowGroupRows(portId, eq(documents.companyId, id));
|
||
if (g.rows.length === 0) continue;
|
||
groups.push({
|
||
label: `FROM COMPANY — ${name.toUpperCase()}`,
|
||
source: 'company',
|
||
workflows: g.rows,
|
||
total: g.total,
|
||
});
|
||
}
|
||
for (const { id, name } of related.yachts) {
|
||
const g = await fetchWorkflowGroupRows(portId, eq(documents.yachtId, id));
|
||
if (g.rows.length === 0) continue;
|
||
groups.push({
|
||
label: `FROM YACHT — ${name.toUpperCase()}`,
|
||
source: 'yacht',
|
||
workflows: g.rows,
|
||
total: g.total,
|
||
});
|
||
}
|
||
for (const { id, name } of related.clients) {
|
||
const g = await fetchWorkflowGroupRows(portId, eq(documents.clientId, id));
|
||
if (g.rows.length === 0) continue;
|
||
groups.push({
|
||
label: `FROM CLIENT — ${name.toUpperCase()}`,
|
||
source: 'client',
|
||
workflows: g.rows,
|
||
total: g.total,
|
||
});
|
||
}
|
||
return { groups };
|
||
}
|
||
|
||
async function fetchWorkflowGroupRows(
|
||
portId: string,
|
||
predicate: ReturnType<typeof eq>,
|
||
): Promise<{ rows: Array<typeof documents.$inferSelect>; total: number }> {
|
||
const rows = await db
|
||
.select()
|
||
.from(documents)
|
||
.where(
|
||
and(
|
||
eq(documents.portId, portId),
|
||
inArray(documents.status, INFLIGHT_STATUSES as unknown as string[]),
|
||
predicate,
|
||
),
|
||
)
|
||
.orderBy(desc(documents.updatedAt))
|
||
.limit(WORKFLOW_GROUP_LIMIT);
|
||
const [{ count }] = await db
|
||
.select({ count: sql<number>`count(*)::int` })
|
||
.from(documents)
|
||
.where(
|
||
and(
|
||
eq(documents.portId, portId),
|
||
inArray(documents.status, INFLIGHT_STATUSES as unknown as string[]),
|
||
predicate,
|
||
),
|
||
);
|
||
return { rows, total: Number(count ?? 0) };
|
||
}
|
||
```
|
||
|
||
Make sure `inArray` and `desc` are imported in `documents.service.ts` (read the top of the file first; if they're not, add them).
|
||
|
||
- [ ] **Step 6: Add a small workflow-aggregation test**
|
||
|
||
Append to `tests/unit/aggregated-projection.test.ts`:
|
||
|
||
```typescript
|
||
import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service';
|
||
|
||
describe('documents service · listInflightWorkflowsAggregatedByEntity', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const [c] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = c!.id;
|
||
});
|
||
|
||
it('returns in-flight workflows in DIRECTLY ATTACHED group', async () => {
|
||
const { documents: documentsTable } = await import('@/lib/db/schema/documents');
|
||
await db.insert(documentsTable).values({
|
||
portId,
|
||
clientId,
|
||
title: 'EOI',
|
||
documentType: 'eoi',
|
||
status: 'sent',
|
||
createdBy: TEST_USER_ID,
|
||
});
|
||
await db.insert(documentsTable).values({
|
||
portId,
|
||
clientId,
|
||
title: 'Old signed EOI',
|
||
documentType: 'eoi',
|
||
status: 'completed',
|
||
createdBy: TEST_USER_ID,
|
||
});
|
||
const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId);
|
||
const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||
expect(direct?.workflows).toHaveLength(1);
|
||
expect(direct?.workflows[0]?.status).toBe('sent');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Run the tests — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/aggregated-projection.test.ts`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 8: Add `applyEntityFkFromFolder` + wire into `uploadFile` (E8)**
|
||
|
||
Append to `src/lib/services/files.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* E8: when a rep manually uploads a file into a system-managed entity
|
||
* subfolder (e.g. `Clients/Smith, John/`), auto-set the matching entity
|
||
* FK on the file row from the folder's `entityType + entityId`. Custom
|
||
* (non-system) folders → returns the input unchanged.
|
||
*
|
||
* Returns the mutated insert payload so callers can keep their
|
||
* single-insert flow.
|
||
*/
|
||
export async function applyEntityFkFromFolder<
|
||
T extends {
|
||
clientId?: string | null;
|
||
companyId?: string | null;
|
||
yachtId?: string | null;
|
||
folderId?: string | null;
|
||
},
|
||
>(portId: string, payload: T): Promise<T> {
|
||
if (!payload.folderId) return payload;
|
||
const folder = await db.query.documentFolders.findFirst({
|
||
where: and(eq(documentFolders.id, payload.folderId), eq(documentFolders.portId, portId)),
|
||
columns: { systemManaged: true, entityType: true, entityId: true },
|
||
});
|
||
if (!folder || !folder.systemManaged || !folder.entityType || !folder.entityId) {
|
||
return payload;
|
||
}
|
||
if (folder.entityType === 'client' && !payload.clientId) {
|
||
return { ...payload, clientId: folder.entityId };
|
||
}
|
||
if (folder.entityType === 'company' && !payload.companyId) {
|
||
return { ...payload, companyId: folder.entityId };
|
||
}
|
||
if (folder.entityType === 'yacht' && !payload.yachtId) {
|
||
return { ...payload, yachtId: folder.entityId };
|
||
}
|
||
return payload;
|
||
}
|
||
```
|
||
|
||
Then wire it into `uploadFile` (~line 33). The current insert builds the `.values({...})` payload directly; route the payload through `applyEntityFkFromFolder` before the insert. Make sure `UploadFileInput` includes `folderId: z.string().uuid().optional()` after Task 9's validator extension — if it doesn't, add it.
|
||
|
||
Add a unit test in `tests/unit/aggregated-projection.test.ts`:
|
||
|
||
```typescript
|
||
import { applyEntityFkFromFolder } from '@/lib/services/files';
|
||
|
||
describe('files service · applyEntityFkFromFolder', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
let folderId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const [c] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'A',
|
||
lastName: 'B',
|
||
email: `a-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = c!.id;
|
||
folderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id;
|
||
});
|
||
|
||
it('sets clientId when uploading into a client entity folder', async () => {
|
||
const out = await applyEntityFkFromFolder(portId, { folderId, clientId: null });
|
||
expect(out.clientId).toBe(clientId);
|
||
});
|
||
|
||
it('preserves existing entity FK when already set', async () => {
|
||
const out = await applyEntityFkFromFolder(portId, { folderId, clientId: 'pre-existing-id' });
|
||
expect(out.clientId).toBe('pre-existing-id');
|
||
});
|
||
|
||
it('is a no-op for non-system folders', async () => {
|
||
const [user] = await db
|
||
.insert(documentFolders)
|
||
.values({
|
||
portId,
|
||
parentId: null,
|
||
name: 'My templates',
|
||
createdBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
const out = await applyEntityFkFromFolder(portId, { folderId: user!.id, clientId: null });
|
||
expect(out.clientId).toBeUndefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `pnpm exec vitest run -t applyEntityFkFromFolder`. Expected: 3/3 pass.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/files.ts src/lib/services/documents.service.ts tests/unit/aggregated-projection.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): owner-aggregated projection (files + workflows)
|
||
|
||
listFilesAggregatedByEntity walks the relationship graph (symmetric
|
||
reach: clients ↔ companies via memberships, ↔ yachts via current
|
||
ownership) and groups results by source: DIRECTLY ATTACHED + FROM
|
||
COMPANY/YACHT/CLIENT. File-FK snapshot is the source of truth so
|
||
historical files survive yacht-ownership transfer. Each group caps at
|
||
20 rows + a total for "Show all (N)" drill-through. Defense-in-depth
|
||
port_id filter at every join.
|
||
|
||
listInflightWorkflowsAggregatedByEntity reuses the same graph walk
|
||
for in-flight signing workflows. Completed workflows are hidden —
|
||
they surface via their signed-PDF file row instead.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: API — entity-aggregated query params + signing-details route
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/validators/documents.ts`
|
||
- Modify: `src/lib/validators/files.ts`
|
||
- Modify: `src/app/api/v1/documents/route.ts`
|
||
- Modify: `src/app/api/v1/files/route.ts`
|
||
- Create: `src/app/api/v1/documents/[id]/signing-details/route.ts`
|
||
- Test: `tests/integration/files-folder-aggregation.test.ts`
|
||
|
||
- [ ] **Step 1: Extend validators**
|
||
|
||
In `src/lib/validators/documents.ts`, locate `listDocumentsSchema`. Add `entityType` + `entityId` query params (mutually exclusive with `folderId`):
|
||
|
||
```typescript
|
||
import { z } from 'zod';
|
||
|
||
// In the existing listDocumentsSchema, add:
|
||
entityType: z.enum(['client', 'company', 'yacht']).optional(),
|
||
entityId: z.string().uuid().optional(),
|
||
// Cross-field validation — these are mutually exclusive with folderId.
|
||
// Append .refine(...) after the .strict() or directly on the schema object:
|
||
```
|
||
|
||
Append a `.refine` to the schema:
|
||
|
||
```typescript
|
||
.refine(
|
||
(q) => !(q.folderId !== undefined && (q.entityType || q.entityId)),
|
||
{ message: 'folderId is mutually exclusive with entityType/entityId' },
|
||
)
|
||
.refine(
|
||
(q) => Boolean(q.entityType) === Boolean(q.entityId),
|
||
{ message: 'entityType and entityId must be provided together' },
|
||
)
|
||
```
|
||
|
||
Read the current `listDocumentsSchema` shape first — `parseQuery` flattens repeated params and the existing schema already coerces some fields. Match the existing style.
|
||
|
||
Same pattern in `src/lib/validators/files.ts`: extend `listFilesSchema` (or whichever name the file uses — grep `parseQuery.*files` to confirm) with the same three optional params + the same two `.refine` rules.
|
||
|
||
- [ ] **Step 2: Wire the aggregated branch into the files route**
|
||
|
||
Modify `src/app/api/v1/files/route.ts`. The current `GET` handler calls `listFiles(portId, query)`. Add a branch on `query.entityType`:
|
||
|
||
```typescript
|
||
import { listFilesAggregatedByEntity } from '@/lib/services/files';
|
||
|
||
export const GET = withAuth(
|
||
withPermission('documents', 'view', async (req, ctx) => {
|
||
try {
|
||
const query = parseQuery(req, listFilesSchema);
|
||
if (query.entityType && query.entityId) {
|
||
const result = await listFilesAggregatedByEntity(
|
||
ctx.portId,
|
||
query.entityType,
|
||
query.entityId,
|
||
);
|
||
return NextResponse.json({ data: result });
|
||
}
|
||
const result = await listFiles(ctx.portId, query);
|
||
// ... existing pagination envelope
|
||
} catch (error) {
|
||
return errorResponse(error);
|
||
}
|
||
}),
|
||
);
|
||
```
|
||
|
||
Read the current `route.ts` first to get the exact existing envelope; add the branch above it.
|
||
|
||
- [ ] **Step 3: Wire the aggregated branch into the documents route**
|
||
|
||
Same pattern in `src/app/api/v1/documents/route.ts`:
|
||
|
||
```typescript
|
||
import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service';
|
||
|
||
// At the top of the GET handler, after parseQuery:
|
||
if (query.entityType && query.entityId) {
|
||
const result = await listInflightWorkflowsAggregatedByEntity(
|
||
ctx.portId,
|
||
query.entityType,
|
||
query.entityId,
|
||
);
|
||
return NextResponse.json({ data: result });
|
||
}
|
||
// ...existing listDocuments call follows
|
||
```
|
||
|
||
- [ ] **Step 4: Create the signing-details route**
|
||
|
||
Create `src/app/api/v1/documents/[id]/signing-details/route.ts`:
|
||
|
||
```typescript
|
||
import { NextResponse } from 'next/server';
|
||
|
||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||
import { errorResponse } from '@/lib/errors';
|
||
import {
|
||
getDocumentById,
|
||
listDocumentSigners,
|
||
listDocumentEvents,
|
||
} from '@/lib/services/documents.service';
|
||
|
||
export const GET = withAuth(
|
||
withPermission('documents', 'view', async (_req, ctx, { params }) => {
|
||
try {
|
||
const { id } = await params;
|
||
const [doc, signers, events] = await Promise.all([
|
||
getDocumentById(id, ctx.portId),
|
||
listDocumentSigners(id, ctx.portId),
|
||
listDocumentEvents(id, ctx.portId),
|
||
]);
|
||
return NextResponse.json({
|
||
data: {
|
||
workflow: doc,
|
||
signers,
|
||
events,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
return errorResponse(error);
|
||
}
|
||
}),
|
||
);
|
||
```
|
||
|
||
Verify the exact signature of `withPermission` + the `{ params }` destructure by reading another `[id]` route handler in `src/app/api/v1/documents/[id]/` (e.g., `route.ts` or `folder/route.ts`). Next 15 App Router treats `params` as a Promise; the await above matches the project convention.
|
||
|
||
- [ ] **Step 5: Add the integration test**
|
||
|
||
Create `tests/integration/files-folder-aggregation.test.ts`. Hit the API handler directly via the sibling `handlers.ts` pattern if it exists, or via `fetch` against a running dev server. Read an existing integration test to copy the pattern. The test should:
|
||
|
||
1. Seed a port, client, company, yacht with files attached.
|
||
2. Hit `GET /api/v1/files?entityType=client&entityId=<id>` (mock the handler dependencies if needed).
|
||
3. Assert the `groups` shape, that DIRECTLY ATTACHED + FROM COMPANY appear.
|
||
|
||
- [ ] **Step 6: Run the tests**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/files-folder-aggregation.test.ts tests/unit/aggregated-projection.test.ts`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 7: Run the wider listDocuments tests**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts`
|
||
Expected: still passes (Wave 11.B's folderId filter is untouched).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/lib/validators/documents.ts src/lib/validators/files.ts src/app/api/v1/documents/route.ts src/app/api/v1/files/route.ts src/app/api/v1/documents/[id]/signing-details/route.ts tests/integration/files-folder-aggregation.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): entity-aggregated query params + signing-details API
|
||
|
||
GET /api/v1/files?entityType=client&entityId=… and the same params on
|
||
the documents route return the owner-aggregated projection
|
||
{ groups: [{ label, source, files|workflows, total }] }. folderId
|
||
remains for direct-folder listing; the two modes are mutually
|
||
exclusive (zod refine).
|
||
|
||
GET /api/v1/documents/[id]/signing-details returns
|
||
{ workflow, signers, events } for the "view signing details" dialog
|
||
on signed-PDF rows.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Hide completed workflows from folder views
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/lib/services/documents.service.ts`
|
||
- Test: `tests/integration/documents-list-folder-filter.test.ts` (extend)
|
||
|
||
- [ ] **Step 1: Add a failing test**
|
||
|
||
Append to `tests/integration/documents-list-folder-filter.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
|
||
it('hides completed workflows when folderId is set', async () => {
|
||
const portId = await setupTestPort();
|
||
await ensureSystemRoots(portId, TEST_USER_ID);
|
||
const [folder] = await db
|
||
.insert(documentFolders)
|
||
.values({
|
||
portId,
|
||
parentId: null,
|
||
name: 'Deals 2026',
|
||
createdBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
await db.insert(documents).values([
|
||
{
|
||
portId,
|
||
folderId: folder!.id,
|
||
title: 'In flight',
|
||
documentType: 'eoi',
|
||
status: 'sent',
|
||
createdBy: TEST_USER_ID,
|
||
},
|
||
{
|
||
portId,
|
||
folderId: folder!.id,
|
||
title: 'Done',
|
||
documentType: 'eoi',
|
||
status: 'completed',
|
||
createdBy: TEST_USER_ID,
|
||
},
|
||
]);
|
||
const result = await listDocuments(portId, {
|
||
folderId: folder!.id,
|
||
page: 1,
|
||
limit: 50,
|
||
sort: 'createdAt',
|
||
order: 'desc',
|
||
tab: 'all',
|
||
});
|
||
expect(result.data.map((d) => d.title)).toEqual(['In flight']);
|
||
});
|
||
```
|
||
|
||
Adjust imports + `listDocuments` call shape by reading the existing tests in the same file.
|
||
|
||
- [ ] **Step 2: Run the test — expect failure**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts -t 'hides completed'`
|
||
Expected: fails (both rows currently surface).
|
||
|
||
- [ ] **Step 3: Modify `listDocuments`**
|
||
|
||
In `src/lib/services/documents.service.ts:listDocuments` (~line 146), find the existing folderId filter (added by Wave 11.B). Add a status filter that excludes `'completed'` when `query.folderId` is set:
|
||
|
||
```typescript
|
||
// When viewing a specific folder, hide completed workflows — they surface
|
||
// via their resulting signed-PDF file row in the Files section, not the
|
||
// Signing section.
|
||
if (query.folderId !== undefined) {
|
||
filters.push(ne(documents.status, 'completed'));
|
||
}
|
||
```
|
||
|
||
Add `ne` to the drizzle imports if it isn't already there.
|
||
|
||
- [ ] **Step 4: Run the test — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/documents-list-folder-filter.test.ts`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/services/documents.service.ts tests/integration/documents-list-folder-filter.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): hide completed workflows from folder views
|
||
|
||
When listDocuments is called with folderId set, exclude
|
||
status='completed' rows. The signed-PDF file appears in the Files
|
||
section with a "view signing details" link; the workflow row would
|
||
just be noise alongside the file.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Backfill script (idempotent, port-isolated)
|
||
|
||
**Files:**
|
||
|
||
- Create: `scripts/backfill-document-folders.ts`
|
||
- Test: `tests/integration/backfill-document-folders.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing integration test**
|
||
|
||
Create `tests/integration/backfill-document-folders.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { and, eq } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { documentFolders, files, documents } from '@/lib/db/schema/documents';
|
||
import { clients } from '@/lib/db/schema/clients';
|
||
import { runBackfill } from '@/scripts/backfill-document-folders';
|
||
import { setupTestPort, TEST_USER_ID } from '../helpers/test-fixtures';
|
||
|
||
describe('backfill-document-folders · idempotency + isolation', () => {
|
||
let portId: string;
|
||
let clientId: string;
|
||
|
||
beforeEach(async () => {
|
||
portId = await setupTestPort();
|
||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||
const [c] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId,
|
||
firstName: 'John',
|
||
lastName: 'Smith',
|
||
email: `john-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
clientId = c!.id;
|
||
});
|
||
|
||
it('creates system roots and entity subfolders for entities with attached files', async () => {
|
||
await db.insert(files).values({
|
||
portId,
|
||
clientId,
|
||
filename: 'a.pdf',
|
||
originalName: 'a.pdf',
|
||
storagePath: `test/${crypto.randomUUID()}`,
|
||
storageBucket: 'test',
|
||
uploadedBy: TEST_USER_ID,
|
||
});
|
||
await runBackfill({ portId });
|
||
const roots = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
|
||
expect(roots.map((r) => r.name).sort()).toEqual(['Clients', 'Companies', 'Yachts']);
|
||
const sub = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)));
|
||
expect(sub).toHaveLength(1);
|
||
});
|
||
|
||
it('sets files.folder_id from entity FKs', async () => {
|
||
const [file] = await db
|
||
.insert(files)
|
||
.values({
|
||
portId,
|
||
clientId,
|
||
filename: 'a.pdf',
|
||
originalName: 'a.pdf',
|
||
storagePath: `test/${crypto.randomUUID()}`,
|
||
storageBucket: 'test',
|
||
uploadedBy: TEST_USER_ID,
|
||
})
|
||
.returning();
|
||
await runBackfill({ portId });
|
||
const updated = await db.query.files.findFirst({ where: eq(files.id, file!.id) });
|
||
expect(updated?.folderId).not.toBeNull();
|
||
});
|
||
|
||
it('copies entity FKs from completed workflows onto signed files', async () => {
|
||
const [signedFile] = await db
|
||
.insert(files)
|
||
.values({
|
||
portId, // NOTE: no clientId — legacy completion left it blank
|
||
filename: 'signed.pdf',
|
||
originalName: 'signed.pdf',
|
||
storagePath: `test/${crypto.randomUUID()}`,
|
||
storageBucket: 'test',
|
||
uploadedBy: 'system',
|
||
})
|
||
.returning();
|
||
await db.insert(documents).values({
|
||
portId,
|
||
clientId,
|
||
signedFileId: signedFile!.id,
|
||
title: 'EOI',
|
||
documentType: 'eoi',
|
||
status: 'completed',
|
||
createdBy: TEST_USER_ID,
|
||
});
|
||
await runBackfill({ portId });
|
||
const updated = await db.query.files.findFirst({ where: eq(files.id, signedFile!.id) });
|
||
expect(updated?.clientId).toBe(clientId);
|
||
expect(updated?.folderId).not.toBeNull();
|
||
});
|
||
|
||
it('is idempotent — second run produces the same result', async () => {
|
||
await db.insert(files).values({
|
||
portId,
|
||
clientId,
|
||
filename: 'a.pdf',
|
||
originalName: 'a.pdf',
|
||
storagePath: `test/${crypto.randomUUID()}`,
|
||
storageBucket: 'test',
|
||
uploadedBy: TEST_USER_ID,
|
||
});
|
||
await runBackfill({ portId });
|
||
const after1 = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(eq(documentFolders.portId, portId));
|
||
await runBackfill({ portId });
|
||
const after2 = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(eq(documentFolders.portId, portId));
|
||
expect(after2).toHaveLength(after1.length);
|
||
});
|
||
|
||
it('respects port isolation — does not touch other ports', async () => {
|
||
const otherPort = await setupTestPort();
|
||
const [otherClient] = await db
|
||
.insert(clients)
|
||
.values({
|
||
portId: otherPort,
|
||
firstName: 'Other',
|
||
lastName: 'Port',
|
||
email: `other-${crypto.randomUUID()}@example.com`,
|
||
})
|
||
.returning();
|
||
await db.insert(files).values({
|
||
portId: otherPort,
|
||
clientId: otherClient!.id,
|
||
filename: 'b.pdf',
|
||
originalName: 'b.pdf',
|
||
storagePath: `test/${crypto.randomUUID()}`,
|
||
storageBucket: 'test',
|
||
uploadedBy: TEST_USER_ID,
|
||
});
|
||
await runBackfill({ portId }); // only this port
|
||
const otherRoots = await db
|
||
.select()
|
||
.from(documentFolders)
|
||
.where(eq(documentFolders.portId, otherPort));
|
||
expect(otherRoots).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test — expect failure**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/backfill-document-folders.test.ts`
|
||
Expected: script doesn't exist.
|
||
|
||
- [ ] **Step 3: Implement the backfill script**
|
||
|
||
Create `scripts/backfill-document-folders.ts`:
|
||
|
||
```typescript
|
||
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { ports } from '@/lib/db/schema/ports';
|
||
import { documentFolders, files, documents } from '@/lib/db/schema/documents';
|
||
import {
|
||
ensureSystemRoots,
|
||
ensureEntityFolder,
|
||
type EntityType,
|
||
} from '@/lib/services/document-folders.service';
|
||
import { logger } from '@/lib/logger';
|
||
|
||
interface BackfillOptions {
|
||
portId?: string; // when set, only this port; otherwise all ports
|
||
systemUserId?: string;
|
||
}
|
||
|
||
/**
|
||
* One-time idempotent backfill: ensure every port has the three system
|
||
* roots, every entity with attached files (or completed workflows) has
|
||
* a subfolder, every file with entity FKs has folder_id set, and every
|
||
* signed file from a completed workflow has the workflow's entity FKs
|
||
* propagated. Per-port advisory lock serializes concurrent runs.
|
||
*/
|
||
export async function runBackfill(opts: BackfillOptions = {}): Promise<void> {
|
||
const portRows = opts.portId
|
||
? [{ id: opts.portId }]
|
||
: await db.select({ id: ports.id }).from(ports);
|
||
const systemUser = opts.systemUserId ?? 'system-backfill';
|
||
|
||
for (const { id: portId } of portRows) {
|
||
// Advisory lock: hash the portId string into a bigint for pg_advisory_xact_lock.
|
||
await db.transaction(async (tx) => {
|
||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${portId})::bigint)`);
|
||
|
||
// 1. System roots.
|
||
await ensureSystemRoots(portId, systemUser);
|
||
|
||
// 2. For every completed workflow, copy entity FKs onto the signed file row.
|
||
const completedDocs = await tx
|
||
.select({
|
||
id: documents.id,
|
||
signedFileId: documents.signedFileId,
|
||
clientId: documents.clientId,
|
||
companyId: documents.companyId,
|
||
yachtId: documents.yachtId,
|
||
})
|
||
.from(documents)
|
||
.where(
|
||
and(
|
||
eq(documents.portId, portId),
|
||
eq(documents.status, 'completed'),
|
||
isNotNull(documents.signedFileId),
|
||
),
|
||
);
|
||
for (const d of completedDocs) {
|
||
if (!d.signedFileId) continue;
|
||
const owner = d.clientId
|
||
? ({ type: 'client', id: d.clientId } as const)
|
||
: d.companyId
|
||
? ({ type: 'company', id: d.companyId } as const)
|
||
: d.yachtId
|
||
? ({ type: 'yacht', id: d.yachtId } as const)
|
||
: null;
|
||
if (!owner) continue;
|
||
await tx
|
||
.update(files)
|
||
.set({
|
||
clientId: owner.type === 'client' ? owner.id : files.clientId,
|
||
companyId: owner.type === 'company' ? owner.id : files.companyId,
|
||
yachtId: owner.type === 'yacht' ? owner.id : files.yachtId,
|
||
})
|
||
.where(
|
||
and(
|
||
eq(files.id, d.signedFileId),
|
||
eq(files.portId, portId),
|
||
isNull(
|
||
owner.type === 'client'
|
||
? files.clientId
|
||
: owner.type === 'company'
|
||
? files.companyId
|
||
: files.yachtId,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// 3. For every file with entity FKs, ensure the subfolder + assign folder_id.
|
||
const fileRows = await tx
|
||
.select()
|
||
.from(files)
|
||
.where(and(eq(files.portId, portId), isNull(files.folderId)));
|
||
for (const f of fileRows) {
|
||
const owner: { type: EntityType; id: string } | null = f.clientId
|
||
? { type: 'client', id: f.clientId }
|
||
: f.companyId
|
||
? { type: 'company', id: f.companyId }
|
||
: f.yachtId
|
||
? { type: 'yacht', id: f.yachtId }
|
||
: null;
|
||
if (!owner) continue;
|
||
try {
|
||
const folder = await ensureEntityFolder(portId, owner.type, owner.id, systemUser);
|
||
await tx
|
||
.update(files)
|
||
.set({ folderId: folder.id })
|
||
.where(and(eq(files.id, f.id), eq(files.portId, portId)));
|
||
} catch (err) {
|
||
logger.error({ err, fileId: f.id, portId }, 'backfill: ensureEntityFolder failed');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// CLI entry point — `pnpm tsx scripts/backfill-document-folders.ts [--port <id>]`
|
||
if (require.main === module) {
|
||
const portIdArg = process.argv.indexOf('--port');
|
||
const portId = portIdArg !== -1 ? process.argv[portIdArg + 1] : undefined;
|
||
runBackfill({ portId })
|
||
.then(() => {
|
||
// eslint-disable-next-line no-console
|
||
console.log('Backfill complete');
|
||
process.exit(0);
|
||
})
|
||
.catch((err) => {
|
||
logger.error({ err }, 'Backfill failed');
|
||
process.exit(1);
|
||
});
|
||
}
|
||
```
|
||
|
||
Verify by reading existing scripts in `scripts/` (e.g., `scripts/import-berths-from-nocodb.ts` mentioned in CLAUDE.md) — they typically use the same `if (require.main === module)` CLI guard and the `db` import path. Adjust if the convention differs.
|
||
|
||
- [ ] **Step 4: Run the tests — expect pass**
|
||
|
||
Run: `pnpm exec vitest run tests/integration/backfill-document-folders.test.ts`
|
||
Expected: 5/5 pass.
|
||
|
||
- [ ] **Step 5: Manual sanity run in dev**
|
||
|
||
```bash
|
||
pnpm tsx scripts/backfill-document-folders.ts
|
||
```
|
||
|
||
Expected output: "Backfill complete" + zero errors. Check the dev DB has three system roots per port:
|
||
|
||
```bash
|
||
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
|
||
-c "SELECT port_id, name FROM document_folders WHERE entity_type='root' ORDER BY port_id, name"
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add scripts/backfill-document-folders.ts tests/integration/backfill-document-folders.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): backfill script for system roots + entity folders
|
||
|
||
Idempotent one-time backfill that runs as part of the deploy:
|
||
1. Ensures Clients/Companies/Yachts roots per port.
|
||
2. Copies entity FKs from completed workflows onto signed file rows
|
||
(legacy completions ran before the auto-deposit handler shipped).
|
||
3. Ensures per-entity subfolders for every entity with attached
|
||
files and sets files.folder_id.
|
||
|
||
pg_advisory_xact_lock(hashtext(portId)::bigint) per port so concurrent
|
||
runs serialize. Safe to re-run; the SELECT-then-UPDATE pattern targets
|
||
only rows where folder_id IS NULL.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: UI — `AggregatedSection` component + `useAggregatedListing` hook
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/hooks/use-aggregated-listing.ts`
|
||
- Create: `src/components/documents/aggregated-section.tsx`
|
||
|
||
- [ ] **Step 1: Create the hook**
|
||
|
||
Create `src/hooks/use-aggregated-listing.ts`:
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
|
||
import { apiFetch } from '@/lib/api/client';
|
||
|
||
interface AggregatedFile {
|
||
id: string;
|
||
filename: string;
|
||
mimeType: string | null;
|
||
createdAt: string;
|
||
folderId: string | null;
|
||
}
|
||
|
||
interface AggregatedWorkflow {
|
||
id: string;
|
||
title: string;
|
||
status: string;
|
||
documentType: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export interface AggregatedGroup<T> {
|
||
label: string;
|
||
source: 'direct' | 'client' | 'company' | 'yacht';
|
||
files?: T[]; // present when this is a files group
|
||
workflows?: T[]; // present when this is a workflows group
|
||
total: number;
|
||
}
|
||
|
||
export function useAggregatedFiles(
|
||
portId: string,
|
||
entityType: 'client' | 'company' | 'yacht' | undefined,
|
||
entityId: string | undefined,
|
||
) {
|
||
return useQuery<{ data: { groups: AggregatedGroup<AggregatedFile>[] } }>({
|
||
queryKey: ['files', 'aggregated', entityType, entityId],
|
||
queryFn: () => apiFetch(`/api/v1/files?entityType=${entityType}&entityId=${entityId}`),
|
||
enabled: Boolean(entityType && entityId),
|
||
staleTime: 10_000,
|
||
});
|
||
}
|
||
|
||
export function useAggregatedWorkflows(
|
||
portId: string,
|
||
entityType: 'client' | 'company' | 'yacht' | undefined,
|
||
entityId: string | undefined,
|
||
) {
|
||
return useQuery<{ data: { groups: AggregatedGroup<AggregatedWorkflow>[] } }>({
|
||
queryKey: ['documents', 'aggregated', entityType, entityId],
|
||
queryFn: () => apiFetch(`/api/v1/documents?entityType=${entityType}&entityId=${entityId}`),
|
||
enabled: Boolean(entityType && entityId),
|
||
staleTime: 10_000,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the component**
|
||
|
||
Create `src/components/documents/aggregated-section.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { FileText, Loader2 } from 'lucide-react';
|
||
|
||
import type { AggregatedGroup } from '@/hooks/use-aggregated-listing';
|
||
import { Button } from '@/components/ui/button';
|
||
import { StatusPill } from '@/components/ui/status-pill';
|
||
|
||
interface AggregatedSectionProps<T> {
|
||
title: string;
|
||
icon?: React.ReactNode;
|
||
groups: AggregatedGroup<T>[];
|
||
renderRow: (item: T, group: AggregatedGroup<T>) => React.ReactNode;
|
||
emptyMessage?: string;
|
||
loading?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Renders a Signing or Files section with one labelled subsection per
|
||
* owner-source group. Each group shows up to 20 rows; a `Show all (N)`
|
||
* link drills into the source-scoped flat list. Hidden when groups is
|
||
* empty.
|
||
*/
|
||
export function AggregatedSection<T>({
|
||
title,
|
||
icon,
|
||
groups,
|
||
renderRow,
|
||
emptyMessage = 'Nothing here yet.',
|
||
loading,
|
||
}: AggregatedSectionProps<T>) {
|
||
const total = groups.reduce((sum, g) => sum + g.total, 0);
|
||
|
||
if (loading) {
|
||
return (
|
||
<section className="rounded-md border bg-white p-3">
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||
{icon}
|
||
{title}
|
||
<Loader2 className="ml-1 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||
</h3>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (groups.length === 0) {
|
||
return (
|
||
<section className="rounded-md border bg-white p-3 text-sm text-muted-foreground">
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||
{icon}
|
||
{title}
|
||
<span className="ml-1 text-xs text-muted-foreground tabular-nums">· 0</span>
|
||
</h3>
|
||
<p className="mt-2">{emptyMessage}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-md border bg-white">
|
||
<h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold text-foreground">
|
||
{icon}
|
||
{title}
|
||
<span className="ml-1 text-xs text-muted-foreground tabular-nums">· {total}</span>
|
||
</h3>
|
||
<div className="divide-y">
|
||
{groups.map((g) => (
|
||
<GroupBlock key={`${g.source}-${g.label}`} group={g} renderRow={renderRow} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function GroupBlock<T>({
|
||
group,
|
||
renderRow,
|
||
}: {
|
||
group: AggregatedGroup<T>;
|
||
renderRow: (item: T, group: AggregatedGroup<T>) => React.ReactNode;
|
||
}) {
|
||
const items = (group.files ?? group.workflows ?? []) as T[];
|
||
return (
|
||
<div className="px-3 py-2">
|
||
<header className="mb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||
{group.label}
|
||
<span className="ml-1.5 text-muted-foreground/70 tabular-nums">· {group.total}</span>
|
||
</header>
|
||
<ul className="space-y-1">
|
||
{items.map((item) => (
|
||
<li key={(item as { id: string }).id}>{renderRow(item, group)}</li>
|
||
))}
|
||
</ul>
|
||
{group.total > items.length ? (
|
||
<button
|
||
type="button"
|
||
className="mt-1 text-xs text-brand hover:underline"
|
||
onClick={() => {
|
||
// Show-all handler is wired by the parent — emit a custom event
|
||
// or accept an onShowAll callback. For v1 a query-param navigation
|
||
// is sufficient; the parent passes the wired handler in renderRow.
|
||
}}
|
||
>
|
||
Show all ({group.total})
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
The `Show all` link is left as a hook — the entity-folder view (Task 15) wires it to a query-param navigation that switches the section to a flat per-source listing. Keep this component dumb: it renders groups, the parent owns the drill-through.
|
||
|
||
- [ ] **Step 3: Verify it type-checks**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: clean exit.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/hooks/use-aggregated-listing.ts src/components/documents/aggregated-section.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): AggregatedSection + useAggregatedListing
|
||
|
||
Two TanStack Query hooks fetch the entity-aggregated payload for files
|
||
and workflows; AggregatedSection renders one labelled subsection per
|
||
owner-source group with a Show all (N) drill-through hook. Dumb
|
||
component — parent owns the row rendering + drill-through navigation.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: UI — `SigningDetailsDialog` + per-row "view signing details" link
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/documents/signing-details-dialog.tsx`
|
||
|
||
- [ ] **Step 1: Build the dialog**
|
||
|
||
Create `src/components/documents/signing-details-dialog.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Loader2 } from 'lucide-react';
|
||
|
||
import { apiFetch } from '@/lib/api/client';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import { StatusPill } from '@/components/ui/status-pill';
|
||
|
||
interface SigningDetailsResponse {
|
||
data: {
|
||
workflow: {
|
||
id: string;
|
||
title: string;
|
||
status: string;
|
||
documentType: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
};
|
||
signers: Array<{
|
||
id: string;
|
||
signerName: string;
|
||
signerEmail: string;
|
||
signerRole: string;
|
||
status: string;
|
||
signedAt: string | null;
|
||
}>;
|
||
events: Array<{
|
||
id: string;
|
||
eventType: string;
|
||
createdAt: string;
|
||
}>;
|
||
};
|
||
}
|
||
|
||
interface Props {
|
||
documentId: string | null;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
}
|
||
|
||
export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props) {
|
||
const { data, isLoading } = useQuery<SigningDetailsResponse>({
|
||
queryKey: ['document-signing-details', documentId],
|
||
queryFn: () =>
|
||
apiFetch<SigningDetailsResponse>(`/api/v1/documents/${documentId}/signing-details`),
|
||
enabled: Boolean(documentId) && open,
|
||
});
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Signing details</DialogTitle>
|
||
<DialogDescription>
|
||
Audit trail for this signed document — signers and timeline.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{isLoading || !data ? (
|
||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Loading…
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<section>
|
||
<h4 className="mb-1 text-sm font-semibold">{data.data.workflow.title}</h4>
|
||
<p className="text-xs text-muted-foreground">
|
||
Status: <StatusPill status="completed">Completed</StatusPill> · Created{' '}
|
||
{new Date(data.data.workflow.createdAt).toLocaleString('en-GB')}
|
||
</p>
|
||
</section>
|
||
|
||
<section>
|
||
<h5 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
Signers
|
||
</h5>
|
||
<ul className="divide-y rounded border bg-muted/30">
|
||
{data.data.signers.map((s) => (
|
||
<li
|
||
key={s.id}
|
||
className="flex items-center justify-between gap-2 px-3 py-2 text-xs"
|
||
>
|
||
<div className="min-w-0">
|
||
<span className="font-medium">{s.signerName}</span>
|
||
<span className="ml-2 text-muted-foreground">{s.signerEmail}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{s.signedAt ? (
|
||
<span className="tabular-nums text-muted-foreground">
|
||
{new Date(s.signedAt).toLocaleDateString('en-GB')}
|
||
</span>
|
||
) : null}
|
||
<StatusPill status={s.status as 'signed' | 'pending' | 'declined'}>
|
||
{s.status}
|
||
</StatusPill>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
|
||
<section>
|
||
<h5 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
Timeline
|
||
</h5>
|
||
<ol className="space-y-1 text-xs">
|
||
{data.data.events.map((e) => (
|
||
<li key={e.id} className="flex items-center gap-2 text-muted-foreground">
|
||
<span className="tabular-nums">
|
||
{new Date(e.createdAt).toLocaleString('en-GB')}
|
||
</span>
|
||
<span>{e.eventType.replace(/_/g, ' ')}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</section>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify it type-checks**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: clean exit. If `StatusPillStatus` enum doesn't include `'pending'` / `'declined'`, cast or fall back to a default — read `src/components/ui/status-pill.tsx` for the allowed set.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/documents/signing-details-dialog.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): SigningDetailsDialog
|
||
|
||
Modal rendering workflow + signers + events for a signed-PDF file.
|
||
Wired to GET /api/v1/documents/[id]/signing-details. The "view signing
|
||
details" link on signed-file rows in the Files section opens this
|
||
dialog (wiring in the entity-folder view task).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: UI — `FolderTreeSidebar` + `FolderActionsMenu` system-folder awareness
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/components/documents/folder-tree-sidebar.tsx`
|
||
- Modify: `src/components/documents/folder-actions-menu.tsx`
|
||
|
||
- [ ] **Step 1: Read both files end-to-end**
|
||
|
||
```bash
|
||
cat src/components/documents/folder-tree-sidebar.tsx
|
||
cat src/components/documents/folder-actions-menu.tsx
|
||
```
|
||
|
||
Both already exist (Wave 11.B). The folder fetch returns `FolderNode[]` with the new `systemManaged` / `entityType` / `entityId` / `archivedAt` fields (Drizzle auto-includes them via `.select()` in `listTree`). The UI already accepts the tree and renders names; this task adds visual + behavioral awareness of the new flags.
|
||
|
||
- [ ] **Step 2: Add 🔒 marker + archived muted style to `FolderTreeSidebar`**
|
||
|
||
In `folder-tree-sidebar.tsx`, find the row-rendering block (the `<li>` or `<button>` that renders one folder's name + chevron). Add conditional rendering:
|
||
|
||
```tsx
|
||
import { Lock } from 'lucide-react';
|
||
|
||
// Inside the row template:
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-accent',
|
||
selectedFolderId === node.id && 'bg-accent font-medium',
|
||
node.archivedAt && 'text-muted-foreground',
|
||
)}
|
||
onClick={() => onSelect(node.id)}
|
||
>
|
||
{/* existing chevron + folder icon */}
|
||
<span className="truncate">{node.name}</span>
|
||
{node.systemManaged ? (
|
||
<Lock className="ml-1 h-3 w-3 text-muted-foreground" aria-label="System folder" />
|
||
) : null}
|
||
</button>;
|
||
```
|
||
|
||
Read the actual existing template first — the file is ~160 lines, copy its current row JSX and patch in the lock icon + archived muted class. Do not invent props or rewrite the structure.
|
||
|
||
- [ ] **Step 3: Suppress destructive actions in `FolderActionsMenu`**
|
||
|
||
The actions menu shows Create / Rename / Move / Delete buttons or menu items keyed off `selectedFolderId`. The component already fetches the selected folder's row (likely via `useDocumentFolders`). Add a check:
|
||
|
||
```tsx
|
||
// Find the selected folder in the tree
|
||
const selected = selectedFolderId ? findInTree(tree, selectedFolderId) : null;
|
||
const isSystem = selected?.systemManaged ?? false;
|
||
|
||
// Disable Rename + Move + Delete buttons (or hide them) when isSystem:
|
||
<Button disabled={isSystem || pending} onClick={handleRename}>
|
||
Rename
|
||
</Button>;
|
||
// + a tooltip explaining "System folders can't be renamed/moved/deleted"
|
||
```
|
||
|
||
Read the actual current implementation to match its DropdownMenu / Dialog structure. The `findInTree` helper may already exist in the file or in `use-document-folders.ts`; if not, write it inline:
|
||
|
||
```typescript
|
||
function findInTree(tree: FolderNode[], id: string): FolderNode | null {
|
||
for (const node of tree) {
|
||
if (node.id === id) return node;
|
||
const found = findInTree(node.children, id);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
```
|
||
|
||
Wrap each disabled button in a `<Tooltip>` (from `@/components/ui/tooltip`) explaining why when `isSystem`:
|
||
|
||
```tsx
|
||
{
|
||
isSystem ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span>
|
||
<Button disabled>Rename</Button>
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>System folders can't be renamed.</TooltipContent>
|
||
</Tooltip>
|
||
) : (
|
||
<Button onClick={handleRename}>Rename</Button>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify type + visual sanity in dev**
|
||
|
||
Run: `pnpm exec tsc --noEmit && pnpm dev`. Open the Documents hub, observe that:
|
||
|
||
- Clients/Companies/Yachts roots render with a 🔒 icon.
|
||
- Selecting a system folder grays out the Rename / Move / Delete actions.
|
||
- An archived entity's folder renders muted (after Task 6 has run on a test client).
|
||
|
||
If the dev server can't be started in your environment, document this as a manual verification step in the PR description.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/documents/folder-tree-sidebar.tsx src/components/documents/folder-actions-menu.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): folder tree + actions UI for system-managed folders
|
||
|
||
FolderTreeSidebar shows a 🔒 marker on system_managed rows and renders
|
||
archived entity folders muted. FolderActionsMenu disables Rename /
|
||
Move / Delete with a tooltip explanation when a system folder is
|
||
selected; the server-side guard (Task 4) is the authoritative
|
||
rejection — the UI is the friendly first line.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: UI — `HubRootView` + `EntityFolderView` + rebuild `DocumentsHub`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/documents/hub-root-view.tsx`
|
||
- Create: `src/components/documents/entity-folder-view.tsx`
|
||
- Modify: `src/components/documents/documents-hub.tsx`
|
||
|
||
This is the biggest UI task — wiring together the new hub layout. Break it into three sub-tasks.
|
||
|
||
- [ ] **Step 1: Build `HubRootView`**
|
||
|
||
Create `src/components/documents/hub-root-view.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { FileText, ClipboardSignature } from 'lucide-react';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||
|
||
interface HubRootDoc {
|
||
id: string;
|
||
title: string;
|
||
documentType: string;
|
||
status: string;
|
||
createdAt: string;
|
||
}
|
||
interface HubRootFile {
|
||
id: string;
|
||
filename: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface Props {
|
||
portSlug: string;
|
||
}
|
||
|
||
/**
|
||
* Default landing when the rep clicks Documents in the sidebar — no
|
||
* folder selected. Shows port-wide in-flight workflows + recent files,
|
||
* each paginated.
|
||
*/
|
||
export function HubRootView({ portSlug }: Props) {
|
||
const { data: workflows, isLoading: workflowsLoading } = usePaginatedQuery<HubRootDoc>({
|
||
queryKey: ['documents', 'hub-root', 'workflows'],
|
||
endpoint: '/api/v1/documents?tab=in_progress',
|
||
filterDefinitions: [],
|
||
});
|
||
const { data: filesData, isLoading: filesLoading } = usePaginatedQuery<HubRootFile>({
|
||
queryKey: ['files', 'hub-root'],
|
||
endpoint: '/api/v1/files?sort=createdAt&order=desc&limit=20',
|
||
filterDefinitions: [],
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<section className="rounded-md border bg-white">
|
||
<h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
|
||
<ClipboardSignature className="h-4 w-4 text-muted-foreground" />
|
||
Signing in progress
|
||
</h3>
|
||
{workflowsLoading ? (
|
||
<div className="p-3 text-sm text-muted-foreground">Loading…</div>
|
||
) : workflows.length === 0 ? (
|
||
<div className="p-3 text-sm text-muted-foreground">No workflows in flight.</div>
|
||
) : (
|
||
<ul className="divide-y">
|
||
{workflows.map((w) => (
|
||
<li key={w.id} className="px-3 py-2 text-sm">
|
||
<Link href={`/${portSlug}/documents/${w.id}`} className="hover:underline">
|
||
{w.title}
|
||
</Link>
|
||
<span className="ml-2 text-xs text-muted-foreground">{w.status}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className="rounded-md border bg-white">
|
||
<h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
|
||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||
Recent files
|
||
</h3>
|
||
{filesLoading ? (
|
||
<div className="p-3 text-sm text-muted-foreground">Loading…</div>
|
||
) : filesData.length === 0 ? (
|
||
<div className="p-3 text-sm text-muted-foreground">No files yet.</div>
|
||
) : (
|
||
<ul className="divide-y">
|
||
{filesData.map((f) => (
|
||
<li key={f.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||
<span className="truncate">{f.filename}</span>
|
||
<span className="text-xs text-muted-foreground tabular-nums">
|
||
{new Date(f.createdAt).toLocaleDateString('en-GB')}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build `EntityFolderView`**
|
||
|
||
Create `src/components/documents/entity-folder-view.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { ClipboardSignature, FileText, Eye } from 'lucide-react';
|
||
|
||
import { AggregatedSection } from './aggregated-section';
|
||
import { SigningDetailsDialog } from './signing-details-dialog';
|
||
import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing';
|
||
import { StatusPill } from '@/components/ui/status-pill';
|
||
|
||
interface Props {
|
||
portSlug: string;
|
||
entityType: 'client' | 'company' | 'yacht';
|
||
entityId: string;
|
||
}
|
||
|
||
/**
|
||
* The entity-folder view: stacked Signing in progress + Files sections,
|
||
* each grouped by owner-source via the aggregated projection API.
|
||
*/
|
||
export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
|
||
const [detailsId, setDetailsId] = useState<string | null>(null);
|
||
|
||
const { data: workflowsResp, isLoading: workflowsLoading } = useAggregatedWorkflows(
|
||
portSlug,
|
||
entityType,
|
||
entityId,
|
||
);
|
||
const { data: filesResp, isLoading: filesLoading } = useAggregatedFiles(
|
||
portSlug,
|
||
entityType,
|
||
entityId,
|
||
);
|
||
|
||
const workflowGroups = workflowsResp?.data.groups ?? [];
|
||
const fileGroups = filesResp?.data.groups ?? [];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<AggregatedSection
|
||
title="Signing in progress"
|
||
icon={<ClipboardSignature className="h-4 w-4 text-muted-foreground" />}
|
||
groups={workflowGroups}
|
||
loading={workflowsLoading}
|
||
emptyMessage="No workflows in flight for this entity."
|
||
renderRow={(w) => (
|
||
<div className="flex items-center justify-between gap-2 text-sm">
|
||
<Link href={`/${portSlug}/documents/${w.id}`} className="truncate hover:underline">
|
||
{w.title}
|
||
</Link>
|
||
<StatusPill status={w.status as 'sent'}>{w.status}</StatusPill>
|
||
</div>
|
||
)}
|
||
/>
|
||
|
||
<AggregatedSection
|
||
title="Files"
|
||
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
|
||
groups={fileGroups}
|
||
loading={filesLoading}
|
||
emptyMessage="No files for this entity yet."
|
||
renderRow={(f) => {
|
||
const isSigned = f.filename?.startsWith('signed-');
|
||
return (
|
||
<div className="flex items-center justify-between gap-2 text-sm">
|
||
<span className="truncate">{f.filename}</span>
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
|
||
{isSigned ? (
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-1 text-brand hover:underline"
|
||
onClick={() => setDetailsId(f.id)}
|
||
>
|
||
<Eye className="h-3 w-3" />
|
||
view signing details
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}}
|
||
/>
|
||
|
||
<SigningDetailsDialog
|
||
documentId={detailsId}
|
||
open={Boolean(detailsId)}
|
||
onOpenChange={(open) => !open && setDetailsId(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Note: identifying signed-PDF files via `filename.startsWith('signed-')` is a quick heuristic; the spec recommends checking via `documents.signedFileId` lookup. For v1, the heuristic is acceptable since the auto-deposit handler names files exactly this way. If you want to do the right thing, extend the `/api/v1/files` aggregated response to surface a `signedFromDocumentId` field on rows where one exists — that's the principled fix and a small change to `listFilesAggregatedByEntity` (LEFT JOIN documents on signedFileId). Document this as a follow-up if the heuristic ships.
|
||
|
||
- [ ] **Step 3: Rebuild `documents-hub.tsx`**
|
||
|
||
Read the existing file:
|
||
|
||
```bash
|
||
cat src/components/documents/documents-hub.tsx
|
||
```
|
||
|
||
Replace its rendering branch:
|
||
|
||
- Drop the `<Tabs>` (signing-status tabs) entirely. Drop `documentsHubTabs` import + `TAB_LABELS`.
|
||
- Drop the `useQuery` for `hub-counts` (or reduce to in-flight count).
|
||
- Keep `FolderTreeSidebar` + `FolderBreadcrumb` + `FolderActionsMenu`.
|
||
- Add a fetch for the selected folder's row (use the `useDocumentFolders` hook + `findInTree`) and branch the main panel:
|
||
- If `selectedFolderId` is undefined → render `<HubRootView portSlug={portSlug} />`.
|
||
- If the selected folder is system-managed and has an `entityType + entityId` → render `<EntityFolderView portSlug={portSlug} entityType={...} entityId={...} />`.
|
||
- Otherwise → render the current flat folder listing (existing `documents` query keyed by `folderId`).
|
||
|
||
Key snippet:
|
||
|
||
```tsx
|
||
const tree = foldersResp?.data ?? [];
|
||
const selectedFolder =
|
||
typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null;
|
||
|
||
return (
|
||
<div className="flex flex-col sm:flex-row h-full">
|
||
<FolderTreeSidebar
|
||
selectedFolderId={selectedFolderId}
|
||
onSelect={handleFolderSelect}
|
||
footer={
|
||
<PermissionGate resource="documents" action="manage_folders">
|
||
<FolderActionsMenu
|
||
selectedFolderId={selectedFolderId}
|
||
onAfterDelete={() => handleFolderSelect(undefined)}
|
||
/>
|
||
</PermissionGate>
|
||
}
|
||
/>
|
||
<div className="flex-1 min-w-0 p-4 space-y-4">
|
||
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
|
||
<PageHeader title="Documents" /* ... */ />
|
||
|
||
{selectedFolderId === undefined ? (
|
||
<HubRootView portSlug={portSlug} />
|
||
) : selectedFolder?.entityType &&
|
||
selectedFolder.entityType !== 'root' &&
|
||
selectedFolder.entityId ? (
|
||
<EntityFolderView
|
||
portSlug={portSlug}
|
||
entityType={selectedFolder.entityType as 'client' | 'company' | 'yacht'}
|
||
entityId={selectedFolder.entityId}
|
||
/>
|
||
) : (
|
||
/* existing flat folder listing — keep the search input + type chips + paginated list */
|
||
<FlatFolderListing portSlug={portSlug} folderId={selectedFolderId} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
```
|
||
|
||
You'll want to extract the existing flat listing block (search + chips + ul of doc rows) into a `<FlatFolderListing>` sub-component inside the same file (or a sibling file). Keep the props minimal: `portSlug` + `folderId`.
|
||
|
||
Remove the `useQuery` for `hub-counts` (or reduce to a single in-flight count if the page header still uses it). Drop `documentsHubTabs` from imports.
|
||
|
||
- [ ] **Step 4: Verify TypeScript + manual sanity check**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: clean exit.
|
||
|
||
Start dev server: `pnpm dev`. Open Documents:
|
||
|
||
- Root view shows in-flight workflows + recent files.
|
||
- Click `Clients/` in the tree → shows the subfolders for clients with files.
|
||
- Click into a specific client's subfolder → shows Signing in progress + Files sections, grouped by source.
|
||
- Click "view signing details" on a signed file → dialog opens.
|
||
|
||
If the dev server isn't runnable in your environment, document the manual checks for the PR description and rely on the Playwright tests in Task 18 instead.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/documents/hub-root-view.tsx src/components/documents/entity-folder-view.tsx src/components/documents/documents-hub.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(documents): rebuild hub — root view + entity-folder view
|
||
|
||
Three rendering modes for the main panel:
|
||
- HubRootView (no folder selected): port-wide Signing + recent Files.
|
||
- EntityFolderView (system-managed entity subfolder selected):
|
||
AggregatedSection × 2 with owner-grouped subsections + per-row
|
||
"view signing details" link on signed files.
|
||
- FlatFolderListing (any other folder): existing search + chips + list.
|
||
|
||
Drops the signing-status tab strip (in_progress / awaiting_them / etc.)
|
||
— folders are the primary navigation now. hub-counts query is no
|
||
longer used.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Files page removal + 301 redirect
|
||
|
||
**Files:**
|
||
|
||
- Delete: `src/app/(dashboard)/[portSlug]/documents/files/page.tsx`
|
||
- Delete: `src/components/files/folder-tree.tsx`
|
||
- Modify: `next.config.mjs`
|
||
- Modify: `src/stores/file-browser-store.ts`
|
||
|
||
- [ ] **Step 1: Confirm nothing else imports the legacy page**
|
||
|
||
```bash
|
||
grep -rn "from '@/components/files/folder-tree'\|/documents/files\"" src/ --include="*.tsx" --include="*.ts"
|
||
```
|
||
|
||
Expected: only `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` references the legacy tree. If anything else does, deal with it before deleting (e.g., a sidebar link in `src/components/layout/sidebar.tsx`).
|
||
|
||
- [ ] **Step 2: Delete the legacy page + folder tree**
|
||
|
||
```bash
|
||
git rm src/app/(dashboard)/[portSlug]/documents/files/page.tsx
|
||
git rm src/components/files/folder-tree.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Add a 301 redirect to `next.config.mjs`**
|
||
|
||
In `next.config.mjs`, add a `redirects()` function alongside the existing `headers()`:
|
||
|
||
```javascript
|
||
async redirects() {
|
||
return [
|
||
{
|
||
source: '/:portSlug/documents/files',
|
||
destination: '/:portSlug/documents',
|
||
permanent: true,
|
||
},
|
||
{
|
||
source: '/:portSlug/documents/files/:path*',
|
||
destination: '/:portSlug/documents',
|
||
permanent: true,
|
||
},
|
||
];
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 4: Repurpose `file-browser-store.ts`**
|
||
|
||
The store currently tracks `currentFolder` as a `storagePath` prefix. Repurpose it to hold a `selectedFolderId` (the `document_folders.id` reference):
|
||
|
||
```typescript
|
||
import { create } from 'zustand';
|
||
|
||
interface FileBrowserState {
|
||
viewMode: 'grid' | 'list';
|
||
setViewMode: (mode: 'grid' | 'list') => void;
|
||
selectedFolderId: string | null | undefined; // undefined = no selection, null = root
|
||
setSelectedFolderId: (id: string | null | undefined) => void;
|
||
}
|
||
|
||
export const useFileBrowserStore = create<FileBrowserState>((set) => ({
|
||
viewMode: 'list',
|
||
setViewMode: (viewMode) => set({ viewMode }),
|
||
selectedFolderId: undefined,
|
||
setSelectedFolderId: (selectedFolderId) => set({ selectedFolderId }),
|
||
}));
|
||
```
|
||
|
||
The `currentFolder` + `setCurrentFolder` exports are gone — verify with `grep -rn 'currentFolder\|setCurrentFolder' src` that nothing else references them. The only consumer was the deleted page.
|
||
|
||
- [ ] **Step 5: Sidebar link cleanup**
|
||
|
||
```bash
|
||
grep -rn '/documents/files' src/components/layout/
|
||
```
|
||
|
||
If a sidebar link points to `/documents/files`, change it to `/documents`. (Likely no change needed since the redirect catches stray bookmarks too.)
|
||
|
||
- [ ] **Step 6: TypeScript + a smoke run**
|
||
|
||
```bash
|
||
pnpm exec tsc --noEmit
|
||
```
|
||
|
||
Expected: clean exit. If the dev server is up, navigate to `/<port>/documents/files` and confirm the 301 lands on `/<port>/documents`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "$(cat <<'EOF'
|
||
chore(documents): remove legacy /documents/files route + folder tree
|
||
|
||
The /documents/files page rendered a storagePath-prefix folder tree
|
||
disconnected from document_folders. Replaced by the unified hub
|
||
(Task 15). 301 redirect catches stray bookmarks. file-browser-store
|
||
repurposed to hold the document_folders.id selection.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Run backfill on deploy
|
||
|
||
**Files:**
|
||
|
||
- Modify: `package.json` (add a `postinstall` or `db:backfill` script entry)
|
||
- Modify: deploy docs/runbook (if one exists in `docs/`)
|
||
|
||
The migration in Task 1 ships the schema. The backfill in Task 11 ships the script. This task wires the two together so backfill runs as part of the deploy.
|
||
|
||
- [ ] **Step 1: Add an npm script**
|
||
|
||
In `package.json`, add to the `"scripts"` block:
|
||
|
||
```json
|
||
"db:backfill:doc-folders": "tsx scripts/backfill-document-folders.ts"
|
||
```
|
||
|
||
- [ ] **Step 2: Document the deploy sequence**
|
||
|
||
Find the deploy runbook. Likely candidates:
|
||
|
||
```bash
|
||
ls docs/ | grep -i deploy
|
||
grep -rln 'deploy' docs/ | head -5
|
||
```
|
||
|
||
If a runbook exists, add a step **after** the migration step:
|
||
|
||
````markdown
|
||
### Documents hub split (Wave 11.B+)
|
||
|
||
Run after the `0051_documents_hub_split.sql` migration applies:
|
||
|
||
```bash
|
||
pnpm db:backfill:doc-folders
|
||
```
|
||
````
|
||
|
||
Idempotent — safe to re-run if the deploy is interrupted.
|
||
|
||
````
|
||
|
||
If no runbook exists, add the step to the README or a fresh `docs/deploy.md`. Don't create a docs file unless one exists or is the obvious home — the user-facing repo conventions matter more than perfect docs.
|
||
|
||
- [ ] **Step 3: Manual local-deploy rehearsal**
|
||
|
||
```bash
|
||
# Roll back the local DB to pre-Task-1 state (skip if you don't have a fixture)
|
||
# Apply migration:
|
||
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
|
||
-f src/lib/db/migrations/0051_documents_hub_split.sql
|
||
# Run backfill:
|
||
pnpm db:backfill:doc-folders
|
||
````
|
||
|
||
Expected: zero errors, system roots populated, file folder_ids set.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add package.json docs/
|
||
git commit -m "$(cat <<'EOF'
|
||
chore(documents): wire backfill script into deploy sequence
|
||
|
||
Adds db:backfill:doc-folders npm script + runbook step. Run after the
|
||
0051 migration applies. Idempotent; safe to re-run on interrupted
|
||
deploys.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: E2E + visual snapshots
|
||
|
||
**Files:**
|
||
|
||
- Create: `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts`
|
||
- Create: `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts`
|
||
- Modify: `tests/e2e/visual/snapshots.spec.ts`
|
||
|
||
- [ ] **Step 1: Write the aggregated-view smoke spec**
|
||
|
||
Create `tests/e2e/smoke/04-documents-hub-aggregated.spec.ts`. Read `tests/e2e/smoke/04-documents.spec.ts` (the Wave 11.B smoke) first to match the auth + setup helper pattern. The spec should:
|
||
|
||
1. Log in as the seeded sales-manager user.
|
||
2. Seed a client + a yacht owned by that client (via the test API or DB helper).
|
||
3. Trigger a Documenso completion for an EOI on the client (via the polling worker mock or the realapi path — for smoke, fake the webhook by hitting the test-only API that fires the handler).
|
||
4. Navigate to `/<port>/documents`.
|
||
5. Click `Clients` in the sidebar, then the client's subfolder.
|
||
6. Assert visible: `DIRECTLY ATTACHED` group, the signed PDF row, the "view signing details" button.
|
||
7. Click the button; assert the dialog opens with signers list.
|
||
|
||
```typescript
|
||
import { test, expect } from '@playwright/test';
|
||
// ... existing project setup imports
|
||
test('open client entity folder, see aggregated groups, view signing details', async ({ page }) => {
|
||
// Setup ...
|
||
await page.goto('/port-nimara/documents');
|
||
await page.getByRole('button', { name: /Clients/i }).click();
|
||
// Click the seeded client's subfolder
|
||
await page.getByRole('button', { name: /Smith, John/ }).click();
|
||
await expect(page.getByText(/DIRECTLY ATTACHED/i)).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /view signing details/i })).toBeVisible();
|
||
await page
|
||
.getByRole('button', { name: /view signing details/i })
|
||
.first()
|
||
.click();
|
||
await expect(page.getByRole('dialog', { name: /Signing details/i })).toBeVisible();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Write the upload-into-entity smoke spec**
|
||
|
||
Create `tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts`. Seed a client, navigate to their entity folder, click Upload, attach a small PDF fixture (`tests/e2e/fixtures/sample.pdf`), submit. Assert:
|
||
|
||
1. After upload, the row appears in DIRECTLY ATTACHED.
|
||
2. The file row's API shape (verified via a follow-up `fetch('/api/v1/files/<id>')` from the page context, or via the test-only DB helper) has `clientId` set to the seeded client + `folderId` set to the entity subfolder.
|
||
|
||
- [ ] **Step 3: Add visual snapshots**
|
||
|
||
In `tests/e2e/visual/snapshots.spec.ts`, append two new snapshot blocks following the existing pattern:
|
||
|
||
```typescript
|
||
test('hub-root', async ({ page }) => {
|
||
await page.goto('/port-nimara/documents');
|
||
await page.waitForSelector('text=Signing in progress');
|
||
await expect(page).toHaveScreenshot('hub-root.png', { fullPage: true });
|
||
});
|
||
|
||
test('hub-entity-folder', async ({ page }) => {
|
||
// Seed assumed by global setup; navigate to a fixed seeded client.
|
||
await page.goto('/port-nimara/documents');
|
||
await page.getByRole('button', { name: /Clients/i }).click();
|
||
await page.getByRole('button', { name: /Smith, John/ }).click();
|
||
await page.waitForSelector('text=DIRECTLY ATTACHED');
|
||
await expect(page).toHaveScreenshot('hub-entity-folder.png', { fullPage: true });
|
||
});
|
||
```
|
||
|
||
Run with `--update-snapshots` to generate baselines:
|
||
|
||
```bash
|
||
pnpm exec playwright test --project=visual --update-snapshots tests/e2e/visual/snapshots.spec.ts
|
||
```
|
||
|
||
Verify the generated PNGs look right (open them in the OS file viewer). Commit the new snapshot PNGs under `tests/e2e/visual/snapshots.spec.ts-snapshots/`.
|
||
|
||
- [ ] **Step 4: Run the smoke project end-to-end**
|
||
|
||
```bash
|
||
pnpm exec playwright test --project=smoke
|
||
```
|
||
|
||
Expected: passes including the two new specs. Allow ~10 min runtime.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/e2e/smoke/04-documents-hub-aggregated.spec.ts tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts tests/e2e/visual/snapshots.spec.ts tests/e2e/visual/snapshots.spec.ts-snapshots/
|
||
git commit -m "$(cat <<'EOF'
|
||
test(documents): E2E smoke + visual snapshots for hub rebuild
|
||
|
||
Two smoke specs cover the headline flows:
|
||
- open client entity folder → see grouped Signing + Files → click
|
||
"view signing details" → dialog renders signers + events.
|
||
- upload PDF into Clients/Smith/ → client_id auto-set from the
|
||
destination folder → file appears in DIRECTLY ATTACHED.
|
||
|
||
Visual baselines for hub-root + hub-entity-folder catch unintended
|
||
layout drift on the new screens.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: CLAUDE.md update + final verification
|
||
|
||
**Files:**
|
||
|
||
- Modify: `CLAUDE.md`
|
||
|
||
- [ ] **Step 1: Extend the Document folders subsection**
|
||
|
||
Open `CLAUDE.md`. Find the existing `**Document folders:**` bullet under the Conventions block. Replace it with:
|
||
|
||
```markdown
|
||
- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain.
|
||
|
||
Three system roots (`Clients/`, `Companies/`, `Yachts/`) are auto-created on port init via `ensureSystemRoots`. Per-entity subfolders are created lazily on first auto-deposit / manual upload via `ensureEntityFolder` — concurrent callers race safely via the partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. The `chk_system_folder_shape` CHECK pins the shape of system rows. Rename/move/delete on `system_managed = true` folders is rejected by `assertNotSystemManaged` (service-level, not DB-level). Entity rename auto-syncs the folder name; archive applies a ` (archived)` suffix; hard-delete demotes (`system_managed = false`) + appends ` (deleted)`.
|
||
|
||
Auto-deposit on signing completion: `handleDocumentCompleted` resolves the owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.primaryClientId ?? .primaryCompanyId ?? .primaryYachtId`), ensures the matching entity subfolder, and sets `files.folder_id` + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable.
|
||
|
||
Aggregated projection: `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk the relationship graph from the requested entity (symmetric reach: Client ↔ Company via `company_memberships`, ↔ Yacht via `yachts.current_owner_type/id`) and return results grouped by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT). Each group caps at 20 rows with a total for `Show all (N)`. **File-FK snapshot is the source of truth** — historical files stay where they were filed even if the linked entity's relationships change. **Defense-in-depth `port_id` filter at every join** (per recommender precedent) — entry-point check alone is rejected. Completed workflows are hidden from folder views; the signed-PDF file surfaces with a "view signing details" link to the workflow audit trail.
|
||
|
||
Permission gating: `documents.view` for read of folders + entity-aggregated listing; `documents.manage_folders` for create / rename / move / delete of user folders (system folders are immutable through the API entirely).
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the change reads cleanly**
|
||
|
||
```bash
|
||
grep -A 20 '**Document folders:**' CLAUDE.md | head -30
|
||
```
|
||
|
||
Expected: the new block is in place; surrounding bullets are untouched.
|
||
|
||
- [ ] **Step 3: Run the full verification**
|
||
|
||
```bash
|
||
pnpm exec tsc --noEmit
|
||
pnpm exec vitest run
|
||
pnpm exec playwright test --project=smoke
|
||
```
|
||
|
||
Expected:
|
||
|
||
- tsc: clean exit.
|
||
- vitest: all green (existing suite + the new tests from Tasks 2, 3, 4, 5, 6, 7, 8, 10, 11 — should net to roughly +60 new tests on top of the Wave 11.B baseline of 1213).
|
||
- playwright smoke: passes.
|
||
|
||
If any test fails, fix the root cause before committing. Do not use `--no-verify` and do not skip tests.
|
||
|
||
- [ ] **Step 4: Run prettier + lint to catch formatting drift**
|
||
|
||
```bash
|
||
pnpm format
|
||
pnpm lint
|
||
```
|
||
|
||
Expected: zero diffs / zero errors. If prettier reformats anything you wrote, stage the change.
|
||
|
||
- [ ] **Step 5: Final commit**
|
||
|
||
```bash
|
||
git add CLAUDE.md
|
||
git commit -m "$(cat <<'EOF'
|
||
docs(claude-md): documents hub split + auto-filed client folders
|
||
|
||
Extends the Document folders subsection with: three system roots
|
||
+ per-entity subfolders + lifecycle hooks (rename/archive/delete);
|
||
Owner-wins owner resolution in handleDocumentCompleted; aggregated
|
||
projection with symmetric reach + file-FK-as-source-of-truth +
|
||
defense-in-depth port_id filter; permission gating.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
- [ ] **Step 6: Open the PR**
|
||
|
||
Use `gh pr create` per the conventions in CLAUDE.md. PR description should include:
|
||
|
||
- Link to the spec.
|
||
- Summary: "Documents hub split + auto-filed client folders".
|
||
- Test plan: tsc clean, vitest all green, playwright smoke pass, visual baselines regenerated.
|
||
- Deploy note: migration `0051_documents_hub_split.sql` + `pnpm db:backfill:doc-folders` after migrate.
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
After landing the final commit, run this checklist before opening the PR:
|
||
|
||
**Spec coverage:**
|
||
|
||
- [ ] §"Conceptual model" — File / Signing workflow / Folder as first-class concepts. ✓ Tasks 1, 7, 8.
|
||
- [ ] §"Folder tree structure & governance" — system roots, lazy entity subfolders, suffix lifecycle. ✓ Tasks 2, 3, 5, 6.
|
||
- [ ] §"Routing on workflow completion" steps 3a–3c. ✓ Task 7.
|
||
- [ ] §"Owner-aggregation projection" — symmetric walk, per-group pagination, file-FK as source of truth, defense-in-depth port_id. ✓ Task 8.
|
||
- [ ] §"UI layout" — stacked Signing/Files, owner-grouped headers, system-folder integration, view-signing-details. ✓ Tasks 12, 13, 14, 15.
|
||
- [ ] §"Edge cases" E1–E14 — every decision row has a code path:
|
||
- E1 (entity renamed) → Task 5.
|
||
- E2 (name collision) → Task 3 + Task 5.
|
||
- E3 (archived) → Task 6.
|
||
- E4 (hard-deleted) → Task 6.
|
||
- E5 (yacht ownership transferred) → Task 8 (file-FK snapshot).
|
||
- E6 (owner changed mid-signing) → Task 7 (resolve at completion).
|
||
- E7 (rep moves file out of system folder) → no code change; the file's entity FK is unchanged so aggregation still surfaces it. Verified by Task 8 test.
|
||
- E8 (manual upload into entity folder) → covered by `applyEntityFkFromFolder` in Task 12's upload path; if not wired in Task 12, add a sub-step or split into a Task 12b.
|
||
- E9 (no owner) → Task 7.
|
||
- E10 (interest with no owner) → Task 7 (resolveDocumentOwner returns null).
|
||
- E11 (1000+ files) → Task 8 (GROUP_LIMIT + total).
|
||
- E12 (hub root view) → Task 15.
|
||
- E13 (concurrent completions race) → Task 3 (ON CONFLICT + re-SELECT).
|
||
- E14 (cross-port leak) → Task 8 (port_id at every join).
|
||
- [ ] §"Schema deltas" — Task 1.
|
||
- [ ] §"Backfill migration" — Task 11.
|
||
- [ ] §"Testing strategy" — Tasks 2–11 cover unit + integration; Task 18 covers E2E + visual.
|
||
|
||
**Placeholder scan:** none — every code block in this plan contains real syntax. The two known carve-outs:
|
||
|
||
1. Task 8 references "the existing `pagination` shape" and Task 9 says "the existing envelope" — these reference unchanged behaviour the engineer will read in-context.
|
||
2. Task 12 calls the parent of `AggregatedSection` for the Show-all drill-through — left intentionally as a hook because the drill-through routing depends on the entity-folder view (Task 15) that wraps it.
|
||
|
||
**Type consistency:**
|
||
|
||
- `EntityType` defined in `document-folders.service.ts` (Task 3), imported in `documents.service.ts` (Task 7), `files.ts` (Task 8), and the backfill (Task 11). Same shape everywhere.
|
||
- `AggregatedFileGroup` / `AggregatedWorkflowGroup` defined in service files (Task 8); hook in Task 12 re-declares a compatible shape for the UI side (avoid leaking server types directly).
|
||
- `ensureSystemRoots(portId, userId) → Promise<DocumentFolder[]>` consistent in Task 2, Task 3 (called from `ensureEntityFolder` self-heal path), Task 11 (called from backfill).
|
||
- `ensureEntityFolder(portId, entityType, entityId, userId) → Promise<DocumentFolder>` consistent in Task 3 (definition), Task 7 (auto-deposit), Task 11 (backfill).
|
||
|
||
**Risks called out in the spec mitigated:**
|
||
|
||
- Aggregation slow on large portfolios: indexes `idx_files_client / _company / _yacht` already exist; `idx_files_port_folder` added in Task 1.
|
||
- Backfill locks production: per-port advisory lock + idempotent (Task 11).
|
||
- System-folder bypass via direct DB write: accepted; spec-explicit risk.
|
||
- Hard cutover with backfill failure: idempotent re-run, rollback = revert migration + redeploy old hub binary.
|
||
|
||
---
|
||
|
||
## Resume prompt for fresh sessions
|
||
|
||
To resume mid-plan from a fresh Claude session, paste:
|
||
|
||
```
|
||
I'm resuming the documents-hub-split plan execution on branch
|
||
feat/documents-folders. Plan:
|
||
docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md.
|
||
Spec: docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md.
|
||
|
||
Wave 11.B (folders foundation) is complete; this plan layers system
|
||
roots + entity subfolders + auto-deposit + aggregated projection +
|
||
hub rebuild on top. Check the commits on the branch to determine the
|
||
current task, then continue with superpowers:subagent-driven-development.
|
||
```
|