// P4 Files (AK-P4a) §3/§7 — domain service for the Files module. // // Pure-ish server logic: builds/queries File rows (always tenant-scoped), // orchestrates chunked upload + assembly, enforces visibility (404, not 403), // and fans out to the scan/preview queues. NO React/UI imports. Routes call // these functions from their loaders/actions and build the View-Model from the // returned rows. // // Tenant isolation is enforced THREEFOLD: every query carries `where: { tenantId }` // (app-level scoping), the storage keys carry the `tenants/{tenantId}/` prefix // (assertFileKeyBelongsToTenant), and every DB access runs inside withTenantRLS() // (R-P2b) so `app.tenant_id` is set for the policies shipped in // local/AK-P4a-merge-migration.sql. Until that RLS block is activated (P5 cutover) // the wrapper is a harmless no-op (a transaction that sets a GUC no policy reads); // it makes Files RLS-ready without changing runtime behaviour. // // Wrapping rules (so activation is safe): slow I/O (S3, queue) stays OUTSIDE the // transaction, and functions never nest withTenantRLS() — internal composition // goes through *WithTx(tx, ...) helpers that take the active transaction client. import { CatalogError, foundationErrors } from "@toolctrl/foundation"; import { buildFileStorageKeys, buildUploadChunkKey, uploadChunkPrefix } from "@toolctrl/storage"; import { FILE_UPLOAD_CHUNK_SIZE_BYTES, FILE_UPLOAD_MAX_FILE_SIZE_BYTES, } from "@toolctrl/view-models"; import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server"; import { assembleChunkedUpload, deleteFile, putBufferAsFile, resolveStorageBucket } from "./storageService"; import { enqueueFileScan } from "./fileScanQueue.server"; import { enqueueFilePreview } from "./filePreviewQueue.server"; /** * Domain error carrying an i18n message key (M002 Klartext). Routes map * `messageKey` to a catalog error / VM error branch — never a bare HTTP code. */ export class FileServiceError extends Error { readonly messageKey: string; constructor(messageKey: string) { super(messageKey); this.name = "FileServiceError"; this.messageKey = messageKey; } } export type FileEntityType = | "contact" | "company" | "lead" | "deal" | "project" | "user" | "form_submission" | "order" | "task" | "appointment"; export type FileSource = "manual_upload" | "customer_portal" | "form_submission" | "email_attachment" | "automation" | "api"; /** * Curated upload allow-list for the Files module. Deliberately broader than the * shared scan `DEFAULT_ALLOW_LIST` (which is per-context and lacks HEIC) — Files * is a general document store and HEIC is mandatory (iPhone photos, AK-P4a). * The shared allow-list stays untouched (mergeable boilerplate). Layer-2 ClamAV * still scans the stored bytes regardless of declared MIME. */ export const FILES_ALLOWED_MIME_TYPES: ReadonlySet = new Set([ "image/png", "image/jpeg", "image/webp", "image/gif", "image/heic", "image/heif", "application/pdf", "text/plain", "text/markdown", "text/csv", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", ]); export type FileVisibilityContext = { userId: string; /** The user's permission names for this tenant (from getPermissionsByUser). */ permissions: readonly string[]; }; function hasPermission(ctx: FileVisibilityContext, permission: string): boolean { return ctx.permissions.includes(permission); } // --- Upload session ------------------------------------------------------- export type CreateUploadSessionInput = { tenantId: string; userId: string; originalFilename: string; mimeType: string; totalSizeBytes: number; }; export type CreateUploadSessionResult = { uploadId: string; chunkSize: number; totalChunks: number; }; export async function createUploadSession(input: CreateUploadSessionInput): Promise { if (input.totalSizeBytes <= 0) { throw new FileServiceError("files.error.emptyFile"); } if (input.totalSizeBytes > FILE_UPLOAD_MAX_FILE_SIZE_BYTES) { throw new FileServiceError("files.error.tooLarge"); } if (!FILES_ALLOWED_MIME_TYPES.has(input.mimeType)) { throw new FileServiceError("files.error.mimeBlocked"); } const totalChunks = Math.max(1, Math.ceil(input.totalSizeBytes / FILE_UPLOAD_CHUNK_SIZE_BYTES)); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); const upload = await withTenantRLS(input.tenantId, (tx) => tx.fileUpload.create({ data: { tenantId: input.tenantId, createdByUserId: input.userId, originalFilename: input.originalFilename, totalSizeBytes: input.totalSizeBytes, mimeType: input.mimeType, totalChunks, status: "in_progress", expiresAt, }, select: { id: true }, }), ); return { uploadId: upload.id, chunkSize: FILE_UPLOAD_CHUNK_SIZE_BYTES, totalChunks }; } export type StoreUploadChunkInput = { tenantId: string; userId: string; uploadId: string; chunkIndex: number; bytes: Uint8Array; }; export async function storeUploadChunk(input: StoreUploadChunkInput): Promise<{ receivedChunks: number; totalChunks: number }> { const upload = await withTenantRLS(input.tenantId, (tx) => tx.fileUpload.findFirst({ where: { id: input.uploadId, tenantId: input.tenantId, createdByUserId: input.userId }, select: { id: true, status: true, totalChunks: true, expiresAt: true }, }), ); if (!upload) { throw new CatalogError(foundationErrors.notFound, { uploadId: input.uploadId }); } if (upload.status !== "in_progress") { throw new FileServiceError("files.error.uploadNotActive"); } if (upload.expiresAt.getTime() < Date.now()) { throw new FileServiceError("files.error.uploadExpired"); } if (input.chunkIndex < 0 || input.chunkIndex >= upload.totalChunks) { throw new FileServiceError("files.error.chunkIndexOutOfRange"); } const bucket = resolveStorageBucket(); const chunkKey = buildUploadChunkKey(input.tenantId, input.uploadId, input.chunkIndex); // S3 PUT stays OUTSIDE the RLS transaction (no slow I/O inside a tx). await putBufferAsFile({ bucket, key: chunkKey, body: input.bytes, contentType: "application/octet-stream" }); // Persist the chunk + recount in one tenant-scoped transaction. const received = await withTenantRLS(input.tenantId, async (tx) => { // Upsert so a retried chunk does not double-count receivedChunks. const existing = await tx.fileUploadChunk.findUnique({ where: { uploadId_chunkIndex: { uploadId: input.uploadId, chunkIndex: input.chunkIndex } }, select: { id: true }, }); if (existing) { await tx.fileUploadChunk.update({ where: { uploadId_chunkIndex: { uploadId: input.uploadId, chunkIndex: input.chunkIndex } }, data: { storageKey: chunkKey, sizeBytes: input.bytes.byteLength }, }); } else { await tx.fileUploadChunk.create({ data: { tenantId: input.tenantId, uploadId: input.uploadId, chunkIndex: input.chunkIndex, storageKey: chunkKey, sizeBytes: input.bytes.byteLength, }, }); } const count = await tx.fileUploadChunk.count({ where: { uploadId: input.uploadId, tenantId: input.tenantId } }); await tx.fileUpload.update({ where: { id: input.uploadId }, data: { receivedChunks: count } }); return count; }); return { receivedChunks: received, totalChunks: upload.totalChunks }; } export type CompleteUploadInput = { tenantId: string; userId: string; uploadId: string; displayName?: string; source?: FileSource; createdByPortalUserId?: string | null; isDemoData?: boolean; }; export type CompletedFile = { id: string; displayName: string; mimeType: string; sizeBytes: number; storageKey: string; }; export async function completeUpload(input: CompleteUploadInput): Promise { // Segment 1 (DB): validate the session, read the chunk manifest and create the // pending File row. The File id is needed for the storage key, so the row is // created here (before S3 assembly). All contiguous DB work, no slow I/O. const { upload, chunks, file } = await withTenantRLS(input.tenantId, async (tx) => { const session = await tx.fileUpload.findFirst({ where: { id: input.uploadId, tenantId: input.tenantId, createdByUserId: input.userId }, select: { id: true, status: true, totalChunks: true, originalFilename: true, mimeType: true, totalSizeBytes: true }, }); if (!session) { throw new CatalogError(foundationErrors.notFound, { uploadId: input.uploadId }); } if (session.status !== "in_progress") { throw new FileServiceError("files.error.uploadNotActive"); } const manifest = await tx.fileUploadChunk.findMany({ where: { uploadId: input.uploadId, tenantId: input.tenantId }, orderBy: { chunkIndex: "asc" }, select: { chunkIndex: true, storageKey: true }, }); if (manifest.length !== session.totalChunks) { throw new FileServiceError("files.error.uploadIncomplete"); } // Verify contiguous 0..totalChunks-1 ordering. for (let index = 0; index < manifest.length; index += 1) { if (manifest[index].chunkIndex !== index) { throw new FileServiceError("files.error.uploadIncomplete"); } } const created = await tx.file.create({ data: { tenantId: input.tenantId, createdByUserId: input.userId, displayName: (input.displayName ?? session.originalFilename).slice(0, 255), mimeType: session.mimeType, sizeBytes: session.totalSizeBytes, storageKey: "", // set after assembly below source: input.source ?? "manual_upload", createdByPortalUserId: input.createdByPortalUserId ?? null, isDemoData: input.isDemoData ?? false, previewStatus: "pending", scanStatus: "pending", }, select: { id: true, displayName: true, mimeType: true, sizeBytes: true }, }); return { upload: session, chunks: manifest, file: created }; }); const bucket = resolveStorageBucket(); const { original } = buildFileStorageKeys(input.tenantId, file.id); try { // S3 assembly stays OUTSIDE the RLS transaction. await assembleChunkedUpload({ bucket, targetKey: original, chunkSourceKeys: chunks.map((chunk) => chunk.storageKey), contentType: upload.mimeType, }); } catch (error) { // Roll the half-created File back so a failed assembly leaves no orphan row. await withTenantRLS(input.tenantId, async (tx) => { await tx.file.delete({ where: { id: file.id } }).catch(() => {}); await tx.fileUpload.update({ where: { id: input.uploadId }, data: { status: "failed", failureReason: "assembly-failed" } }).catch(() => {}); }).catch(() => {}); throw error instanceof FileServiceError ? error : new FileServiceError("files.error.assemblyFailed"); } await withTenantRLS(input.tenantId, (tx) => tx.file.update({ where: { id: file.id }, data: { storageKey: original } })); // Clean up temp chunk objects (best-effort, S3 outside the tx) + mark the // session done in one tenant-scoped transaction. await Promise.all(chunks.map((chunk) => deleteFile({ bucket, key: chunk.storageKey }).catch(() => {}))); await withTenantRLS(input.tenantId, async (tx) => { await tx.fileUploadChunk.deleteMany({ where: { uploadId: input.uploadId, tenantId: input.tenantId } }).catch(() => {}); await tx.fileUpload.update({ where: { id: input.uploadId }, data: { status: "completed", targetFileId: file.id } }); }); // Fan out async scan (ClamAV, layer 2) + preview generation. When scanning is // disabled (dev / SCAN_QUEUE_ENABLED!=true) the enqueue is a no-op; in that // case mark the file scanStatus=skipped so the fail-closed media proxy (§6) // still serves it. In prod the file stays scanStatus=pending until the worker // writes a clean/infected verdict. const scanResult = await enqueueFileScan({ bucket, key: original, tenantId: input.tenantId, contentType: upload.mimeType, fileId: file.id }).catch( () => ({ enqueued: false, reason: "queue-unavailable" }) as const, ); if (!scanResult.enqueued && scanResult.reason === "scan-disabled") { await withTenantRLS(input.tenantId, (tx) => tx.file.update({ where: { id: file.id }, data: { scanStatus: "skipped" } })).catch(() => {}); } await enqueueFilePreview({ fileId: file.id, tenantId: input.tenantId, bucket, storageKey: original, mimeType: upload.mimeType }).catch(() => {}); return { id: file.id, displayName: file.displayName, mimeType: file.mimeType, sizeBytes: file.sizeBytes, storageKey: original }; } // --- Visibility / read ---------------------------------------------------- export type FileRow = { id: string; tenantId: string; createdByUserId: string; assignedToUserId: string | null; displayName: string; mimeType: string; sizeBytes: number; storageKey: string; previewStorageKey: string | null; thumbnailStorageKey: string | null; previewStatus: string; scanStatus: string; tags: string[]; notes: string | null; source: string; createdAt: Date; archivedAt: Date | null; }; /** * Owner/link visibility for files.read_own (§7). Visible when the user owns or * is assigned the file, OR holds files.read_all, OR a readable FileEntityLink * exists. v1 link-readability is minimal (the link's mere existence in-tenant); * full transitive entity-permission checks are v2. */ // Internal: visibility check on an already-loaded file, using the active RLS // transaction client (no own transaction → safe to call inside withTenantRLS). async function isFileVisibleWithTx( tx: TenantRlsClient, file: Pick, ctx: FileVisibilityContext, ): Promise { if (hasPermission(ctx, "files.read_all")) { return true; } if (file.createdByUserId === ctx.userId || file.assignedToUserId === ctx.userId) { return true; } const link = await tx.fileEntityLink.findFirst({ where: { fileId: file.id, tenantId: file.tenantId }, select: { id: true }, }); return Boolean(link); } export async function isFileVisible(file: Pick, ctx: FileVisibilityContext): Promise { // Fast paths need no DB; only the link lookup opens a tenant-scoped transaction. if (hasPermission(ctx, "files.read_all") || file.createdByUserId === ctx.userId || file.assignedToUserId === ctx.userId) { return true; } return withTenantRLS(file.tenantId, (tx) => isFileVisibleWithTx(tx, file, ctx)); } // Internal: load + visibility on the active transaction client (no own tx). async function getVisibleFileOrThrowWithTx(tx: TenantRlsClient, tenantId: string, fileId: string, ctx: FileVisibilityContext): Promise { const file = await tx.file.findFirst({ where: { id: fileId, tenantId } }); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId }); } if (!(await isFileVisibleWithTx(tx, file as FileRow, ctx))) { throw new CatalogError(foundationErrors.notFound, { fileId }); } return file as FileRow; } /** Load a single file (tenant-scoped) and enforce visibility, else 404. */ export async function getVisibleFileOrThrow(tenantId: string, fileId: string, ctx: FileVisibilityContext): Promise { return withTenantRLS(tenantId, (tx) => getVisibleFileOrThrowWithTx(tx, tenantId, fileId, ctx)); } export type FileLink = { entityType: string; entityId: string; linkRole: string | null }; /** Load a file with its entity links (tenant-scoped + visibility), else 404. */ export async function getVisibleFileWithLinks( tenantId: string, fileId: string, ctx: FileVisibilityContext, ): Promise<{ file: FileRow; links: FileLink[] }> { return withTenantRLS(tenantId, async (tx) => { const file = await getVisibleFileOrThrowWithTx(tx, tenantId, fileId, ctx); const links = await tx.fileEntityLink.findMany({ where: { fileId, tenantId }, select: { entityType: true, entityId: true, linkRole: true }, orderBy: { createdAt: "asc" }, }); return { file, links: links as FileLink[] }; }); } export type ListFilesFilter = { mimeGroup?: string | null; tags?: readonly string[]; assignedToUserId?: string | null; includeArchived?: boolean; }; const MIME_GROUP_PREFIX: Record = { image: "image/", pdf: "application/pdf", text: "text/" }; /** * List files for the browser (tenant-scoped + read_own visibility), newest * first, cursor-paginated. Visibility for non-read_all users: owner/assignee or * any linked file (v1 minimal, §7). */ export async function listFilesForBrowser(input: { tenantId: string; ctx: FileVisibilityContext; filter?: ListFilesFilter; cursor?: string | null; limit?: number; }): Promise<{ rows: FileRow[]; nextCursor: string | null }> { const limit = Math.min(Math.max(input.limit ?? 50, 1), 100); const filter = input.filter ?? {}; const visibility = hasPermission(input.ctx, "files.read_all") ? {} : { OR: [ { createdByUserId: input.ctx.userId }, { assignedToUserId: input.ctx.userId }, { links: { some: { tenantId: input.tenantId } } }, ], }; const mimePrefix = filter.mimeGroup ? MIME_GROUP_PREFIX[filter.mimeGroup] : undefined; const where = { tenantId: input.tenantId, ...(filter.includeArchived ? {} : { archivedAt: null }), ...(filter.assignedToUserId ? { assignedToUserId: filter.assignedToUserId } : {}), ...(filter.tags && filter.tags.length > 0 ? { tags: { hasEvery: [...filter.tags] } } : {}), ...(mimePrefix ? { mimeType: { startsWith: mimePrefix } } : {}), ...visibility, }; const rows = await withTenantRLS(input.tenantId, (tx) => tx.file.findMany({ where, orderBy: { createdAt: "desc" }, take: limit + 1, ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), }), ); const nextCursor = rows.length > limit ? rows[limit - 1].id : null; return { rows: rows.slice(0, limit) as FileRow[], nextCursor }; } // --- Mutations ------------------------------------------------------------ export async function linkFile(input: { tenantId: string; userId: string; fileId: string; entityType: FileEntityType; entityId: string; linkRole?: string | null; }): Promise { await withTenantRLS(input.tenantId, async (tx) => { // Ensure the file belongs to the tenant before linking. const file = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true } }); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } await tx.fileEntityLink.upsert({ where: { fileId_entityType_entityId: { fileId: input.fileId, entityType: input.entityType, entityId: input.entityId } }, update: { linkRole: input.linkRole ?? null }, create: { tenantId: input.tenantId, createdByUserId: input.userId, fileId: input.fileId, entityType: input.entityType, entityId: input.entityId, linkRole: input.linkRole ?? null, }, }); }); } export async function unlinkFile(input: { tenantId: string; fileId: string; entityType: FileEntityType; entityId: string }): Promise { await withTenantRLS(input.tenantId, (tx) => tx.fileEntityLink.deleteMany({ where: { tenantId: input.tenantId, fileId: input.fileId, entityType: input.entityType, entityId: input.entityId }, }), ); } export async function archiveFile(input: { tenantId: string; fileId: string }): Promise { await withTenantRLS(input.tenantId, (tx) => tx.file.updateMany({ where: { id: input.fileId, tenantId: input.tenantId }, data: { archivedAt: new Date() } }), ); } export async function reactivateFile(input: { tenantId: string; fileId: string }): Promise { await withTenantRLS(input.tenantId, (tx) => tx.file.updateMany({ where: { id: input.fileId, tenantId: input.tenantId }, data: { archivedAt: null } }), ); } export async function updateFileMeta(input: { tenantId: string; fileId: string; tags?: string[]; notes?: string | null; assignedToUserId?: string | null; displayName?: string; }): Promise { await withTenantRLS(input.tenantId, (tx) => tx.file.updateMany({ where: { id: input.fileId, tenantId: input.tenantId }, data: { ...(input.tags !== undefined ? { tags: input.tags } : {}), ...(input.notes !== undefined ? { notes: input.notes } : {}), ...(input.assignedToUserId !== undefined ? { assignedToUserId: input.assignedToUserId } : {}), ...(input.displayName !== undefined ? { displayName: input.displayName.slice(0, 255) } : {}), }, }), ); } export async function regenerateFilePreview(input: { tenantId: string; fileId: string }): Promise { const file = await withTenantRLS(input.tenantId, async (tx) => { const row = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true, storageKey: true, mimeType: true, previewStatus: true }, }); if (!row) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } if (row.previewStatus !== "failed") { throw new FileServiceError("files.error.previewNotRegenerable"); } await tx.file.update({ where: { id: row.id }, data: { previewStatus: "pending" } }); return row; }); const bucket = resolveStorageBucket(); // Enqueue stays OUTSIDE the RLS transaction. await enqueueFilePreview({ fileId: file.id, tenantId: input.tenantId, bucket, storageKey: file.storageKey, mimeType: file.mimeType }).catch(() => {}); } export async function hardDeleteFile(input: { tenantId: string; fileId: string }): Promise { const file = await withTenantRLS(input.tenantId, (tx) => tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true, storageKey: true, previewStorageKey: true, thumbnailStorageKey: true }, }), ); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } const bucket = resolveStorageBucket(); // S3 deletes stay OUTSIDE the RLS transaction. for (const key of [file.storageKey, file.previewStorageKey, file.thumbnailStorageKey]) { if (key) { await deleteFile({ bucket, key }).catch(() => {}); } } // FileEntityLink rows cascade on File delete (schema onDelete: Cascade). await withTenantRLS(input.tenantId, (tx) => tx.file.delete({ where: { id: file.id } })); } /** Best-effort cleanup of all temp chunk objects of an upload session. */ export async function purgeUploadChunks(tenantId: string, uploadId: string, chunkKeys: readonly string[]): Promise { const bucket = resolveStorageBucket(); await Promise.all(chunkKeys.map((key) => deleteFile({ bucket, key }).catch(() => {}))); void uploadChunkPrefix(tenantId, uploadId); }