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

@@ -41,6 +41,7 @@ import {
type FolderNode,
type EntityType,
} from '@/lib/services/document-folders.service';
import { assertEntityInPort, collectRelatedEntities } from '@/lib/services/files';
import type {
CreateDocumentInput,
UpdateDocumentInput,
@@ -1822,8 +1823,6 @@ export async function createFromUpload(
// ─── Aggregated Workflow Projection ───────────────────────────────────────────
import { collectRelatedEntities } from '@/lib/services/files';
export interface AggregatedWorkflowGroup {
label: string;
source: 'direct' | 'client' | 'company' | 'yacht';
@@ -1844,6 +1843,9 @@ export async function listInflightWorkflowsAggregatedByEntity(
entityType: 'client' | 'company' | 'yacht',
entityId: string,
): Promise<{ groups: AggregatedWorkflowGroup[] }> {
const entityExists = await assertEntityInPort(portId, entityType, entityId);
if (!entityExists) return { groups: [] };
const related = await collectRelatedEntities(portId, entityType, entityId);
const groups: AggregatedWorkflowGroup[] = [];
@@ -1910,11 +1912,7 @@ async function fetchWorkflowGroupRows(
.select()
.from(documents)
.where(
and(
eq(documents.portId, portId),
inArray(documents.status, inflightStatuses),
predicate,
),
and(eq(documents.portId, portId), inArray(documents.status, inflightStatuses), predicate),
)
.orderBy(desc(documents.updatedAt))
.limit(WORKFLOW_GROUP_LIMIT);
@@ -1923,11 +1921,7 @@ async function fetchWorkflowGroupRows(
.select({ count: sql<number>`count(*)::int` })
.from(documents)
.where(
and(
eq(documents.portId, portId),
inArray(documents.status, inflightStatuses),
predicate,
),
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, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { and, arrayContains, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files, documents } from '@/lib/db/schema/documents';
@@ -364,7 +364,7 @@ export async function listFilesAggregatedByEntity(
return { groups };
}
async function assertEntityInPort(
export async function assertEntityInPort(
portId: string,
entityType: EntityType,
entityId: string,
@@ -415,7 +415,9 @@ export async function collectRelatedEntities(
companies,
and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)),
)
.where(eq(companyMemberships.clientId, entityId));
.where(
and(eq(companyMemberships.clientId, entityId), isNull(companyMemberships.endDate)),
);
const directYachts = await db
.select({ id: yachts.id, name: yachts.name })
@@ -461,7 +463,9 @@ export async function collectRelatedEntities(
clients,
and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)),
)
.where(eq(companyMemberships.companyId, entityId));
.where(
and(eq(companyMemberships.companyId, entityId), isNull(companyMemberships.endDate)),
);
const ownedYachts = await db
.select({ id: yachts.id, name: yachts.name })
@@ -561,10 +565,7 @@ export async function applyEntityFkFromFolder<
if (!payload.folderId) return payload;
const folder = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.id, payload.folderId),
eq(documentFolders.portId, portId),
),
where: and(eq(documentFolders.id, payload.folderId), eq(documentFolders.portId, portId)),
columns: { systemManaged: true, entityType: true, entityId: true },
});