Add request single page

This commit is contained in:
2026-01-01 11:52:24 +01:00
parent 42f23dddcf
commit 2fcf19c0df
13 changed files with 1007 additions and 83 deletions

View File

@ -1,13 +1,10 @@
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { DeleteObjectsCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod/v4";
export const runtime = "nodejs"; // required for AWS SDK on many setups
export const dynamic = "force-dynamic"; // API endpoint should be dynamic
const payloadSchema = z.object({
typeId: z.string().min(1).optional().nullable(),
optionId: z.string().min(1).optional().nullable(),
@ -17,8 +14,6 @@ const payloadSchema = z.object({
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
// customFields: z.record(z.string(), z.unknown()).optional(),
});
function safeJsonParse(input: string) {
@ -30,55 +25,56 @@ function safeJsonParse(input: string) {
}
export async function POST(request: Request) {
// Optional: basic origin allowlist check (recommended)
// const origin = request.headers.get("origin");
// if (origin && !["https://domain.com", "https://www.domain.com"].includes(origin)) {
// return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
// }
const form = await request.formData();
const payloadRaw = form.get("payload");
if (typeof payloadRaw !== "string") {
return NextResponse.json({ error: "Missing payload" }, { status: 400 });
}
const parsedJson = safeJsonParse(payloadRaw);
if (!parsedJson) {
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
}
const payload = payloadSchema.safeParse(parsedJson);
if (!payload.success) {
return NextResponse.json(
{ error: "Validation error", issues: payload.error.issues },
{ status: 422 }
);
}
const files = form.getAll("files").filter((v): v is File => v instanceof File);
// Optional: enforce limits
const MAX_FILES = 10;
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
if (files.length > MAX_FILES) {
return NextResponse.json({ error: "Too many files" }, { status: 413 });
}
for (const f of files) {
if (f.size > MAX_BYTES_EACH) {
return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 });
try {
const bucket = process.env.BUCKET_NAME;
if (!bucket) {
return NextResponse.json(
{ error: "Commission submission failed", message: "BUCKET_NAME is not set" },
{ status: 500 }
);
}
}
// Capture basic metadata
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
const userAgent = request.headers.get("user-agent") ?? null;
const form = await request.formData();
// Create request first to get requestId; then upload files and store file rows
// Use a transaction to keep DB consistent.
const result = await prisma.$transaction(async (tx) => {
const created = await tx.commissionRequest.create({
const payloadRaw = form.get("payload");
if (typeof payloadRaw !== "string") {
return NextResponse.json({ error: "Missing payload" }, { status: 400 });
}
const parsedJson = safeJsonParse(payloadRaw);
if (!parsedJson) {
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
}
const payload = payloadSchema.safeParse(parsedJson);
if (!payload.success) {
return NextResponse.json(
{ error: "Validation error", issues: payload.error.issues },
{ status: 422 }
);
}
const files = form.getAll("files").filter((v): v is File => v instanceof File);
// Optional: enforce limits
const MAX_FILES = 10;
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
if (files.length > MAX_FILES) {
return NextResponse.json({ error: "Too many files" }, { status: 413 });
}
for (const f of files) {
if (f.size > MAX_BYTES_EACH) {
return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 });
}
}
// Capture basic metadata
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
const userAgent = request.headers.get("user-agent") ?? null;
// 1) Create request first (DB only — keep it fast)
const created = await prisma.commissionRequest.create({
data: {
status: "NEW",
customerName: payload.data.customerName,
@ -92,9 +88,6 @@ export async function POST(request: Request) {
ipAddress,
userAgent,
// customFields: payload.data.customFields ?? undefined,
// Extras are M:N; connect by ids
extras: payload.data.extraIds?.length
? { connect: payload.data.extraIds.map((id) => ({ id })) }
: undefined,
@ -102,38 +95,99 @@ export async function POST(request: Request) {
select: { id: true, createdAt: true },
});
for (const f of files) {
const fileKey = uuidv4();
const fileType = f.type;
const realFileType = fileType.split("/")[1];
const originalKey = `commissions/${fileKey}.${realFileType}`;
const arrayBuffer = await f.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 2) Upload to S3 outside any Prisma transaction
const uploadedKeys: string[] = [];
const fileRows: {
requestId: string;
fileKey: string;
originalFile: string;
fileType: string;
fileSize: number;
uploadDate: Date;
}[] = [];
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: originalKey,
Body: buffer,
ContentType: f.type || "application/octet-stream",
})
);
try {
for (const f of files) {
const fileKey = uuidv4();
await tx.commissionRequestFile.create({
data: {
const mime = f.type || "application/octet-stream";
// Do NOT trust client mime too much; but ok for extension here
const ext = mime.includes("/") ? mime.split("/")[1] : "bin";
// Note: keep your existing shape: commissions/<uuid>.<ext>
const originalKey = `commissions/${fileKey}.${ext}`;
const arrayBuffer = await f.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: originalKey,
Body: buffer,
ContentType: mime,
})
);
uploadedKeys.push(originalKey);
fileRows.push({
requestId: created.id,
fileKey: originalKey,
originalFile: f.name,
fileType: fileType || "application/octet-stream",
fileType: mime,
fileSize: f.size,
// Use the request creation time (matches your prior logic)
uploadDate: created.createdAt,
},
});
}
} catch (uploadErr) {
// Best-effort cleanup to avoid orphaned objects
if (uploadedKeys.length) {
try {
await s3.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: uploadedKeys.map((Key) => ({ Key })),
Quiet: true,
},
})
);
} catch (cleanupErr) {
console.error("[POST /api/v1/commissions] S3 cleanup failed", cleanupErr);
}
}
// Keep request for audit OR delete it. Pick one:
// Option A: delete it (strict consistency)
await prisma.commissionRequest.delete({ where: { id: created.id } });
// Option B (alternative): keep but mark a status like FAILED_UPLOAD if you add it.
// await prisma.commissionRequest.update({ where: { id: created.id }, data: { status: "FAILED_UPLOAD" } });
throw uploadErr;
}
// 3) Insert file rows (DB only — keep it fast)
if (fileRows.length) {
await prisma.commissionRequestFile.createMany({
data: fileRows,
});
}
return created;
});
return NextResponse.json({ id: created.id, createdAt: created.createdAt }, { status: 201 });
} catch (err) {
console.error("[POST /api/v1/commissions] failed", err);
return NextResponse.json(result, { status: 201 });
const message = err instanceof Error ? err.message : "Unknown server error";
return NextResponse.json(
{
error: "Commission submission failed",
message,
},
{ status: 500 }
);
}
}