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

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