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 { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
@@ -1819,3 +1819,116 @@ export async function createFromUpload(
|
|||||||
|
|
||||||
return doc;
|
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 { db } from '@/lib/db';
|
||||||
import { files, documents } from '@/lib/db/schema/documents';
|
import { files, documents } from '@/lib/db/schema/documents';
|
||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
} from '@/lib/constants/file-validation';
|
} from '@/lib/constants/file-validation';
|
||||||
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
|
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
|
||||||
import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files';
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -66,23 +71,25 @@ export async function uploadFile(
|
|||||||
sizeBytes: file.size,
|
sizeBytes: file.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [record] = await db
|
// E8: auto-set entity FK from system-managed folder when the rep uploads
|
||||||
.insert(files)
|
// directly into a client/company/yacht folder. No-op for non-system folders.
|
||||||
.values({
|
const enrichedValues = await applyEntityFkFromFolder(portId, {
|
||||||
portId,
|
portId,
|
||||||
clientId: data.clientId ?? null,
|
clientId: data.clientId ?? null,
|
||||||
yachtId: data.yachtId ?? null,
|
yachtId: data.yachtId ?? null,
|
||||||
companyId: data.companyId ?? null,
|
companyId: data.companyId ?? null,
|
||||||
filename: sanitizedFilename,
|
folderId: data.folderId ?? null,
|
||||||
originalName: sanitizedOriginal,
|
filename: sanitizedFilename,
|
||||||
mimeType: file.mimeType,
|
originalName: sanitizedOriginal,
|
||||||
sizeBytes: String(file.size),
|
mimeType: file.mimeType,
|
||||||
storagePath,
|
sizeBytes: String(file.size),
|
||||||
storageBucket: env.MINIO_BUCKET,
|
storagePath,
|
||||||
category: data.category ?? null,
|
storageBucket: env.MINIO_BUCKET,
|
||||||
uploadedBy: meta.userId,
|
category: data.category ?? null,
|
||||||
})
|
uploadedBy: meta.userId,
|
||||||
.returning();
|
});
|
||||||
|
|
||||||
|
const [record] = await db.insert(files).values(enrichedValues).returning();
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
@@ -270,3 +277,310 @@ export async function getFileById(id: string, portId: string) {
|
|||||||
|
|
||||||
return file;
|
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(),
|
clientId: z.string().optional(),
|
||||||
yachtId: z.string().optional(),
|
yachtId: z.string().optional(),
|
||||||
companyId: z.string().optional(),
|
companyId: z.string().optional(),
|
||||||
|
folderId: z.string().uuid().optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
entityType: z.string().optional(),
|
entityType: z.string().optional(),
|
||||||
entityId: z.string().optional(),
|
entityId: z.string().optional(),
|
||||||
|
|||||||
320
tests/unit/aggregated-projection.test.ts
Normal file
320
tests/unit/aggregated-projection.test.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* Task 8 — aggregated projection (TDD).
|
||||||
|
*
|
||||||
|
* Tests for:
|
||||||
|
* 1. listFilesAggregatedByEntity (4 cases)
|
||||||
|
* 2. listInflightWorkflowsAggregatedByEntity (1 case)
|
||||||
|
* 3. applyEntityFkFromFolder (3 cases)
|
||||||
|
*
|
||||||
|
* Fixture convention: makePort / makeClient / makeCompany / makeYacht from
|
||||||
|
* helpers/factories; TEST_USER_ID resolved once via beforeAll from a seeded
|
||||||
|
* user — same pattern as document-folders-system-folders.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { files, documents, documentFolders } from '@/lib/db/schema/documents';
|
||||||
|
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 { listInflightWorkflowsAggregatedByEntity } from '@/lib/services/documents.service';
|
||||||
|
import { makePort, makeClient, makeCompany, makeYacht, makeMembership } from '../helpers/factories';
|
||||||
|
|
||||||
|
let TEST_USER_ID = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
||||||
|
if (!u) throw new Error('No user available; run pnpm db:seed first');
|
||||||
|
TEST_USER_ID = u.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helper to insert a file row directly ────────────────────────────────────
|
||||||
|
|
||||||
|
async function insertFile(
|
||||||
|
portId: string,
|
||||||
|
overrides: {
|
||||||
|
clientId?: string | null;
|
||||||
|
companyId?: string | null;
|
||||||
|
yachtId?: string | null;
|
||||||
|
folderId?: string | null;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(files)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
clientId: overrides.clientId ?? null,
|
||||||
|
companyId: overrides.companyId ?? null,
|
||||||
|
yachtId: overrides.yachtId ?? null,
|
||||||
|
folderId: overrides.folderId ?? null,
|
||||||
|
filename: `file-${crypto.randomUUID().slice(0, 8)}.pdf`,
|
||||||
|
originalName: `original-${crypto.randomUUID().slice(0, 8)}.pdf`,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
sizeBytes: '1024',
|
||||||
|
storagePath: `test/${crypto.randomUUID()}.pdf`,
|
||||||
|
storageBucket: 'crm-files',
|
||||||
|
uploadedBy: TEST_USER_ID,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── listFilesAggregatedByEntity ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('files service · listFilesAggregatedByEntity', () => {
|
||||||
|
describe('groups DIRECTLY ATTACHED + FROM COMPANY + FROM YACHT for a client view', () => {
|
||||||
|
let portId: string;
|
||||||
|
let clientId: string;
|
||||||
|
let companyId: string;
|
||||||
|
let yachtId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
|
||||||
|
const client = await makeClient({ portId });
|
||||||
|
clientId = client.id;
|
||||||
|
|
||||||
|
const company = await makeCompany({ portId });
|
||||||
|
companyId = company.id;
|
||||||
|
|
||||||
|
await makeMembership({ companyId, clientId });
|
||||||
|
|
||||||
|
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: clientId });
|
||||||
|
yachtId = yacht.id;
|
||||||
|
|
||||||
|
// Three files: directly on client, on company, on yacht
|
||||||
|
await insertFile(portId, { clientId });
|
||||||
|
await insertFile(portId, { companyId });
|
||||||
|
await insertFile(portId, { yachtId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns DIRECTLY ATTACHED, FROM COMPANY, and FROM YACHT groups', async () => {
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||||||
|
const labels = result.groups.map((g) => g.label);
|
||||||
|
|
||||||
|
expect(labels).toContain('DIRECTLY ATTACHED');
|
||||||
|
expect(labels.some((l) => l.startsWith('FROM COMPANY'))).toBe(true);
|
||||||
|
expect(labels.some((l) => l.startsWith('FROM YACHT'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each group has the correct source tag', async () => {
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||||||
|
const sourceMap: Record<string, string> = {};
|
||||||
|
for (const g of result.groups) {
|
||||||
|
sourceMap[g.label] = g.source;
|
||||||
|
}
|
||||||
|
expect(sourceMap['DIRECTLY ATTACHED']).toBe('direct');
|
||||||
|
const companyGroup = result.groups.find((g) => g.label.startsWith('FROM COMPANY'));
|
||||||
|
expect(companyGroup?.source).toBe('company');
|
||||||
|
const yachtGroup = result.groups.find((g) => g.label.startsWith('FROM YACHT'));
|
||||||
|
expect(yachtGroup?.source).toBe('yacht');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('caps each group at 20 rows + surfaces total', () => {
|
||||||
|
let portId: string;
|
||||||
|
let clientId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
|
||||||
|
const client = await makeClient({ portId });
|
||||||
|
clientId = client.id;
|
||||||
|
|
||||||
|
// Insert 25 files all with clientId
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 25 }, () => insertFile(portId, { clientId })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DIRECTLY ATTACHED group has 20 files and total=25', async () => {
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||||||
|
const group = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
expect(group!.files).toHaveLength(20);
|
||||||
|
expect(group!.total).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file-FK snapshot survives yacht transfer', () => {
|
||||||
|
let portId: string;
|
||||||
|
let johnId: string;
|
||||||
|
let maryId: string;
|
||||||
|
let yachtId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
|
||||||
|
const john = await makeClient({ portId, overrides: { fullName: 'John Smith' } });
|
||||||
|
johnId = john.id;
|
||||||
|
|
||||||
|
const mary = await makeClient({ portId, overrides: { fullName: 'Mary Jones' } });
|
||||||
|
maryId = mary.id;
|
||||||
|
|
||||||
|
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: johnId });
|
||||||
|
yachtId = yacht.id;
|
||||||
|
|
||||||
|
// File attached to the yacht at the time john owns it
|
||||||
|
await insertFile(portId, { yachtId, clientId: johnId });
|
||||||
|
|
||||||
|
// Transfer yacht to Mary (update currentOwner in place — simulates transfer)
|
||||||
|
await db
|
||||||
|
.update(yachts)
|
||||||
|
.set({ currentOwnerType: 'client', currentOwnerId: maryId })
|
||||||
|
.where(and(eq(yachts.id, yachtId), eq(yachts.portId, portId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("John's view still shows the file via yachtId FK", async () => {
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', johnId);
|
||||||
|
// clientId=johnId → DIRECTLY ATTACHED group
|
||||||
|
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||||||
|
expect(directGroup).toBeDefined();
|
||||||
|
expect(directGroup!.files.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Mary's view does NOT see john's file (it has clientId=john, not mary)", async () => {
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', maryId);
|
||||||
|
// Mary owns the yacht now, so FROM YACHT group will appear — but the
|
||||||
|
// file has clientId=johnId (snapshotted FK), so it WON'T appear under
|
||||||
|
// Mary's DIRECTLY ATTACHED. The FROM YACHT group WILL appear since the
|
||||||
|
// file still has yachtId set.
|
||||||
|
// The key invariant: there is no DIRECTLY ATTACHED for Mary.
|
||||||
|
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||||||
|
expect(directGroup).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cross-port leakage rejected', () => {
|
||||||
|
let portId: string;
|
||||||
|
let otherPortId: string;
|
||||||
|
let otherClientId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
|
||||||
|
const otherPort = await makePort();
|
||||||
|
otherPortId = otherPort.id;
|
||||||
|
|
||||||
|
const otherClient = await makeClient({ portId: otherPortId });
|
||||||
|
otherClientId = otherClient.id;
|
||||||
|
|
||||||
|
// File for other client in other port
|
||||||
|
await insertFile(otherPortId, { clientId: otherClientId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty groups when entity belongs to a different port', async () => {
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', otherClientId);
|
||||||
|
expect(result.groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── listInflightWorkflowsAggregatedByEntity ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('documents service · listInflightWorkflowsAggregatedByEntity', () => {
|
||||||
|
let portId: string;
|
||||||
|
let clientId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
|
||||||
|
const client = await makeClient({ portId });
|
||||||
|
clientId = client.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns in-flight workflows in DIRECTLY ATTACHED group, hides completed', async () => {
|
||||||
|
// Insert two documents: one in-flight (status='sent'), one completed
|
||||||
|
await db.insert(documents).values([
|
||||||
|
{
|
||||||
|
portId,
|
||||||
|
clientId,
|
||||||
|
documentType: 'contract',
|
||||||
|
title: 'In-flight Doc',
|
||||||
|
status: 'sent',
|
||||||
|
createdBy: TEST_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portId,
|
||||||
|
clientId,
|
||||||
|
documentType: 'contract',
|
||||||
|
title: 'Completed Doc',
|
||||||
|
status: 'completed',
|
||||||
|
createdBy: TEST_USER_ID,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId);
|
||||||
|
|
||||||
|
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||||||
|
expect(directGroup).toBeDefined();
|
||||||
|
expect(directGroup!.workflows).toHaveLength(1);
|
||||||
|
expect(directGroup!.workflows[0]!.status).toBe('sent');
|
||||||
|
expect(directGroup!.workflows[0]!.title).toBe('In-flight Doc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── applyEntityFkFromFolder ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('files service · applyEntityFkFromFolder', () => {
|
||||||
|
let portId: string;
|
||||||
|
let clientId: string;
|
||||||
|
let entityFolderId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
|
||||||
|
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||||||
|
await ensureSystemRoots(portId, TEST_USER_ID);
|
||||||
|
|
||||||
|
const client = await makeClient({ portId });
|
||||||
|
clientId = client.id;
|
||||||
|
|
||||||
|
const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
entityFolderId = folder.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets clientId when uploading into a client entity folder', async () => {
|
||||||
|
const out = await applyEntityFkFromFolder(portId, { folderId: entityFolderId, clientId: null });
|
||||||
|
expect(out.clientId).toBe(clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing entity FK when already set', async () => {
|
||||||
|
const out = await applyEntityFkFromFolder(portId, {
|
||||||
|
folderId: entityFolderId,
|
||||||
|
clientId: 'pre-existing-id',
|
||||||
|
});
|
||||||
|
expect(out.clientId).toBe('pre-existing-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op for non-system folders', async () => {
|
||||||
|
const [userFolder] = await db
|
||||||
|
.insert(documentFolders)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
parentId: null,
|
||||||
|
name: 'My templates',
|
||||||
|
createdBy: TEST_USER_ID,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const out = await applyEntityFkFromFolder(portId, {
|
||||||
|
folderId: userFolder!.id,
|
||||||
|
clientId: null,
|
||||||
|
});
|
||||||
|
expect(out.clientId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user