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