fix(documents): tighten aggregation — filter ended memberships + symmetry

Four follow-ups from Task 8 code review:
1. Aggregation now filters companyMemberships to active rows only
   (isNull(endDate)) on both client→companies and company→clients
   joins. Previously a rep who left a company 2y ago would still
   see that company's files in their aggregated view. Brings this
   service in line with the 8 other call sites in the codebase that
   already filter on endDate.
2. Move collectRelatedEntities import to the top of
   documents.service.ts — was wedged mid-file.
3. listInflightWorkflowsAggregatedByEntity now calls
   assertEntityInPort for symmetry with the files version. Cross-
   port reads short-circuit early instead of executing N empty
   port-scoped queries.
4. Add a cross-port leakage regression test for the workflow
   projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 12:02:33 +02:00
parent 3037d832c6
commit d2b0d42e84
3 changed files with 29 additions and 31 deletions

View File

@@ -16,16 +16,11 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files, documents, documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
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 { 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';
@@ -133,9 +128,7 @@ describe('files service · listFilesAggregatedByEntity', () => {
clientId = client.id;
// Insert 25 files all with clientId
await Promise.all(
Array.from({ length: 25 }, () => insertFile(portId, { clientId })),
);
await Promise.all(Array.from({ length: 25 }, () => insertFile(portId, { clientId })));
});
it('DIRECTLY ATTACHED group has 20 files and total=25', async () => {
@@ -265,6 +258,16 @@ describe('documents service · listInflightWorkflowsAggregatedByEntity', () => {
expect(directGroup!.workflows[0]!.status).toBe('sent');
expect(directGroup!.workflows[0]!.title).toBe('In-flight Doc');
});
it('rejects cross-port leakage with defense-in-depth port filter', async () => {
const otherPort = await makePort();
const [otherClient] = await db
.insert(clients)
.values({ portId: otherPort.id, fullName: 'Other Port Client' })
.returning();
const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', otherClient!.id);
expect(result.groups).toEqual([]);
});
});
// ─── applyEntityFkFromFolder ──────────────────────────────────────────────────