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

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;