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:
2026-02-21 18:31:08 +00:00
parent c8909befb1
commit b65f018a8b
12 changed files with 3326 additions and 1 deletions

View File

@@ -4,14 +4,20 @@ import { ProtectedRoute } from '@/components/ProtectedRoute';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { RegisterPage } from '@/pages/RegisterPage'; import { RegisterPage } from '@/pages/RegisterPage';
import { DashboardPage } from '@/pages/DashboardPage'; import { DashboardPage } from '@/pages/DashboardPage';
import { GuestListPage } from '@/pages/GuestListPage';
import { RsvpPage } from '@/pages/RsvpPage';
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Routes> <Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/rsvp/:token" element={<RsvpPage />} />
{/* Protected routes */}
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
@@ -20,7 +26,15 @@ export default function App() {
</ProtectedRoute> </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 />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>

View 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>
);
}

View 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>
);
}

View 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 };

View 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 };

View 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>
);
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,10 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"crypto": "^1.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.18.3", "express": "^4.18.3",
"json2csv": "^6.0.0-alpha.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"pg": "^8.11.3" "pg": "^8.11.3"
}, },

406
routes/guests.js Normal file
View 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
View 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;

View File

@@ -5,6 +5,8 @@ const cors = require('cors');
const path = require('path'); const path = require('path');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const guestRoutes = require('./routes/guests');
const rsvpRoutes = require('./routes/rsvp');
const { authMiddleware } = require('./middleware/auth'); const { authMiddleware } = require('./middleware/auth');
const app = express(); const app = express();
@@ -23,9 +25,15 @@ app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Auth routes — no middleware (register/login are public) // Auth routes — no middleware (register/login are public)
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
// Public RSVP routes — no auth required
app.use('/api/rsvp', rsvpRoutes);
// All routes below require valid JWT // All routes below require valid JWT
app.use('/api', authMiddleware); app.use('/api', authMiddleware);
// Guest management routes (auth enforced above)
app.use('/api', guestRoutes);
// Serve React frontend in production // Serve React frontend in production
app.use(express.static(path.join(__dirname, 'client', 'dist'))); app.use(express.static(path.join(__dirname, 'client', 'dist')));
app.get('*', (req, res) => { app.get('*', (req, res) => {