feat: Implement guest import functionality with a new API route and an admin dashboard dialog.

This commit is contained in:
2026-01-29 10:31:41 +02:00
parent 5f22444332
commit 527f643a51
5 changed files with 567 additions and 9 deletions

View File

@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyAdminSession } from "@/lib/supabase-auth";
import { prisma } from "@/lib/db";
import { generateUniqueInviteCode } from "@/lib/invite-code";
import { sanitizeName, sanitizeMessage } from "@/lib/sanitize";
import { CACHE_TAGS, invalidateCacheByTag } from "@/lib/api-cache";
import { getRequestId } from "@/lib/request-id";
import { logger } from "@/lib/logger";
export const maxDuration = 60; // Allow 60 seconds for bulk import
export async function POST(request: NextRequest) {
const requestId = getRequestId(request);
try {
const session = await verifyAdminSession(request);
if (!session || !session.authenticated) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Verify CSRF token
const { verifyCSRF } = await import('@/lib/csrf');
const csrfValid = await verifyCSRF(request);
if (!csrfValid) {
return NextResponse.json(
{ error: 'CSRF token invalid or missing' },
{ status: 403 }
);
}
const body = await request.json();
const { guests } = body;
if (!Array.isArray(guests) || guests.length === 0) {
return NextResponse.json(
{ error: 'No guests provided for import' },
{ status: 400 }
);
}
// Process guests
let successCount = 0;
const errors: string[] = [];
// We process sequentially to ensure invite code uniqueness (to avoid race conditions on DB check)
// and to avoid overwhelming the DB connection pool with hundreds of parallel requests
for (const [index, guest] of guests.entries()) {
try {
if (!guest.name) {
errors.push(`Row ${index + 1}: Name is required`);
continue;
}
// Sanitize fields
const sanitizedName = sanitizeName(guest.name);
const sanitizedEmail = guest.email ? sanitizeMessage(guest.email) : null;
const sanitizedPhone = guest.phone ? sanitizeMessage(guest.phone) : null;
const sanitizedRelationship = guest.relationship ? sanitizeMessage(guest.relationship) : 'Guest';
const notes = guest.notes ? sanitizeMessage(guest.notes) : null;
// Handle RSVP status from import
let rsvpStatus = 'PENDING';
let isAttending = null;
if (guest.status) {
const status = guest.status.toLowerCase();
if (status === 'confirmed' || status === 'accepted' || status === 'attending') {
rsvpStatus = 'ACCEPTED';
isAttending = true;
} else if (status === 'declined' || status === 'regret') {
rsvpStatus = 'DECLINED';
isAttending = false;
}
}
// Generate Code
const inviteCode = await generateUniqueInviteCode();
await prisma.guest.create({
data: {
name: sanitizedName,
email: sanitizedEmail,
phone: sanitizedPhone,
inviteCode: inviteCode,
maxPax: guest.maxPax ? Number(guest.maxPax) : 1,
relationship: sanitizedRelationship,
notes: notes,
rsvpStatus: rsvpStatus as any,
isAttending: isAttending as any,
plusOnesCount: guest.plusOne ? 1 : 0, // Simplified: if plusOne is true, allow 1 guest
tableId: guest.tableId || null,
}
});
successCount++;
} catch (err: any) {
logger.error(`Import failed for row ${index + 1}`, err, { requestId });
errors.push(`Row ${index + 1}: ${err.message || 'Unknown error'}`);
}
}
// Invalidate caches
await Promise.all([
invalidateCacheByTag(CACHE_TAGS.GUESTS),
invalidateCacheByTag(CACHE_TAGS.STATS),
invalidateCacheByTag(CACHE_TAGS.ADMIN),
]);
// return summary
return NextResponse.json({
success: true,
count: successCount,
errors: errors.length > 0 ? errors : undefined,
});
} catch (error) {
logger.error('Import API error', error instanceof Error ? error : new Error(String(error)), { requestId });
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -51,6 +51,7 @@ import {
import { AddGuestDialog } from "./dialogs/add-guest-dialog";
import { EditGuestDialog } from "./dialogs/edit-guest-dialog";
import { MagicLinkDialog } from "./dialogs/magic-link-dialog";
import { ImportGuestsDialog } from "./dialogs/import-guests-dialog";
import { AdminRSVPOnBehalf } from "./admin-rsvp-on-behalf";
import { Guest } from "@/types/admin";
import AdminTabErrorBoundary from "./admin-tab-error-boundary";
@@ -82,6 +83,7 @@ export function AdminDashboard({
const [isMagicLinkOpen, setIsMagicLinkOpen] = useState(false);
const [magicLinkDestination, setMagicLinkDestination] = useState<"rsvp" | "tickets" | "dashboard">("rsvp");
const [isRSVPBehalfOpen, setIsRSVPBehalfOpen] = useState(false);
const [isImportGuestsOpen, setIsImportGuestsOpen] = useState(false);
const [targetGuest, setTargetGuest] = useState<Guest | null>(null);
// Guest Handlers
@@ -151,12 +153,8 @@ export function AdminDashboard({
}, [toast]);
const handleImportGuests = useCallback(() => {
toast({
title: "Coming Soon",
description: "CSV import functionality is being finalized.",
variant: "info"
});
}, [toast]);
setIsImportGuestsOpen(true);
}, []);
const handleRSVPOnBehalf = useCallback((guest: Guest) => {
setTargetGuest(guest);
@@ -455,6 +453,12 @@ export function AdminDashboard({
onSuccess={() => guestListData.refreshGuestList()}
/>
)}
<ImportGuestsDialog
open={isImportGuestsOpen}
onOpenChange={setIsImportGuestsOpen}
onSuccess={() => guestListData.refreshGuestList()}
/>
</div>
);
}

View File

@@ -0,0 +1,294 @@
"use client";
import React, { useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { Loader2, Upload, AlertCircle, CheckCircle2, FileText, X } from "lucide-react";
import { parseCSV, ParseCSVResult } from "@/lib/import-utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Guest } from "@/types/admin";
interface ImportGuestsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function ImportGuestsDialog({
open,
onOpenChange,
onSuccess,
}: ImportGuestsDialogProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [parsedResult, setParsedResult] = useState<ParseCSVResult<Partial<Guest>> | null>(null);
const [dragActive, setDragActive] = useState(false);
const handleFile = async (file: File) => {
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
toast({
variant: "error",
title: "Invalid file type",
description: "Please upload a CSV file.",
});
return;
}
setFile(file);
try {
const result = await parseCSV<Partial<Guest>>(file);
setParsedResult(result);
} catch (error) {
toast({
variant: "error",
title: "Parse Error",
description: "Failed to parse CSV file.",
});
setFile(null);
setParsedResult(null);
}
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
const reset = () => {
setFile(null);
setParsedResult(null);
};
const getCSRFToken = useCallback(async (): Promise<string | null> => {
try {
const response = await fetch("/api/csrf", {
method: "GET",
credentials: "include",
});
if (response.ok) {
const data = await response.json();
return data.csrfToken || data.token;
}
} catch (error) {
console.error("Failed to get CSRF token:", error);
}
return null;
}, []);
const handleImport = async () => {
if (!parsedResult?.data.length) return;
setIsSubmitting(true);
try {
const csrfToken = await getCSRFToken();
const response = await fetch("/api/admin/guests/import", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken && { "x-csrf-token": csrfToken }),
},
credentials: "include",
body: JSON.stringify({ guests: parsedResult.data }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to import guests");
}
toast({
variant: "success",
title: "Import Successful",
description: `Successfully imported ${data.count} guests.`,
});
onSuccess();
onOpenChange(false);
reset();
} catch (error) {
toast({
variant: "error",
title: "Import Failed",
description: error instanceof Error ? error.message : "Failed to import guests",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="font-heading">Import Guests</DialogTitle>
<DialogDescription className="font-body">
Upload a CSV file to bulk import guests. The file should have headers like Name, Email, Status, etc.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col gap-4 py-4">
{!file ? (
<div
className={`flex flex-col items-center justify-center p-10 border-2 border-dashed rounded-xl transition-colors ${dragActive
? "border-wedding-sage bg-wedding-sage/5"
: "border-gray-200 hover:border-wedding-sage/50"
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<Upload className="h-10 w-10 text-gray-400 mb-4" />
<p className="font-medium text-gray-700 mb-1">Drag and drop your CSV here</p>
<p className="text-sm text-gray-400 mb-4">or click to browse</p>
<div className="relative">
<Button variant="outline" size="sm">
Browse Files
</Button>
<input
type="file"
className="absolute inset-0 opacity-0 cursor-pointer"
accept=".csv"
onChange={handleChange}
/>
</div>
</div>
) : (
<div className="flex flex-col h-full gap-4">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg text-green-700">
<FileText className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-sm">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB {parsedResult?.data.length || 0} guests found
</p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={reset}>
<X className="h-4 w-4" />
</Button>
</div>
{parsedResult?.errors && parsedResult.errors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Errors found in file</AlertTitle>
<AlertDescription>
{parsedResult.errors.slice(0, 3).map((err, i) => (
<div key={i}>{err}</div>
))}
{parsedResult.errors.length > 3 && (
<div>...and {parsedResult.errors.length - 3} more errors</div>
)}
</AlertDescription>
</Alert>
)}
<div className="flex-1 border rounded-md overflow-hidden flex flex-col min-h-0">
<div className="p-2 bg-gray-50 border-b text-xs font-medium text-gray-500">
Preview ({parsedResult?.data.length} records)
</div>
<ScrollArea className="flex-1">
<Table>
<TableHeader>
<TableRow>
<TableHead>Row</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Max Pax</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parsedResult?.data.slice(0, 50).map((row, i) => (
<TableRow key={i}>
<TableCell className="font-mono text-xs">{i + 1}</TableCell>
<TableCell className="font-medium">
{row.name || <span className="text-red-400 italic">Missing</span>}
</TableCell>
<TableCell>{row.email || '-'}</TableCell>
<TableCell>
{row.status ? (
<Badge variant="outline" className="text-xs">{row.status}</Badge>
) : (
<span className="text-gray-400 text-xs">Default (Pending)</span>
)}
</TableCell>
<TableCell>{row.maxPax || 1}</TableCell>
</TableRow>
))}
{parsedResult?.data && parsedResult.data.length > 50 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-xs text-gray-500">
...and {parsedResult.data.length - 50} more
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={!file || !parsedResult?.data.length || (parsedResult?.errors?.length ?? 0) > 0 || isSubmitting}
className="bg-wedding-evergreen hover:bg-wedding-moss"
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Import {parsedResult?.data.length ? `${parsedResult.data.length} Guests` : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,6 +2,7 @@
import React from "react";
import { RefreshCw, Settings, Radio, BarChart3, X } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import {
Card,
CardContent,
@@ -24,6 +25,7 @@ export function OverviewTab({
onDeleteMusicRequest,
adminData: propAdminData,
}: OverviewTabProps) {
const { toast } = useToast();
const internalAdminData = useAdminData();
const adminData = propAdminData || internalAdminData;
@@ -148,15 +150,27 @@ export function OverviewTab({
<CardTitle className="font-heading">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button className="w-full justify-start" variant="ghost">
<Button
className="w-full justify-start"
variant="ghost"
onClick={() => toast({ title: "Coming Soon", description: "Broadcast messaging is under development.", variant: "info" })}
>
<Radio className="h-4 w-4 mr-2" />
Send Broadcast Message
</Button>
<Button className="w-full justify-start" variant="ghost">
<Button
className="w-full justify-start"
variant="ghost"
onClick={() => toast({ title: "Coming Soon", description: "Reporting features are coming in the next update.", variant: "info" })}
>
<BarChart3 className="h-4 w-4 mr-2" />
Generate Report
</Button>
<Button className="w-full justify-start" variant="ghost">
<Button
className="w-full justify-start"
variant="ghost"
onClick={() => onTabChange?.("settings")}
>
<Settings className="h-4 w-4 mr-2" />
Event Settings
</Button>

119
src/lib/import-utils.ts Normal file
View File

@@ -0,0 +1,119 @@
export interface ParseCSVResult<T> {
data: T[];
errors: string[];
}
/**
* Simple CSV parser that handles quoted values and basic validation
*/
export async function parseCSV<T = any>(file: File): Promise<ParseCSVResult<T>> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
try {
const text = event.target?.result as string;
if (!text) {
resolve({ data: [], errors: ["Empty file"] });
return;
}
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
if (lines.length < 2) {
resolve({ data: [], errors: ["File must contain a header row and at least one data row"] });
return;
}
// Parse headers
const headers = parseCSVLine(lines[0]).map(h => h.trim());
const data: T[] = [];
const errors: string[] = [];
// Parse data rows
for (let i = 1; i < lines.length; i++) {
const currentLine = lines[i];
if (!currentLine.trim()) continue;
const values = parseCSVLine(currentLine);
if (values.length !== headers.length) {
errors.push(`Row ${i + 1}: Expected ${headers.length} columns but found ${values.length}`);
continue;
}
const row: any = {};
headers.forEach((header, index) => {
// Normalize header key (camelCase preferred but keep it simple for now)
const key = header.replace(/\s+/g, '').replace(/[^a-zA-Z0-9]/g, '');
let value: any = values[index]?.trim();
// Basic type inference
if (value === "true") value = true;
else if (value === "false") value = false;
else if (value === "" || value === "null") value = null;
else if (!isNaN(Number(value)) && value !== "") value = Number(value);
// Map common CSV headers to Guest type keys if needed
// This mapping matches the export templates
let mappedKey = key;
if (key.toLowerCase() === 'name') mappedKey = 'name';
else if (key.toLowerCase() === 'email') mappedKey = 'email';
else if (key.toLowerCase() === 'phone') mappedKey = 'phone';
else if (key.toLowerCase() === 'dietary') mappedKey = 'dietaryRestrictions';
else if (key.toLowerCase() === 'dietaryrestrictions') mappedKey = 'dietaryRestrictions';
else if (key.toLowerCase() === 'plusone') mappedKey = 'plusOne'; // Although plusOne is boolean, might come as 'Yes'/'No'
else if (key.toLowerCase() === 'maxpax') mappedKey = 'maxPax';
else if (key.toLowerCase() === 'table') mappedKey = 'tableId';
else if (key.toLowerCase() === 'notes') mappedKey = 'notes';
row[mappedKey] = value;
});
data.push(row);
}
resolve({ data, errors });
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsText(file);
});
}
/**
* Helper to parse a single CSV line handling quotes
*/
function parseCSVLine(text: string): string[] {
const result: string[] = [];
let currentValue = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') {
if (inQuotes && text[i + 1] === '"') {
// Escaped quote
currentValue += '"';
i++;
} else {
// Toggle quotes
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// End of value
result.push(currentValue);
currentValue = '';
} else {
currentValue += char;
}
}
result.push(currentValue);
return result;
}