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

View File

@@ -1,20 +1,32 @@
const express = require('express');
const crypto = require('crypto');
const multer = require('multer');
const XLSX = require('xlsx');
const { parse: csvParse } = require('csv-parse/sync');
const { Parser } = require('json2csv');
const pool = require('../db/pool');
const { authMiddleware } = require('../middleware/auth');
// Memory storage — files never hit disk
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
const router = express.Router();
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Normalize Israeli phone to E.164: 05X-XXXXXXX or 05XXXXXXXXX → +972XXXXXXXXX */
/**
* Normalize Israeli phone to E.164 (+972XXXXXXXXX).
* Handles: local 05X-XXXXXXX, already E.164, international without +.
* Returns null for unrecognizable formats (caller decides how to handle).
*/
function normalizeIsraeliPhone(phone) {
if (!phone) return null;
const digits = phone.replace(/\D/g, '');
// Strip spaces, hyphens, parentheses
const digits = String(phone).replace(/[\s\-\(\)]/g, '');
if (digits.startsWith('+972')) return digits;
if (digits.startsWith('972')) return `+${digits}`;
if (digits.startsWith('0')) return `+972${digits.slice(1)}`;
return `+${digits}`;
return null; // invalid — caller sets phone=null and records warning
}
/** Generate a cryptographically secure RSVP token (128 bits = 32 hex chars) */
@@ -403,4 +415,165 @@ router.delete('/guests/:guestId', authMiddleware, async (req, res) => {
}
});
// ─── GET /api/events/:eventId/guests/reminders — Pending reminder links ───────
router.get('/events/:eventId/guests/reminders', 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.id, g.name_hebrew, g.name_transliteration, g.phone,
i.token, i.whatsapp_link,
EXTRACT(DAY FROM (e.event_date::date - CURRENT_DATE)) AS days_until
FROM guests g
JOIN invitations i ON i.guest_id = g.id
JOIN events e ON e.id = g.event_id
WHERE g.event_id = $1
AND g.rsvp_status = 'pending'
AND e.event_date > NOW()
AND i.whatsapp_link IS NOT NULL
ORDER BY g.name_hebrew`,
[eventId]
);
return res.json({ reminders: rows });
} catch (err) {
console.error('Reminders error:', err.message);
return res.status(500).json({ error: 'Failed to fetch reminders' });
}
});
// ─── POST /api/events/:eventId/guests/import — CSV/Excel bulk import ──────────
const VALID_DIETARY = ['none', 'vegetarian', 'vegan', 'kosher_regular', 'kosher_mehadrin'];
const VALID_RELATIONSHIP = ['family_bride', 'family_groom', 'friends', 'work', 'community', 'other'];
const MAX_IMPORT_ROWS = 500;
function normalizeImportRow(raw) {
const name_hebrew = (raw.name_hebrew || raw['שם בעברית'] || raw.name || '').trim();
const name_transliteration = (raw.name_transliteration || raw.name_latin || raw['שם באנגלית'] || '').trim() || null;
const rawPhone = raw.phone || raw['טלפון'] || raw['phone'] || '';
const rawDietary = (raw.dietary_preference || raw['העדפה תזונתית'] || '').trim().toLowerCase();
const rawRelationship = (raw.relationship_group || raw['קבוצת יחסים'] || '').trim().toLowerCase();
const email = (raw.email || raw['אימייל'] || '').trim().toLowerCase() || null;
const phone = normalizeIsraeliPhone(rawPhone);
const phoneWarning = rawPhone && !phone ? `טלפון לא תקין: "${rawPhone}"` : null;
const dietary_preference = VALID_DIETARY.includes(rawDietary) ? rawDietary : 'none';
const relationship_group = VALID_RELATIONSHIP.includes(rawRelationship) ? rawRelationship : (rawRelationship ? 'other' : null);
return { name_hebrew, name_transliteration, phone, phoneWarning, email, dietary_preference, relationship_group };
}
router.post('/events/:eventId/guests/import', authMiddleware, upload.single('file'), 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 import guests' });
}
const event = await verifyEventOwner(eventId, organizerId).catch(() => null);
if (!event) return res.status(404).json({ error: 'Event not found' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const mimeType = req.file.mimetype;
const originalName = req.file.originalname.toLowerCase();
let rows = [];
try {
if (originalName.endsWith('.xlsx') || originalName.endsWith('.xls') || mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
const wb = XLSX.read(req.file.buffer, { type: 'buffer' });
const ws = wb.Sheets[wb.SheetNames[0]];
rows = XLSX.utils.sheet_to_json(ws, { defval: '' });
} else {
// CSV (utf-8 or utf-8 with BOM)
const content = req.file.buffer.toString('utf-8').replace(/^\uFEFF/, '');
rows = csvParse(content, { columns: true, skip_empty_lines: true, trim: true });
}
} catch (parseErr) {
return res.status(400).json({ error: `לא ניתן לנתח את הקובץ: ${parseErr.message}` });
}
if (rows.length === 0) return res.status(400).json({ error: 'הקובץ ריק' });
if (rows.length > MAX_IMPORT_ROWS) {
return res.status(400).json({ error: `מקסימום ${MAX_IMPORT_ROWS} שורות לייבוא. הקובץ מכיל ${rows.length} שורות.` });
}
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const imported = [];
const skipped = [];
const warnings = [];
const client = await pool.connect();
try {
await client.query('BEGIN');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const normalized = normalizeImportRow(row);
if (!normalized.name_hebrew) {
skipped.push({ row: i + 2, reason: 'שם בעברית חסר' });
continue;
}
if (normalized.phoneWarning) {
warnings.push({ row: i + 2, name: normalized.name_hebrew, warning: normalized.phoneWarning });
}
const guestResult = await client.query(
`INSERT INTO guests (event_id, name_hebrew, name_transliteration, email, phone,
dietary_preference, relationship_group, source, privacy_accepted_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,'registered', NOW())
RETURNING id`,
[
eventId,
normalized.name_hebrew,
normalized.name_transliteration,
normalized.email,
normalized.phone,
normalized.dietary_preference,
normalized.relationship_group,
]
);
const guestId = guestResult.rows[0].id;
const token = generateRsvpToken();
const rsvpUrl = `${baseUrl}/rsvp/${token}`;
const whatsappLink = normalized.phone ? buildWhatsAppLink(normalized.phone, event.title, rsvpUrl) : null;
await client.query(
`INSERT INTO invitations (event_id, guest_id, token, channel, whatsapp_link)
VALUES ($1,$2,$3,'whatsapp',$4)`,
[eventId, guestId, token, whatsappLink]
);
imported.push(normalized.name_hebrew);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
console.error('Import error:', err.message);
return res.status(500).json({ error: 'ייבוא נכשל, הנתונים לא נשמרו' });
} finally {
client.release();
}
return res.status(201).json({
imported: imported.length,
skipped: skipped.length,
warnings: warnings.length,
details: { skipped, warnings },
message: `יובאו ${imported.length} אורחים. ${skipped.length} שורות דולגו.`,
});
});
module.exports = router;