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>
140 lines
5.0 KiB
JavaScript
140 lines
5.0 KiB
JavaScript
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;
|