diff --git a/next.config.ts b/next.config.ts index a113923..1037045 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,14 +5,14 @@ const nextConfig: NextConfig = { optimizePackageImports: ["lucide-react", "@radix-ui/react-icons"], serverActions: { // Job files bucket caps individual files at 30MB; allow batch uploads - // (multipart overhead + ~16 files of 30MB worst case). - bodySizeLimit: "500mb", + // up to ~6 files of 30MB worst case. + bodySizeLimit: "200mb", }, // Next 16 renamed `middlewareClientMaxBodySize` to `proxyClientMaxBodySize` // (middleware.ts → proxy.ts). Default 10MB gates every body that flows // through our auth proxy — without this override multipart uploads exceed // the cap and the parser dies with "Unexpected end of form". - proxyClientMaxBodySize: "500mb", + proxyClientMaxBodySize: "200mb", }, turbopack: {}, diff --git a/src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx b/src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx index 933eebe..52f3743 100644 --- a/src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx +++ b/src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useActionState, useEffect, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; @@ -15,13 +16,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - deleteJobFileAction, - uploadJobFilesAction, -} from "@/lib/appwrite/job-file-actions"; +import { Progress } from "@/components/ui/progress"; +import { deleteJobFileAction } from "@/lib/appwrite/job-file-actions"; import { initialJobFileActionState, - initialJobFileUploadState, JOB_FILE_KIND_LABELS, } from "@/lib/appwrite/job-file-types"; import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries"; @@ -64,93 +62,150 @@ export function JobFilesPanel({ } const MAX_FILE_BYTES = 30 * 1024 * 1024; -const MAX_BATCH_BYTES = 400 * 1024 * 1024; // leaves headroom under the 500MB proxy cap +const MAX_BATCH_BYTES = 180 * 1024 * 1024; // headroom under the 200MB proxy cap function UploadForm({ jobId }: { jobId: string }) { - const [state, action, pending] = useActionState( - uploadJobFilesAction, - initialJobFileUploadState, - ); - const formRef = useRef(null); + const router = useRouter(); const inputRef = useRef(null); + const xhrRef = useRef(null); const [selected, setSelected] = useState([]); - - useEffect(() => { - if (state.ok && state.uploaded) { - toast.success(`${state.uploaded} dosya yüklendi.`); - formRef.current?.reset(); - setSelected([]); - } else if (state.error) { - toast.error(state.error); - } - }, [state]); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); // 0–100 const totalBytes = selected.reduce((s, f) => s + f.size, 0); const overSize = selected.find((f) => f.size > MAX_FILE_BYTES); const overBatch = totalBytes > MAX_BATCH_BYTES; const blocked = Boolean(overSize) || overBatch; + function reset() { + setSelected([]); + setProgress(0); + setUploading(false); + if (inputRef.current) inputRef.current.value = ""; + } + + function startUpload() { + if (selected.length === 0 || blocked) return; + const formData = new FormData(); + for (const f of selected) formData.append("files", f); + + const xhr = new XMLHttpRequest(); + xhrRef.current = xhr; + setUploading(true); + setProgress(0); + + xhr.upload.addEventListener("progress", (e) => { + if (!e.lengthComputable) return; + setProgress(Math.round((e.loaded / e.total) * 100)); + }); + + xhr.addEventListener("load", () => { + setUploading(false); + let payload: { ok?: boolean; uploaded?: number; error?: string } = {}; + try { + payload = JSON.parse(xhr.responseText); + } catch { + /* non-JSON response */ + } + if (xhr.status >= 200 && xhr.status < 300 && payload.ok) { + toast.success(`${payload.uploaded ?? selected.length} dosya yüklendi.`); + reset(); + router.refresh(); + } else { + toast.error(payload.error || `Yükleme başarısız (HTTP ${xhr.status}).`); + setProgress(0); + } + }); + + xhr.addEventListener("error", () => { + setUploading(false); + setProgress(0); + toast.error("Ağ hatası. Tekrar deneyin."); + }); + + xhr.addEventListener("abort", () => { + setUploading(false); + setProgress(0); + toast.message("Yükleme iptal edildi."); + }); + + xhr.open("POST", `/api/jobs/${jobId}/files`); + xhr.send(formData); + } + + function cancelUpload() { + xhrRef.current?.abort(); + } + return ( -
- - { - const list = e.target.files ? Array.from(e.target.files) : []; - setSelected(list); - }} - /> - - - {selected.length > 0 ? ( - overSize ? ( - - {overSize.name} 30MB'tan büyük (her dosya maksimum 30MB). - - ) : overBatch ? ( - - Toplam {formatSize(totalBytes)} — 400MB sınırını aşıyor. Daha az dosya seçin. - +
+
+ { + const list = e.target.files ? Array.from(e.target.files) : []; + setSelected(list); + setProgress(0); + }} + /> + + + {selected.length > 0 ? ( + overSize ? ( + + {overSize.name} 30MB'tan büyük (her dosya maksimum 30MB). + + ) : overBatch ? ( + + Toplam {formatSize(totalBytes)} — 180MB sınırını aşıyor. Daha az dosya seçin. + + ) : ( + <> + {selected.length} dosya seçildi ({formatSize(totalBytes)}) + + ) ) : ( - <> - {selected.length} dosya seçildi ({formatSize(totalBytes)}) - - ) + "Tarama (STL/OBJ), görsel veya PDF — max 30MB / dosya, batch 180MB" + )} + + {uploading ? ( + ) : ( - "Tarama (STL/OBJ), görsel veya PDF — max 30MB / dosya" - )} - - )} - - +
+ {uploading && ( +
+ + + {progress}% + +
+ )} +
); } diff --git a/src/app/api/jobs/[jobId]/files/route.ts b/src/app/api/jobs/[jobId]/files/route.ts new file mode 100644 index 0000000..44d0efa --- /dev/null +++ b/src/app/api/jobs/[jobId]/files/route.ts @@ -0,0 +1,179 @@ +import { NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { AppwriteException, ID, Permission, Role } from "node-appwrite"; +import { InputFile } from "node-appwrite/file"; + +import { logAudit } from "@/lib/appwrite/audit"; +import { + BUCKETS, + DATABASE_ID, + TABLES, + type Job, + type JobFileKind, +} from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { requireRole, requireTenant } from "@/lib/appwrite/tenant-guard"; + +const MAX_FILE_BYTES = 30 * 1024 * 1024; + +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; + } +} + +function errorMessage(e: unknown, fallback: string): string { + if (e instanceof AppwriteException) return e.message || fallback; + if (process.env.NODE_ENV !== "production" && e instanceof Error) { + return `${fallback} (${e.message})`; + } + return fallback; +} + +export async function POST( + request: Request, + ctx: { params: Promise<{ jobId: string }> }, +) { + const { jobId } = await ctx.params; + if (!jobId) { + return NextResponse.json({ ok: false, error: "İş bulunamadı." }, { status: 400 }); + } + + let tenantCtx; + try { + tenantCtx = await requireTenant(); + requireRole(tenantCtx, ["owner", "admin", "member"]); + } catch { + return NextResponse.json({ ok: false, error: "Yetkiniz yok." }, { status: 401 }); + } + + const job = await loadJobForTenant(jobId, tenantCtx.tenantId); + if (!job) { + return NextResponse.json({ ok: false, error: "İş bulunamadı." }, { status: 404 }); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch (e) { + console.error("[POST /api/jobs/files] formData parse", e); + return NextResponse.json( + { ok: false, error: "İstek okunamadı." }, + { status: 400 }, + ); + } + + const files = formData + .getAll("files") + .filter((v): v is File => v instanceof File && v.size > 0); + + if (files.length === 0) { + return NextResponse.json({ ok: false, error: "Dosya seçin." }, { status: 400 }); + } + + for (const f of files) { + if (f.size > MAX_FILE_BYTES) { + return NextResponse.json( + { ok: false, error: `${f.name} 30MB sınırını aşıyor.` }, + { status: 400 }, + ); + } + } + + 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: tenantCtx.user.id, + kind, + fileId, + name: f.name.slice(0, 255), + size: f.size, + mimeType: f.type ? f.type.slice(0, 100) : undefined, + }, + filePermissions(job.clinicTenantId, job.labTenantId), + ); + createdRowIds.push(row.$id); + } + + await logAudit({ + tenantId: tenantCtx.tenantId, + userId: tenantCtx.user.id, + action: "create", + entityType: "job_files", + entityId: jobId, + changes: { count: files.length }, + }); + + revalidatePath(`/jobs/${jobId}`); + return NextResponse.json({ ok: true, uploaded: files.length }); + } catch (e) { + console.error("[POST /api/jobs/files]", e); + 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 NextResponse.json( + { ok: false, error: errorMessage(e, "Dosya yüklenemedi.") }, + { status: 500 }, + ); + } +}