const prisma = require('../config/db'); const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email'); const { computeRegistrationTotalDue } = require('./pricing'); // ─── Formatting helpers ─────────────────────────────────────────────────────── const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`; function fmtAmount(amt) { const n = Number(amt || 0); return `R${n.toFixed(2)}`; } function fmtDate(d) { try { return new Date(d).toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return String(d); } } function fmtDateShort(d) { try { return new Date(d).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); } catch { return String(d); } } const { getSettingSync } = require('./settingsCache'); function getOrg() { return { name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'), email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '', url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''), }; } function getRegistrationsInbox() { // Supports comma-separated list; return the first address for single-recipient use const raw = getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || ''); return raw.split(',')[0].trim() || ''; } function getRegistrationsInboxAll() { // Returns the full comma-separated string for multi-recipient sends return getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || ''); } /** * Resolves who should receive registration/payment/daily-summary notices for an event: * the event's configured `notifyRecipients`, or the event creator when none are set. */ function getEventNotifyEmails(event) { const recipients = Array.isArray(event?.notifyRecipients) ? event.notifyRecipients : []; const emails = recipients.map(u => u?.email).filter(Boolean); if (emails.length) return emails; return event?.createdBy?.email ? [event.createdBy.email] : []; } /** * Builds the deduplicated admin "to" list for an event notification: the global * registrations inbox plus that event's notify recipients (or its creator as fallback). */ function buildEventNotifyRecipientList(event) { const inbox = getRegistrationsInbox(); const seen = new Set(); const to = []; if (inbox) { seen.add(inbox); to.push(inbox); } for (const email of getEventNotifyEmails(event)) { if (!seen.has(email)) { seen.add(email); to.push(email); } } return to; } // ─── Data loaders ───────────────────────────────────────────────────────────── async function loadRegistrationFull(registrationId) { return prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, }, }); } async function loadTicketFull(ticketId) { return prisma.ticket.findUnique({ where: { id: ticketId }, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, event: true, registrationOption: { include: { eventOption: true, variant: true } }, }, }); } async function loadPaymentFull(paymentId) { return prisma.payment.findUnique({ where: { id: paymentId }, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, registration: { include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, }, }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, }, }); } // ─── Shared building blocks ─────────────────────────────────────────────────── /** Renders the selections summary table. */ function selectionsTable(registrationOptions) { const rows = (registrationOptions || []).map(ro => { const name = ro.eventOption?.name || 'Option'; const qty = ro.quantity || 1; const price = ro.eventOption?.price || 0; return `
No items
`; return ``; } /** Renders a financial summary line. */ function financialSummary(totalDue, totalPaid, balance) { const ff2 = ff; return `| Total due | ${fmtAmount(totalDue)} |
| Amount paid | ${fmtAmount(totalPaid)} |
| ${balance <= 0 ? 'Fully paid' : 'Balance due'} | ${fmtAmount(balance)} |
Manage your registration online
Log in to your account to view your registration, make payments, and download your tickets.
${ctaButton('Log in to your account', siteUrl + '/login', { bg: '#0f172a' })}`; } return `${divider()}Create your account
Create a free account to manage your registrations, make payments online, and access your tickets — all in one place.
${ctaButton('Create your account', siteUrl + '/register', { bg: '#0f172a' })}`; } /** * Renders the payment options section for registration emails. * @param {{ balance, yocoLink, source, siteUrl, formRequired }} * source: 'admin' (manual/self-service/at-door) | 'user' (self-register via website) */ function paymentSection({ balance, yocoLink, source, siteUrl, formRequired, isUserActive }) { if (balance <= 0 && !formRequired) { return callout( `🎟️ You\'re all set!How to pay
Choose any of the following payment methods:
Your tickets will be emailed once your payment is confirmed.
`; } // ─── Registration confirmation (user self-registered via website) ────────────── function buildRegistrationConfirmation(reg, { isNew = true } = {}) { const org = getOrg(); const eventTitle = reg.event?.title || 'the event'; const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : ''; const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const balance = Math.max(totalDue - totalPaid, 0); const isUserActive = reg.user?.isActive; const heading = isNew ? 'Registration confirmed!' : 'Registration updated'; const subtext = isNew ? `Thank you for registering for ${eventTitle}.` : `Your registration for ${eventTitle} has been updated.`; const subject = isNew ? `Registration confirmed – ${eventTitle}` : `Registration updated – ${eventTitle}`; const preheader = isNew ? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!` : `Your registration for ${eventTitle} has been updated.`; const body = `${heading}
${isNew ? 'Your spot is reserved' : 'Changes saved'}
Hi ${reg.user?.name || 'there'},
${subtext}${eventDate ? ` — ${eventDate}` : ''}
Your registration
${selectionsTable(reg.registrationOptions)} ${financialSummary(totalDue, totalPaid, balance)} ${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })} ${accountCta(isUserActive, org.url)}`; const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`; return { subject, text, html: emailWrapper(body, { preheader }) }; } // ─── Registration confirmation (admin / self-service / at-door) ─────────────── function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, formRequired = false, isNew = true } = {}) { const org = getOrg(); const eventTitle = reg.event?.title || 'the event'; const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : ''; const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const balance = Math.max(totalDue - totalPaid, 0); const isUserActive = reg.user?.isActive; const heading = isNew ? 'Registration confirmed!' : 'Registration updated'; const subtext = isNew ? `You have been registered for ${eventTitle}.` : `Your registration for ${eventTitle} has been updated.`; const subject = isNew ? `Registration confirmed – ${eventTitle}` : `Registration updated – ${eventTitle}`; const preheader = isNew ? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!` : `Your registration for ${eventTitle} has been updated.`; const body = `${heading}
${isNew ? 'Your spot is reserved' : 'Changes saved'}
Hi ${reg.user?.name || 'there'},
${subtext}${eventDate ? ` The event takes place on ${eventDate}.` : ''}
Your registration
${selectionsTable(reg.registrationOptions)} ${financialSummary(totalDue, totalPaid, balance)} ${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })} ${accountCta(isUserActive, org.url)}`; const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); const payText = balance > 0 ? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : formRequired ? 'An attendee form is required before your tickets are issued. Please complete it at the registration desk.' : 'No payment required — your tickets have been sent in a separate email.'; const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You have been registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${payText}\n\n${org.name} — ${org.email}\n${org.url}`; return { subject, text, html: emailWrapper(body, { preheader }) }; } // ─── Admin notification (internal) ─────────────────────────────────────────── function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } = {}) { const org = getOrg(); const eventTitle = reg.event?.title || 'Event'; const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const balance = Math.max(totalDue - totalPaid, 0); const verb = isUpdated ? 'updated' : (isNew ? 'created' : 'modified'); const subject = isUpdated ? `Registration updated: ${eventTitle} — ${reg.user?.name || 'Unknown'}` : `New registration: ${eventTitle} — ${reg.user?.name || 'Unknown'}`; const itemRows = (reg.registrationOptions || []).map(ro => `Registration ${verb}
${org.name} — internal notification
${itemRows ? `Items
` : ''}| Total due | ${fmtAmount(totalDue)} |
| Paid | ${fmtAmount(totalPaid)} |
| Balance | ${fmtAmount(balance)} |
Payment received
Thank you — we've got your payment
Hi ${payment.user?.name || 'there'},
We received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.
${callout(`${fmtAmount(payment.amount)} received| Total due | ${fmtAmount(totalDue)} |
| Total paid | ${fmtAmount(totalPaid)} |
| ${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} | ${fmtAmount(balance)} |
Payment history
` : ''} ${balance <= 0 ? callout('You\'re fully paid! Your tickets have been emailed to you separately.', 'success') : ''} ${accountCta(isUserActive, org.url)}`; const text = `Payment received\n\nHi ${payment.user?.name || 'there'},\n\nWe received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\nThank you!\n\n${org.name} — ${org.email}`; return { subject, text, html: emailWrapper(body, { preheader }) }; } // Donation receipt const subject = `Donation received – ${eventTitle}`; const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`; const body = `Thank you for your donation!
Your generosity makes a difference
Hi ${payment.user?.name || 'there'},
We received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}. Your support means the world to us.
${callout(`${fmtAmount(payment.amount)} donatedWe appreciate your generous support. Thank you!
`; const text = `Thank you for your donation!\n\nHi ${payment.user?.name || 'there'},\n\nWe received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.\n\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\n\nThank you!\n\n${org.name} — ${org.email}`; return { subject, text, html: emailWrapper(body, { preheader }) }; } // ─── Check-in confirmation (Main Tickets, door check-in) ─────────────────────── function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) { const org = getOrg(); const eventTitle = ticket.event?.title || 'the event'; const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket'; const fullyCheckedIn = remaining <= 0; const subject = `Checked in – ${eventTitle}`; const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`; const body = `You're checked in! ✅
See you inside
Hi ${ticket.user?.name || 'there'},
${qtyRedeemed} ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for ${eventTitle}.
${callout(`${totalRedeemed} of ${ticket.quantity || 1} checked in${ fullyCheckedIn ? '' : `A donation was applied to your registration
Good news about your balance
Hi ${reg?.user?.name || 'there'},
A donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle} by our team.
${callout(`${fmtAmount(payment.amount)} applied| Total due | ${fmtAmount(totalDue)} |
| Total paid | ${fmtAmount(totalPaid)} |
| ${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} | ${fmtAmount(balance)} |
Payment recorded
Internal notification
`; const event = isReg ? payment.registration?.event : payment.event; const to = buildEventNotifyRecipientList(event); const text = `Payment recorded\n\nAmount: ${fmtAmount(payment.amount)}\nType: ${type}\nPayer: ${payerName} <${payerEmail}>\nEvent: ${eventTitle}\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\nID: ${payment.id}`; return { to, subject, text, html: emailWrapper(body) }; } // ─── Refund email ───────────────────────────────────────────────────────────── function buildRefundEmail(payment) { const org = getOrg(); const user = payment.user; const amt = Math.abs(payment.amount || 0); const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event'; const subject = `Refund processed – ${fmtAmount(amt)} for ${eventTitle}`; const preheader = `Your refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.`; const body = `Refund processed
Your refund is on its way
Hi ${user?.name || 'there'},
A refund of ${fmtAmount(amt)} has been processed for ${eventTitle}.
${payment.status ? `
Reason: ${payment.status}` : ''}
Refunds may take a few business days to appear depending on your bank and payment method. If you have any questions, contact us at ${org.email}.
`; const text = `Refund processed\n\nHi ${user?.name || 'there'},\n\nA refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.\n\nMethod: ${payment.method || 'original method'}\nDate: ${fmtDate(payment.createdAt)}\n\n${org.name} — ${org.email}`; return { subject, text, html: emailWrapper(body, { preheader }) }; } // ─── Daily event summary ────────────────────────────────────────────────────── function buildDailySummary(ev, registrations, payments, now) { const org = getOrg(); const subject = `Daily summary: ${ev.title} — ${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`; const regRows = registrations.map(r => { const totalDue = computeRegistrationTotalDue(r, now); const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const balance = Math.max(totalDue - totalPaid, 0); return { name: r.user?.name || '?', email: r.user?.email || '', status: r.status, totalDue, totalPaid, balance }; }); const payRows = payments.map(p => ({ date: fmtDate(p.createdAt), amount: p.amount, method: p.method || '—', isDonation: p.isDonation || !p.registrationId, payerName: p.registration?.user?.name || p.user?.name || '?', payerEmail: p.user?.email || p.registration?.user?.email || '', })); const totalRegistrations = regRows.length; const totalRevenue = payRows.reduce((s, p) => s + (p.amount || 0), 0); const paidCount = regRows.filter(r => r.status === 'paid').length; const pendingCount = regRows.filter(r => r.status === 'pending' || r.status === 'partial_paid').length; const statsRow = (label, value, color = '#1e293b') => `No registrations yet.
`; const payTableHtml = payRows.length ? `` : `No payments recorded yet.
`; const body = `Daily Summary
${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
${ev.title}
${divider()}Registrations
${regTableHtml}Payments & Donations
${payTableHtml}Event starts: ${fmtDate(ev.startDate)} • Event ends: ${fmtDate(ev.endDate)}
`; const text = [ `Daily Summary — ${ev.title}`, new Date(now).toLocaleDateString(), '', `Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`, '', 'Registrations:', ...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']), '', 'Payments:', ...(payRows.length ? payRows.map(p => ` ${p.date} — ${fmtAmount(p.amount)} via ${p.method} — ${p.isDonation ? 'Donation' : 'Registration'} — ${p.payerName}`) : [' (none)']), ].join('\n'); return { subject, text, html: emailWrapper(body) }; } // ─── Send functions ─────────────────────────────────────────────────────────── async function sendRegistrationEmails(registrationId) { try { const reg = await loadRegistrationFull(registrationId); if (!reg) return; const { shouldEmail, waText } = require('./notify'); const { buildWARegistration } = require('./waMessages'); const { computeRegistrationTotalDue } = require('./pricing'); const sends = []; const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); // Email: only for real addresses (skip guest.local placeholders) if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { const msg = buildRegistrationConfirmation(reg, { isNew: true }); if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); } // WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true }); if (adminMsg.to && adminMsg.to.length) { sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); } await Promise.all(sends); } catch (e) { console.error('Failed to send registration emails:', e); } } async function sendRegistrationUpdatedEmails(registrationId) { try { const reg = await loadRegistrationFull(registrationId); if (!reg) return; const { shouldEmail, waText } = require('./notify'); const { buildWARegistration } = require('./waMessages'); const { computeRegistrationTotalDue } = require('./pricing'); const sends = []; const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { const msg = buildRegistrationConfirmation(reg, { isNew: false }); if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); } sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true }); if (adminMsg.to && adminMsg.to.length) { sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); } await Promise.all(sends); } catch (e) { console.error('Failed to send registration updated emails:', e); } } async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) { try { const reg = await loadRegistrationFull(registrationId); if (!reg) return; const { shouldEmail, waText } = require('./notify'); const { buildWARegistration } = require('./waMessages'); const { computeRegistrationTotalDue } = require('./pricing'); const sends = []; const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew }); if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); } sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew }); if (adminMsg.to && adminMsg.to.length) { sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); } await Promise.all(sends); } catch (e) { console.error('Failed to send self-service registration emails:', e); } } async function sendPaymentEmails(paymentId) { try { const payment = await loadPaymentFull(paymentId); if (!payment) return; const user = payment.registration?.user || payment.user; const { shouldEmail, waText, waTextAny } = require('./notify'); const { buildWAPayment } = require('./waMessages'); const sends = []; const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); if (hasValidEmail) { const msg = buildPaymentReceipt(payment); if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); } // WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't if (hasValidEmail) { sends.push(waText(user, buildWAPayment(payment))); } else { sends.push(waTextAny(user, buildWAPayment(payment))); } const hasEvent = !!(payment.registration?.eventId || payment.eventId); if (hasEvent) { const adminMsg = buildPaymentAdminNotice(payment); if (adminMsg.to && adminMsg.to.length) { sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); } } await Promise.all(sends); } catch (e) { console.error('Failed to send payment emails:', e); } } async function sendRefundEmail(refundPaymentId) { try { const payment = await loadPaymentFull(refundPaymentId); if (!payment) return; const user = payment.user; if (!user?.email || user.email.endsWith('@guest.local')) return; const msg = buildRefundEmail(payment); const sends = [sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })]; const adminMsg = buildPaymentAdminNotice(payment); if (adminMsg.to && adminMsg.to.length) { sends.push(sendMail({ to: adminMsg.to.join(','), subject: `Refund: ${adminMsg.subject}`, html: adminMsg.html, text: adminMsg.text })); } await Promise.all(sends); } catch (e) { console.error('Failed to send refund email:', e); } } async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) { try { const ticket = await loadTicketFull(ticketId); if (!ticket) return; const user = ticket.user; const { shouldEmail, waText, waTextAny } = require('./notify'); const { buildWACheckIn } = require('./waMessages'); const sends = []; const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); if (hasValidEmail && shouldEmail(user)) { const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining); sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); } const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining); if (hasValidEmail) { sends.push(waText(user, waMsg)); } else { sends.push(waTextAny(user, waMsg)); } await Promise.all(sends); } catch (e) { console.error('Failed to send check-in emails:', e); } } // Sent when staff apply a donation to someone's registration. Only the registrant is // notified (anonymously, per design) — the donor already received their donation-received // notification when the donation was originally made, so they are deliberately not emailed // again here, and any leftover/unassigned remainder from a partial allocation is silent too. async function sendDonationAssignmentEmails(paymentId) { try { const payment = await loadPaymentFull(paymentId); if (!payment || !payment.registration) return; const user = payment.registration.user; const { shouldEmail, waText, waTextAny } = require('./notify'); const { buildWADonationAppliedToRegistrant } = require('./waMessages'); const sends = []; const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); if (hasValidEmail) { const msg = buildDonationAppliedToRegistrant(payment); if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); } if (hasValidEmail) { sends.push(waText(user, buildWADonationAppliedToRegistrant(payment))); } else { sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment))); } const adminMsg = buildPaymentAdminNotice(payment); if (adminMsg.to && adminMsg.to.length) { sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); } await Promise.all(sends); } catch (e) { console.error('Failed to send donation-assignment emails:', e); } } async function sendDailyEventSummaries(now = new Date()) { try { const today = new Date(now); const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } }; let events = await prisma.event.findMany({ where: { isActive: true, startDate: { gte: today } }, include: notifyInclude, orderBy: { startDate: 'asc' }, }); try { events = await prisma.event.findMany({ where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } }, include: notifyInclude, orderBy: { startDate: 'asc' }, }); } catch {} await Promise.allSettled(events.map(async ev => { const registrations = await prisma.registration.findMany({ where: { eventId: ev.id }, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true, }, orderBy: { createdAt: 'asc' }, }); const payments = await prisma.payment.findMany({ where: { OR: [{ eventId: ev.id }, { registration: { eventId: ev.id } }] }, include: { user: { select: { id: true, name: true, email: true } }, registration: { select: { id: true, user: { select: { id: true, name: true, email: true } } } }, }, orderBy: { createdAt: 'asc' }, }); const { subject, text, html } = buildDailySummary(ev, registrations, payments, now); const to = buildEventNotifyRecipientList(ev); if (to.length) await sendMail({ to: to.join(','), subject, html, text }); })); } catch (e) { console.error('Failed to send daily event summaries:', e); } } module.exports = { sendRegistrationEmails, sendRegistrationUpdatedEmails, sendPaymentEmails, sendDailyEventSummaries, sendRefundEmail, sendSelfServiceRegistrationEmails, sendDonationAssignmentEmails, sendCheckInEmails, buildCheckInConfirmation, };