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:
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;
|
||||
Reference in New Issue
Block a user