194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import { s3 } from "@/lib/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";
|
|
|
|
const payloadSchema = z.object({
|
|
typeId: z.string().min(1).optional().nullable(),
|
|
optionId: z.string().min(1).optional().nullable(),
|
|
extraIds: z.array(z.string().min(1)).default([]),
|
|
|
|
customerName: z.string().min(1).max(200),
|
|
customerEmail: z.string().email().max(320),
|
|
customerSocials: z.string().max(2000).optional().nullable(),
|
|
message: z.string().min(1).max(20_000),
|
|
});
|
|
|
|
function safeJsonParse(input: string) {
|
|
try {
|
|
return JSON.parse(input) as unknown;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const bucket = process.env.BUCKET_NAME;
|
|
if (!bucket) {
|
|
return NextResponse.json(
|
|
{ error: "Commission submission failed", message: "BUCKET_NAME is not set" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
customerEmail: payload.data.customerEmail,
|
|
customerSocials: payload.data.customerSocials ?? null,
|
|
message: payload.data.message,
|
|
|
|
typeId: payload.data.typeId ?? null,
|
|
optionId: payload.data.optionId ?? null,
|
|
|
|
ipAddress,
|
|
userAgent,
|
|
|
|
extras: payload.data.extraIds?.length
|
|
? { connect: payload.data.extraIds.map((id) => ({ id })) }
|
|
: undefined,
|
|
},
|
|
select: { id: true, createdAt: true },
|
|
});
|
|
|
|
// 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;
|
|
}[] = [];
|
|
|
|
try {
|
|
for (const f of files) {
|
|
const fileKey = uuidv4();
|
|
|
|
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: 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 NextResponse.json({ id: created.id, createdAt: created.createdAt }, { status: 201 });
|
|
} catch (err) {
|
|
console.error("[POST /api/v1/commissions] failed", err);
|
|
|
|
const message = err instanceof Error ? err.message : "Unknown server error";
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: "Commission submission failed",
|
|
message,
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|