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

@@ -6,7 +6,7 @@ import {
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
Smartphone, Wifi, WifiOff, UserPlus, Trash2, Copy,
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight,
Zap, Pencil
Zap, Pencil, Mail
} from "lucide-react"
/**
@@ -48,6 +48,8 @@ interface OrgSettings {
bankAccountName: string; refPrefix: string; primaryColor: string
gcAccessToken: string; gcEnvironment: string; orgType: string
stripeSecretKey: string; stripeWebhookSecret: string
emailProvider: string; emailApiKey: string; emailFromAddress: string; emailFromName: string
smsProvider: string; smsAccountSid: string; smsAuthToken: string; smsFromNumber: string
}
interface TeamMember {
@@ -364,6 +366,85 @@ export default function SettingsPage() {
</div>
</SettingRow>
{/* ▸ Email ─────────────────────────────── */}
<SettingRow
configured={!!settings.emailApiKey}
icon={<Mail className={`h-4 w-4 ${settings.emailApiKey ? "text-[#1E40AF]" : "text-gray-400"}`} />}
title="Email"
summary={settings.emailApiKey ? `${settings.emailProvider || "Resend"} · ${settings.emailFromAddress}` : "Send receipts and reminders by email"}
isOpen={open === "email"}
onToggle={() => toggle("email")}
optional
saving={saving === "email"}
saved={saved === "email"}
>
<div className="space-y-4">
<div className="border-l-2 border-[#1E40AF] pl-3 text-xs text-gray-500">
Send receipts and reminders to donors who don&apos;t have WhatsApp. Connect your <strong className="text-gray-700">own email provider</strong> messages come from your domain.
</div>
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-2">Provider</label>
<div className="flex gap-2">
{["resend", "sendgrid"].map(p => (
<button key={p} onClick={() => update("emailProvider", p)} className={`px-4 py-2 text-xs font-bold border-2 transition-colors ${(settings.emailProvider || "resend") === p ? "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}>
{p === "resend" ? "Resend" : "SendGrid"}
</button>
))}
</div>
{(settings.emailProvider || "resend") === "resend" && !settings.emailApiKey && (
<p className="text-[10px] text-gray-400 mt-2">Free: 3,000 emails/month at <a href="https://resend.com" target="_blank" rel="noopener noreferrer" className="text-[#1E40AF] font-bold hover:underline">resend.com</a></p>
)}
</div>
<Field label="API key" value={settings.emailApiKey || ""} onChange={v => update("emailApiKey", v)} placeholder={settings.emailProvider === "sendgrid" ? "SG.xxxxx" : "re_xxxxx"} type="password" />
<div className="grid grid-cols-2 gap-3">
<Field label="From address" value={settings.emailFromAddress || ""} onChange={v => update("emailFromAddress", v)} placeholder="donations@mymosque.org" />
<Field label="From name" value={settings.emailFromName || ""} onChange={v => update("emailFromName", v)} placeholder="Al Furqan Mosque" />
</div>
<SaveRow
section="email" saving={saving} saved={saved}
onSave={() => save("email", { emailProvider: settings.emailProvider || "resend", emailApiKey: settings.emailApiKey || "", emailFromAddress: settings.emailFromAddress || "", emailFromName: settings.emailFromName || "" })}
onCancel={() => setOpen(null)}
/>
</div>
</SettingRow>
{/* ▸ SMS ──────────────────────────────── */}
<SettingRow
configured={!!settings.smsAccountSid}
icon={<Smartphone className={`h-4 w-4 ${settings.smsAccountSid ? "text-[#F59E0B]" : "text-gray-400"}`} />}
title="SMS"
summary={settings.smsAccountSid ? `Twilio · ${settings.smsFromNumber}` : "Text reminders for donors without WhatsApp"}
isOpen={open === "sms"}
onToggle={() => toggle("sms")}
optional
saving={saving === "sms"}
saved={saved === "sms"}
>
<div className="space-y-4">
<div className="border-l-2 border-[#F59E0B] pl-3 text-xs text-gray-500">
Send SMS reminders via <strong className="text-gray-700">Twilio</strong>. Reaches donors who don&apos;t have WhatsApp and haven&apos;t provided an email. Pay-as-you-go (~3p per SMS).
</div>
{!settings.smsAccountSid && (
<div className="bg-[#F9FAFB] border border-gray-100 p-3">
<p className="text-[10px] font-bold text-[#111827] mb-1">Get your Twilio credentials</p>
<ol className="text-[10px] text-gray-500 space-y-1 list-decimal list-inside">
<li>Sign up at <a href="https://www.twilio.com" target="_blank" rel="noopener noreferrer" className="text-[#F59E0B] font-bold hover:underline">twilio.com</a></li>
<li>Copy your <strong className="text-gray-700">Account SID</strong> and <strong className="text-gray-700">Auth Token</strong> from the dashboard</li>
<li>Buy a phone number (or use the trial number)</li>
</ol>
</div>
)}
<Field label="Account SID" value={settings.smsAccountSid || ""} onChange={v => update("smsAccountSid", v)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
<Field label="Auth token" value={settings.smsAuthToken || ""} onChange={v => update("smsAuthToken", v)} placeholder="Your auth token" type="password" />
<Field label="From number" value={settings.smsFromNumber || ""} onChange={v => update("smsFromNumber", v)} placeholder="+447123456789" />
<SaveRow
section="sms" saving={saving} saved={saved}
onSave={() => save("sms", { smsProvider: "twilio", smsAccountSid: settings.smsAccountSid || "", smsAuthToken: settings.smsAuthToken || "", smsFromNumber: settings.smsFromNumber || "" })}
onCancel={() => setOpen(null)}
/>
</div>
</SettingRow>
{/* ▸ Team ─────────────────────────────── */}
{isAdmin && (
<TeamRow