Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Escape user-controlled strings before inserting them into HTML
|
||||
function escapeHtml(str) {
|
||||
return String(str || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Utilities shared with attendees emailing
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
|
||||
function getFrontendBaseUrl() {
|
||||
const base = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
return String(base).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function buildEventContext(event) {
|
||||
if (!event) return { eventTitle: '', eventStart: '', eventLink: '', eventLinkHtml: '' };
|
||||
const eventTitle = event.title || '';
|
||||
const eventStart = event.startDate ? fmtDate(event.startDate) : '';
|
||||
const eventLink = `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}`;
|
||||
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
||||
return { eventTitle, eventStart, eventLink, eventLinkHtml };
|
||||
}
|
||||
|
||||
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.eventLinkHtml || ctx.eventLink || ''));
|
||||
}
|
||||
|
||||
function parseFreeformEmails(lines) {
|
||||
// Supports formats:
|
||||
// - email@example.com
|
||||
// - Name <email@example.com>
|
||||
// - "Name" <email@example.com>
|
||||
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;
|
||||
let name = '';
|
||||
let email = '';
|
||||
const m = s.match(/^(.*?)<\s*([^>\s]+@[^>\s]+)\s*>\s*$/);
|
||||
if (m) {
|
||||
name = m[1].trim().replace(/^"|"$/g, '').trim();
|
||||
email = m[2].trim();
|
||||
} else {
|
||||
// If it just looks like an email, accept it
|
||||
const em = s.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) ? s : '';
|
||||
if (em) email = em; else continue;
|
||||
}
|
||||
recipients.push({ email, name });
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
// @desc Preview broadcast recipients and sample
|
||||
// @route POST /api/broadcasts/preview
|
||||
// @access Private/Supervisor or Admin
|
||||
const previewBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { userIds, emails, eventId } = req.body || {};
|
||||
|
||||
// Resolve users
|
||||
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, email: true } });
|
||||
}
|
||||
|
||||
// Parse freeform emails
|
||||
const extra = parseFreeformEmails(emails);
|
||||
|
||||
// Merge and de-duplicate by email
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const email = String(u.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const email = String(r.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
|
||||
// Optionally resolve event info for link/title placeholder
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send broadcast now
|
||||
// @route POST /api/broadcasts/send
|
||||
// @access Private/Supervisor or Admin
|
||||
const sendBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
if (!subject || !(html || text)) {
|
||||
return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
}
|
||||
|
||||
// Resolve recipients similar to preview
|
||||
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, email: true } });
|
||||
}
|
||||
const extra = parseFreeformEmails(emails);
|
||||
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const email = String(u.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const email = String(r.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients' });
|
||||
}
|
||||
|
||||
// Resolve event
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
const { sendMail } = require('../utils/email');
|
||||
|
||||
// Send all emails in parallel instead of sequentially — critical for large recipient lists
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctxBase = {
|
||||
name: rcpt.name || '',
|
||||
eventTitle: eventCtx.eventTitle,
|
||||
eventStart: eventCtx.eventStart,
|
||||
eventLink: eventCtx.eventLink,
|
||||
};
|
||||
const ctxForHtml = html ? {
|
||||
name: escapeHtml(rcpt.name || ''),
|
||||
eventTitle: escapeHtml(eventCtx.eventTitle),
|
||||
eventStart: escapeHtml(eventCtx.eventStart),
|
||||
eventLink: escapeHtml(eventCtx.eventLink),
|
||||
eventLinkHtml: eventCtx.eventLinkHtml,
|
||||
} : ctxBase;
|
||||
const finalSubject = replacePlaceholders(subject, ctxBase);
|
||||
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
|
||||
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[broadcast] failed to send to', recipients[i]?.email, 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 broadcast
|
||||
// @route POST /api/broadcasts/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, subject, html, text, userIds, emails, 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 (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
|
||||
const payload = { subject, html, text, userIds, emails, eventId };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
broadcast: true,
|
||||
scheduledAt: when.toISOString(),
|
||||
createdById: req.user?.id || null,
|
||||
payload,
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'Broadcast scheduled', job: created });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { previewBroadcast, sendBroadcast, scheduleBroadcast };
|
||||
Reference in New Issue
Block a user