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

@@ -0,0 +1,119 @@
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface ImportResult {
imported: number;
skipped: number;
warnings: number;
message: string;
details: {
skipped: { row: number; reason: string }[];
warnings: { row: number; name: string; warning: string }[];
};
}
interface ImportGuestsFormProps {
eventId: string;
onImported: () => void;
}
export function ImportGuestsForm({ eventId, onImported }: ImportGuestsFormProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [error, setError] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
async function handleImport() {
if (!fileRef.current?.files?.length) {
setError('נא לבחור קובץ');
return;
}
const file = fileRef.current.files[0];
const formData = new FormData();
formData.append('file', file);
setLoading(true);
setError('');
setResult(null);
try {
const res = await fetch(`/api/events/${eventId}/guests/import`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'שגיאה בייבוא');
setResult(data);
onImported();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'שגיאה');
} finally {
setLoading(false);
}
}
if (!open) {
return (
<Button variant="outline" onClick={() => setOpen(true)}>
ייבוא מ-Excel / CSV
</Button>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">ייבוא אורחים מקובץ</CardTitle>
</CardHeader>
<CardContent dir="rtl" className="space-y-4">
<p className="text-sm text-muted-foreground">
עמודות נדרשות: <span className="font-mono text-xs">name_hebrew, phone</span> (אופציונלי: name_transliteration, email, dietary_preference, relationship_group)
</p>
<p className="text-sm text-muted-foreground">פורמטים: CSV (.csv) או Excel (.xlsx, .xls) עד 500 שורות</p>
<div className="space-y-2">
<input
ref={fileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="block w-full text-sm text-muted-foreground file:ml-4 file:rounded file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:text-primary-foreground file:cursor-pointer"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{result && (
<div className={`rounded-md p-3 text-sm ${result.skipped > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'}`}>
<p className="font-medium">{result.message}</p>
{result.warnings > 0 && (
<ul className="mt-1 space-y-0.5 text-xs text-yellow-800">
{result.details.warnings.map((w, i) => (
<li key={i}>שורה {w.row} ({w.name}): {w.warning}</li>
))}
</ul>
)}
{result.details.skipped.length > 0 && (
<ul className="mt-1 space-y-0.5 text-xs text-red-800">
{result.details.skipped.map((s, i) => (
<li key={i}>שורה {s.row}: {s.reason}</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleImport} disabled={loading}>
{loading ? 'מייבא...' : 'ייבא אורחים'}
</Button>
<Button variant="outline" onClick={() => { setOpen(false); setResult(null); setError(''); }}>
סגור
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Reminder {
id: string;
name_hebrew: string;
name_transliteration?: string;
phone?: string;
whatsapp_link: string;
days_until: number;
}
interface PendingRemindersPanelProps {
eventId: string;
refreshTrigger: number;
}
export function PendingRemindersPanel({ eventId, refreshTrigger }: PendingRemindersPanelProps) {
const [reminders, setReminders] = useState<Reminder[]>([]);
useEffect(() => {
fetch(`/api/events/${eventId}/guests/reminders`, { credentials: 'include' })
.then(r => r.json())
.then(d => setReminders(d.reminders || []))
.catch(() => {});
}, [eventId, refreshTrigger]);
if (reminders.length === 0) return null;
return (
<Card className="border-orange-200 bg-orange-50">
<CardHeader className="pb-2">
<CardTitle className="text-base text-orange-800" dir="rtl">
🔔 תזכורות ממתינות ({reminders.length})
</CardTitle>
</CardHeader>
<CardContent dir="rtl">
<p className="text-sm text-orange-700 mb-3">
האורחים הבאים עדיין לא אישרו הגעה. לחץ על הקישור לשלוח תזכורת ב-WhatsApp.
</p>
<ul className="space-y-2">
{reminders.map(r => (
<li key={r.id} className="flex items-center justify-between gap-3 rounded bg-white px-3 py-2 text-sm shadow-sm">
<div>
<span className="font-medium">{r.name_hebrew}</span>
{r.name_transliteration && (
<span className="text-xs text-muted-foreground mr-2" dir="ltr">{r.name_transliteration}</span>
)}
<span className="text-xs text-orange-600 mr-2">({r.days_until} ימים לאירוע)</span>
</div>
<a
href={r.whatsapp_link}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700"
>
📲 שלח תזכורת
</a>
</li>
))}
</ul>
</CardContent>
</Card>
);
}

View File

@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { RsvpSummaryCard } from '@/components/RsvpSummaryCard';
import { AddGuestForm } from '@/components/AddGuestForm';
import { ImportGuestsForm } from '@/components/ImportGuestsForm';
import { PendingRemindersPanel } from '@/components/PendingRemindersPanel';
interface Guest {
id: string;
@@ -52,6 +54,7 @@ export function GuestListPage() {
const [statusFilter, setStatusFilter] = useState('');
const [search, setSearch] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const fetchGuests = useCallback(async () => {
if (!eventId) return;
@@ -80,12 +83,17 @@ export function GuestListPage() {
return () => clearInterval(interval);
}, [fetchGuests]);
function triggerRefresh() {
fetchGuests();
setRefreshTrigger(n => n + 1);
}
async function handleDelete(guestId: string, guestName: string) {
if (!confirm(`האם למחוק את האורח "${guestName}"? פעולה זו היא סופית.`)) return;
setDeletingId(guestId);
try {
await fetch(`/api/guests/${guestId}`, { method: 'DELETE', credentials: 'include' });
fetchGuests();
triggerRefresh();
} finally {
setDeletingId(null);
}
@@ -98,7 +106,7 @@ export function GuestListPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rsvp_status: newStatus }),
});
fetchGuests();
triggerRefresh();
}
function handleExport() {
@@ -119,7 +127,12 @@ export function GuestListPage() {
<RsvpSummaryCard summary={summary} warning={warning} />
<AddGuestForm eventId={eventId!} onGuestAdded={fetchGuests} />
<PendingRemindersPanel eventId={eventId!} refreshTrigger={refreshTrigger} />
<div className="flex gap-3 flex-wrap">
<AddGuestForm eventId={eventId!} onGuestAdded={triggerRefresh} />
<ImportGuestsForm eventId={eventId!} onImported={triggerRefresh} />
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">

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 };

197
package-lock.json generated
View File

@@ -12,11 +12,15 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"csv-parse": "^6.1.0",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"json2csv": "^6.0.0-alpha.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3"
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"pg": "^8.11.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
@@ -63,6 +67,14 @@
"node": ">= 0.6"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -116,6 +128,11 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
@@ -225,6 +242,22 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -260,6 +293,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -292,6 +337,14 @@
"node": ">=10"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -313,6 +366,20 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -378,12 +445,28 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in."
},
"node_modules/csv-parse": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -594,6 +677,14 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -1154,6 +1245,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
@@ -1201,6 +1300,34 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/multer/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1214,6 +1341,14 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -1780,6 +1915,17 @@
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1788,6 +1934,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -1895,6 +2049,11 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1952,11 +2111,47 @@
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -14,11 +14,15 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"csv-parse": "^6.1.0",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"json2csv": "^6.0.0-alpha.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3"
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"pg": "^8.11.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.1.0"

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;

View File

@@ -8,6 +8,7 @@ const authRoutes = require('./routes/auth');
const guestRoutes = require('./routes/guests');
const rsvpRoutes = require('./routes/rsvp');
const { authMiddleware } = require('./middleware/auth');
const { startReminderCron } = require('./jobs/reminderCron');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -42,4 +43,5 @@ app.get('*', (req, res) => {
app.listen(PORT, () => {
console.log(`אירועית server running on port ${PORT}`);
startReminderCron();
});