Files
lab/src/app/api/jobs/[jobId]/files/route.ts
T
kovakmedya c990a177eb feat(upload): 200mb cap + API route with XHR progress
- 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.
2026-05-21 21:18:51 +03:00

180 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Job | null> {
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 },
);
}
}