Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
@@ -0,0 +1,170 @@
const prisma = require('../config/db');
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function getFrontendBaseUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '');
}
function parseFreeformPhones(lines) {
const recipients = [];
const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/);
for (const raw of input) {
const s = String(raw || '').trim();
if (!s) continue;
// Format: Name <phone> or just phone
const m = s.match(/^(.*?)<\s*([+\d\s()-]+)\s*>\s*$/);
if (m) {
recipients.push({ phone: m[2].trim(), name: m[1].trim().replace(/^"|"$/g, '').trim() });
} else {
// Accept raw phone-like strings
const digits = s.replace(/\D/g, '');
if (digits.length >= 9) recipients.push({ phone: s, name: '' });
}
}
return recipients;
}
// @desc Preview WhatsApp broadcast recipients
// @route POST /api/whatsapp-broadcasts/preview
// @access Private/Supervisor or Admin
const previewWhatsAppBroadcast = async (req, res) => {
try {
const { userIds, phones, eventId } = req.body || {};
const { normalizeZAPhone } = require('../utils/whatsapp');
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
let users = [];
if (ids.length) {
users = await prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, name: true, phoneNumber: true, notificationPreference: true }
});
}
const extra = parseFreeformPhones(phones);
const map = new Map();
for (const u of users) {
const phone = normalizeZAPhone(u.phoneNumber);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
}
for (const r of extra) {
const phone = normalizeZAPhone(r.phone);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
}
const recipients = Array.from(map.values());
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// @desc Send WhatsApp broadcast now
// @route POST /api/whatsapp-broadcasts/send
// @access Private/Supervisor or Admin
const sendWhatsAppBroadcast = async (req, res) => {
try {
const { message, userIds, phones, eventId } = req.body || {};
if (!message) {
return res.status(400).json({ message: 'message is required' });
}
const { normalizeZAPhone, sendText } = require('../utils/whatsapp');
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
let users = [];
if (ids.length) {
users = await prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, name: true, phoneNumber: true }
});
}
const extra = parseFreeformPhones(phones);
const map = new Map();
for (const u of users) {
const phone = normalizeZAPhone(u.phoneNumber);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
}
for (const r of extra) {
const phone = normalizeZAPhone(r.phone);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
}
const recipients = Array.from(map.values());
if (recipients.length === 0) {
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
}
let event = null;
if (eventId && typeof eventId === 'string') {
event = await prisma.event.findUnique({ where: { id: eventId } });
}
const eventTitle = event?.title || '';
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
const eventLink = event ? `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}` : '';
const results = await Promise.allSettled(recipients.map(async rcpt => {
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
const finalMessage = replacePlaceholders(message, ctx);
await sendText(rcpt.phone, finalMessage);
}));
const sent = results.filter(r => r.status === 'fulfilled').length;
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[wa-broadcast] failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
});
return res.json({ matched: recipients.length, sent });
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// @desc Schedule a WhatsApp broadcast
// @route POST /api/whatsapp-broadcasts/schedule
// @access Private/Supervisor or Admin
const scheduleWhatsAppBroadcast = async (req, res) => {
try {
const { scheduledAt, message, userIds, phones, eventId } = req.body || {};
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
const when = new Date(scheduledAt);
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
if (!message) return res.status(400).json({ message: 'message is required' });
const payload = { message, userIds, phones, eventId };
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
broadcast: true,
channel: 'whatsapp',
scheduledAt: when.toISOString(),
createdById: req.user?.id || null,
payload,
});
return res.status(201).json({ message: 'WhatsApp broadcast scheduled', job: created });
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
module.exports = { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast };