Website Notification Role Routing — Expected Output Spec
§0 Current behaviour (verified in code 2026-09-29, origin/main f48448909)
- Chat leads — every write tool in
src/lib/chatbot-tools.ts(22 call sites) callsnotifyChurchAdmin(ctx, subject, summary). It reads ONLYpremium_churches.admin_emailand sends one email. No SMS, ever, for any chat event — includingflag_safety_concernlevel=critical. Whenadmin_emailis NULL it emails nobody (a paying church gets a founder alert vianotification-recipient-gap.ts, but the church itself hears nothing). - Contact form —
/api/contact/churchresolvescontact_routing[routeIndex] .email(+cc) → falls back toadmin_email. Email only. A null destination writes anops_errorsrow (PR #1750). - Voice (for contrast) —
church_voice_agents.notification_phone(admin),pastor_phone,notify_every_calldrive a route × urgency SMS matrix (voice spec §1.2), opt-out checked againstvoice_sms_opt_outs, one-tap "mark handled" link (PR #1748), staff SMS in the church's language. - The Settings → Notifications copy already tells customers the notification
email covers "prayer requests, visitor contacts, callbacks, and safety
alerts" from "AI agents" — but chat never used
notification_email.
§0a Builds on church-notification-routing-mvp (knowledge PR #190)
This is an EXTENSION of [[church-notification-routing-mvp]] (the voice-line spec, merged into this branch), not a parallel mechanism. It stays a separate document because it covers a different surface (website chat + contact form) with its own acceptance checks; #190 remains the authority for voice.
| #190 element | Here | Why |
|---|---|---|
| §1.2 route × urgency delivery matrix | KEPT as the website's default SMS behaviour (§2.2) | A church that set up its phone line gets identical website behaviour with no new setup. |
route param chosen by the LLM | CHANGED — the website derives the route from the event type: prayer = pastor route, callback/contact = unknown route, urgent = both | The chat tools already say what kind of event it is (submit_prayer_request vs request_callback); adding an LLM routing param to 22 chat call sites would be a new failure surface for no gain. |
| §1.3 "Text me every call" | KEPT, applied to chat callbacks + new contacts (the website's "unknown route") | Same meaning: text the admin even when the request is not routed. |
§1.4 failure → ops_errors | KEPT (source: 'role-notify', last-4 digits only) | Same sink, same fingerprint dedupe. |
| §1.5 save-time E.164 normalization | KEPT — same normalizeNanpE164 for override people and contact-route numbers | One normalizer for save and send. |
| §1.6 STOP store, check fails open | KEPT — same voice_sms_opt_outs table | One opt-out list across voice and website. |
| §1.2 dedupe by E.164 | KEPT | A one-person office gets one text. |
| Email unconditional (§1.2) | KEPT, recipients CHANGED: admin_email ∪ notification_email (voice already emails notification_email) | Brings chat email in line with voice and with the Settings copy; additive for everyone emailed today. |
| §1.7 tier gate (pastor fields voice-only) | KEPT unchanged for the existing fields | The new "Who gets what" card is a separate, ungated card (every plan), because chat and forms exist on every plan. |
| §1.8 Spanish dashboard labels | KEPT as a gap (English UI) | Same finding: no dashboard i18n mechanism. Staff SMS is Spanish-aware (voice staff_sms_language rule). |
| §1.10 "Who gets texted" copy | CHANGED into a live per-event preview (§6) computed from the resolver | Shows what will actually happen instead of describing rules. |
| §3 quiet hours out of scope | KEPT out of scope | — |
| (none) | ADDED: per-tenant override map (§5.2), per-route contact-form texting (§5.1), ministry-safe SMS copy (§4), handled link on chat callbacks | The build request. |
| §4 migration dependency | DROPPED as a dependency — already applied 2026-09-14 (status note added to #190's §4) | Verified via information_schema. This spec's only new DDL is §5.2. |
Dashboard placement vs knowledge PR #191 (Admin View v2). The "Who gets
what" card is rendered INSIDE NotificationsForm.tsx, which #191 §6 moves from
Settings ▸ Notifications to the new "People & Alerts" destination. The card
moves with the component; no change needed here. #191 §6.2's "don't
pre-write copy for routing the code doesn't have" is satisfied: the card only
describes shipped behaviour. PR #189 (support front door) explicitly excludes
a church's own alert routing, so it doesn't overlap.
§1 Events
| Event key | Chat tools that raise it | Contact form |
|---|---|---|
prayer_request | submit_prayer_request | — |
callback_request | request_callback (normal), book_appointment (both paths), schedule_counseling, request_pastoral_visit (normal) | — |
visitor_contact | capture_visitor_contact, start_visitor_followup, signup_for_volunteer_role, register_for_event | — |
urgent | flag_safety_concern (level urgent/critical), request_callback urgency=urgent, report_care_need urgency=urgent, request_pastoral_visit non-normal | — |
general | every other notifying tool (subscriptions, giving interest, facility booking, child check-in, benevolence, staff messages, conversation summary, giving history) | — |
contact_form | — | every /api/contact/church submission (per route) |
flag_safety_concern level=warning stays general (email only) — a
warning-level flag does not text anyone.
§2 Precedence — who is told
2.1 Email (always sent — never gated by SMS config)
- Override —
notification_routing.events[event].people[]→ each person'semail. Used when ≥1 valid address resolves. - Defaults —
premium_churches.admin_email∪church_voice_agents.notification_email(deduped, case-insensitive). This is additive for existing customers: everyone who gets chat emails today still gets them; a church that set a separate notification email in Settings (whose copy already promised it) now also receives chat emails. - None resolves → no email;
ops_errorsrow (sourcerole-notify, message "no email destination") + the existing paying-church founder alert. Never silent.
Contact form email is unchanged: route email/cc → admin_email (PR #1750
path). The override map does not apply to contact form email.
2.2 SMS (opt-in by configuration — no phone configured ⇒ no text)
-
Override — if
events[event]exists:sms: true→ each listed person'sphone;sms: false→ no text for that event. -
Voice-field defaults (event absent from override):
Event SMS goes to urgentadmin phone and pastor phone prayer_requestpastor phone (pastoral route); none if unset callback_request,visitor_contactadmin phone only if notify_every_callis ongeneralnobody Status gate (founder decision 2026-09-29, opt-in): the voice-line phone numbers below are used as defaults ONLY when
church_voice_agents.status = 'active'(applyVoiceStatusGate, churchwiseai-web #1773 commit 1dff3a5). A trial or inactive voice row texts nobody by default. Its email default (notification_email) is unaffected, and the override map (step 1) is never gated, so such a tenant opts in by naming people in "Who gets what". The Settings preview applies the same gate. Trigger case: Beckwith Hills CRC has a leftover trial voice row but doesn't use the phone product.admin phone =
notification_phone, elseescalation_contact_methodwhen (and only when) it normalizes to a NANP E.164 number. pastor phone =pastor_phone. -
Contact form — the route's
sms_phonewhen that route hasnotify_sms: true. Default off. No voice-field fallback for forms.
A church with no phones configured (or whose voice line is not active) and no
override sends zero texts and byte-identical email to today, apart from
the additive notification_email in §2.1.
§3 Send rules
- Normalise + dedupe: every number through
normalizeNanpE164; numbers that fail are skipped +ops_errors; the same E.164 number never gets two texts for one event (one-person office). - Opt-out: skip any number present in
voice_sms_opt_outs. The check fails open (DB error ⇒ send). Email still goes. - Sender: the tenant's own Telnyx line (
church_voice_agents. twilio_phone_numberwhentwilio_phone_sid = 'telnyx'), else the sharedTELNYX_SMS_FROM. No sender ⇒ no text +ops_errors. No new provider — reusessrc/lib/inbox/sms-provider.ts:sendSms. - Failures (provider error, bad number, no sender) are non-fatal, never
block email or the visitor's reply, and write
ops_errors(source: 'role-notify', metadata: church_id, event, target label, last 4 digits only, reason). - Synthetic/CI sessions (
isEmailSuppressedTestSession) send neither email nor SMS — same as today. - Dry run:
ROLE_NOTIFY_SMS_DRY_RUN=1logs the would-be SMS and sends nothing (tests / preview). - Quiet hours: none in v1 (see §7).
urgentmust always go. - Save permission: the
notification_routingsection is allowed wherevernotifications_voiceis — in BOTHSECTION_CAPand the legacyROLE_SETTINGSallowlist (admin, office_admin). Missing the latter made every save 403 on the first preview; pinned by a unit test. - Sender reality (2026-09-29): Production
TELNYX_SMS_FROMis messaging-enabled and delivers. The Preview value has NO Telnyx messaging profile (40305 "Invalid 'from' address"), so live texts cannot be tested on Vercel previews until that env var is fixed. Tenants with their own Telnyx line send from it.
§4 SMS copy (≤ 2 GSM-7 segments, tenant language)
Language = Spanish when church_voice_agents.multilingual primary language
is es or its languages include es (mirrors voice staff_sms_language);
English otherwise. Text is GSM-7 sanitised (accents stripped where needed).
Prefix is the tenant's display name. No church-only vocabulary in any SMS
(no "church", "pastor", "congregation"), so the same copy serves a ministry.
| Event | English |
|---|---|
| prayer_request | {Tenant}: new prayer request from {Name} via your website chat. Details are in your email. (never the prayer text) |
| callback_request | {Tenant}: {Name} asked for a callback ({contact}): {reason}. Mark handled: {link} |
| visitor_contact | {Tenant}: new website contact - {Name} ({contact}). Details are in your email. |
| urgent | {Tenant} URGENT: {Name} needs follow-up now (from your website chat). Check your email. (safety flags never include the description) |
| contact_form | {Tenant}: new "{Route label}" message from {Name} ({phone or email}). Details are in your email. |
{link} is the signed one-tap "mark handled" URL (PR #1748 token, 14-day
expiry) — present only when the event created a voice_callback_requests
row whose id is known (request_callback, book_appointment fallback).
§5 Data contract
5.1 contact_routing[] entries (existing JSONB, draft/publish via Website editor)
{ "label": "Prayer", "email": "prayer@x.org", "cc": null,
"sms_phone": "+13045550100", "notify_sms": true }
sms_phoneoptional, stored E.164 (normalised on save; unparseable ⇒ save rejected with a field message).notify_smsoptional, default false.- A route may have no email when it has
notify_sms+ a validsms_phone(email then falls back toadmin_emailas today). - Never rendered to visitors: public pages receive only labels/indexes (asserted by a contract test).
5.2 premium_churches.notification_routing (NEW nullable JSONB — migration file, NOT applied)
{
"people": [
{ "id": "p1", "name": "Stephanie", "role": "Director", "phone": "+13045550100", "email": "stephanie@x.org" }
],
"events": {
"prayer_request": { "people": ["p2"], "sms": true },
"urgent": { "people": ["p1", "p2"], "sms": true }
}
}
- ≤ 6 people, ≤ 3 people per event; event keys limited to
prayer_request,callback_request,visitor_contact,urgent. Absent event ⇒ §2 defaults. - Saved live (Settings semantics — no draft/publish), like every other Settings → Notifications field.
- Until the migration is applied: reads treat the column as absent (defaults only); the "Who gets what" editor shows a read-only "current routing" preview and saving an override returns a clear "not yet switched on" message — never a silent drop.
§6 Dashboard — what the customer sees (Settings → Notifications)
New card "Who gets what" under the existing notification fields, every plan, every vertical:
- One row per event: Prayer requests · Callback requests · New contacts · Urgent / safety. Each row shows the people it currently reaches and how ("Email + text" / "Email only"), computed from the same resolver as the server, labelled "(from your phone settings)" when it comes from defaults.
- Edit → choose people (from a small "People" list: name, role, mobile, email — phone first) and an "Also text them" switch per row.
- "Reset to default" per row removes the override.
- Copy is vertical-aware (ministry sees "your team", church sees "your church team"); no "pastor" wording for non-church verticals.
- Website → Contact ("Where messages go"): each route gets a Text switch and mobile number field, off by default.
§7 Out of scope (v1)
- Quiet hours / do-not-disturb windows (urgent must always go; per-person quiet hours is a separate decision).
- Changing voice routing — voice keeps its own matrix; this spec only reads the voice fields as defaults.
- An SMS audit table for church tenants (no church equivalent of
re_sms_messagesexists; evidence is Telnyx message ids + logs). - Override for contact-form email (routes already have per-route email).
- Staff replying to the SMS to talk to the visitor.
§8 Acceptance checks
- Precedence (SMS) — override present ⇒ override phones only; event absent ⇒ voice-field defaults per §2.2 table (every row); no phones ⇒ no SMS.
- Precedence (email) — override emails when ≥1 valid; else admin_email ∪
notification_email deduped; none ⇒
ops_errorsrow. - Byte-identical regression — a tenant with admin_email set, no notification_email, no phones, no override ⇒ exactly one email to admin_email with today's subject/body, zero SMS.
- Santa María shape (read-only fixture: notification_phone, pastor_phone, notify_every_call=true, multilingual es) ⇒ prayer → pastor SMS in Spanish; callback → admin SMS with handled link; urgent → both; general → none.
- Dedupe — admin and pastor phones equal ⇒ one SMS.
- Opt-out — opted-out number skipped, email still sent; opt-out store error ⇒ SMS still sent (positive + negative control).
- Failure visibility — provider throws ⇒ email sent, tool result
unchanged, one
ops_errorsrow naming church + event + target. - Contact form — route with
notify_sms+sms_phone⇒ email as today- one SMS;
notify_smsfalse ⇒ zero SMS; submitter confirmation unchanged; public page HTML contains nosms_phonevalue.
- one SMS;
- Ministry wording — every SMS template contains none of church/pastor/congregation (case-insensitive) for any event/language.
- Save validation —
940-208-2152⇒+19402082152;abcrejected with a field message; >6 people or unknown event keys rejected. - Synthetic sessions — no email, no SMS.
- Public pages — church and ministry
/s/[slug]HTML byte-identical before/after (no public surface changes).