diff --git a/.env.example b/.env.example index 6d137ca6..b26ac170 100644 --- a/.env.example +++ b/.env.example @@ -443,6 +443,34 @@ TWILIO_CALLER_ID= # spoken disclaimer plays. Ontario is one-party consent; keep the disclaimer. TWILIO_RECORD_CALLS= +# ── SMS outreach (admin → /admin/sms, Twilio Messages API) ────────────────── +# Reuses TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN above. Needs an SMS SENDER — +# a Messaging Service (MGxxxx, preferred: A2P 10DLC + native opt-out) OR a plain +# SMS-capable number. Separate from TWILIO_CALLER_ID (Voice-only). +TWILIO_MESSAGING_SERVICE_SID= +TWILIO_SMS_FROM= +# SAFETY: dry-run until explicitly false. Dry-run logs + prices without sending. +SMS_DRY_RUN=true +# Cost planning: your Twilio per-segment price (CA long-code ≈ 0.0079 USD). +SMS_PRICE_PER_SEGMENT=0.0079 +SMS_CURRENCY=USD +# CTA link + label placed in every message (defaults to NEXT_PUBLIC_SITE_URL/onboarding). +SMS_CTA_URL= +SMS_CTA_LABEL=Get started +# Throttle + quiet hours (recipient local time — TCPA/CASL friendly). +SMS_RATE_PER_SEC=1 +SMS_QUIET_START_HOUR=9 +SMS_QUIET_END_HOUR=21 +SMS_TIMEZONE=America/Toronto +SMS_ENFORCE_QUIET_HOURS=true +# Groq model for AI personalization (defaults to GROQ_MODEL / llama-3.1-8b-instant). +GROQ_SMS_MODEL= +# Where Twilio POSTs delivery receipts + STOP replies. Defaults to NEXT_PUBLIC_SITE_URL. +# In dev, set to a tunnel (ngrok/cloudflared) so the webhook is reachable. +SMS_WEBHOOK_BASE_URL= +# Set false only if TLS/proxy termination prevents Twilio signature verification. +SMS_VALIDATE_SIGNATURE=true + # ── Web Push (device notifications, VAPID) ────────────────────────────────── # Device push as an extra channel on createNotification (high-value types only). # Generate a keypair: node -e "console.log(require('web-push').generateVAPIDKeys())" diff --git a/.gitignore b/.gitignore index e077f412..80c59230 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ Urban Company EnE.pdf # Local MCP config (may hold a Supabase access token) .mcp.json +# SMS outreach starter list — PII (real names + phone numbers). Loaded at runtime +# by /api/admin/sms/seed; must never be committed. +data/sms-seed-taskers.csv + # playwright-cli scratch (snapshots/screenshots) .playwright-cli/ verify-wizard.png diff --git a/app/(admin)/admin/AdminShell.tsx b/app/(admin)/admin/AdminShell.tsx index 9a036f1e..2b4ea893 100644 --- a/app/(admin)/admin/AdminShell.tsx +++ b/app/(admin)/admin/AdminShell.tsx @@ -36,6 +36,7 @@ import { Coins, Newspaper, Globe, + Send, } from 'lucide-react'; import { cn } from '@/lib/utils'; import NotificationsDropdown from '@/components/customer/NotificationsDropdown'; @@ -95,6 +96,12 @@ const NAV: ReadonlyArray = [ { href: '/admin/pages', label: 'Pages', icon: Globe }, ], }, + { + title: 'Growth', + items: [ + { href: '/admin/sms', label: 'SMS outreach', icon: Send }, + ], + }, { title: 'Analytics', items: [ diff --git a/app/(admin)/admin/sms/page.tsx b/app/(admin)/admin/sms/page.tsx new file mode 100644 index 00000000..51ed8bfe --- /dev/null +++ b/app/(admin)/admin/sms/page.tsx @@ -0,0 +1,444 @@ +'use client'; + +// Admin → SMS outreach. Paste/upload a list (or load the starter taskers / approved +// providers), preview AI-personalized messages, see the cost, then dry-run or send +// live. The send is client-orchestrated in chunks so it never hits a function +// timeout; each chunk POSTs to /api/admin/sms/send and streams results back. + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Card, Textarea, Badge } from '@/components/ui'; +import { parseRecipients } from '@/lib/sms/recipients'; +import { composeBody, type Recipient } from '@/lib/sms/message'; +import { isValidE164 } from '@/lib/sms/segments'; + +const CHUNK = 25; + +interface SummaryConfig { + dryRun: boolean; + pricePerSegment: number; + currency: string; + ctaUrl: string; + ctaLabel: string; + quietHours: string; + ratePerSec: number; + smsConfigured: boolean; + sender: string | null; +} +interface Stats { + totalAttempts: number; + liveSent: number; + delivered: number; + failed: number; + optedOut: number; + spend: number; + currency: string; +} +interface LogRow { + phone: string; + name: string | null; + status: string; + segments: number | null; + price: number | null; + currency: string; + error: string | null; + dry_run: boolean; + created_at: string; +} +interface Preview { + name: string; + phone: string; + body: string; + segments: number; + encoding: string; + source: string; + cost: number; +} +interface SendItem { + phone: string; + name?: string; + status: 'sent' | 'dry-run' | 'failed' | 'skipped'; + reason?: string; +} + +function money(n: number | null | undefined, c = 'USD') { + const v = Number(n || 0); + return (c === 'USD' ? '$' : '') + v.toFixed(4) + (c && c !== 'USD' ? ' ' + c : ''); +} + +export default function SmsOutreachPage() { + const [config, setConfig] = useState(null); + const [stats, setStats] = useState(null); + const [log, setLog] = useState([]); + const [raw, setRaw] = useState(''); + const [dryRun, setDryRun] = useState(true); + const [previews, setPreviews] = useState([]); + const [busy, setBusy] = useState(''); + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); + const [runResults, setRunResults] = useState([]); + const [err, setErr] = useState(''); + + const loadSummary = useCallback(async () => { + try { + const r = await fetch('/api/admin/sms/summary').then((x) => x.json()); + setConfig(r.config); + setStats(r.stats); + setDryRun(r.config?.dryRun ?? true); + } catch (e) { + setErr(String(e)); + } + }, []); + const loadLog = useCallback(async () => { + try { + const r = await fetch('/api/admin/sms/log?limit=100').then((x) => x.json()); + setLog(r.rows || []); + } catch { + /* ignore */ + } + }, []); + + useEffect(() => { + loadSummary(); + loadLog(); + }, [loadSummary, loadLog]); + + // Parse + estimate happen entirely client-side (shared pure lib). + const parsed = useMemo(() => parseRecipients(raw), [raw]); + const estimate = useMemo(() => { + if (!config) return null; + let segments = 0; + for (const r of parsed.recipients) { + const c = composeBody(r, null, { ctaUrl: config.ctaUrl, ctaLabel: config.ctaLabel }); + segments += c.segments; + } + return { + count: parsed.recipients.length, + segments, + cost: +(segments * config.pricePerSegment).toFixed(4), + }; + }, [parsed, config]); + + async function loadSeed() { + setBusy('seed'); + setErr(''); + try { + const r = await fetch('/api/admin/sms/seed').then((x) => x.json()); + if (r.available) setRaw(r.csv); + else setErr(r.note || 'No starter list available.'); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(''); + } + } + + async function loadProviders() { + setBusy('providers'); + setErr(''); + try { + const r = await fetch('/api/admin/sms/providers').then((x) => x.json()); + const lines = ['name,phone,city,category']; + for (const rec of r.recipients as Recipient[]) { + lines.push([csv(rec.name), rec.phone, csv(rec.city), csv(rec.category)].join(',')); + } + setRaw(lines.join('\n')); + if (r.withoutPhone) setErr(`${r.withoutPhone} approved providers skipped (no phone on file).`); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(''); + } + } + + async function onFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setRaw(await file.text()); + } + + async function preview() { + setBusy('preview'); + setErr(''); + try { + const r = await fetch('/api/admin/sms/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipients: parsed.recipients.slice(0, 5) }), + }).then((x) => x.json()); + setPreviews(r.previews || []); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(''); + } + } + + async function run() { + const recipients = parsed.recipients.filter((r) => isValidE164(r.phone)); + if (recipients.length === 0) { + setErr('No valid recipients to send.'); + return; + } + if (!dryRun) { + const ok = window.confirm( + `LIVE SEND to ${recipients.length} numbers. This sends real SMS via Twilio and charges the account. ` + + `Only contact people you have a lawful basis to message. Continue?`, + ); + if (!ok) return; + } + setBusy('send'); + setErr(''); + setRunResults([]); + setProgress({ done: 0, total: recipients.length }); + const acc: SendItem[] = []; + try { + for (let i = 0; i < recipients.length; i += CHUNK) { + const chunk = recipients.slice(i, i + CHUNK); + const res = await fetch('/api/admin/sms/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipients: chunk, dryRun }), + }); + const j = await res.json(); + if (!res.ok) { + setErr(j.error || `Send failed (${res.status})`); + break; + } + acc.push(...(j.results as SendItem[])); + setRunResults([...acc]); + setProgress({ done: Math.min(i + CHUNK, recipients.length), total: recipients.length }); + } + } catch (e) { + setErr(String(e)); + } finally { + setBusy(''); + loadSummary(); + loadLog(); + } + } + + async function syncStatuses() { + setBusy('sync'); + setErr(''); + try { + const r = await fetch('/api/admin/sms/sync', { method: 'POST' }).then((x) => x.json()); + if (r.error) setErr(r.error); + await Promise.all([loadLog(), loadSummary()]); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(''); + } + } + + const runSummary = useMemo(() => { + const sent = runResults.filter((r) => r.status === 'sent' || r.status === 'dry-run').length; + const failed = runResults.filter((r) => r.status === 'failed').length; + const skipped = runResults.filter((r) => r.status === 'skipped').length; + return { sent, failed, skipped }; + }, [runResults]); + + return ( +
+
+
+

SMS outreach

+

+ Personalized bulk SMS via Twilio{config?.sender ? ` · ${config.sender}` : ''}. +

+
+ + {config?.dryRun ? 'DRY-RUN MODE' : 'LIVE MODE'} + +
+ + {config && !config.smsConfigured && ( + + ⚠ Twilio SMS isn't configured. Set TWILIO_MESSAGING_SERVICE_SID (or{' '} + TWILIO_SMS_FROM) with TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN. Dry-run + and preview work without it. + + )} + + {/* Stats */} +
+ + + + + + +
+ + {/* Recipients */} + +
+

Recipients

+
+ + + +
+
+