From fd028f63df59fca2a495837342bd52dfd1fc8ea9 Mon Sep 17 00:00:00 2001 From: denverm Date: Sun, 18 Jan 2026 10:05:40 +0200 Subject: [PATCH] Update src/app/api/admin/guests/export/route.ts --- src/app/api/admin/guests/export/route.ts | 181 +++++++---------------- 1 file changed, 51 insertions(+), 130 deletions(-) diff --git a/src/app/api/admin/guests/export/route.ts b/src/app/api/admin/guests/export/route.ts index a89021c..d317d6d 100644 --- a/src/app/api/admin/guests/export/route.ts +++ b/src/app/api/admin/guests/export/route.ts @@ -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; - - 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"; -} \ No newline at end of file