feat: Guest Management & RSVP system (7ead7758)
Backend: - POST /api/events/:id/guests — add guest, auto-generate RSVP token (128-bit crypto.randomBytes), build wa.me WhatsApp deep-link, store in invitations - GET /api/events/:id/guests — list with pg_trgm fuzzy Hebrew name search, status filter, pagination; returns RSVP summary + capacity warning at 90% - GET /api/events/:id/guests/export — CSV export with UTF-8 BOM for Excel Hebrew support (json2csv) - PUT /api/guests/:id — PATCH-style update (COALESCE), organizer ownership check - DELETE /api/guests/:id — hard delete per Israeli Privacy Law 2023 - GET /api/rsvp/:token — public (no auth), marks opened_at on first visit - POST /api/rsvp/:token — idempotent RSVP submit, supports dietary update, handles cancelled events (410) - Israeli phone normalization: 05X-XXXXXXX → +972XXXXXXXXX E.164 - Capacity warning returned in add/list/update responses when confirmed ≥ 90% of venue_capacity (fire safety compliance) Frontend: - GuestListPage — sortable/filterable table, inline RSVP status override, WhatsApp send links, delete with confirmation, 30s polling for real-time updates - AddGuestForm — RTL Hebrew form, all guest fields, shows WhatsApp link on success - RsvpSummaryCard — 4-metric summary (total/confirmed/declined/pending) + capacity warning - RsvpPage — public page at /rsvp/:token, shows event details, confirm/decline, dietary preference update; no login required - New UI components: Badge, Select (shadcn/ui compatible) - App.tsx: added /events/:eventId/guests (protected) and /rsvp/:token (public) routes Build: 0 TS errors, all routes wired in server.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,20 @@ import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { RegisterPage } from '@/pages/RegisterPage';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { GuestListPage } from '@/pages/GuestListPage';
|
||||
import { RsvpPage } from '@/pages/RsvpPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/rsvp/:token" element={<RsvpPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -20,7 +26,15 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Default: redirect root to dashboard (ProtectedRoute will redirect to /login if unauthed) */}
|
||||
<Route
|
||||
path="/events/:eventId/guests"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GuestListPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
227
client/src/components/AddGuestForm.tsx
Normal file
227
client/src/components/AddGuestForm.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface AddGuestFormProps {
|
||||
eventId: string;
|
||||
onGuestAdded: () => void;
|
||||
}
|
||||
|
||||
export function AddGuestForm({ eventId, onGuestAdded }: AddGuestFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [whatsappLink, setWhatsappLink] = useState('');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name_hebrew: '',
|
||||
name_transliteration: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
relationship_group: '',
|
||||
dietary_preference: 'none',
|
||||
dietary_notes: '',
|
||||
table_number: '',
|
||||
plus_one_allowance: '0',
|
||||
});
|
||||
|
||||
function handleChange(field: string, value: string) {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setWhatsappLink('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/events/${eventId}/guests`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
table_number: form.table_number ? parseInt(form.table_number) : null,
|
||||
plus_one_allowance: parseInt(form.plus_one_allowance) || 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'שגיאה בהוספת אורח');
|
||||
|
||||
setWhatsappLink(data.whatsapp_link || '');
|
||||
|
||||
// Reset form
|
||||
setForm({
|
||||
name_hebrew: '', name_transliteration: '', phone: '', email: '',
|
||||
relationship_group: '', dietary_preference: 'none',
|
||||
dietary_notes: '', table_number: '', plus_one_allowance: '0',
|
||||
});
|
||||
|
||||
onGuestAdded();
|
||||
|
||||
if (!data.whatsapp_link) setOpen(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'שגיאה בהוספת אורח');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button onClick={() => setOpen(true)}>+ הוסף אורח</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">הוספת אורח חדש</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{whatsappLink && (
|
||||
<div className="mb-4 rounded-md bg-green-50 border border-green-200 p-3 text-sm" dir="rtl">
|
||||
<p className="font-medium text-green-800 mb-2">האורח נוסף! שלח הזמנה ב-WhatsApp:</p>
|
||||
<a
|
||||
href={whatsappLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block rounded bg-green-600 px-3 py-1.5 text-white text-sm hover:bg-green-700"
|
||||
>
|
||||
📲 פתח WhatsApp לשליחה
|
||||
</a>
|
||||
<Button variant="ghost" size="sm" className="mr-2" onClick={() => { setWhatsappLink(''); setOpen(false); }}>
|
||||
סגור
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} dir="rtl" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name_hebrew">שם בעברית *</Label>
|
||||
<Input
|
||||
id="name_hebrew"
|
||||
value={form.name_hebrew}
|
||||
onChange={e => handleChange('name_hebrew', e.target.value)}
|
||||
placeholder="ישראל ישראלי"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name_transliteration">שם באנגלית</Label>
|
||||
<Input
|
||||
id="name_transliteration"
|
||||
value={form.name_transliteration}
|
||||
onChange={e => handleChange('name_transliteration', e.target.value)}
|
||||
placeholder="Israel Israeli"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="phone">טלפון (WhatsApp)</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={form.phone}
|
||||
onChange={e => handleChange('phone', e.target.value)}
|
||||
placeholder="050-1234567"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">אימייל</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => handleChange('email', e.target.value)}
|
||||
placeholder="guest@email.com"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="relationship_group">קבוצת יחסים</Label>
|
||||
<Select
|
||||
id="relationship_group"
|
||||
value={form.relationship_group}
|
||||
onChange={e => handleChange('relationship_group', e.target.value)}
|
||||
>
|
||||
<option value="">-- בחר --</option>
|
||||
<option value="family_bride">משפחת כלה</option>
|
||||
<option value="family_groom">משפחת חתן</option>
|
||||
<option value="friends">חברים</option>
|
||||
<option value="work">עבודה</option>
|
||||
<option value="community">קהילה</option>
|
||||
<option value="other">אחר</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dietary_preference">העדפה תזונתית</Label>
|
||||
<Select
|
||||
id="dietary_preference"
|
||||
value={form.dietary_preference}
|
||||
onChange={e => handleChange('dietary_preference', e.target.value)}
|
||||
>
|
||||
<option value="none">ללא הגבלה</option>
|
||||
<option value="vegetarian">צמחוני</option>
|
||||
<option value="vegan">טבעוני</option>
|
||||
<option value="kosher_regular">כשר רגיל</option>
|
||||
<option value="kosher_mehadrin">כשר מהדרין</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="table_number">מספר שולחן</Label>
|
||||
<Input
|
||||
id="table_number"
|
||||
type="number"
|
||||
value={form.table_number}
|
||||
onChange={e => handleChange('table_number', e.target.value)}
|
||||
placeholder="1"
|
||||
min="1"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="plus_one_allowance">מלווים מורשים</Label>
|
||||
<Input
|
||||
id="plus_one_allowance"
|
||||
type="number"
|
||||
value={form.plus_one_allowance}
|
||||
onChange={e => handleChange('plus_one_allowance', e.target.value)}
|
||||
min="0"
|
||||
max="10"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dietary_notes">הערות תזונה / נגישות</Label>
|
||||
<Input
|
||||
id="dietary_notes"
|
||||
value={form.dietary_notes}
|
||||
onChange={e => handleChange('dietary_notes', e.target.value)}
|
||||
placeholder="אלרגיה לבוטנים, כסא גלגלים..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'מוסיף...' : 'הוסף אורח'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
ביטול
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
67
client/src/components/RsvpSummaryCard.tsx
Normal file
67
client/src/components/RsvpSummaryCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface Summary {
|
||||
total: string | number;
|
||||
confirmed: string | number;
|
||||
declined: string | number;
|
||||
pending: string | number;
|
||||
}
|
||||
|
||||
interface CapacityWarning {
|
||||
message: string;
|
||||
percent: number;
|
||||
confirmed: number;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
interface RsvpSummaryCardProps {
|
||||
summary: Summary;
|
||||
warning?: CapacityWarning | null;
|
||||
}
|
||||
|
||||
export function RsvpSummaryCard({ summary, warning }: RsvpSummaryCardProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-1 pt-4 px-4">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">סה״כ מוזמנים</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<p className="text-2xl font-bold">{summary.total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-green-200">
|
||||
<CardHeader className="pb-1 pt-4 px-4">
|
||||
<CardTitle className="text-sm font-medium text-green-700">מאושרים</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<p className="text-2xl font-bold text-green-700">{summary.confirmed}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-red-200">
|
||||
<CardHeader className="pb-1 pt-4 px-4">
|
||||
<CardTitle className="text-sm font-medium text-red-700">לא מגיעים</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<p className="text-2xl font-bold text-red-700">{summary.declined}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-yellow-200">
|
||||
<CardHeader className="pb-1 pt-4 px-4">
|
||||
<CardTitle className="text-sm font-medium text-yellow-700">ממתינים</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<p className="text-2xl font-bold text-yellow-700">{summary.pending}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{warning && (
|
||||
<div className="rounded-md border border-orange-300 bg-orange-50 p-3 text-sm text-orange-800" dir="rtl">
|
||||
⚠️ {warning.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
client/src/components/ui/badge.tsx
Normal file
30
client/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-muted text-muted-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-100 text-green-800',
|
||||
warning: 'border-transparent bg-yellow-100 text-yellow-800',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
22
client/src/components/ui/select.tsx
Normal file
22
client/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
);
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export { Select };
|
||||
222
client/src/pages/GuestListPage.tsx
Normal file
222
client/src/pages/GuestListPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { RsvpSummaryCard } from '@/components/RsvpSummaryCard';
|
||||
import { AddGuestForm } from '@/components/AddGuestForm';
|
||||
|
||||
interface Guest {
|
||||
id: string;
|
||||
name_hebrew: string;
|
||||
name_transliteration?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
rsvp_status: 'pending' | 'confirmed' | 'declined';
|
||||
table_number?: number;
|
||||
dietary_preference: string;
|
||||
dietary_notes?: string;
|
||||
whatsapp_link?: string;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total: string;
|
||||
confirmed: string;
|
||||
declined: string;
|
||||
pending: string;
|
||||
}
|
||||
|
||||
interface CapacityWarning {
|
||||
message: string;
|
||||
percent: number;
|
||||
confirmed: number;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
|
||||
const DIETARY_LABELS: Record<string, string> = {
|
||||
none: 'ללא',
|
||||
vegetarian: 'צמחוני',
|
||||
vegan: 'טבעוני',
|
||||
kosher_regular: 'כשר',
|
||||
kosher_mehadrin: 'כשר מהדרין',
|
||||
};
|
||||
|
||||
export function GuestListPage() {
|
||||
const { eventId } = useParams<{ eventId: string }>();
|
||||
const [guests, setGuests] = useState<Guest[]>([]);
|
||||
const [summary, setSummary] = useState<Summary>({ total: '0', confirmed: '0', declined: '0', pending: '0' });
|
||||
const [warning, setWarning] = useState<CapacityWarning | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchGuests = useCallback(async () => {
|
||||
if (!eventId) return;
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (search) params.set('search', search);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/events/${eventId}/guests?${params}`, { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setGuests(data.guests);
|
||||
setSummary(data.summary);
|
||||
setWarning(data.warning || null);
|
||||
} catch {
|
||||
// silently fail on poll
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId, statusFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGuests();
|
||||
// Poll every 30 seconds for real-time updates (MVP approach)
|
||||
const interval = setInterval(fetchGuests, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchGuests]);
|
||||
|
||||
async function handleDelete(guestId: string, guestName: string) {
|
||||
if (!confirm(`האם למחוק את האורח "${guestName}"? פעולה זו היא סופית.`)) return;
|
||||
setDeletingId(guestId);
|
||||
try {
|
||||
await fetch(`/api/guests/${guestId}`, { method: 'DELETE', credentials: 'include' });
|
||||
fetchGuests();
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusOverride(guestId: string, newStatus: string) {
|
||||
await fetch(`/api/guests/${guestId}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rsvp_status: newStatus }),
|
||||
});
|
||||
fetchGuests();
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
window.open(`/api/events/${eventId}/guests/export`, '_blank');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40 p-6" dir="rtl">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold">רשימת אורחים</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
ייצוא CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RsvpSummaryCard summary={summary} warning={warning} />
|
||||
|
||||
<AddGuestForm eventId={eventId!} onGuestAdded={fetchGuests} />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Input
|
||||
placeholder="חיפוש לפי שם..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="max-w-[180px]"
|
||||
>
|
||||
<option value="">כל הסטטוסים</option>
|
||||
<option value="pending">ממתינים</option>
|
||||
<option value="confirmed">מאושרים</option>
|
||||
<option value="declined">לא מגיעים</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Guest Table */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">טוען...</p>
|
||||
) : guests.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center">אין אורחים עדיין. הוסף את האורח הראשון!</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-right">
|
||||
<th className="px-4 py-3 font-medium">שם</th>
|
||||
<th className="px-4 py-3 font-medium">טלפון</th>
|
||||
<th className="px-4 py-3 font-medium">סטטוס RSVP</th>
|
||||
<th className="px-4 py-3 font-medium">שולחן</th>
|
||||
<th className="px-4 py-3 font-medium">תזונה</th>
|
||||
<th className="px-4 py-3 font-medium">WhatsApp</th>
|
||||
<th className="px-4 py-3 font-medium">פעולות</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{guests.map(guest => (
|
||||
<tr key={guest.id} className="border-b hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{guest.name_hebrew}</p>
|
||||
{guest.name_transliteration && (
|
||||
<p className="text-xs text-muted-foreground" dir="ltr">{guest.name_transliteration}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground" dir="ltr">{guest.phone || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Select
|
||||
value={guest.rsvp_status}
|
||||
onChange={e => handleStatusOverride(guest.id, e.target.value)}
|
||||
className="h-7 text-xs py-0 w-32"
|
||||
>
|
||||
<option value="pending">ממתין</option>
|
||||
<option value="confirmed">מאושר</option>
|
||||
<option value="declined">לא מגיע</option>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{guest.table_number || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={guest.dietary_preference === 'none' ? 'secondary' : 'outline'}>
|
||||
{DIETARY_LABELS[guest.dietary_preference] || guest.dietary_preference}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{guest.whatsapp_link ? (
|
||||
<a
|
||||
href={guest.whatsapp_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 hover:underline text-xs"
|
||||
>
|
||||
📲 שלח
|
||||
</a>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(guest.id, guest.name_hebrew)}
|
||||
disabled={deletingId === guest.id}
|
||||
className="text-destructive hover:text-destructive h-7 px-2"
|
||||
>
|
||||
מחק
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
client/src/pages/RsvpPage.tsx
Normal file
214
client/src/pages/RsvpPage.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface RsvpData {
|
||||
guest: {
|
||||
id: string;
|
||||
name_hebrew: string;
|
||||
name_transliteration?: string;
|
||||
rsvp_status: string;
|
||||
dietary_preference: string;
|
||||
dietary_notes?: string;
|
||||
};
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
event_date?: string;
|
||||
venue_name?: string;
|
||||
venue_address?: string;
|
||||
kashrut_level?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const KASHRUT_LABELS: Record<string, string> = {
|
||||
none: '',
|
||||
regular: 'כשר',
|
||||
mehadrin: 'כשר מהדרין',
|
||||
chalav_yisrael: 'חלב ישראל',
|
||||
};
|
||||
|
||||
const DIETARY_LABELS: Record<string, string> = {
|
||||
none: 'ללא הגבלה',
|
||||
vegetarian: 'צמחוני',
|
||||
vegan: 'טבעוני',
|
||||
kosher_regular: 'כשר',
|
||||
kosher_mehadrin: 'כשר מהדרין',
|
||||
};
|
||||
|
||||
export function RsvpPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [data, setData] = useState<RsvpData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
const [dietary_preference, setDietaryPreference] = useState('');
|
||||
const [dietary_notes, setDietaryNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/rsvp/${token}`)
|
||||
.then(res => res.json())
|
||||
.then(d => {
|
||||
if (d.error) throw new Error(d.error);
|
||||
setData(d);
|
||||
setDietaryPreference(d.guest.dietary_preference || 'none');
|
||||
setDietaryNotes(d.guest.dietary_notes || '');
|
||||
if (d.guest.rsvp_status !== 'pending') setSubmitted(true);
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
async function handleRsvp(status: 'confirmed' | 'declined') {
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
try {
|
||||
const res = await fetch(`/api/rsvp/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rsvp_status: status, dietary_preference, dietary_notes }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || 'שגיאה בעדכון');
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
guest: { ...prev.guest, rsvp_status: status },
|
||||
} : prev);
|
||||
setSubmitted(true);
|
||||
} catch (err: unknown) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'שגיאה');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">טוען הזמנה...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full text-center">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-destructive text-lg">{error}</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">ייתכן שהקישור אינו תקין או פג תוקפו.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { guest, event } = data!;
|
||||
const formattedDate = event.event_date
|
||||
? new Date(event.event_date).toLocaleDateString('he-IL', {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader className="text-center" dir="rtl">
|
||||
<CardTitle className="text-2xl">{event.title}</CardTitle>
|
||||
{formattedDate && <CardDescription>{formattedDate}</CardDescription>}
|
||||
{event.venue_name && (
|
||||
<CardDescription>{event.venue_name}{event.venue_address ? ` — ${event.venue_address}` : ''}</CardDescription>
|
||||
)}
|
||||
{event.kashrut_level && event.kashrut_level !== 'none' && (
|
||||
<CardDescription className="text-xs">🕍 {KASHRUT_LABELS[event.kashrut_level]}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent dir="rtl" className="space-y-4">
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="font-medium text-lg">{guest.name_hebrew}</p>
|
||||
{guest.name_transliteration && (
|
||||
<p className="text-sm text-muted-foreground" dir="ltr">{guest.name_transliteration}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="text-center py-4 space-y-2">
|
||||
{guest.rsvp_status === 'confirmed' ? (
|
||||
<>
|
||||
<p className="text-3xl">🎉</p>
|
||||
<p className="font-semibold text-green-700 text-lg">אישרת הגעה!</p>
|
||||
<p className="text-muted-foreground text-sm">תודה! נתראה באירוע.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-3xl">🙏</p>
|
||||
<p className="font-semibold text-lg">תשובתך נרשמה</p>
|
||||
<p className="text-muted-foreground text-sm">תודה על העדכון.</p>
|
||||
</>
|
||||
)}
|
||||
{guest.rsvp_status === 'pending' && (
|
||||
<Button variant="outline" className="mt-2" onClick={() => setSubmitted(false)}>
|
||||
שנה תשובה
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-center font-medium">האם אתה מגיע לאירוע?</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dietary">העדפה תזונתית</Label>
|
||||
<Select
|
||||
id="dietary"
|
||||
value={dietary_preference}
|
||||
onChange={e => setDietaryPreference(e.target.value)}
|
||||
>
|
||||
{Object.entries(DIETARY_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">הערות נוספות (אופציונלי)</Label>
|
||||
<Input
|
||||
id="notes"
|
||||
value={dietary_notes}
|
||||
onChange={e => setDietaryNotes(e.target.value)}
|
||||
placeholder="אלרגיות, דרישות מיוחדות..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError && <p className="text-sm text-destructive text-center">{submitError}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<Button
|
||||
onClick={() => handleRsvp('confirmed')}
|
||||
disabled={submitting}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
✓ אני מגיע/ה
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRsvp('declined')}
|
||||
disabled={submitting}
|
||||
>
|
||||
✗ לא אוכל להגיע
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1974
package-lock.json
generated
Normal file
1974
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,10 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"json2csv": "^6.0.0-alpha.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
|
||||
406
routes/guests.js
Normal file
406
routes/guests.js
Normal file
@@ -0,0 +1,406 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { Parser } = require('json2csv');
|
||||
const pool = require('../db/pool');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalize Israeli phone to E.164: 05X-XXXXXXX or 05XXXXXXXXX → +972XXXXXXXXX */
|
||||
function normalizeIsraeliPhone(phone) {
|
||||
if (!phone) return null;
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.startsWith('972')) return `+${digits}`;
|
||||
if (digits.startsWith('0')) return `+972${digits.slice(1)}`;
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
/** Generate a cryptographically secure RSVP token (128 bits = 32 hex chars) */
|
||||
function generateRsvpToken() {
|
||||
return crypto.randomBytes(16).toString('hex'); // 128 bits
|
||||
}
|
||||
|
||||
/** Build wa.me deep-link for WhatsApp RSVP */
|
||||
function buildWhatsAppLink(phone, eventTitle, rsvpUrl) {
|
||||
if (!phone) return null;
|
||||
const normalized = normalizeIsraeliPhone(phone);
|
||||
const phoneDigits = normalized.replace('+', '');
|
||||
const message = encodeURIComponent(
|
||||
`הוזמנת לאירוע "${eventTitle}". לאישור הגעה: ${rsvpUrl}`
|
||||
);
|
||||
return `https://wa.me/${phoneDigits}?text=${message}`;
|
||||
}
|
||||
|
||||
/** Verify organizer owns the event */
|
||||
async function verifyEventOwner(eventId, organizerId) {
|
||||
const result = await pool.query(
|
||||
'SELECT id, title, venue_capacity FROM events WHERE id = $1 AND organizer_id = $2 AND deleted_at IS NULL',
|
||||
[eventId, organizerId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/** Return capacity warning if confirmed RSVPs ≥ 90% of venue_capacity */
|
||||
async function getCapacityWarning(eventId, venueCapacity) {
|
||||
if (!venueCapacity) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COUNT(*) FROM guests WHERE event_id = $1 AND rsvp_status = 'confirmed'`,
|
||||
[eventId]
|
||||
);
|
||||
const confirmed = parseInt(rows[0].count, 10);
|
||||
const pct = confirmed / venueCapacity;
|
||||
if (pct >= 0.9) {
|
||||
return {
|
||||
type: 'capacity_warning',
|
||||
message: `אזהרה: ${confirmed} מתוך ${venueCapacity} מקומות מאושרים (${Math.round(pct * 100)}%)`,
|
||||
confirmed,
|
||||
capacity: venueCapacity,
|
||||
percent: Math.round(pct * 100),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── POST /api/events/:eventId/guests — Add guest ────────────────────────────
|
||||
|
||||
router.post('/events/:eventId/guests', authMiddleware, async (req, res) => {
|
||||
const { eventId } = req.params;
|
||||
const organizerId = req.user.id;
|
||||
|
||||
if (req.user.role !== 'organizer') {
|
||||
return res.status(403).json({ error: 'Only organizers can add guests' });
|
||||
}
|
||||
|
||||
const event = await verifyEventOwner(eventId, organizerId).catch(() => null);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const {
|
||||
name_hebrew,
|
||||
name_transliteration,
|
||||
email,
|
||||
phone,
|
||||
relationship_group,
|
||||
dietary_preference,
|
||||
dietary_notes,
|
||||
accessibility_needs,
|
||||
table_number,
|
||||
seat_number,
|
||||
plus_one_of,
|
||||
plus_one_allowance,
|
||||
} = req.body;
|
||||
|
||||
if (!name_hebrew || name_hebrew.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'שם בעברית הוא שדה חובה' });
|
||||
}
|
||||
|
||||
const validDietary = ['none', 'vegetarian', 'vegan', 'kosher_regular', 'kosher_mehadrin'];
|
||||
const validRelationship = ['family_bride', 'family_groom', 'friends', 'work', 'community', 'other'];
|
||||
|
||||
if (dietary_preference && !validDietary.includes(dietary_preference)) {
|
||||
return res.status(400).json({ error: 'סוג תזונה לא תקין' });
|
||||
}
|
||||
if (relationship_group && !validRelationship.includes(relationship_group)) {
|
||||
return res.status(400).json({ error: 'קבוצת יחסים לא תקינה' });
|
||||
}
|
||||
|
||||
const normalizedPhone = normalizeIsraeliPhone(phone);
|
||||
|
||||
try {
|
||||
// Insert guest
|
||||
const guestResult = await pool.query(
|
||||
`INSERT INTO guests (
|
||||
event_id, name_hebrew, name_transliteration, email, phone,
|
||||
relationship_group, dietary_preference, dietary_notes,
|
||||
accessibility_needs, table_number, seat_number,
|
||||
plus_one_of, plus_one_allowance, source, privacy_accepted_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'registered', NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
eventId,
|
||||
name_hebrew.trim(),
|
||||
name_transliteration?.trim() || null,
|
||||
email?.toLowerCase() || null,
|
||||
normalizedPhone,
|
||||
relationship_group || null,
|
||||
dietary_preference || 'none',
|
||||
dietary_notes || null,
|
||||
accessibility_needs || null,
|
||||
table_number || null,
|
||||
seat_number || null,
|
||||
plus_one_of || null,
|
||||
plus_one_allowance || 0,
|
||||
]
|
||||
);
|
||||
const guest = guestResult.rows[0];
|
||||
|
||||
// Generate invitation + WhatsApp link
|
||||
const token = generateRsvpToken();
|
||||
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||
const rsvpUrl = `${baseUrl}/rsvp/${token}`;
|
||||
const whatsappLink = buildWhatsAppLink(normalizedPhone, event.title, rsvpUrl);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO invitations (event_id, guest_id, token, channel, whatsapp_link)
|
||||
VALUES ($1, $2, $3, 'whatsapp', $4)`,
|
||||
[eventId, guest.id, token, whatsappLink]
|
||||
);
|
||||
|
||||
// Capacity warning check
|
||||
const warning = await getCapacityWarning(eventId, event.venue_capacity);
|
||||
|
||||
return res.status(201).json({
|
||||
guest,
|
||||
rsvp_url: rsvpUrl,
|
||||
whatsapp_link: whatsappLink,
|
||||
...(warning && { warning }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Add guest error:', err.message);
|
||||
return res.status(500).json({ error: 'Failed to add guest' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/events/:eventId/guests — List guests ───────────────────────────
|
||||
|
||||
router.get('/events/:eventId/guests', authMiddleware, async (req, res) => {
|
||||
const { eventId } = req.params;
|
||||
const organizerId = req.user.id;
|
||||
|
||||
const event = await verifyEventOwner(eventId, organizerId).catch(() => null);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const { status, search, page = 1, limit = 100 } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const conditions = ['g.event_id = $1'];
|
||||
const params = [eventId];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (status && ['pending', 'confirmed', 'declined'].includes(status)) {
|
||||
conditions.push(`g.rsvp_status = $${paramIdx++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
// pg_trgm fuzzy search on Hebrew name
|
||||
conditions.push(`(g.name_hebrew % $${paramIdx} OR g.name_transliteration ILIKE $${paramIdx + 1})`);
|
||||
params.push(search, `%${search}%`);
|
||||
paramIdx += 2;
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
try {
|
||||
const [guestsResult, countResult, summaryResult] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT g.*, i.token, i.whatsapp_link, i.sent_at, i.opened_at
|
||||
FROM guests g
|
||||
LEFT JOIN invitations i ON i.guest_id = g.id
|
||||
WHERE ${where}
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
|
||||
[...params, parseInt(limit), offset]
|
||||
),
|
||||
pool.query(`SELECT COUNT(*) FROM guests g WHERE ${where}`, params),
|
||||
pool.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE rsvp_status = 'pending') AS pending,
|
||||
COUNT(*) FILTER (WHERE rsvp_status = 'confirmed') AS confirmed,
|
||||
COUNT(*) FILTER (WHERE rsvp_status = 'declined') AS declined,
|
||||
COUNT(*) AS total
|
||||
FROM guests WHERE event_id = $1`,
|
||||
[eventId]
|
||||
),
|
||||
]);
|
||||
|
||||
const warning = await getCapacityWarning(eventId, event.venue_capacity);
|
||||
|
||||
return res.json({
|
||||
guests: guestsResult.rows,
|
||||
summary: summaryResult.rows[0],
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
...(warning && { warning }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('List guests error:', err.message);
|
||||
return res.status(500).json({ error: 'Failed to fetch guests' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/events/:eventId/guests/export — CSV export ─────────────────────
|
||||
|
||||
router.get('/events/:eventId/guests/export', authMiddleware, async (req, res) => {
|
||||
const { eventId } = req.params;
|
||||
const organizerId = req.user.id;
|
||||
|
||||
const event = await verifyEventOwner(eventId, organizerId).catch(() => null);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
g.name_hebrew, g.name_transliteration, g.email, g.phone,
|
||||
g.rsvp_status, g.table_number, g.seat_number,
|
||||
g.relationship_group, g.dietary_preference, g.dietary_notes,
|
||||
g.accessibility_needs, g.plus_one_allowance,
|
||||
g.created_at,
|
||||
i.whatsapp_link, i.sent_at AS invitation_sent_at, i.opened_at AS invitation_opened_at
|
||||
FROM guests g
|
||||
LEFT JOIN invitations i ON i.guest_id = g.id
|
||||
WHERE g.event_id = $1
|
||||
ORDER BY g.name_hebrew`,
|
||||
[eventId]
|
||||
);
|
||||
|
||||
const fields = [
|
||||
{ label: 'שם בעברית', value: 'name_hebrew' },
|
||||
{ label: 'תעתיק', value: 'name_transliteration' },
|
||||
{ label: 'אימייל', value: 'email' },
|
||||
{ label: 'טלפון', value: 'phone' },
|
||||
{ label: 'סטטוס RSVP', value: 'rsvp_status' },
|
||||
{ label: 'מספר שולחן', value: 'table_number' },
|
||||
{ label: 'מספר מושב', value: 'seat_number' },
|
||||
{ label: 'קבוצת יחסים', value: 'relationship_group' },
|
||||
{ label: 'העדפה תזונתית', value: 'dietary_preference' },
|
||||
{ label: 'הערות תזונה', value: 'dietary_notes' },
|
||||
{ label: 'צרכי נגישות', value: 'accessibility_needs' },
|
||||
{ label: 'מלווים מורשים', value: 'plus_one_allowance' },
|
||||
{ label: 'קישור WhatsApp', value: 'whatsapp_link' },
|
||||
{ label: 'הוזמנות נשלחה', value: 'invitation_sent_at' },
|
||||
{ label: 'הוזמנות נפתחה', value: 'invitation_opened_at' },
|
||||
{ label: 'תאריך הוספה', value: 'created_at' },
|
||||
];
|
||||
|
||||
const parser = new Parser({ fields, withBOM: true }); // BOM for Excel Hebrew support
|
||||
const csv = parser.parse(rows);
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="guests-${eventId}.csv"`);
|
||||
return res.send(csv);
|
||||
} catch (err) {
|
||||
console.error('CSV export error:', err.message);
|
||||
return res.status(500).json({ error: 'CSV export failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PUT /api/guests/:guestId — Update guest ─────────────────────────────────
|
||||
|
||||
router.put('/guests/:guestId', authMiddleware, async (req, res) => {
|
||||
const { guestId } = req.params;
|
||||
const organizerId = req.user.id;
|
||||
|
||||
// Verify organizer owns the event this guest belongs to
|
||||
const ownerCheck = await pool.query(
|
||||
`SELECT g.id FROM guests g
|
||||
JOIN events e ON e.id = g.event_id
|
||||
WHERE g.id = $1 AND e.organizer_id = $2 AND e.deleted_at IS NULL`,
|
||||
[guestId, organizerId]
|
||||
).catch(() => ({ rows: [] }));
|
||||
|
||||
if (ownerCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
const {
|
||||
name_hebrew, name_transliteration, email, phone,
|
||||
rsvp_status, table_number, seat_number,
|
||||
relationship_group, dietary_preference, dietary_notes,
|
||||
accessibility_needs, plus_one_allowance,
|
||||
} = req.body;
|
||||
|
||||
const validRsvp = ['pending', 'confirmed', 'declined'];
|
||||
const validDietary = ['none', 'vegetarian', 'vegan', 'kosher_regular', 'kosher_mehadrin'];
|
||||
|
||||
if (rsvp_status && !validRsvp.includes(rsvp_status)) {
|
||||
return res.status(400).json({ error: 'סטטוס RSVP לא תקין' });
|
||||
}
|
||||
if (dietary_preference && !validDietary.includes(dietary_preference)) {
|
||||
return res.status(400).json({ error: 'סוג תזונה לא תקין' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE guests SET
|
||||
name_hebrew = COALESCE($1, name_hebrew),
|
||||
name_transliteration = COALESCE($2, name_transliteration),
|
||||
email = COALESCE($3, email),
|
||||
phone = COALESCE($4, phone),
|
||||
rsvp_status = COALESCE($5::rsvp_status, rsvp_status),
|
||||
table_number = COALESCE($6, table_number),
|
||||
seat_number = COALESCE($7, seat_number),
|
||||
relationship_group = COALESCE($8::relationship_group, relationship_group),
|
||||
dietary_preference = COALESCE($9::dietary_preference, dietary_preference),
|
||||
dietary_notes = COALESCE($10, dietary_notes),
|
||||
accessibility_needs = COALESCE($11, accessibility_needs),
|
||||
plus_one_allowance = COALESCE($12, plus_one_allowance),
|
||||
updated_at = NOW()
|
||||
WHERE id = $13
|
||||
RETURNING *`,
|
||||
[
|
||||
name_hebrew?.trim() || null,
|
||||
name_transliteration?.trim() || null,
|
||||
email?.toLowerCase() || null,
|
||||
phone ? normalizeIsraeliPhone(phone) : null,
|
||||
rsvp_status || null,
|
||||
table_number || null,
|
||||
seat_number || null,
|
||||
relationship_group || null,
|
||||
dietary_preference || null,
|
||||
dietary_notes || null,
|
||||
accessibility_needs || null,
|
||||
plus_one_allowance != null ? parseInt(plus_one_allowance) : null,
|
||||
guestId,
|
||||
]
|
||||
);
|
||||
|
||||
// Check capacity after update
|
||||
const eventRow = await pool.query(
|
||||
'SELECT venue_capacity, id FROM events WHERE id = (SELECT event_id FROM guests WHERE id = $1)',
|
||||
[guestId]
|
||||
);
|
||||
const warning = eventRow.rows[0]
|
||||
? await getCapacityWarning(eventRow.rows[0].id, eventRow.rows[0].venue_capacity)
|
||||
: null;
|
||||
|
||||
return res.json({
|
||||
guest: result.rows[0],
|
||||
...(warning && { warning }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Update guest error:', err.message);
|
||||
return res.status(500).json({ error: 'Failed to update guest' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DELETE /api/guests/:guestId — Hard delete (Israeli Privacy Law) ─────────
|
||||
|
||||
router.delete('/guests/:guestId', authMiddleware, async (req, res) => {
|
||||
const { guestId } = req.params;
|
||||
const organizerId = req.user.id;
|
||||
|
||||
const ownerCheck = await pool.query(
|
||||
`SELECT g.id FROM guests g
|
||||
JOIN events e ON e.id = g.event_id
|
||||
WHERE g.id = $1 AND e.organizer_id = $2 AND e.deleted_at IS NULL`,
|
||||
[guestId, organizerId]
|
||||
).catch(() => ({ rows: [] }));
|
||||
|
||||
if (ownerCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Hard delete per Israeli Privacy Law 2023 (no deleted_at on guests table)
|
||||
await pool.query('DELETE FROM guests WHERE id = $1', [guestId]);
|
||||
return res.json({ message: 'Guest deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error('Delete guest error:', err.message);
|
||||
return res.status(500).json({ error: 'Failed to delete guest' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
139
routes/rsvp.js
Normal file
139
routes/rsvp.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const express = require('express');
|
||||
const pool = require('../db/pool');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ─── GET /api/rsvp/:token — Public RSVP page data (no auth) ─────────────────
|
||||
|
||||
router.get('/:token', async (req, res) => {
|
||||
const { token } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
i.token, i.opened_at,
|
||||
g.id AS guest_id, g.name_hebrew, g.name_transliteration,
|
||||
g.rsvp_status, g.dietary_preference, g.dietary_notes,
|
||||
e.id AS event_id, e.title, e.event_date, e.venue_name, e.venue_address,
|
||||
e.kashrut_level, e.language_pref
|
||||
FROM invitations i
|
||||
JOIN guests g ON g.id = i.guest_id
|
||||
JOIN events e ON e.id = i.event_id
|
||||
WHERE i.token = $1
|
||||
AND e.deleted_at IS NULL`,
|
||||
[token]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'קישור ההזמנה לא נמצא' });
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
// Mark as opened (first visit) — idempotent, only set once
|
||||
if (!row.opened_at) {
|
||||
await pool.query(
|
||||
'UPDATE invitations SET opened_at = NOW() WHERE token = $1',
|
||||
[token]
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: row.guest_id,
|
||||
name_hebrew: row.name_hebrew,
|
||||
name_transliteration: row.name_transliteration,
|
||||
rsvp_status: row.rsvp_status,
|
||||
dietary_preference: row.dietary_preference,
|
||||
dietary_notes: row.dietary_notes,
|
||||
},
|
||||
event: {
|
||||
id: row.event_id,
|
||||
title: row.title,
|
||||
event_date: row.event_date,
|
||||
venue_name: row.venue_name,
|
||||
venue_address: row.venue_address,
|
||||
kashrut_level: row.kashrut_level,
|
||||
language_pref: row.language_pref,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('RSVP get error:', err.message);
|
||||
return res.status(500).json({ error: 'שגיאה בטעינת ההזמנה' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/rsvp/:token — Submit RSVP (idempotent, no auth) ───────────────
|
||||
|
||||
router.post('/:token', async (req, res) => {
|
||||
const { token } = req.params;
|
||||
const { rsvp_status, dietary_preference, dietary_notes } = req.body;
|
||||
|
||||
const validRsvp = ['confirmed', 'declined'];
|
||||
if (!rsvp_status || !validRsvp.includes(rsvp_status)) {
|
||||
return res.status(400).json({ error: 'נא לבחור אישור או דחייה' });
|
||||
}
|
||||
|
||||
const validDietary = ['none', 'vegetarian', 'vegan', 'kosher_regular', 'kosher_mehadrin'];
|
||||
if (dietary_preference && !validDietary.includes(dietary_preference)) {
|
||||
return res.status(400).json({ error: 'סוג תזונה לא תקין' });
|
||||
}
|
||||
|
||||
try {
|
||||
const invResult = await pool.query(
|
||||
`SELECT i.guest_id, e.deleted_at
|
||||
FROM invitations i
|
||||
JOIN guests g ON g.id = i.guest_id
|
||||
JOIN events e ON e.id = g.event_id
|
||||
WHERE i.token = $1`,
|
||||
[token]
|
||||
);
|
||||
|
||||
if (invResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'קישור ההזמנה לא נמצא' });
|
||||
}
|
||||
|
||||
if (invResult.rows[0].deleted_at) {
|
||||
return res.status(410).json({ error: 'האירוע בוטל' });
|
||||
}
|
||||
|
||||
const guestId = invResult.rows[0].guest_id;
|
||||
|
||||
// Idempotent upsert — safe to call multiple times for the same guest
|
||||
const updatedFields = [rsvp_status, guestId];
|
||||
const dietaryUpdate = dietary_preference
|
||||
? `, dietary_preference = $3::dietary_preference${dietary_notes !== undefined ? ', dietary_notes = $4' : ''}`
|
||||
: dietary_notes !== undefined
|
||||
? ', dietary_notes = $3'
|
||||
: '';
|
||||
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (dietary_preference && dietary_notes !== undefined) {
|
||||
query = `UPDATE guests SET rsvp_status = $1::rsvp_status${dietaryUpdate}, updated_at = NOW() WHERE id = $2 RETURNING rsvp_status, dietary_preference, dietary_notes`;
|
||||
params = [rsvp_status, guestId, dietary_preference, dietary_notes];
|
||||
} else if (dietary_preference) {
|
||||
query = `UPDATE guests SET rsvp_status = $1::rsvp_status${dietaryUpdate}, updated_at = NOW() WHERE id = $2 RETURNING rsvp_status, dietary_preference, dietary_notes`;
|
||||
params = [rsvp_status, guestId, dietary_preference];
|
||||
} else if (dietary_notes !== undefined) {
|
||||
query = `UPDATE guests SET rsvp_status = $1::rsvp_status, dietary_notes = $3, updated_at = NOW() WHERE id = $2 RETURNING rsvp_status, dietary_preference, dietary_notes`;
|
||||
params = [rsvp_status, guestId, dietary_notes];
|
||||
} else {
|
||||
query = `UPDATE guests SET rsvp_status = $1::rsvp_status, updated_at = NOW() WHERE id = $2 RETURNING rsvp_status, dietary_preference, dietary_notes`;
|
||||
params = [rsvp_status, guestId];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
return res.json({
|
||||
message: rsvp_status === 'confirmed' ? 'תודה! הגעתך אושרה.' : 'תודה! עדכנו את מצבך.',
|
||||
guest: result.rows[0],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('RSVP submit error:', err.message);
|
||||
return res.status(500).json({ error: 'שגיאה בעדכון ה-RSVP' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -5,6 +5,8 @@ const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const guestRoutes = require('./routes/guests');
|
||||
const rsvpRoutes = require('./routes/rsvp');
|
||||
const { authMiddleware } = require('./middleware/auth');
|
||||
|
||||
const app = express();
|
||||
@@ -23,9 +25,15 @@ app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
||||
// Auth routes — no middleware (register/login are public)
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Public RSVP routes — no auth required
|
||||
app.use('/api/rsvp', rsvpRoutes);
|
||||
|
||||
// All routes below require valid JWT
|
||||
app.use('/api', authMiddleware);
|
||||
|
||||
// Guest management routes (auth enforced above)
|
||||
app.use('/api', guestRoutes);
|
||||
|
||||
// Serve React frontend in production
|
||||
app.use(express.static(path.join(__dirname, 'client', 'dist')));
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user