From e91055f784a91de62c3ce48b7b63dd85bf82aa01 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 20:18:40 +0200 Subject: [PATCH] feat(documents): foundation for nested interest subfolders (phase 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — " (when a primary berth is linked) or "Deal " as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../db/migrations/0078_files_interest_id.sql | 30 ++++++++ src/lib/db/schema/documents.ts | 14 ++++ src/lib/services/document-folders.service.ts | 70 ++++++++++++++----- src/lib/services/files.ts | 1 + 4 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 src/lib/db/migrations/0078_files_interest_id.sql diff --git a/src/lib/db/migrations/0078_files_interest_id.sql b/src/lib/db/migrations/0078_files_interest_id.sql new file mode 100644 index 00000000..e5c88085 --- /dev/null +++ b/src/lib/db/migrations/0078_files_interest_id.sql @@ -0,0 +1,30 @@ +-- Phase 1 of the nested document subfolders feature (master UAT line 728+): +-- +-- 1. Add a nullable `interest_id` column to `files` so uploads scoped +-- to a deal can be filed under the interest subfolder while still +-- rolling up to the parent client folder. Mirrors the existing +-- `documents.interest_id` semantics: scoping FK that holds a +-- "from-interest" attribution even when the parent client/yacht/ +-- company shifts. +-- +-- 2. Index on `(port_id, interest_id)` for the aggregated-projection +-- queries that will surface "this-deal" files vs "from-client" files +-- in InterestDocumentsTab. +-- +-- 3. Soft FK (`ON DELETE SET NULL`) so a hard-deleted interest doesn't +-- orphan the file — the audit trail stays intact and the file remains +-- findable under the parent client folder. +-- +-- Apply in dev: +-- PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \ +-- -f src/lib/db/migrations/0078_files_interest_id.sql + +ALTER TABLE files + ADD COLUMN IF NOT EXISTS interest_id text + REFERENCES interests(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_files_interest + ON files (interest_id); + +CREATE INDEX IF NOT EXISTS idx_files_port_interest + ON files (port_id, interest_id); diff --git a/src/lib/db/schema/documents.ts b/src/lib/db/schema/documents.ts index 7a46ff8b..8318d213 100644 --- a/src/lib/db/schema/documents.ts +++ b/src/lib/db/schema/documents.ts @@ -31,6 +31,18 @@ export const files = pgTable( clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }), yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }), companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }), + /** + * Optional deal scope. When a file is uploaded inside an interest + * (the EOI tab, the documents tab on the interest detail page), this + * FK captures which deal it belongs to so the file can be filed + * under the interest subfolder while still rolling up to the parent + * client folder. NULL for client/yacht/company-level uploads. + * + * Added by migration 0078; not yet wired into ensureEntityFolder + * (interest subfolder nesting) — see master UAT line 728+ for the + * remaining work plan. + */ + interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }), folderId: text('folder_id').references((): AnyPgColumn => documentFolders.id, { onDelete: 'set null', }), @@ -57,6 +69,8 @@ export const files = pgTable( index('idx_files_port_client').on(table.portId, table.clientId), index('idx_files_port_company').on(table.portId, table.companyId), index('idx_files_port_yacht').on(table.portId, table.yachtId), + index('idx_files_interest').on(table.interestId), + index('idx_files_port_interest').on(table.portId, table.interestId), ], ); diff --git a/src/lib/services/document-folders.service.ts b/src/lib/services/document-folders.service.ts index fd84ac5c..b142a57f 100644 --- a/src/lib/services/document-folders.service.ts +++ b/src/lib/services/document-folders.service.ts @@ -5,6 +5,7 @@ import { documentFolders, documents, files, type DocumentFolder } from '@/lib/db import { clients } from '@/lib/db/schema/clients'; import { companies } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; +import { interests } from '@/lib/db/schema/interests'; import { createAuditLog } from '@/lib/audit'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; @@ -410,13 +411,17 @@ export async function ensureSystemRoots(portId: string, userId: string): Promise // ─── ensureEntityFolder ────────────────────────────────────────────────────── -export type EntityType = 'client' | 'company' | 'yacht'; -const ENTITY_TYPES = new Set(['client', 'company', 'yacht']); +export type EntityType = 'client' | 'company' | 'yacht' | 'interest'; +const ENTITY_TYPES = new Set(['client', 'company', 'yacht', 'interest']); /** * Returns the display name for an entity used to label its subfolder. * Clients use `fullName` verbatim (matching rep-facing list views). - * Companies and yachts use their `name` column verbatim. + * Companies and yachts use their `name` column verbatim. Interest + * folders are derived from their primary berth (when set) so the + * tree reads "Acme Corp / A1-A3" rather than a meaningless interest + * UUID; falls back to the createdAt date as a stable label when no + * primary berth is linked yet. */ async function resolveEntityDisplayName( portId: string, @@ -439,6 +444,21 @@ async function resolveEntityDisplayName( if (!co) throw new NotFoundError('Company'); return co.name; } + if (entityType === 'interest') { + const i = await db.query.interests.findFirst({ + where: and(eq(interests.id, entityId), eq(interests.portId, portId)), + columns: { createdAt: true }, + }); + if (!i) throw new NotFoundError('Interest'); + // Defer to the interest-berths service for the primary berth label + // — circular-dep avoidance via dynamic import. Falls back to the + // ISO date slice ("Deal 2026-05-12") when no berth is linked yet. + const { getPrimaryBerth } = await import('@/lib/services/interest-berths.service'); + const primary = await getPrimaryBerth(entityId).catch(() => null); + if (primary?.mooringNumber) return `Deal — ${primary.mooringNumber}`; + const dateSlice = i.createdAt.toISOString().slice(0, 10); + return `Deal ${dateSlice}`; + } // yacht const y = await db.query.yachts.findFirst({ where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)), @@ -510,21 +530,37 @@ export async function ensureEntityFolder( }); 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); + // Resolve the parent folder. For client / company / yacht this is the + // matching system root. For interest, parent is the owning client's + // entity folder so the tree renders nested: Clients/Acme/Deal A1-A3/. + let parent: DocumentFolder | undefined; + if (entityType === 'interest') { + const interestRow = await db.query.interests.findFirst({ + where: and(eq(interests.id, entityId), eq(interests.portId, portId)), + columns: { clientId: true }, + }); + if (!interestRow) throw new NotFoundError('Interest'); + // Recursively ensure the parent client's folder first — guarantees + // we always land inside the existing Clients// subfolder even + // when the deal's first artifact predates any client-level upload. + parent = await ensureEntityFolder(portId, 'client', interestRow.clientId, userId); + } else { + const rootName: SystemRootName = + entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts'; + parent = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, 'root'), + eq(documentFolders.name, rootName), + ), + }); + if (!parent) { + // Self-heal: the port-init hook may have been skipped (legacy port). + await ensureSystemRoots(portId, userId); + return ensureEntityFolder(portId, entityType, entityId, userId); + } } + const root = parent; const baseName = await resolveEntityDisplayName(portId, entityType, entityId); diff --git a/src/lib/services/files.ts b/src/lib/services/files.ts index d51d07a7..e314f100 100644 --- a/src/lib/services/files.ts +++ b/src/lib/services/files.ts @@ -581,6 +581,7 @@ async function fetchGroupRows( clientId: files.clientId, yachtId: files.yachtId, companyId: files.companyId, + interestId: files.interestId, folderId: files.folderId, filename: files.filename, originalName: files.originalName,