MEGAINFRA-WEBAPP/src/app/api/purchase-orders/upload/route.ts
DEls 2be0c8de8b Surface n8n failures + add upload timeout + dedupe MIME/size constants
- Purchase order upload route now returns 502 n8n_rejected when n8n
  responds with a non-2xx status, instead of silently claiming success.
- Add a 10s AbortController timeout on the n8n fetch, returning 504
  n8n_timeout on expiry (502 n8n_unreachable on other network errors).
- Extract ALLOWED_MIME and MAX_FILE_BYTES from upload/route.ts into
  src/lib/purchase-order-schema.ts so the upcoming ingest route can
  share the same validation constants.
2026-08-19 15:03:47 +02:00

102 lines
No EOL
2.8 KiB
TypeScript

import { NextResponse } from "next/server";
import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(request: Request) {
const start = Date.now();
let form: FormData;
try {
form = await request.formData();
} catch {
return NextResponse.json({ error: "invalid_form_data" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "missing_file" }, { status: 400 });
}
if (file.size === 0) {
return NextResponse.json({ error: "empty_file" }, { status: 400 });
}
if (file.size > MAX_FILE_BYTES) {
return NextResponse.json(
{ error: "file_too_large", max_bytes: MAX_FILE_BYTES },
{ status: 400 },
);
}
if (!ALLOWED_MIME.has(file.type)) {
return NextResponse.json(
{ error: "unsupported_type", got: file.type, allowed: [...ALLOWED_MIME] },
{ status: 400 },
);
}
const n8nUrl = process.env.N8N_PO_INGEST_URL;
if (!n8nUrl) {
console.error("[po-upload] N8N_PO_INGEST_URL not configured");
return NextResponse.json({ error: "n8n_unconfigured" }, { status: 500 });
}
// Forward to n8n as multipart
const n8nForm = new FormData();
n8nForm.append("file", file, file.name);
n8nForm.append("filename", file.name);
n8nForm.append("contentType", file.type);
n8nForm.append("size", String(file.size));
let n8nRes: Response;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
try {
n8nRes = await fetch(n8nUrl, {
method: "POST",
body: n8nForm,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timeoutId);
const isAbort = err instanceof Error && err.name === "AbortError";
console.error("[po-upload] n8n unreachable", {
filename: file.name,
timed_out: isAbort,
err: String(err),
});
return NextResponse.json(
{ error: isAbort ? "n8n_timeout" : "n8n_unreachable" },
{ status: isAbort ? 504 : 502 },
);
}
clearTimeout(timeoutId);
if (!n8nRes.ok) {
console.error("[po-upload] n8n rejected", {
filename: file.name,
n8n_status: n8nRes.status,
statusText: n8nRes.statusText,
});
return NextResponse.json(
{
error: "n8n_rejected",
n8n_status: n8nRes.status,
statusText: n8nRes.statusText,
},
{ status: 502 },
);
}
console.log("[po-upload] forwarded", {
filename: file.name,
size: file.size,
contentType: file.type,
n8n_status: n8nRes.status,
duration_ms: Date.now() - start,
});
return NextResponse.json(
{ status: "processing", filename: file.name },
{ status: 202 },
);
}