feat(documents): foundation for nested interest subfolders (phase 1/3)

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 — <mooringNumber>"
    (when a primary berth is linked) or "Deal <YYYY-MM-DD>" 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:18:40 +02:00
parent 0c6e7b72af
commit e91055f784
4 changed files with 98 additions and 17 deletions

View File

@@ -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);

View File

@@ -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),
],
);

View File

@@ -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<EntityType>(['client', 'company', 'yacht']);
export type EntityType = 'client' | 'company' | 'yacht' | 'interest';
const ENTITY_TYPES = new Set<EntityType>(['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/<Name>/ 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);

View File

@@ -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,