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

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;