feat: Guest import, RSVP reminders — 7ead7758 scope additions

CSV/Excel Import:
- POST /api/events/:id/guests/import (multer memory storage, max 5MB)
- Accepts .csv and .xlsx/.xls via xlsx + csv-parse/sync
- Handles Hebrew column names and English column names interchangeably
- Phone normalization (domain expert spec): strips spaces/hyphens/parens,
  handles 05X-XXXXXXX → +972..., +972... passthrough, 972... → +972...
  Invalid phone → guest imported with phone=null, warning recorded
- Unknown dietary_preference → 'none'; unknown relationship_group → 'other'
- Bulk insert in transaction (all-or-nothing), max 500 rows
- Returns: { imported, skipped, warnings, details } with per-row reasons
- UTF-8 BOM handled on CSV parse (Excel exports)

RSVP Reminder Cron:
- jobs/reminderCron.js: node-cron, daily at 09:00 Asia/Jerusalem
- Queries guests with rsvp_status=pending where event is 7 or 2 days away
- Regenerates wa.me reminder deep-link with urgency text (עוד שבוע / עוד 2 ימים)
- Updates invitations.whatsapp_link in-place
- No auto-send (MVP): organizer clicks link manually
- Started automatically in server.js app.listen callback

GET /api/events/:id/guests/reminders:
- Returns pending guests who have whatsapp_link set (reminder generated by cron)
- Organizer uses this to surface the Pending Reminders panel

Frontend additions:
- ImportGuestsForm component: file picker, POST multipart, shows import summary
  with per-row skipped/warning details
- PendingRemindersPanel component: orange card listing pending guests with
  wa.me reminder links; hides itself when no reminders
- GuestListPage: integrated both components, refreshTrigger propagates to
  reminders panel after any add/import/delete/status-change

Build: 0 TS errors, 62 modules transformed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:35:54 +00:00
parent b65f018a8b
commit c878eee62b
8 changed files with 661 additions and 8 deletions

82
jobs/reminderCron.js Normal file
View File

@@ -0,0 +1,82 @@
/**
* RSVP Reminder Cron Job
*
* Runs daily at 09:00 Asia/Jerusalem.
* Finds events at 7-day and 2-day thresholds with pending guests.
* Updates invitations.whatsapp_link with a fresh wa.me reminder deep-link.
* Organizer clicks the link manually (no auto-send in MVP).
*/
const cron = require('node-cron');
const pool = require('../db/pool');
const BASE_URL = process.env.APP_BASE_URL || 'http://localhost:3000';
function buildReminderWhatsAppLink(phone, eventTitle, rsvpUrl, daysUntilEvent) {
if (!phone) return null;
const phoneDigits = phone.replace('+', '');
const urgency = daysUntilEvent <= 2 ? 'עוד 2 ימים' : 'עוד שבוע';
const message = encodeURIComponent(
`תזכורת: ${urgency} לאירוע "${eventTitle}". אנא אשר/י הגעה: ${rsvpUrl}`
);
return `https://wa.me/${phoneDigits}?text=${message}`;
}
async function generateReminders() {
console.log('[ReminderCron] Running RSVP reminder generation…');
try {
// Find pending guests whose event is 7 or 2 days from now (±12h window for daily run)
const { rows } = await pool.query(`
SELECT
g.id AS guest_id,
g.name_hebrew,
g.phone,
e.id AS event_id,
e.title,
e.event_date,
i.token,
EXTRACT(DAY FROM (e.event_date::date - CURRENT_DATE)) AS days_until
FROM guests g
JOIN events e ON e.id = g.event_id
JOIN invitations i ON i.guest_id = g.id
WHERE g.rsvp_status = 'pending'
AND e.deleted_at IS NULL
AND e.event_date > NOW()
AND EXTRACT(DAY FROM (e.event_date::date - CURRENT_DATE)) IN (7, 2)
`);
if (rows.length === 0) {
console.log('[ReminderCron] No pending reminders due today.');
return;
}
for (const row of rows) {
const rsvpUrl = `${BASE_URL}/rsvp/${row.token}`;
const whatsappLink = buildReminderWhatsAppLink(
row.phone, row.title, rsvpUrl, parseInt(row.days_until)
);
if (whatsappLink) {
await pool.query(
'UPDATE invitations SET whatsapp_link = $1 WHERE token = $2',
[whatsappLink, row.token]
);
}
}
console.log(`[ReminderCron] Updated ${rows.length} pending reminder links.`);
} catch (err) {
console.error('[ReminderCron] Error:', err.message);
}
}
function startReminderCron() {
// 09:00 every day, Asia/Jerusalem timezone
cron.schedule('0 9 * * *', generateReminders, {
timezone: 'Asia/Jerusalem',
});
console.log('[ReminderCron] Scheduled: daily at 09:00 Asia/Jerusalem');
}
module.exports = { startReminderCron, generateReminders };