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,