From c990a177eb35a4def8ff03714cd37ba2f1e339d5 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 21 May 2026 21:18:51 +0300 Subject: [PATCH] feat(upload): 200mb cap + API route with XHR progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - next.config: serverActions.bodySizeLimit + experimental.proxyClientMaxBodySize bumped from 500mb back down to 200mb. Batch ceiling (client side) is 180mb to stay comfortably under the proxy cap. - New POST /api/jobs/[jobId]/files endpoint replaces the server action for upload. Same auth/permissions/rollback semantics, but Returns JSON so the client can read the response. Server action is retained for delete only. - JobFilesPanel switched from useActionState to XMLHttpRequest.upload — xhr.upload.onprogress feeds a Progress bar (real bytes, not a fake ticker). Cancel button aborts the in-flight request. Successful upload triggers router.refresh() to repopulate the file list. Server actions can't expose upload progress (no streaming feedback in the RSC protocol yet), so any progress UX needs to go through fetch/XHR against a route handler. Trade-off accepted. --- next.config.ts | 6 +- .../[jobId]/components/job-files-panel.tsx | 207 +++++++++++------- src/app/api/jobs/[jobId]/files/route.ts | 179 +++++++++++++++ 3 files changed, 313 insertions(+), 79 deletions(-) create mode 100644 src/app/api/jobs/[jobId]/files/route.ts 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 }, + ); + } +}