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(),
|
||||
|
||||
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