538 lines
17 KiB
TypeScript
538 lines
17 KiB
TypeScript
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<PaginatedFiles, Err> {
|
|
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<File, Err> {
|
|
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<File, "createdAt" | "updatedAt">,
|
|
): ResultAsync<File, Err> {
|
|
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<File, Err> {
|
|
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<boolean, Err> {
|
|
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;
|
|
});
|
|
}
|
|
|
|
updateFileStatus(
|
|
fctx: FlowExecCtx,
|
|
fileId: string,
|
|
status: string,
|
|
processingError?: string,
|
|
): ResultAsync<boolean, Err> {
|
|
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<boolean, Err> {
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
}
|