Sets up the schema + service primitives the rest of the nested-
document-subfolders feature will build on (master UAT line 728+).
This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio,
lifecycle hooks for outcome rename, aggregated-projection list
query, and backfill script are deferred to follow-up commits.
Schema (migration 0078_files_interest_id.sql):
- `files.interest_id` text REFERENCES interests(id) ON DELETE SET
NULL. Mirrors the existing documents.interest_id; lets file
uploads be scoped to a deal while still rolling up to the parent
client folder.
- idx_files_interest + idx_files_port_interest for the aggregated-
projection queries that will surface "This deal" vs "From
client" file lists.
Service:
- EntityType extended to include 'interest'. Interest folders parent
under the owning client's entity folder (not at a system root), so
the tree reads Clients/Acme/Deal A1-A3/ — nested.
- ensureEntityFolder recursively ensures the parent client folder
first when given an interest, guaranteeing the deal folder lands
inside the right client subfolder even when the first artifact on
the deal predates any client-level upload.
- resolveEntityDisplayName for interest: "Deal — <mooringNumber>"
(when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the
stable fallback. Dynamic-import on getPrimaryBerth dodges the
circular dep between document-folders.service and
interest-berths.service.
Aggregated projection (files.ts):
- listFilesAggregatedByEntity SELECT now includes the new
interest_id column so AggregatedFileRow's structural type matches.
Downstream consumers gain access to the deal scope; the actual
"From this deal" subheading in InterestDocumentsTab is wired in
the follow-up.
Remaining work (tracked in master UAT line 728+, parked for next
session):
- UploadZone `scopeOptions` radio (single-option pickers hide the
radio entirely for client/yacht/company surfaces).
- Lifecycle hooks for interest outcome → folder rename ("Deal
A1-A3 (Won)") via soft-rescue per CLAUDE.md.
- listFilesAggregatedByEntity rewrite to surface "This deal" vs
"From client" subheadings on InterestDocumentsTab.
- Documents Hub tree rendering for nested interest folders.
- backfill script: existing files with entity_type='interest' +
entity_id but missing interest_id column → populate.
Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
671 lines
22 KiB
TypeScript
671 lines
22 KiB
TypeScript
import { and, arrayContains, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { files, documents } from '@/lib/db/schema/documents';
|
|
import { expenses } from '@/lib/db/schema/financial';
|
|
import { berthMaintenanceLog } from '@/lib/db/schema/berths';
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
import { env } from '@/lib/env';
|
|
import {
|
|
ALLOWED_MIME_TYPES,
|
|
MAX_FILE_SIZE,
|
|
PREVIEWABLE_MIMES,
|
|
bufferMatchesMime,
|
|
} 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 ────────────────────────────────────────────────────────────────────
|
|
|
|
interface UploadFileParams {
|
|
buffer: Buffer;
|
|
originalName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
}
|
|
|
|
// ─── Upload ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function uploadFile(
|
|
portId: string,
|
|
portSlug: string,
|
|
file: UploadFileParams,
|
|
data: UploadFileInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
if (!ALLOWED_MIME_TYPES.has(file.mimeType)) {
|
|
throw new ValidationError(`File type '${file.mimeType}' is not allowed`);
|
|
}
|
|
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
throw new ValidationError('File exceeds maximum size of 50MB');
|
|
}
|
|
|
|
// Magic-byte verification — without this, the browser-declared MIME is
|
|
// attacker-controlled and a malicious uploader could ship arbitrary
|
|
// bytes through the ALLOWED_MIME_TYPES allow-list (auditor-E3 §27).
|
|
// Berth-PDF and brochure paths already do this; the generic uploader
|
|
// matches their guarantee here.
|
|
if (!bufferMatchesMime(file.buffer, file.mimeType)) {
|
|
throw new ValidationError(`File contents do not match the declared type '${file.mimeType}'`);
|
|
}
|
|
|
|
// Image normalisation (asset-auditor C1+C2+H1+H3): re-encode via
|
|
// sharp to strip EXIF (incl. GPS), drop polyglot trailing bytes, cap
|
|
// dimensions against decompression-bomb PNGs, and freeze animated
|
|
// GIFs to a single frame. Skips when sharp isn't available or the
|
|
// declared MIME isn't an image; failures fall back to the original
|
|
// buffer with a warning.
|
|
let normalizedBuffer = file.buffer;
|
|
let normalizedSize = file.size;
|
|
if (file.mimeType.startsWith('image/')) {
|
|
try {
|
|
const { normalizeImage } = await import('@/lib/services/image-normalize');
|
|
const normalized = await normalizeImage(file.buffer, file.mimeType);
|
|
normalizedBuffer = normalized.buffer;
|
|
normalizedSize = normalized.buffer.byteLength;
|
|
} catch (err) {
|
|
const { logger } = await import('@/lib/logger');
|
|
logger.warn(
|
|
{ err, mimeType: file.mimeType },
|
|
'image normalization failed; storing original (EXIF retained)',
|
|
);
|
|
}
|
|
}
|
|
|
|
const entity = data.entityType ?? 'general';
|
|
const entityId = data.entityId ?? portId;
|
|
const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType);
|
|
const sanitizedOriginal = sanitizeFilename(file.originalName);
|
|
const sanitizedFilename = sanitizeFilename(data.filename);
|
|
|
|
const backend = await getStorageBackend();
|
|
await backend.put(storagePath, normalizedBuffer, {
|
|
contentType: file.mimeType,
|
|
sizeBytes: normalizedSize,
|
|
});
|
|
|
|
// Derive the entity FK from (entityType, entityId) when the caller
|
|
// didn't pass it explicitly. Without this, an interest-tab upload that
|
|
// sets `entityType='client'` + `entityId=<UUID>` lands with
|
|
// `client_id=NULL` — the Attachments list filters on `clientId` and
|
|
// the file vanishes from the interest's Documents tab.
|
|
const derivedClientId =
|
|
data.clientId ?? (data.entityType === 'client' ? (data.entityId ?? null) : null);
|
|
const derivedCompanyId =
|
|
data.companyId ?? (data.entityType === 'company' ? (data.entityId ?? null) : null);
|
|
const derivedYachtId =
|
|
data.yachtId ?? (data.entityType === 'yacht' ? (data.entityId ?? null) : null);
|
|
|
|
// 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: derivedClientId,
|
|
yachtId: derivedYachtId,
|
|
companyId: derivedCompanyId,
|
|
folderId: data.folderId ?? null,
|
|
filename: sanitizedFilename,
|
|
originalName: sanitizedOriginal,
|
|
mimeType: file.mimeType,
|
|
sizeBytes: String(normalizedSize),
|
|
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,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'file',
|
|
entityId: record!.id,
|
|
newValue: { filename: record!.filename, mimeType: file.mimeType, size: file.size },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'file:uploaded', {
|
|
fileId: record!.id,
|
|
filename: record!.filename,
|
|
});
|
|
|
|
return record!;
|
|
}
|
|
|
|
// ─── Download / Preview URLs ──────────────────────────────────────────────────
|
|
|
|
export async function getDownloadUrl(id: string, portId: string) {
|
|
const file = await getFileById(id, portId);
|
|
// Pass the canonical filename through to the presign so MinIO/S3
|
|
// returns Content-Disposition with the original name. Without the
|
|
// override the file lands with the bare storage-key UUID (no
|
|
// extension) in every browser.
|
|
const url = await presignDownloadUrl(file.storagePath, 900, file.filename);
|
|
return { url, filename: file.filename };
|
|
}
|
|
|
|
export async function getPreviewUrl(id: string, portId: string) {
|
|
const file = await getFileById(id, portId);
|
|
|
|
if (!file.mimeType || !PREVIEWABLE_MIMES.has(file.mimeType)) {
|
|
throw new ValidationError('This file type cannot be previewed');
|
|
}
|
|
|
|
const url = await presignDownloadUrl(file.storagePath);
|
|
return { url, mimeType: file.mimeType };
|
|
}
|
|
|
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function updateFile(
|
|
id: string,
|
|
portId: string,
|
|
data: UpdateFileInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await getFileById(id, portId);
|
|
|
|
const updates: { filename?: string; category?: string } = {};
|
|
if (data.filename !== undefined) updates.filename = sanitizeFilename(data.filename);
|
|
if (data.category !== undefined) updates.category = data.category;
|
|
|
|
const [updated] = await db
|
|
.update(files)
|
|
.set(updates)
|
|
.where(and(eq(files.id, id), eq(files.portId, portId)))
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'file',
|
|
entityId: id,
|
|
oldValue: { filename: existing.filename, category: existing.category },
|
|
newValue: updates,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'file:updated', { fileId: id });
|
|
|
|
return updated!;
|
|
}
|
|
|
|
// ─── Delete (BR-091) ──────────────────────────────────────────────────────────
|
|
|
|
export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
|
|
const existing = await getFileById(id, portId);
|
|
|
|
// BR-091: check references before deleting
|
|
const [docRefs, expenseRefs, maintenanceRefs] = await Promise.all([
|
|
db
|
|
.select({ id: documents.id })
|
|
.from(documents)
|
|
.where(
|
|
and(
|
|
eq(documents.portId, portId),
|
|
or(eq(documents.fileId, id), eq(documents.signedFileId, id)),
|
|
),
|
|
)
|
|
.limit(1),
|
|
db
|
|
.select({ id: expenses.id })
|
|
.from(expenses)
|
|
.where(and(eq(expenses.portId, portId), arrayContains(expenses.receiptFileIds, [id])))
|
|
.limit(1),
|
|
db
|
|
.select({ id: berthMaintenanceLog.id })
|
|
.from(berthMaintenanceLog)
|
|
.where(
|
|
and(
|
|
eq(berthMaintenanceLog.portId, portId),
|
|
arrayContains(berthMaintenanceLog.photoFileIds, [id]),
|
|
),
|
|
)
|
|
.limit(1),
|
|
]);
|
|
|
|
if (docRefs.length > 0 || expenseRefs.length > 0 || maintenanceRefs.length > 0) {
|
|
throw new ConflictError('File cannot be deleted because it is referenced by other records');
|
|
}
|
|
|
|
// Delete the blob first, then DB. The storage backend's delete is
|
|
// idempotent, so a partial replay (worker crashed mid-delete) does not
|
|
// throw on the missing-object retry.
|
|
await (await getStorageBackend()).delete(existing.storagePath);
|
|
|
|
await db.delete(files).where(and(eq(files.id, id), eq(files.portId, portId)));
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'delete',
|
|
entityType: 'file',
|
|
entityId: id,
|
|
oldValue: { filename: existing.filename },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'file:deleted', { fileId: id });
|
|
}
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function listFiles(portId: string, query: ListFilesInput) {
|
|
const { page, limit, sort, order, search, clientId, yachtId, companyId, category } = query;
|
|
|
|
const filters = [];
|
|
|
|
if (clientId) {
|
|
filters.push(eq(files.clientId, clientId));
|
|
}
|
|
if (yachtId) {
|
|
filters.push(eq(files.yachtId, yachtId));
|
|
}
|
|
if (companyId) {
|
|
filters.push(eq(files.companyId, companyId));
|
|
}
|
|
if (category) {
|
|
filters.push(eq(files.category, category));
|
|
}
|
|
|
|
const sortColumn =
|
|
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
|
|
|
|
return buildListQuery({
|
|
table: files,
|
|
portIdColumn: files.portId,
|
|
portId,
|
|
idColumn: files.id,
|
|
updatedAtColumn: files.createdAt, // no updatedAt on files
|
|
searchColumns: [files.filename, files.originalName],
|
|
searchTerm: search,
|
|
filters,
|
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
|
page,
|
|
pageSize: limit,
|
|
// no archivedAtColumn - files are immutable records
|
|
});
|
|
}
|
|
|
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getFileById(id: string, portId: string) {
|
|
const file = await db.query.files.findFirst({
|
|
where: eq(files.id, id),
|
|
});
|
|
|
|
if (!file || file.portId !== portId) {
|
|
throw new NotFoundError('File');
|
|
}
|
|
|
|
return file;
|
|
}
|
|
|
|
// ─── Aggregated Projection ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Row shape returned by the aggregated projection. Note this intentionally
|
|
* omits `storagePath` and `storageBucket` — those are internal storage
|
|
* implementation details and must not leak out of the API to rep clients.
|
|
* Callers that need to download a file must use the documents/file
|
|
* download endpoint, which presigns from the bucket using the id, not the
|
|
* raw path.
|
|
*/
|
|
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
|
|
signedFromDocumentId: string | null;
|
|
};
|
|
|
|
export interface AggregatedFileGroup {
|
|
label: string;
|
|
source: 'direct' | 'client' | 'company' | 'yacht';
|
|
files: AggregatedFileRow[];
|
|
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 };
|
|
}
|
|
|
|
export 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(and(eq(companyMemberships.clientId, entityId), isNull(companyMemberships.endDate)));
|
|
|
|
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(and(eq(companyMemberships.companyId, entityId), isNull(companyMemberships.endDate)));
|
|
|
|
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: AggregatedFileRow[];
|
|
total: number;
|
|
}> {
|
|
// A3: keep the LEFT JOIN's ON clause minimal so the planner can use the
|
|
// point-lookup index `idx_docs_signed_file_id` on the join, and apply the
|
|
// port_id residual in the WHERE (with the `OR d.id IS NULL` clause so the
|
|
// LEFT-JOIN semantics still preserve unjoined file rows). With port_id in
|
|
// the ON we used to fall back to `idx_docs_port` which is a wide-range scan.
|
|
const rows = await db
|
|
.select({
|
|
id: files.id,
|
|
portId: files.portId,
|
|
clientId: files.clientId,
|
|
yachtId: files.yachtId,
|
|
companyId: files.companyId,
|
|
interestId: files.interestId,
|
|
folderId: files.folderId,
|
|
filename: files.filename,
|
|
originalName: files.originalName,
|
|
mimeType: files.mimeType,
|
|
sizeBytes: files.sizeBytes,
|
|
// storagePath + storageBucket intentionally omitted — see AggregatedFileRow doc.
|
|
category: files.category,
|
|
uploadedBy: files.uploadedBy,
|
|
createdAt: files.createdAt,
|
|
// Reverse-link: if any document row has this file as its signed_file_id,
|
|
// surface that document's id.
|
|
signedFromDocumentId: documents.id,
|
|
})
|
|
.from(files)
|
|
.leftJoin(documents, eq(documents.signedFileId, files.id))
|
|
.where(
|
|
and(
|
|
eq(files.portId, portId),
|
|
predicate,
|
|
// Defense-in-depth: keep the cross-port-leakage guard on the joined
|
|
// doc row but allow unjoined files (id IS NULL).
|
|
or(eq(documents.portId, portId), isNull(documents.id)),
|
|
),
|
|
)
|
|
.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;
|
|
}
|