Files
illusory-mapp/packages/logic/domains/files/controller.ts
2026-03-02 21:40:02 +02:00

352 lines
14 KiB
TypeScript

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 || "",
);
}