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:
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user