Update src/app/api/admin/guests/export/route.ts
This commit is contained in:
@@ -1,32 +1,12 @@
|
||||
// src/app/api/admin/guests/export/route.ts
|
||||
// Force dynamic rendering
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyAdminSession } from "@/lib/auth";
|
||||
import { verifyCsrfToken } from "@/lib/csrf";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getRequestId } from "@/lib/request-id";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
type ExportFormat = "csv" | "json" | "xlsx";
|
||||
|
||||
type GuestExportRow = {
|
||||
Name: string;
|
||||
Surname: string;
|
||||
Title: string;
|
||||
Email: string;
|
||||
Phone: string;
|
||||
Status: string;
|
||||
"Party Size": number;
|
||||
Table: string;
|
||||
"Dietary Restrictions": string;
|
||||
Relationship: string;
|
||||
"Relationship To": string;
|
||||
"Invite Code": string;
|
||||
"RSVP Date": string;
|
||||
Notes: string;
|
||||
};
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = getRequestId(request);
|
||||
@@ -38,63 +18,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Resolve admin identity for CSRF binding (robust across session shapes)
|
||||
const adminId =
|
||||
(session as any)?.adminId ||
|
||||
(session as any)?.userId ||
|
||||
(session as any)?.id ||
|
||||
"admin";
|
||||
const userType: "admin" = "admin";
|
||||
|
||||
// CSRF token (export is a privileged operation)
|
||||
const csrfToken =
|
||||
request.headers.get("x-csrf-token") || request.headers.get("csrf-token");
|
||||
|
||||
if (!csrfToken) {
|
||||
return NextResponse.json({ error: "Missing CSRF token" }, { status: 403 });
|
||||
}
|
||||
|
||||
const csrfValid = await verifyCsrfToken(csrfToken, String(adminId), userType);
|
||||
if (!csrfValid) {
|
||||
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const idsParam = searchParams.get("ids");
|
||||
const formatParam = (searchParams.get("format") || "csv").toLowerCase();
|
||||
|
||||
const format: ExportFormat =
|
||||
formatParam === "json" || formatParam === "csv" || formatParam === "xlsx"
|
||||
? (formatParam as ExportFormat)
|
||||
: "csv";
|
||||
const format = searchParams.get("format") || "csv";
|
||||
|
||||
// Get guest IDs to export
|
||||
const guestIds = idsParam
|
||||
? idsParam
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const guestIds = idsParam ? idsParam.split(",") : [];
|
||||
|
||||
// Fetch guests (typed select; avoid implicit any)
|
||||
// Fetch guests
|
||||
const guests = await prisma.guest.findMany({
|
||||
where: guestIds.length > 0 ? { id: { in: guestIds } } : undefined,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
surname: true,
|
||||
title: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
isAttending: true,
|
||||
maxPax: true,
|
||||
dietaryRestrictions: true,
|
||||
relationship: true,
|
||||
relationshipTo: true,
|
||||
inviteCode: true,
|
||||
rsvpTimestamp: true,
|
||||
notes: true,
|
||||
table: { select: { name: true } },
|
||||
include: {
|
||||
table: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
@@ -103,65 +40,73 @@ export async function GET(request: NextRequest) {
|
||||
requestId,
|
||||
format,
|
||||
guestCount: guests.length,
|
||||
hasIdsFilter: guestIds.length > 0,
|
||||
});
|
||||
|
||||
const exportData: GuestExportRow[] = guests.map((guest) => ({
|
||||
// Format data
|
||||
type GuestWithTable = Prisma.GuestGetPayload<{
|
||||
include: { table: { select: { name: true } } };
|
||||
}>;
|
||||
const exportData = guests.map((guest: GuestWithTable) => ({
|
||||
Name: guest.name,
|
||||
Surname: guest.surname || "",
|
||||
Title: guest.title || "",
|
||||
Email: guest.email || "",
|
||||
Phone: guest.phone || "",
|
||||
Status:
|
||||
guest.isAttending === true
|
||||
? "Confirmed"
|
||||
: guest.isAttending === false
|
||||
? "Declined"
|
||||
: "Pending",
|
||||
"Party Size": guest.maxPax ?? 1,
|
||||
Status: guest.isAttending === true ? "Confirmed" : guest.isAttending === false ? "Declined" : "Pending",
|
||||
"Party Size": guest.maxPax,
|
||||
Table: guest.table?.name || "Unassigned",
|
||||
"Dietary Restrictions": guest.dietaryRestrictions || "",
|
||||
Relationship: guest.relationship || "",
|
||||
"Relationship To": guest.relationshipTo || "",
|
||||
"Invite Code": guest.inviteCode || "",
|
||||
"RSVP Date": guest.rsvpTimestamp
|
||||
? new Date(guest.rsvpTimestamp).toLocaleDateString("en-ZA")
|
||||
: "",
|
||||
"RSVP Date": guest.rsvpTimestamp ? new Date(guest.rsvpTimestamp).toLocaleDateString() : "",
|
||||
Notes: guest.notes || "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="guests-export.json"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: For now, xlsx returns CSV to keep the build dependency-free.
|
||||
// If you want true XLSX, add a library (e.g., exceljs) and generate a workbook.
|
||||
const csv = toCSV(exportData);
|
||||
if (format === "csv" || format === "xlsx") {
|
||||
// Generate CSV
|
||||
const headers = Object.keys(exportData[0] || {});
|
||||
const csvRows = [
|
||||
headers.join(","),
|
||||
...exportData.map((row) =>
|
||||
headers
|
||||
.map((header) => {
|
||||
const value = (row as any)[header] || "";
|
||||
// Escape quotes and wrap in quotes if contains comma
|
||||
if (typeof value === "string" && (value.includes(",") || value.includes('"'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.join(",")
|
||||
),
|
||||
];
|
||||
|
||||
return new NextResponse(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="guests-export.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
const csv = csvRows.join("\n");
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="guests-export.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid format" }, { status: 400 });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Guest export error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{
|
||||
requestId,
|
||||
tags: { route: "admin/guests/export" },
|
||||
}
|
||||
);
|
||||
logger.error("Guest export error", error instanceof Error ? error : new Error(String(error)), {
|
||||
requestId,
|
||||
tags: { route: "admin/guests/export" },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -172,27 +117,3 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toCSV(rows: GuestExportRow[]): string {
|
||||
if (!rows || rows.length === 0) {
|
||||
return "No data\n";
|
||||
}
|
||||
|
||||
const headers = Object.keys(rows[0]) as Array<keyof GuestExportRow>;
|
||||
|
||||
const escape = (value: unknown) => {
|
||||
const s = value === null || value === undefined ? "" : String(value);
|
||||
if (/[",\n\r]/.test(s)) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const lines = [
|
||||
headers.map((h) => escape(h)).join(","),
|
||||
...rows.map((row) => headers.map((h) => escape(row[h])).join(",")),
|
||||
];
|
||||
|
||||
// Excel-friendly UTF-8 BOM
|
||||
return "\uFEFF" + lines.join("\n") + "\n";
|
||||
}
|
||||
Reference in New Issue
Block a user