feat(documents): owner-aggregated projection (files + workflows)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 11:54:23 +02:00
parent 8e2e2ea113
commit 3037d832c6
4 changed files with 767 additions and 19 deletions

View File

@@ -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<typeof documents.$inferSelect>;
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<typeof eq>,
): Promise<{ rows: Array<typeof documents.$inferSelect>; 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<number>`count(*)::int` })
.from(documents)
.where(
and(
eq(documents.portId, portId),
inArray(documents.status, inflightStatuses),
predicate,
),
);
return { rows, total: Number(countRow?.count ?? 0) };
}

View File

@@ -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<typeof files.$inferSelect>;
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<AggregatedFilesResult> {
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<boolean> {
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<RelatedEntities> {
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<typeof eq>,
limit: number,
): Promise<{ rows: Array<typeof files.$inferSelect>; 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<number>`count(*)::int` })
.from(files)
.where(and(eq(files.portId, portId), predicate));
return { rows, total: Number(countRow?.count ?? 0) };
}
function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {
const seen = new Set<K>();
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<T> {
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;
}

View File

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

View File

@@ -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<string, string> = {};
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();
});
});