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) };
}