Automations engine: multi-channel messaging + dashboard
THE STAR OF THE SHOW — the automation engine is now visible. ## New: Unified Messaging Layer (src/lib/messaging.ts) Channel waterfall: WhatsApp → SMS → Email - sendToDonor() routes to best available channel - Respects donor consent flags (whatsappOptIn, emailOptIn) - Falls back automatically if primary channel fails - Every attempt logged to AnalyticsEvent for dashboard ## New: Email Integration (src/lib/email.ts) Bring-your-own-key: charity pastes their Resend or SendGrid API key - Resend: free 3,000 emails/month - SendGrid: free 100/day - Messages come from THEIR domain (donations@mymosque.org) - Plain text auto-converted to clean HTML ## New: SMS Integration (src/lib/sms.ts) Bring-your-own-key: charity pastes their Twilio credentials - Pay-as-you-go (~3p per SMS) - UK number normalization (07xxx → +447xxx) - Reaches donors without WhatsApp or email ## New: /dashboard/automations — the visible engine A. Dark hero stats: Messages this week per channel + delivery rate B. Live channels: WhatsApp/Email/SMS with status, features, stats C. The Pipeline: visual 4-step automation sequence - What triggers, what's sent, which channels, waterfall explanation D. Scheduled reminders: upcoming messages with timing E. Message feed: recent messages with channel icon, status, time ## New: /api/messaging/status — dashboard data endpoint Returns channels, stats (7 day), history (50 recent), pending reminders ## New: /api/messaging/test — send test message to admin ## Schema: 8 new Organization columns emailProvider, emailApiKey, emailFromAddress, emailFromName smsProvider, smsAccountSid, smsAuthToken, smsFromNumber ## Settings: 2 new channel rows in the checklist - Email: provider selector (Resend/SendGrid) + API key + from address - SMS: Twilio credentials + from number Both follow the same checklist expand/collapse pattern ## Nav: Automations added between Money and Reports Home → Collect → Money → Automations → Reports → Settings ## Stats tracking Messages logged as AnalyticsEvent: message.whatsapp.receipt.sent message.email.reminder_1.failed message.sms.reminder_2.sent Donor PII masked in logs (last 4 digits of phone, email obfuscated)
This commit is contained in:
113
pledge-now-pay-later/src/lib/email.ts
Normal file
113
pledge-now-pay-later/src/lib/email.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Email sending — per-org, bring your own key.
|
||||
*
|
||||
* Supported providers:
|
||||
* - Resend (recommended — free tier: 3,000 emails/month)
|
||||
* - SendGrid
|
||||
*
|
||||
* Each charity pastes their API key in Settings.
|
||||
* Emails come from THEIR domain (e.g. donations@mymosque.org).
|
||||
*/
|
||||
|
||||
import { Resend } from "resend"
|
||||
|
||||
interface EmailConfig {
|
||||
provider: string
|
||||
apiKey: string
|
||||
fromAddress: string
|
||||
fromName: string
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
success: boolean
|
||||
messageId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using the org's configured provider.
|
||||
*/
|
||||
export async function sendEmail(
|
||||
config: EmailConfig,
|
||||
to: string,
|
||||
subject: string,
|
||||
text: string,
|
||||
html?: string
|
||||
): Promise<SendResult> {
|
||||
if (!config.apiKey || !config.fromAddress) {
|
||||
return { success: false, error: "Email not configured" }
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.provider === "resend") {
|
||||
return await sendViaResend(config, to, subject, text, html)
|
||||
}
|
||||
if (config.provider === "sendgrid") {
|
||||
return await sendViaSendGrid(config, to, subject, text, html)
|
||||
}
|
||||
return { success: false, error: `Unknown provider: ${config.provider}` }
|
||||
} catch (err) {
|
||||
console.error("[EMAIL]", err)
|
||||
return { success: false, error: String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaResend(
|
||||
config: EmailConfig, to: string, subject: string, text: string, html?: string
|
||||
): Promise<SendResult> {
|
||||
const resend = new Resend(config.apiKey)
|
||||
const result = await resend.emails.send({
|
||||
from: `${config.fromName} <${config.fromAddress}>`,
|
||||
to: [to],
|
||||
subject,
|
||||
text,
|
||||
html: html || textToHtml(text),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error.message }
|
||||
}
|
||||
return { success: true, messageId: result.data?.id }
|
||||
}
|
||||
|
||||
async function sendViaSendGrid(
|
||||
config: EmailConfig, to: string, subject: string, text: string, html?: string
|
||||
): Promise<SendResult> {
|
||||
const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
personalizations: [{ to: [{ email: to }] }],
|
||||
from: { email: config.fromAddress, name: config.fromName },
|
||||
subject,
|
||||
content: [
|
||||
{ type: "text/plain", value: text },
|
||||
{ type: "text/html", value: html || textToHtml(text) },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 202) {
|
||||
return { success: true, messageId: res.headers.get("x-message-id") || undefined }
|
||||
}
|
||||
const err = await res.text().catch(() => "Unknown error")
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert plain text to simple HTML email.
|
||||
*/
|
||||
function textToHtml(text: string): string {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n/g, "<br>")
|
||||
.replace(/\*(.+?)\*/g, "<strong>$1</strong>")
|
||||
.replace(/`(.+?)`/g, "<code style='background:#f3f4f6;padding:2px 6px;font-family:monospace'>$1</code>")
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:15px;line-height:1.6;color:#111827;max-width:500px;margin:0 auto;padding:20px}code{background:#f3f4f6;padding:2px 6px;font-family:monospace}</style></head><body>${escaped}</body></html>`
|
||||
}
|
||||
307
pledge-now-pay-later/src/lib/messaging.ts
Normal file
307
pledge-now-pay-later/src/lib/messaging.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Unified Messaging Layer
|
||||
*
|
||||
* The BRAIN of the automation engine. Routes messages to donors
|
||||
* across channels with intelligent fallback:
|
||||
*
|
||||
* WhatsApp (free, instant, 2-way)
|
||||
* ↓ fallback
|
||||
* SMS (paid, instant, 1-way)
|
||||
* ↓ fallback
|
||||
* Email (free/cheap, delayed, 1-way)
|
||||
*
|
||||
* Each message attempt is logged to AnalyticsEvent for the
|
||||
* Automations dashboard to display.
|
||||
*/
|
||||
|
||||
import { sendWhatsAppMessage, isWhatsAppReady } from "@/lib/whatsapp"
|
||||
import { sendEmail } from "@/lib/email"
|
||||
import { sendSms } from "@/lib/sms"
|
||||
import prisma from "@/lib/prisma"
|
||||
|
||||
export type Channel = "whatsapp" | "sms" | "email"
|
||||
export type MessageType =
|
||||
| "receipt"
|
||||
| "reminder_1"
|
||||
| "reminder_2"
|
||||
| "reminder_3"
|
||||
| "reminder_4"
|
||||
| "overdue_notice"
|
||||
| "payment_confirmed"
|
||||
| "test"
|
||||
|
||||
interface OrgChannels {
|
||||
whatsapp: boolean
|
||||
email: { provider: string; apiKey: string; fromAddress: string; fromName: string } | null
|
||||
sms: { provider: string; accountSid: string; authToken: string; fromNumber: string } | null
|
||||
}
|
||||
|
||||
interface SendOptions {
|
||||
donorPhone?: string | null
|
||||
donorEmail?: string | null
|
||||
donorName?: string | null
|
||||
whatsappOptIn?: boolean
|
||||
emailOptIn?: boolean
|
||||
messageType: MessageType
|
||||
subject?: string
|
||||
whatsappText: string
|
||||
emailText: string
|
||||
smsText: string
|
||||
orgId: string
|
||||
pledgeId?: string
|
||||
eventId?: string
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
channel: Channel | "none"
|
||||
success: boolean
|
||||
messageId?: string
|
||||
error?: string
|
||||
fallbackAttempted?: Channel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the org's configured channels.
|
||||
*/
|
||||
export async function getOrgChannels(orgId: string): Promise<OrgChannels> {
|
||||
if (!prisma) return { whatsapp: false, email: null, sms: null }
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: orgId },
|
||||
select: {
|
||||
whatsappConnected: true,
|
||||
emailProvider: true, emailApiKey: true, emailFromAddress: true, emailFromName: true, name: true,
|
||||
smsProvider: true, smsAccountSid: true, smsAuthToken: true, smsFromNumber: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!org) return { whatsapp: false, email: null, sms: null }
|
||||
|
||||
const waReady = org.whatsappConnected && await isWhatsAppReady()
|
||||
|
||||
return {
|
||||
whatsapp: waReady,
|
||||
email: org.emailProvider && org.emailApiKey && org.emailFromAddress
|
||||
? { provider: org.emailProvider, apiKey: org.emailApiKey, fromAddress: org.emailFromAddress, fromName: org.emailFromName || org.name }
|
||||
: null,
|
||||
sms: org.smsProvider && org.smsAccountSid && org.smsAuthToken && org.smsFromNumber
|
||||
? { provider: org.smsProvider, accountSid: org.smsAccountSid, authToken: org.smsAuthToken, fromNumber: org.smsFromNumber }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a donor via the best available channel.
|
||||
*
|
||||
* Priority: WhatsApp → SMS → Email
|
||||
* Respects donor consent flags.
|
||||
* Logs every attempt to AnalyticsEvent.
|
||||
*/
|
||||
export async function sendToDonor(opts: SendOptions): Promise<SendResult> {
|
||||
const channels = await getOrgChannels(opts.orgId)
|
||||
|
||||
// Build priority list based on what's available AND what the donor has
|
||||
const attempts: Array<{ channel: Channel; canSend: boolean }> = [
|
||||
{
|
||||
channel: "whatsapp",
|
||||
canSend: channels.whatsapp && !!opts.donorPhone && opts.whatsappOptIn !== false,
|
||||
},
|
||||
{
|
||||
channel: "sms",
|
||||
canSend: !!channels.sms && !!opts.donorPhone,
|
||||
},
|
||||
{
|
||||
channel: "email",
|
||||
canSend: !!channels.email && !!opts.donorEmail && opts.emailOptIn !== false,
|
||||
},
|
||||
]
|
||||
|
||||
let result: SendResult = { channel: "none", success: false, error: "No channel available" }
|
||||
|
||||
for (const attempt of attempts) {
|
||||
if (!attempt.canSend) continue
|
||||
|
||||
try {
|
||||
if (attempt.channel === "whatsapp") {
|
||||
const wa = await sendWhatsAppMessage(opts.donorPhone!, opts.whatsappText)
|
||||
result = { channel: "whatsapp", success: wa.success, messageId: wa.messageId, error: wa.error }
|
||||
} else if (attempt.channel === "sms" && channels.sms) {
|
||||
const smsResult = await sendSms(channels.sms, opts.donorPhone!, opts.smsText)
|
||||
result = { channel: "sms", success: smsResult.success, messageId: smsResult.messageId, error: smsResult.error }
|
||||
} else if (attempt.channel === "email" && channels.email) {
|
||||
const emailResult = await sendEmail(
|
||||
channels.email,
|
||||
opts.donorEmail!,
|
||||
opts.subject || "Pledge update",
|
||||
opts.emailText
|
||||
)
|
||||
result = { channel: "email", success: emailResult.success, messageId: emailResult.messageId, error: emailResult.error }
|
||||
}
|
||||
|
||||
// Log the attempt
|
||||
await logMessage(opts, attempt.channel, result.success, result.error)
|
||||
|
||||
if (result.success) return result
|
||||
|
||||
// Failed — try next channel (add fallback note)
|
||||
result.fallbackAttempted = attempt.channel
|
||||
} catch (err) {
|
||||
await logMessage(opts, attempt.channel, false, String(err))
|
||||
result = { channel: attempt.channel, success: false, error: String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
// All channels exhausted
|
||||
if (!result.success) {
|
||||
await logMessage(opts, "none" as Channel, false, "All channels exhausted")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message attempt to AnalyticsEvent.
|
||||
*/
|
||||
async function logMessage(
|
||||
opts: SendOptions,
|
||||
channel: Channel | string,
|
||||
success: boolean,
|
||||
error?: string
|
||||
) {
|
||||
if (!prisma) return
|
||||
try {
|
||||
await prisma.analyticsEvent.create({
|
||||
data: {
|
||||
eventType: `message.${channel}.${opts.messageType}.${success ? "sent" : "failed"}`,
|
||||
pledgeId: opts.pledgeId || null,
|
||||
eventId: opts.eventId || null,
|
||||
metadata: {
|
||||
channel,
|
||||
messageType: opts.messageType,
|
||||
donorName: opts.donorName,
|
||||
donorPhone: opts.donorPhone ? opts.donorPhone.slice(-4) : null, // privacy: last 4 digits
|
||||
donorEmail: opts.donorEmail ? opts.donorEmail.replace(/(.{2}).*@/, "$1***@") : null,
|
||||
success,
|
||||
error: error || undefined,
|
||||
orgId: opts.orgId,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("[MESSAGING] Log failed:", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message history for the automations dashboard.
|
||||
*/
|
||||
export async function getMessageHistory(orgId: string, limit = 50): Promise<Array<{
|
||||
id: string
|
||||
channel: string
|
||||
messageType: string
|
||||
donorName: string | null
|
||||
success: boolean
|
||||
error: string | null
|
||||
createdAt: Date
|
||||
}>> {
|
||||
if (!prisma) return []
|
||||
|
||||
const events = await prisma.analyticsEvent.findMany({
|
||||
where: {
|
||||
eventType: { startsWith: "message." },
|
||||
metadata: { path: ["orgId"], equals: orgId },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
})
|
||||
|
||||
return events.map(e => {
|
||||
const m = e.metadata as Record<string, unknown> || {}
|
||||
return {
|
||||
id: e.id,
|
||||
channel: String(m.channel || "unknown"),
|
||||
messageType: String(m.messageType || "unknown"),
|
||||
donorName: m.donorName as string | null,
|
||||
success: m.success === true,
|
||||
error: m.error as string | null,
|
||||
createdAt: e.createdAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel stats for the automations dashboard.
|
||||
*/
|
||||
export async function getChannelStats(orgId: string, days = 7): Promise<{
|
||||
whatsapp: { sent: number; failed: number }
|
||||
email: { sent: number; failed: number }
|
||||
sms: { sent: number; failed: number }
|
||||
total: number
|
||||
deliveryRate: number
|
||||
}> {
|
||||
if (!prisma) return { whatsapp: { sent: 0, failed: 0 }, email: { sent: 0, failed: 0 }, sms: { sent: 0, failed: 0 }, total: 0, deliveryRate: 0 }
|
||||
|
||||
const since = new Date(Date.now() - days * 86400000)
|
||||
|
||||
const events = await prisma.analyticsEvent.findMany({
|
||||
where: {
|
||||
eventType: { startsWith: "message." },
|
||||
metadata: { path: ["orgId"], equals: orgId },
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
select: { eventType: true },
|
||||
})
|
||||
|
||||
const stats = { whatsapp: { sent: 0, failed: 0 }, email: { sent: 0, failed: 0 }, sms: { sent: 0, failed: 0 } }
|
||||
|
||||
for (const e of events) {
|
||||
const parts = e.eventType.split(".")
|
||||
const channel = parts[1] as keyof typeof stats
|
||||
const status = parts[3]
|
||||
if (stats[channel]) {
|
||||
if (status === "sent") stats[channel].sent++
|
||||
else if (status === "failed") stats[channel].failed++
|
||||
}
|
||||
}
|
||||
|
||||
const total = stats.whatsapp.sent + stats.email.sent + stats.sms.sent + stats.whatsapp.failed + stats.email.failed + stats.sms.failed
|
||||
const sent = stats.whatsapp.sent + stats.email.sent + stats.sms.sent
|
||||
const deliveryRate = total > 0 ? Math.round((sent / total) * 100) : 0
|
||||
|
||||
return { ...stats, total, deliveryRate }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled/pending reminders for the automations dashboard.
|
||||
*/
|
||||
export async function getPendingReminders(orgId: string, limit = 20): Promise<Array<{
|
||||
id: string
|
||||
donorName: string | null
|
||||
amountPence: number
|
||||
step: number
|
||||
channel: string
|
||||
scheduledAt: Date
|
||||
}>> {
|
||||
if (!prisma) return []
|
||||
|
||||
const reminders = await prisma.reminder.findMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
pledge: { organizationId: orgId, status: { notIn: ["paid", "cancelled"] } },
|
||||
},
|
||||
include: {
|
||||
pledge: { select: { donorName: true, amountPence: true } },
|
||||
},
|
||||
orderBy: { scheduledAt: "asc" },
|
||||
take: limit,
|
||||
})
|
||||
|
||||
return reminders.map(r => ({
|
||||
id: r.id,
|
||||
donorName: r.pledge.donorName,
|
||||
amountPence: r.pledge.amountPence,
|
||||
step: r.step,
|
||||
channel: r.channel,
|
||||
scheduledAt: r.scheduledAt,
|
||||
}))
|
||||
}
|
||||
77
pledge-now-pay-later/src/lib/sms.ts
Normal file
77
pledge-now-pay-later/src/lib/sms.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* SMS sending — per-org, bring your own key.
|
||||
*
|
||||
* Supported providers:
|
||||
* - Twilio
|
||||
*
|
||||
* Each charity pastes their Twilio credentials in Settings.
|
||||
* SMS comes from THEIR number.
|
||||
*/
|
||||
|
||||
interface SmsConfig {
|
||||
provider: string
|
||||
accountSid: string
|
||||
authToken: string
|
||||
fromNumber: string
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
success: boolean
|
||||
messageId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SMS using the org's configured provider.
|
||||
*/
|
||||
export async function sendSms(
|
||||
config: SmsConfig,
|
||||
to: string,
|
||||
body: string
|
||||
): Promise<SendResult> {
|
||||
if (!config.accountSid || !config.authToken || !config.fromNumber) {
|
||||
return { success: false, error: "SMS not configured" }
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.provider === "twilio") {
|
||||
return await sendViaTwilio(config, to, body)
|
||||
}
|
||||
return { success: false, error: `Unknown provider: ${config.provider}` }
|
||||
} catch (err) {
|
||||
console.error("[SMS]", err)
|
||||
return { success: false, error: String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaTwilio(
|
||||
config: SmsConfig, to: string, body: string
|
||||
): Promise<SendResult> {
|
||||
// Normalize UK number
|
||||
let phone = to.replace(/[\s\-\(\)]/g, "")
|
||||
if (phone.startsWith("0")) phone = "+44" + phone.slice(1)
|
||||
if (!phone.startsWith("+")) phone = "+" + phone
|
||||
|
||||
const url = `https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`
|
||||
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64")
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Basic ${auth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
To: phone,
|
||||
From: config.fromNumber,
|
||||
Body: body,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.sid) {
|
||||
return { success: true, messageId: data.sid }
|
||||
}
|
||||
return { success: false, error: data.message || "Twilio error" }
|
||||
}
|
||||
Reference in New Issue
Block a user