import { FileFilters, FileShareRequest, FileUpdateRequest, FileUploadRequest, PaginationOptions, PresignedUploadRequest, } from "./data"; import { FlowExecCtx } from "@core/flow.execution.context"; import { StorageRepository } from "./storage.repository"; import { FileRepository } from "./repository"; import { settings } from "@core/settings"; import { okAsync, ResultAsync } from "neverthrow"; import { traceResultAsync } from "@core/observability"; import { db } from "@pkg/db"; export class FileController { constructor( private fileRepo: FileRepository, private storageRepo: StorageRepository, private publicUrl: string, ) {} getFiles( fctx: FlowExecCtx, filters: FileFilters, pagination: PaginationOptions, ) { return traceResultAsync({ name: "logic.files.controller.getFiles", fctx, attributes: { "app.user.id": filters.userId }, fn: () => this.fileRepo.getFiles(fctx, filters, pagination), }); } getFile(fctx: FlowExecCtx, fileId: string, userId: string) { return traceResultAsync({ name: "logic.files.controller.getFile", fctx, attributes: { "app.user.id": userId, "app.file.id": fileId }, fn: () => this.fileRepo.getFileById(fctx, fileId, userId), }); } getFileAccessUrl( fctx: FlowExecCtx, fileId: string, userId: string, expiresIn: number = 3600, ) { return traceResultAsync({ name: "logic.files.controller.getFileAccessUrl", fctx, attributes: { "app.user.id": userId, "app.file.id": fileId, "app.file.access_ttl_sec": expiresIn, }, fn: () => this.fileRepo .getFileById(fctx, fileId, userId) .andThen((file) => this.storageRepo.generatePresignedDownloadUrl( fctx, file.objectKey, expiresIn, ), ), }); } uploadFile( fctx: FlowExecCtx, userId: string, file: globalThis.File, uploadRequest: FileUploadRequest, ) { return traceResultAsync({ name: "logic.files.controller.uploadFile", fctx, attributes: { "app.user.id": userId, "app.file.name": file.name }, fn: () => ResultAsync.fromPromise(file.arrayBuffer(), (error) => ({ code: "INTERNAL_ERROR", message: "Failed to read file buffer", description: "Please try again", detail: error instanceof Error ? error.message : String(error), })) .map((arrayBuffer) => Buffer.from(arrayBuffer)) .andThen((buffer) => this.storageRepo.uploadFile( fctx, buffer, file.name, file.type, userId, { visibility: (uploadRequest.visibility as | "public" | "private") || "private", metadata: uploadRequest.metadata, tags: uploadRequest.tags, processImage: uploadRequest.processImage, processDocument: uploadRequest.processDocument, processVideo: uploadRequest.processVideo, }, ), ) .andThen((fileMetadata) => this.fileRepo .createFile(fctx, { id: fileMetadata.id, filename: fileMetadata.filename, originalName: fileMetadata.originalName, mimeType: fileMetadata.mimeType, size: fileMetadata.size, hash: fileMetadata.hash, bucketName: fileMetadata.bucketName, objectKey: fileMetadata.objectKey, r2Url: fileMetadata.r2Url, visibility: fileMetadata.visibility, userId: fileMetadata.userId, metadata: fileMetadata.metadata, tags: fileMetadata.tags ? [...fileMetadata.tags] : undefined, status: "ready", uploadedAt: fileMetadata.uploadedAt, }) .map((dbFile) => ({ success: true, file: dbFile, uploadId: fileMetadata.id, })), ), }); } generatePresignedUrl( fctx: FlowExecCtx, userId: string, bucketName: string, request: PresignedUploadRequest, ) { const fileId = crypto.randomUUID(); const extension = request.filename.split(".").pop() || ""; const filename = `${fileId}.${extension}`; const objectKey = `uploads/${userId}/${filename}`; return traceResultAsync({ name: "logic.files.controller.generatePresignedUrl", fctx, attributes: { "app.user.id": userId, "app.file.id": fileId }, fn: () => this.storageRepo .generatePresignedUploadUrl( fctx, objectKey, request.mimeType, 3600, ) .andThen((presignedData) => this.fileRepo .createFile(fctx, { id: fileId, filename, originalName: request.filename, mimeType: request.mimeType, size: request.size, hash: "", bucketName, objectKey, r2Url: `${this.publicUrl}/${bucketName}/${objectKey}`, visibility: request.visibility || "private", userId, status: "processing", uploadedAt: new Date(), }) .map(() => ({ ...presignedData, fileId, objectKey, })), ), }); } updateFile( fctx: FlowExecCtx, fileId: string, userId: string, updates: FileUpdateRequest, ) { return traceResultAsync({ name: "logic.files.controller.updateFile", fctx, attributes: { "app.user.id": userId, "app.file.id": fileId }, fn: () => this.fileRepo.updateFile(fctx, fileId, userId, updates), }); } deleteFiles(fctx: FlowExecCtx, fileIds: readonly string[], userId: string) { return traceResultAsync({ name: "logic.files.controller.deleteFiles", fctx, attributes: { "app.user.id": userId, "app.files.count": fileIds.length, }, fn: () => ResultAsync.combine( [...fileIds].map((fileId) => this.fileRepo.getFileById(fctx, fileId, userId), ), ) .map((files) => files.map((file) => file.objectKey)) .andThen((objectKeys) => this.storageRepo.deleteFiles(fctx, objectKeys), ) .andThen(() => this.fileRepo.deleteFiles(fctx, fileIds, userId), ), }); } deleteFile(fctx: FlowExecCtx, fileId: string, userId: string) { return traceResultAsync({ name: "logic.files.controller.deleteFile", fctx, attributes: { "app.user.id": userId, "app.file.id": fileId }, fn: () => this.deleteFiles(fctx, [fileId], userId), }); } cleanupDanglingStorageFiles(fctx: FlowExecCtx, userId: string) { return traceResultAsync({ name: "logic.files.controller.cleanupDanglingStorageFiles", fctx, attributes: { "app.user.id": userId }, fn: () => this.fileRepo .listMobileMediaReferencedObjectKeysForUser(fctx, userId) .andThen((referencedKeys) => this.fileRepo .listMobileMediaDanglingFileIdsForUser(fctx, userId) .andThen((danglingFileIds) => { const referencedSet = new Set(referencedKeys); return ResultAsync.combine([ this.storageRepo.listObjectKeys( fctx, `uploads/${userId}/`, ), this.storageRepo.listObjectKeys( fctx, `thumbnails/${userId}/`, ), ]).andThen(([uploadKeys, thumbnailKeys]) => { const existingStorageKeys = [ ...new Set([...uploadKeys, ...thumbnailKeys]), ]; const danglingKeys = existingStorageKeys.filter( (key) => !referencedSet.has(key), ); const deleteStorage = danglingKeys.length > 0 ? this.storageRepo.deleteFiles( fctx, danglingKeys, ) : okAsync(true); return deleteStorage.andThen(() => { const deleteRows = danglingFileIds.length > 0 ? this.fileRepo.deleteFiles( fctx, danglingFileIds, userId, ) : okAsync(true); return deleteRows.map(() => ({ scanned: existingStorageKeys.length, referenced: referencedKeys.length, dangling: danglingKeys.length, deleted: danglingKeys.length, deletedRows: danglingFileIds.length, })); }); }); }), ), }); } shareFile( fctx: FlowExecCtx, fileId: string, ownerId: string, shareRequest: FileShareRequest, ) { return traceResultAsync({ name: "logic.files.controller.shareFile", fctx, attributes: { "app.user.id": ownerId, "app.file.id": fileId }, fn: () => this.fileRepo.shareFile(fctx, fileId, ownerId, shareRequest), }); } updateFileStatus( fctx: FlowExecCtx, fileId: string, status: string, processingError?: string, ) { return traceResultAsync({ name: "logic.files.controller.updateFileStatus", fctx, attributes: { "app.file.id": fileId }, fn: () => this.fileRepo.updateFileStatus( fctx, fileId, status, processingError, ), }); } } export function getFileController(): FileController { return new FileController( new FileRepository(db), new StorageRepository({ bucketName: settings.r2BucketName || "", region: settings.r2Region || "", endpoint: settings.r2Endpoint || "", accessKey: settings.r2AccessKey || "", secretKey: settings.r2SecretKey || "", publicUrl: settings.r2PublicUrl || "", maxFileSize: settings.maxFileSize, allowedMimeTypes: settings.allowedMimeTypes, allowedExtensions: settings.allowedExtensions, }), settings.r2PublicUrl || "", ); }