From 3037d832c62ae40f72363cd8c4a6f953d6386fbe Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 May 2026 11:54:23 +0200 Subject: [PATCH] feat(documents): owner-aggregated projection (files + workflows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (draft/sent/partially_signed only). Completed workflows are hidden — they surface via their signed-PDF file row instead. applyEntityFkFromFolder auto-sets the matching entity FK on the file row when the upload target is a system-managed entity subfolder (E8). Wired into uploadFile; validator extended with folderId field. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/services/documents.service.ts | 115 +++++++- src/lib/services/files.ts | 350 +++++++++++++++++++++-- src/lib/validators/files.ts | 1 + tests/unit/aggregated-projection.test.ts | 320 +++++++++++++++++++++ 4 files changed, 767 insertions(+), 19 deletions(-) create mode 100644 tests/unit/aggregated-projection.test.ts diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index ab917760..94ec46f6 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -1,4 +1,4 @@ -import { and, count, eq, gte, inArray, isNull, lt, lte, ne, sql, exists } from 'drizzle-orm'; +import { and, count, desc, eq, gte, inArray, isNull, lt, lte, ne, sql, exists } from 'drizzle-orm'; import { db } from '@/lib/db'; import { @@ -1819,3 +1819,116 @@ export async function createFromUpload( return doc; } + +// ─── Aggregated Workflow Projection ─────────────────────────────────────────── + +import { collectRelatedEntities } from '@/lib/services/files'; + +export interface AggregatedWorkflowGroup { + label: string; + source: 'direct' | 'client' | 'company' | 'yacht'; + workflows: Array; + 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/expired/cancelled workflows are hidden — + * they surface via their signed-PDF file row. + */ +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, +): Promise<{ rows: Array; total: number }> { + const inflightStatuses = INFLIGHT_STATUSES as unknown as string[]; + + const rows = await db + .select() + .from(documents) + .where( + and( + eq(documents.portId, portId), + inArray(documents.status, inflightStatuses), + predicate, + ), + ) + .orderBy(desc(documents.updatedAt)) + .limit(WORKFLOW_GROUP_LIMIT); + + const [countRow] = await db + .select({ count: sql`count(*)::int` }) + .from(documents) + .where( + and( + eq(documents.portId, portId), + inArray(documents.status, inflightStatuses), + predicate, + ), + ); + + return { rows, total: Number(countRow?.count ?? 0) }; +} diff --git a/src/lib/services/files.ts b/src/lib/services/files.ts index be3da90f..fb3a35e1 100644 --- a/src/lib/services/files.ts +++ b/src/lib/services/files.ts @@ -1,4 +1,4 @@ -import { and, arrayContains, eq, or } from 'drizzle-orm'; +import { and, arrayContains, desc, eq, inArray, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { files, documents } from '@/lib/db/schema/documents'; @@ -18,6 +18,11 @@ import { } from '@/lib/constants/file-validation'; import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage'; import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files'; +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'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -66,23 +71,25 @@ export async function uploadFile( sizeBytes: file.size, }); - const [record] = await db - .insert(files) - .values({ - portId, - clientId: data.clientId ?? null, - yachtId: data.yachtId ?? null, - companyId: data.companyId ?? null, - filename: sanitizedFilename, - originalName: sanitizedOriginal, - mimeType: file.mimeType, - sizeBytes: String(file.size), - storagePath, - storageBucket: env.MINIO_BUCKET, - category: data.category ?? null, - uploadedBy: meta.userId, - }) - .returning(); + // E8: auto-set entity FK from system-managed folder when the rep uploads + // directly into a client/company/yacht folder. No-op for non-system folders. + const enrichedValues = await applyEntityFkFromFolder(portId, { + portId, + clientId: data.clientId ?? null, + yachtId: data.yachtId ?? null, + companyId: data.companyId ?? null, + folderId: data.folderId ?? null, + filename: sanitizedFilename, + originalName: sanitizedOriginal, + mimeType: file.mimeType, + sizeBytes: String(file.size), + storagePath, + storageBucket: env.MINIO_BUCKET, + category: data.category ?? null, + uploadedBy: meta.userId, + }); + + const [record] = await db.insert(files).values(enrichedValues).returning(); void createAuditLog({ userId: meta.userId, @@ -270,3 +277,310 @@ export async function getFileById(id: string, portId: string) { return file; } + +// ─── Aggregated Projection ──────────────────────────────────────────────────── + +export interface AggregatedFileGroup { + label: string; + source: 'direct' | 'client' | 'company' | 'yacht'; + files: Array; + 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. + * + * Source of truth: each file's snapshotted entity FKs. + * Defense-in-depth: port_id at every entity / membership / yacht / file join. + */ +export async function listFilesAggregatedByEntity( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + const entityExists = await assertEntityInPort(portId, entityType, entityId); + if (!entityExists) return { groups: [] }; + + const related = await collectRelatedEntities(portId, entityType, entityId); + const groups: AggregatedFileGroup[] = []; + + 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, + }); + } + + 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, + }); + } + + 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, + }); + } + + 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 { + 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); +} + +export 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 per + * source bucket. Symmetric reach. Every join carries port_id. + * + * Note: clients schema has fullName only (no firstName/lastName). + */ +export async function collectRelatedEntities( + portId: string, + entityType: EntityType, + entityId: string, +): Promise { + if (entityType === 'client') { + 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)); + + 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), + ), + ); + + 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') { + // Adapted: use fullName not firstName/lastName. + const memberClients = await db + .select({ id: clients.id, fullName: clients.fullName }) + .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.fullName })), + companies: [], + yachts: ownedYachts, + }; + } + + // yacht view + 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, fullName: true }, + }); + return { + clients: owner ? [{ id: owner.id, name: owner.fullName }] : [], + companies: [], + yachts: [], + }; + } + + 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: [], + }; +} + +async function fetchGroupRows( + portId: string, + predicate: ReturnType, + limit: number, +): Promise<{ rows: Array; total: number }> { + const rows = await db + .select() + .from(files) + .where(and(eq(files.portId, portId), predicate)) + .orderBy(desc(files.createdAt)) + .limit(limit); + + const [countRow] = await db + .select({ count: sql`count(*)::int` }) + .from(files) + .where(and(eq(files.portId, portId), predicate)); + + return { rows, total: Number(countRow?.count ?? 0) }; +} + +function dedupeBy(items: T[], key: (t: T) => K): T[] { + const seen = new Set(); + 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; +} + +// ─── E8: applyEntityFkFromFolder ───────────────────────────────────────────── + +/** + * E8: when a rep manually uploads a file into a system-managed entity + * subfolder, auto-set the matching entity FK on the file row from the + * folder's entityType + entityId. Custom (non-system) folders → + * returns the input unchanged. + */ +export async function applyEntityFkFromFolder< + T extends { + clientId?: string | null; + companyId?: string | null; + yachtId?: string | null; + folderId?: string | null; + }, +>(portId: string, payload: T): Promise { + 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; +} diff --git a/src/lib/validators/files.ts b/src/lib/validators/files.ts index a2c02f5c..63c5bb04 100644 --- a/src/lib/validators/files.ts +++ b/src/lib/validators/files.ts @@ -7,6 +7,7 @@ export const uploadFileSchema = z.object({ clientId: z.string().optional(), yachtId: z.string().optional(), companyId: z.string().optional(), + folderId: z.string().uuid().optional(), category: z.string().optional(), entityType: z.string().optional(), entityId: z.string().optional(), diff --git a/tests/unit/aggregated-projection.test.ts b/tests/unit/aggregated-projection.test.ts new file mode 100644 index 00000000..217fe55a --- /dev/null +++ b/tests/unit/aggregated-projection.test.ts @@ -0,0 +1,320 @@ +/** + * Task 8 — aggregated projection (TDD). + * + * Tests for: + * 1. listFilesAggregatedByEntity (4 cases) + * 2. listInflightWorkflowsAggregatedByEntity (1 case) + * 3. applyEntityFkFromFolder (3 cases) + * + * Fixture convention: makePort / makeClient / makeCompany / makeYacht from + * helpers/factories; TEST_USER_ID resolved once via beforeAll from a seeded + * user — same pattern as document-folders-system-folders.test.ts. + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { files, documents, documentFolders } from '@/lib/db/schema/documents'; +import { user } from '@/lib/db/schema/users'; +import { yachts } from '@/lib/db/schema/yachts'; +import { + ensureSystemRoots, + ensureEntityFolder, +} from '@/lib/services/document-folders.service'; +import { + listFilesAggregatedByEntity, + applyEntityFkFromFolder, +} from '@/lib/services/files'; +import { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service'; +import { makePort, makeClient, makeCompany, makeYacht, makeMembership } from '../helpers/factories'; + +let TEST_USER_ID = ''; + +beforeAll(async () => { + const [u] = await db.select({ id: user.id }).from(user).limit(1); + if (!u) throw new Error('No user available; run pnpm db:seed first'); + TEST_USER_ID = u.id; +}); + +// ─── Helper to insert a file row directly ──────────────────────────────────── + +async function insertFile( + portId: string, + overrides: { + clientId?: string | null; + companyId?: string | null; + yachtId?: string | null; + folderId?: string | null; + } = {}, +) { + const [row] = await db + .insert(files) + .values({ + portId, + clientId: overrides.clientId ?? null, + companyId: overrides.companyId ?? null, + yachtId: overrides.yachtId ?? null, + folderId: overrides.folderId ?? null, + filename: `file-${crypto.randomUUID().slice(0, 8)}.pdf`, + originalName: `original-${crypto.randomUUID().slice(0, 8)}.pdf`, + mimeType: 'application/pdf', + sizeBytes: '1024', + storagePath: `test/${crypto.randomUUID()}.pdf`, + storageBucket: 'crm-files', + uploadedBy: TEST_USER_ID, + }) + .returning(); + return row!; +} + +// ─── listFilesAggregatedByEntity ───────────────────────────────────────────── + +describe('files service · listFilesAggregatedByEntity', () => { + describe('groups DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT for a client view', () => { + let portId: string; + let clientId: string; + let companyId: string; + let yachtId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + + const client = await makeClient({ portId }); + clientId = client.id; + + const company = await makeCompany({ portId }); + companyId = company.id; + + await makeMembership({ companyId, clientId }); + + const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: clientId }); + yachtId = yacht.id; + + // Three files: directly on client, on company, on yacht + await insertFile(portId, { clientId }); + await insertFile(portId, { companyId }); + await insertFile(portId, { yachtId }); + }); + + it('returns DIRECTLY ATTACHED, FROM COMPANY, and FROM YACHT groups', async () => { + const result = await listFilesAggregatedByEntity(portId, 'client', clientId); + const labels = result.groups.map((g) => g.label); + + expect(labels).toContain('DIRECTLY ATTACHED'); + expect(labels.some((l) => l.startsWith('FROM COMPANY'))).toBe(true); + expect(labels.some((l) => l.startsWith('FROM YACHT'))).toBe(true); + }); + + it('each group has the correct source tag', async () => { + const result = await listFilesAggregatedByEntity(portId, 'client', clientId); + const sourceMap: Record = {}; + for (const g of result.groups) { + sourceMap[g.label] = g.source; + } + expect(sourceMap['DIRECTLY ATTACHED']).toBe('direct'); + const companyGroup = result.groups.find((g) => g.label.startsWith('FROM COMPANY')); + expect(companyGroup?.source).toBe('company'); + const yachtGroup = result.groups.find((g) => g.label.startsWith('FROM YACHT')); + expect(yachtGroup?.source).toBe('yacht'); + }); + }); + + describe('caps each group at 20 rows + surfaces total', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + + const client = await makeClient({ portId }); + clientId = client.id; + + // Insert 25 files all with clientId + await Promise.all( + Array.from({ length: 25 }, () => insertFile(portId, { clientId })), + ); + }); + + it('DIRECTLY ATTACHED group has 20 files and total=25', async () => { + const result = await listFilesAggregatedByEntity(portId, 'client', clientId); + const group = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); + expect(group).toBeDefined(); + expect(group!.files).toHaveLength(20); + expect(group!.total).toBe(25); + }); + }); + + describe('file-FK snapshot survives yacht transfer', () => { + let portId: string; + let johnId: string; + let maryId: string; + let yachtId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + + const john = await makeClient({ portId, overrides: { fullName: 'John Smith' } }); + johnId = john.id; + + const mary = await makeClient({ portId, overrides: { fullName: 'Mary Jones' } }); + maryId = mary.id; + + const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: johnId }); + yachtId = yacht.id; + + // File attached to the yacht at the time john owns it + await insertFile(portId, { yachtId, clientId: johnId }); + + // Transfer yacht to Mary (update currentOwner in place — simulates transfer) + await db + .update(yachts) + .set({ currentOwnerType: 'client', currentOwnerId: maryId }) + .where(and(eq(yachts.id, yachtId), eq(yachts.portId, portId))); + }); + + it("John's view still shows the file via yachtId FK", async () => { + const result = await listFilesAggregatedByEntity(portId, 'client', johnId); + // clientId=johnId → DIRECTLY ATTACHED group + const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); + expect(directGroup).toBeDefined(); + expect(directGroup!.files.length).toBeGreaterThan(0); + }); + + it("Mary's view does NOT see john's file (it has clientId=john, not mary)", async () => { + const result = await listFilesAggregatedByEntity(portId, 'client', maryId); + // Mary owns the yacht now, so FROM YACHT group will appear — but the + // file has clientId=johnId (snapshotted FK), so it WON'T appear under + // Mary's DIRECTLY ATTACHED. The FROM YACHT group WILL appear since the + // file still has yachtId set. + // The key invariant: there is no DIRECTLY ATTACHED for Mary. + const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); + expect(directGroup).toBeUndefined(); + }); + }); + + describe('cross-port leakage rejected', () => { + let portId: string; + let otherPortId: string; + let otherClientId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + + const otherPort = await makePort(); + otherPortId = otherPort.id; + + const otherClient = await makeClient({ portId: otherPortId }); + otherClientId = otherClient.id; + + // File for other client in other port + await insertFile(otherPortId, { clientId: otherClientId }); + }); + + it('returns empty groups when entity belongs to a different port', async () => { + const result = await listFilesAggregatedByEntity(portId, 'client', otherClientId); + expect(result.groups).toHaveLength(0); + }); + }); +}); + +// ─── listInflightWorkflowsAggregatedByEntity ───────────────────────────────── + +describe('documents service · listInflightWorkflowsAggregatedByEntity', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + + const client = await makeClient({ portId }); + clientId = client.id; + }); + + it('returns in-flight workflows in DIRECTLY ATTACHED group, hides completed', async () => { + // Insert two documents: one in-flight (status='sent'), one completed + await db.insert(documents).values([ + { + portId, + clientId, + documentType: 'contract', + title: 'In-flight Doc', + status: 'sent', + createdBy: TEST_USER_ID, + }, + { + portId, + clientId, + documentType: 'contract', + title: 'Completed Doc', + status: 'completed', + createdBy: TEST_USER_ID, + }, + ]); + + const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId); + + const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); + expect(directGroup).toBeDefined(); + expect(directGroup!.workflows).toHaveLength(1); + expect(directGroup!.workflows[0]!.status).toBe('sent'); + expect(directGroup!.workflows[0]!.title).toBe('In-flight Doc'); + }); +}); + +// ─── applyEntityFkFromFolder ────────────────────────────────────────────────── + +describe('files service · applyEntityFkFromFolder', () => { + let portId: string; + let clientId: string; + let entityFolderId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + + const client = await makeClient({ portId }); + clientId = client.id; + + const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + entityFolderId = folder.id; + }); + + it('sets clientId when uploading into a client entity folder', async () => { + const out = await applyEntityFkFromFolder(portId, { folderId: entityFolderId, clientId: null }); + expect(out.clientId).toBe(clientId); + }); + + it('preserves existing entity FK when already set', async () => { + const out = await applyEntityFkFromFolder(portId, { + folderId: entityFolderId, + clientId: 'pre-existing-id', + }); + expect(out.clientId).toBe('pre-existing-id'); + }); + + it('is a no-op for non-system folders', async () => { + const [userFolder] = await db + .insert(documentFolders) + .values({ + portId, + parentId: null, + name: 'My templates', + createdBy: TEST_USER_ID, + }) + .returning(); + const out = await applyEntityFkFromFolder(portId, { + folderId: userFolder!.id, + clientId: null, + }); + expect(out.clientId).toBeNull(); + }); +});