diff --git a/client/src/components/ImportGuestsForm.tsx b/client/src/components/ImportGuestsForm.tsx new file mode 100644 index 0000000..b1e784c --- /dev/null +++ b/client/src/components/ImportGuestsForm.tsx @@ -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(null); + const [error, setError] = useState(''); + const fileRef = useRef(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 ( + + ); + } + + return ( + + + ייבוא אורחים מקובץ + + +

+ עמודות נדרשות: name_hebrew, phone (אופציונלי: name_transliteration, email, dietary_preference, relationship_group) +

+

פורמטים: CSV (.csv) או Excel (.xlsx, .xls) — עד 500 שורות

+ +
+ +
+ + {error &&

{error}

} + + {result && ( +
0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'}`}> +

{result.message}

+ {result.warnings > 0 && ( +
    + {result.details.warnings.map((w, i) => ( +
  • שורה {w.row} ({w.name}): {w.warning}
  • + ))} +
+ )} + {result.details.skipped.length > 0 && ( +
    + {result.details.skipped.map((s, i) => ( +
  • שורה {s.row}: {s.reason}
  • + ))} +
+ )} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/client/src/components/PendingRemindersPanel.tsx b/client/src/components/PendingRemindersPanel.tsx new file mode 100644 index 0000000..22ceb97 --- /dev/null +++ b/client/src/components/PendingRemindersPanel.tsx @@ -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([]); + + 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 ( + + + + 🔔 תזכורות ממתינות ({reminders.length}) + + + +

+ האורחים הבאים עדיין לא אישרו הגעה. לחץ על הקישור לשלוח תזכורת ב-WhatsApp. +

+
    + {reminders.map(r => ( +
  • +
    + {r.name_hebrew} + {r.name_transliteration && ( + {r.name_transliteration} + )} + ({r.days_until} ימים לאירוע) +
    + + 📲 שלח תזכורת + +
  • + ))} +
+
+
+ ); +} diff --git a/client/src/pages/GuestListPage.tsx b/client/src/pages/GuestListPage.tsx index e3df411..3ad3585 100644 --- a/client/src/pages/GuestListPage.tsx +++ b/client/src/pages/GuestListPage.tsx @@ -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(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() { - + + +
+ + +
{/* Filters */}
diff --git a/jobs/reminderCron.js b/jobs/reminderCron.js new file mode 100644 index 0000000..63429cb --- /dev/null +++ b/jobs/reminderCron.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json index b03065d..b9b4fd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fa7c85a..40fe517 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/routes/guests.js b/routes/guests.js index a457da4..7b0f78d 100644 --- a/routes/guests.js +++ b/routes/guests.js @@ -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; diff --git a/server.js b/server.js index 0b22398..5a9f276 100644 --- a/server.js +++ b/server.js @@ -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(); });