Skip to main content

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) calls notifyChurchAdmin(ctx, subject, summary). It reads ONLY premium_churches.admin_email and sends one email. No SMS, ever, for any chat event — including flag_safety_concern level=critical. When admin_email is NULL it emails nobody (a paying church gets a founder alert via notification-recipient-gap.ts, but the church itself hears nothing).
  • Contact form — /api/contact/church resolves contact_routing[routeIndex] .email (+cc) → falls back to admin_email. Email only. A null destination writes an ops_errors row (PR #1750).
  • Voice (for contrast) — church_voice_agents.notification_phone (admin), pastor_phone, notify_every_call drive a route × urgency SMS matrix (voice spec §1.2), opt-out checked against voice_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 elementHereWhy
§1.2 route × urgency delivery matrixKEPT 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 LLMCHANGED — the website derives the route from the event type: prayer = pastor route, callback/contact = unknown route, urgent = bothThe 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_errorsKEPT (source: 'role-notify', last-4 digits only)Same sink, same fingerprint dedupe.
§1.5 save-time E.164 normalizationKEPT — same normalizeNanpE164 for override people and contact-route numbersOne normalizer for save and send.
§1.6 STOP store, check fails openKEPT — same voice_sms_opt_outs tableOne opt-out list across voice and website.
§1.2 dedupe by E.164KEPTA 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 fieldsThe new "Who gets what" card is a separate, ungated card (every plan), because chat and forms exist on every plan.
§1.8 Spanish dashboard labelsKEPT 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" copyCHANGED into a live per-event preview (§6) computed from the resolverShows what will actually happen instead of describing rules.
§3 quiet hours out of scopeKEPT 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 callbacksThe build request.
§4 migration dependencyDROPPED 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 keyChat tools that raise itContact form
prayer_requestsubmit_prayer_request—
callback_requestrequest_callback (normal), book_appointment (both paths), schedule_counseling, request_pastoral_visit (normal)—
visitor_contactcapture_visitor_contact, start_visitor_followup, signup_for_volunteer_role, register_for_event—
urgentflag_safety_concern (level urgent/critical), request_callback urgency=urgent, report_care_need urgency=urgent, request_pastoral_visit non-normal—
generalevery 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)​

  1. Override — notification_routing.events[event].people[] → each person's email. Used when ≥1 valid address resolves.
  2. 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.
  3. None resolves → no email; ops_errors row (source role-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)​

  1. Override — if events[event] exists: sms: true → each listed person's phone; sms: false → no text for that event.

  2. Voice-field defaults (event absent from override):

    EventSMS goes to
    urgentadmin phone and pastor phone
    prayer_requestpastor phone (pastoral route); none if unset
    callback_request, visitor_contactadmin phone only if notify_every_call is on
    generalnobody

    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, else escalation_contact_method when (and only when) it normalizes to a NANP E.164 number. pastor phone = pastor_phone.

  3. Contact form — the route's sms_phone when that route has notify_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_number when twilio_phone_sid = 'telnyx'), else the shared TELNYX_SMS_FROM. No sender ⇒ no text + ops_errors. No new provider — reuses src/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=1 logs the would-be SMS and sends nothing (tests / preview).
  • Quiet hours: none in v1 (see §7). urgent must always go.
  • Save permission: the notification_routing section is allowed wherever notifications_voice is — in BOTH SECTION_CAP and the legacy ROLE_SETTINGS allowlist (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_FROM is 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.

EventEnglish
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_phone optional, stored E.164 (normalised on save; unparseable ⇒ save rejected with a field message). notify_sms optional, default false.
  • A route may have no email when it has notify_sms + a valid sms_phone (email then falls back to admin_email as 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_messages exists; 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​

  1. Precedence (SMS) — override present ⇒ override phones only; event absent ⇒ voice-field defaults per §2.2 table (every row); no phones ⇒ no SMS.
  2. Precedence (email) — override emails when ≥1 valid; else admin_email ∪ notification_email deduped; none ⇒ ops_errors row.
  3. 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.
  4. 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.
  5. Dedupe — admin and pastor phones equal ⇒ one SMS.
  6. Opt-out — opted-out number skipped, email still sent; opt-out store error ⇒ SMS still sent (positive + negative control).
  7. Failure visibility — provider throws ⇒ email sent, tool result unchanged, one ops_errors row naming church + event + target.
  8. Contact form — route with notify_sms + sms_phone ⇒ email as today
    • one SMS; notify_sms false ⇒ zero SMS; submitter confirmation unchanged; public page HTML contains no sms_phone value.
  9. Ministry wording — every SMS template contains none of church/pastor/congregation (case-insensitive) for any event/language.
  10. Save validation — 940-208-2152 ⇒ +19402082152; abc rejected with a field message; >6 people or unknown event keys rejected.
  11. Synthetic sessions — no email, no SMS.
  12. Public pages — church and ministry /s/[slug] HTML byte-identical before/after (no public surface changes).