import type { File, FileFilters, FileShareRequest, FileUpdateRequest, PaginatedFiles, PaginationOptions, } from "./data"; import { Database, and, asc, count, desc, eq, inArray, like, or, sql, } from "@pkg/db"; import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { FlowExecCtx } from "@core/flow.execution.context"; import { file, fileAccess } from "@pkg/db/schema"; import { type Err } from "@pkg/result"; import { fileErrors } from "./errors"; import { logDomainEvent } from "@pkg/logger"; export class FileRepository { constructor(private db: Database) {} getFiles( fctx: FlowExecCtx, filters: FileFilters, pagination: PaginationOptions, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "files.list.started", fctx, meta: { userId: filters.userId, hasSearch: Boolean(filters.search), hasTags: Boolean(filters.tags?.length), page: pagination.page, pageSize: pagination.pageSize, }, }); const { userId, mimeType, visibility, status, search, tags } = filters; const { page, pageSize, sortBy = "createdAt", sortOrder = "desc", } = pagination; const conditions = [eq(file.userId, userId)]; if (mimeType) { conditions.push(like(file.mimeType, `${mimeType}%`)); } if (visibility) { conditions.push(eq(file.visibility, visibility)); } if (status) { conditions.push(eq(file.status, status)); } if (search) { conditions.push( or( like(file.filename, `%${search}%`), like(file.originalName, `%${search}%`), )!, ); } if (tags && tags.length > 0) { conditions.push(sql`${file.tags} @> ${JSON.stringify(tags)}`); } const whereClause = and(...conditions); return ResultAsync.fromPromise( this.db.select({ count: count() }).from(file).where(whereClause), (error) => { logDomainEvent({ level: "error", event: "files.list.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { userId }, }); return fileErrors.getFilesFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).andThen((totalResult) => { const total = totalResult[0]?.count || 0; const offset = (page - 1) * pageSize; const getOrderColumn = (currentSortBy: string) => { switch (currentSortBy) { case "createdAt": return file.createdAt; case "uploadedAt": return file.uploadedAt; case "size": return file.size; case "filename": return file.filename; default: return file.createdAt; } }; const orderColumn = getOrderColumn(sortBy); const orderFunc = sortOrder === "asc" ? asc : desc; return ResultAsync.fromPromise( this.db .select() .from(file) .where(whereClause) .orderBy(orderFunc(orderColumn)) .limit(pageSize) .offset(offset), (error) => { logDomainEvent({ level: "error", event: "files.list.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { userId }, }); return fileErrors.getFilesFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).map((data) => { const totalPages = Math.ceil(total / pageSize); logDomainEvent({ event: "files.list.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { userId, page, totalPages, count: data.length, }, }); return { data: data as File[], total, page, pageSize, totalPages, }; }); }); } getFileById( fctx: FlowExecCtx, fileId: string, userId: string, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "files.get.started", fctx, meta: { fileId, userId }, }); return ResultAsync.fromPromise( this.db .select() .from(file) .where(and(eq(file.id, fileId), eq(file.userId, userId))) .limit(1), (error) => { logDomainEvent({ level: "error", event: "files.get.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { fileId, userId }, }); return fileErrors.getFileFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).andThen((result) => { const dbFile = result[0]; if (!dbFile) { logDomainEvent({ level: "warn", event: "files.get.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "NOT_FOUND", message: "File not found" }, meta: { fileId, userId }, }); return errAsync(fileErrors.fileNotFound(fctx, fileId)); } logDomainEvent({ event: "files.get.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { fileId, userId }, }); return okAsync(dbFile as File); }); } createFile( fctx: FlowExecCtx, fileData: Omit, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "files.create.started", fctx, meta: { userId: fileData.userId, filename: fileData.filename }, }); const now = new Date(); const insertData = { ...fileData, createdAt: now, updatedAt: now, } as any; return ResultAsync.fromPromise( this.db.insert(file).values(insertData).returning(), (error) => { logDomainEvent({ level: "error", event: "files.create.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { userId: fileData.userId, filename: fileData.filename }, }); return fileErrors.createFileFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).map((result) => { const created = result[0] as File; logDomainEvent({ event: "files.create.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { fileId: created.id, userId: created.userId }, }); return created; }); } updateFile( fctx: FlowExecCtx, fileId: string, userId: string, updates: FileUpdateRequest, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "files.update.started", fctx, meta: { fileId, userId, hasFilename: updates.filename !== undefined, hasMetadata: updates.metadata !== undefined, hasTags: updates.tags !== undefined, }, }); const updateData = { ...updates, updatedAt: new Date(), } as any; return ResultAsync.fromPromise( this.db .update(file) .set(updateData) .where(and(eq(file.id, fileId), eq(file.userId, userId))) .returning(), (error) => { logDomainEvent({ level: "error", event: "files.update.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { fileId, userId }, }); return fileErrors.updateFileFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).andThen((result) => { const updated = result[0]; if (!updated) { logDomainEvent({ level: "warn", event: "files.update.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "NOT_FOUND", message: "File not found" }, meta: { fileId, userId }, }); return errAsync(fileErrors.fileNotFound(fctx, fileId)); } logDomainEvent({ event: "files.update.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { fileId, userId }, }); return okAsync(updated as File); }); } deleteFiles( fctx: FlowExecCtx, fileIds: readonly string[], userId: string, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "files.delete.started", fctx, meta: { userId, fileCount: fileIds.length }, }); return ResultAsync.fromPromise( this.db .delete(file) .where(and(eq(file.userId, userId), inArray(file.id, [...fileIds]))), (error) => { logDomainEvent({ level: "error", event: "files.delete.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { userId, fileCount: fileIds.length }, }); return fileErrors.deleteFilesFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).map(() => { logDomainEvent({ event: "files.delete.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { userId, fileCount: fileIds.length }, }); return true; }); } listReferencedObjectKeysForUser( fctx: FlowExecCtx, userId: string, ): ResultAsync { return ResultAsync.fromPromise( this.db .select({ objectKey: file.objectKey, metadata: file.metadata, }) .from(file) .where(eq(file.userId, userId)), (error) => fileErrors.getFilesFailed( fctx, error instanceof Error ? error.message : String(error), ), ).map((rows) => { const keys = new Set(); for (const row of rows) { if (row.objectKey) { keys.add(row.objectKey); } const thumbnailKey = row.metadata && typeof row.metadata === "object" && "thumbnailKey" in row.metadata ? (row.metadata as Record).thumbnailKey : undefined; if (typeof thumbnailKey === "string" && thumbnailKey.length > 0) { keys.add(thumbnailKey); } } return [...keys]; }); } updateFileStatus( fctx: FlowExecCtx, fileId: string, status: string, processingError?: string, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "files.status_update.started", fctx, meta: { fileId, status, hasProcessingError: Boolean(processingError), }, }); return ResultAsync.fromPromise( this.db .update(file) .set({ status, processingError, updatedAt: new Date(), }) .where(eq(file.id, fileId)), (error) => { logDomainEvent({ level: "error", event: "files.status_update.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { fileId, status }, }); return fileErrors.updateStatusFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).map(() => { logDomainEvent({ event: "files.status_update.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { fileId, status }, }); return true; }); } shareFile( fctx: FlowExecCtx, fileId: string, ownerId: string, shareRequest: FileShareRequest, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "files.share.started", fctx, meta: { fileId, ownerId, targetUserId: shareRequest.userId, }, }); return ResultAsync.fromPromise( this.db .select() .from(file) .where(and(eq(file.id, fileId), eq(file.userId, ownerId))) .limit(1), (error) => { logDomainEvent({ level: "error", event: "files.share.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { fileId, ownerId, targetUserId: shareRequest.userId }, }); return fileErrors.shareFileFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).andThen((result) => { const ownedFile = result[0]; if (!ownedFile) { logDomainEvent({ level: "warn", event: "files.share.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "NOT_FOUND", message: "File not found or not owned by user", }, meta: { fileId, ownerId, targetUserId: shareRequest.userId }, }); return errAsync(fileErrors.fileNotFound(fctx, fileId)); } const now = new Date(); return ResultAsync.fromPromise( this.db .insert(fileAccess) .values({ fileId, userId: shareRequest.userId, canRead: shareRequest.permissions.canRead || false, canWrite: shareRequest.permissions.canWrite || false, canDelete: shareRequest.permissions.canDelete || false, canShare: shareRequest.permissions.canShare || false, grantedAt: now, expiresAt: shareRequest.expiresAt, createdAt: now, updatedAt: now, }) .onConflictDoNothing(), (error) => { logDomainEvent({ level: "error", event: "files.share.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { fileId, ownerId, targetUserId: shareRequest.userId, }, }); return fileErrors.shareFileFailed( fctx, error instanceof Error ? error.message : String(error), ); }, ).map(() => { logDomainEvent({ event: "files.share.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { fileId, ownerId, targetUserId: shareRequest.userId }, }); return true; }); }); } }