Files
shokuninmarche/routes/guests.js
airewit-developer b65f018a8b 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>
2026-02-21 18:31:08 +00:00

407 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;