"use server"; import { revalidatePath } from "next/cache"; import { AppwriteException, ID, Permission, Role } from "node-appwrite"; import { InputFile } from "node-appwrite/file"; import { logAudit } from "./audit"; import { BUCKETS, DATABASE_ID, TABLES, type Job, type JobFile, type JobFileKind, } from "./schema"; import { createAdminClient } from "./server"; import { requireRole, requireTenant } from "./tenant-guard"; import type { JobFileActionState, JobFileUploadState, } from "./job-file-types"; const MAX_FILE_BYTES = 200 * 1024 * 1024; // 200MB — bucket limit function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string { if (e instanceof AppwriteException) return e.message || fallback; return process.env.NODE_ENV !== "production" && e instanceof Error ? `${fallback} (${e.message})` : fallback; } function classifyFile(mimeType: string | undefined, name: string): JobFileKind { const lower = (mimeType || name).toLowerCase(); if (/\.(stl|obj|ply|3mf|dcm)$/i.test(name)) return "scan"; if (lower.startsWith("image/") || /\.(png|jpe?g|webp|tiff?|heic|heif|bmp)$/i.test(name)) { return "image"; } return "document"; } function filePermissions(clinicTenantId: string, labTenantId: string): string[] { return [ Permission.read(Role.team(clinicTenantId)), Permission.read(Role.team(labTenantId)), Permission.delete(Role.team(clinicTenantId, "owner")), Permission.delete(Role.team(clinicTenantId, "admin")), Permission.delete(Role.team(labTenantId, "owner")), Permission.delete(Role.team(labTenantId, "admin")), ]; } async function loadJobForTenant(jobId: string, tenantId: string): Promise { try { const { tablesDB } = createAdminClient(); const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId); const job = row as unknown as Job; if (job.clinicTenantId !== tenantId && job.labTenantId !== tenantId) { return null; } return job; } catch { return null; } } export async function uploadJobFilesAction( _prev: JobFileUploadState, formData: FormData, ): Promise { const jobId = String(formData.get("jobId") ?? "").trim(); if (!jobId) return { ok: false, error: "İş bulunamadı." }; let ctx; try { ctx = await requireTenant(); requireRole(ctx, ["owner", "admin", "member"]); } catch { return { ok: false, error: "Yüklemek için yetkiniz yok." }; } const job = await loadJobForTenant(jobId, ctx.tenantId); if (!job) return { ok: false, error: "İş bulunamadı." }; const files = formData.getAll("files").filter((v): v is File => v instanceof File && v.size > 0); console.log("[uploadJobFilesAction] jobId=%s files=%d total=%dB", jobId, files.length, files.reduce((s, f) => s + f.size, 0)); if (files.length === 0) { return { ok: false, error: "Dosya seçin." }; } for (const f of files) { if (f.size > MAX_FILE_BYTES) { return { ok: false, error: `${f.name} 200MB sınırını aşıyor.` }; } } const { storage, tablesDB } = createAdminClient(); const uploadedFileIds: string[] = []; const createdRowIds: string[] = []; try { for (const f of files) { const fileId = ID.unique(); const buffer = Buffer.from(await f.arrayBuffer()); const inputFile = InputFile.fromBuffer(buffer, f.name); await storage.createFile({ bucketId: BUCKETS.jobFiles, fileId, file: inputFile, permissions: filePermissions(job.clinicTenantId, job.labTenantId), }); uploadedFileIds.push(fileId); const kind = classifyFile(f.type, f.name); const row = await tablesDB.createRow( DATABASE_ID, TABLES.jobFiles, ID.unique(), { jobId: job.$id, clinicTenantId: job.clinicTenantId, labTenantId: job.labTenantId, uploadedBy: ctx.user.id, kind, fileId, name: f.name.slice(0, 255), size: f.size, mimeType: f.type ? f.type.slice(0, 100) : undefined, }, [ Permission.read(Role.team(job.clinicTenantId)), Permission.read(Role.team(job.labTenantId)), Permission.delete(Role.team(job.clinicTenantId, "owner")), Permission.delete(Role.team(job.clinicTenantId, "admin")), Permission.delete(Role.team(job.labTenantId, "owner")), Permission.delete(Role.team(job.labTenantId, "admin")), ], ); createdRowIds.push(row.$id); } void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", entityType: "job_files", entityId: jobId, changes: { count: files.length }, }); } catch (e) { console.error("[uploadJobFilesAction] failed", e); // Rollback: best-effort cleanup of partially uploaded files and rows. for (const id of createdRowIds) { try { await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, id); } catch { /* ignore */ } } for (const id of uploadedFileIds) { try { await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: id }); } catch { /* ignore */ } } return { ok: false, error: appwriteError(e, "Dosya yüklenemedi.") }; } revalidatePath(`/jobs/${jobId}`); return { ok: true, uploaded: files.length }; } export async function deleteJobFileAction( _prev: JobFileActionState, formData: FormData, ): Promise { const rowId = String(formData.get("rowId") ?? "").trim(); if (!rowId) return { ok: false, error: "Dosya bulunamadı." }; let ctx; try { ctx = await requireTenant(); requireRole(ctx, ["owner", "admin"]); } catch { return { ok: false, error: "Bu işlem için yetkiniz yok." }; } try { const { storage, tablesDB } = createAdminClient(); const row = (await tablesDB.getRow( DATABASE_ID, TABLES.jobFiles, rowId, )) as unknown as JobFile; if ( row.clinicTenantId !== ctx.tenantId && row.labTenantId !== ctx.tenantId ) { return { ok: false, error: "Yetkiniz yok." }; } await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, rowId); try { await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: row.fileId }); } catch { // File may already be gone; row is the source of truth. } void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", entityType: "job_file", entityId: rowId, changes: { fileId: row.fileId, name: row.name }, }); revalidatePath(`/jobs/${row.jobId}`); return { ok: true }; } catch (e) { return { ok: false, error: appwriteError(e, "Silinemedi.") }; } }