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:
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