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