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=` 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 & { 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 { 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 { 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 { 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, 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`count(*)::int` }) .from(files) .where(and(eq(files.portId, portId), predicate)); return { rows, total: Number(countRow?.count ?? 0) }; } function dedupeBy(items: T[], key: (t: T) => K): T[] { const seen = new Set(); 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 { 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; }