Add request single page
This commit is contained in:
26
src/app/(admin)/commissions/requests/[id]/page.tsx
Normal file
26
src/app/(admin)/commissions/requests/[id]/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { getCommissionRequestById } from "@/actions/commissions/requests/getCommissionRequestById";
|
||||
import { CommissionRequestEditor } from "@/components/commissions/requests/CommissionRequestEditor";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export default async function CommissionRequestPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const request = await getCommissionRequestById(id);
|
||||
if (!request) notFound();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-6 p-4 md:p-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Commission Request</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Submitted: {new Date(request.createdAt).toLocaleString()} · ID: {request.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommissionRequestEditor request={request as any} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/app/api/requests/image/route.ts
Normal file
144
src/app/api/requests/image/route.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { s3 } from "@/lib/s3";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import archiver from "archiver";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type Mode = "display" | "download" | "bulk";
|
||||
|
||||
function contentDisposition(filename: string, mode: Mode) {
|
||||
const type = mode === "display" ? "inline" : "attachment";
|
||||
const safe = filename.replace(/["\\]/g, "_");
|
||||
const encoded = encodeURIComponent(filename);
|
||||
return `${type}; filename="${safe}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function sanitizeZipEntryName(name: string) {
|
||||
return name.replace(/[^\w.\- ()\[\]]+/g, "_").slice(0, 180);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const bucket = process.env.BUCKET_NAME;
|
||||
if (!bucket) return new Response("BUCKET_NAME is not set", { status: 500 });
|
||||
|
||||
const mode = (req.nextUrl.searchParams.get("mode") ?? "display") as Mode;
|
||||
|
||||
// ---- Single file: display/download ----
|
||||
if (mode === "display" || mode === "download") {
|
||||
const fileId = req.nextUrl.searchParams.get("fileId");
|
||||
if (!fileId) return new Response("Missing fileId", { status: 400 });
|
||||
|
||||
const file = await prisma.commissionRequestFile.findUnique({
|
||||
where: { id: fileId },
|
||||
select: {
|
||||
fileKey: true,
|
||||
originalFile: true,
|
||||
fileType: true,
|
||||
fileSize: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) return new Response("Not found", { status: 404 });
|
||||
|
||||
const s3Res = await s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: file.fileKey,
|
||||
})
|
||||
);
|
||||
|
||||
if (!s3Res.Body) return new Response("No body", { status: 500 });
|
||||
|
||||
const contentType = file.fileType || s3Res.ContentType || "application/octet-stream";
|
||||
|
||||
return new Response(s3Res.Body as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
// You can tune caching; admin-only content usually should be private.
|
||||
"Cache-Control": mode === "display" ? "private, max-age=300" : "private, max-age=0, no-store",
|
||||
"Content-Disposition": contentDisposition(file.originalFile || "file", mode),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Bulk zip for a request ----
|
||||
if (mode === "bulk") {
|
||||
const requestId = req.nextUrl.searchParams.get("requestId");
|
||||
if (!requestId) return new Response("Missing requestId", { status: 400 });
|
||||
|
||||
const request = await prisma.commissionRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
files: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
fileKey: true,
|
||||
originalFile: true,
|
||||
fileType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) return new Response("Not found", { status: 404 });
|
||||
if (!request.files.length) return new Response("No files", { status: 404 });
|
||||
|
||||
const zipName = `commission-${request.id}.zip`;
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
archive.on("data", (chunk) => controller.enqueue(chunk));
|
||||
archive.on("end", () => controller.close());
|
||||
archive.on("error", (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
archive.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for (const f of request.files) {
|
||||
const obj = await s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: f.fileKey,
|
||||
})
|
||||
);
|
||||
|
||||
if (!obj.Body) continue;
|
||||
|
||||
const entryName = sanitizeZipEntryName(
|
||||
f.originalFile || f.fileKey.split("/").pop() || "file"
|
||||
);
|
||||
|
||||
// obj.Body is a Node stream in Node runtime; works with archiver
|
||||
archive.append(obj.Body as any, { name: entryName });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (err) {
|
||||
archive.destroy(err as Error);
|
||||
}
|
||||
})();
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${zipName}"`,
|
||||
"Cache-Control": "private, max-age=0, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Invalid mode", { status: 400 });
|
||||
} catch (err) {
|
||||
console.error("[GET /api/requests/image] failed", err);
|
||||
return new Response("Internal error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user