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)
78 lines
1.9 KiB
TypeScript
78 lines
1.9 KiB
TypeScript
/**
|
|
* 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" }
|
|
}
|