579 lines
23 KiB
TypeScript
579 lines
23 KiB
TypeScript
// 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<string> = 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<CreateUploadSessionResult> {
|
|
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<CompletedFile> {
|
|
// 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<FileRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId">,
|
|
ctx: FileVisibilityContext,
|
|
): Promise<boolean> {
|
|
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<FileRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId">, ctx: FileVisibilityContext): Promise<boolean> {
|
|
// 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<FileRow> {
|
|
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<FileRow> {
|
|
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<string, string> = { 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const bucket = resolveStorageBucket();
|
|
await Promise.all(chunkKeys.map((key) => deleteFile({ bucket, key }).catch(() => {})));
|
|
void uploadChunkPrefix(tenantId, uploadId);
|
|
}
|