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:
2026-03-04 23:20:50 +08:00
parent ce4f2ba52a
commit c52c97df17
13 changed files with 1518 additions and 25 deletions

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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>`
}

View 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,
}))
}

View 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" }
}