Skip to content

Track L — SMS notifications (#123)#134

Merged
important-new merged 24 commits into
InspectorHub:mainfrom
important-new:main
Jun 8, 2026
Merged

Track L — SMS notifications (#123)#134
important-new merged 24 commits into
InspectorHub:mainfrom
important-new:main

Conversation

@important-new

Copy link
Copy Markdown
Contributor

Summary

SMS as a delivery channel on the existing automation engine (consumes Track J's reserved channel='sms' path). Rules carry channels[] + sms_body; firing fans out one automation_logs row per channel; the cron flush() branches on channel and sends SMS via Twilio. Per-tenant Twilio credentials resolve via an explicit sms_mode toggle that mirrors the email (Resend) own-vs-platform model. Client SMS is gated on a recorded consent event (append-only sms_consent_log); a tenant-routed inbound webhook syncs STOP/START.

What's included

  • Multi-channel rulesautomations.channels (JSON string[], default ["email"]) + sms_body; the old singular channel kept as a DEAD shadow (D1 can't drop FK-bearing-table columns). automation_logs.recipient_emailrecipient (holds email or E.164 phone) + channel.
  • Twilio creds = explicit mode toggle (tenant_configs.sms_mode platform|own, mirrors email_mode): own only when all three keys present, else platform env, else tenant last-resort. SaaS = platform default (zero setup); standalone/OSS = bring-your-own, fail-closed. lib/sms/{phone,resolve-twilio,send-sms}.
  • Consent — client-strict / agent+inspector-implied. Append-only sms_consent_log (latest-event-wins) + sms_disclosure_versions. Captured via booking checkbox / opt-in link (sealed token, no new table) / inspector attestation (soft gate). ensureClientContact auto-creates+links a client contact so free-typed clients can be texted.
  • Inbound webhookPOST /api/public/sms/inbound (platform number, signature via platform token) + /sms/inbound/:tenantSlug (own number, own token). Twilio signature validated before any write.
  • Reminders — due-time derived live from inspection.date − delayMinutes; a reschedule needs zero log writes.
  • Migrations 0025 + 0026 (additive + the recipient rename; consent tables; sms_mode; company_phone; Twilio secret keys).
  • UI — multi-channel rule editor (Email/SMS, sms_body + char counter), run-log channel badge, Settings → Communication "SMS delivery" section (mode toggle, Twilio fields, company phone, inbound webhook URL, send-test), inspection-view consent status + attest.
  • Seeds — 3 touchpoints get compliance-safe sms_body (leads with company name, ends with "Reply STOP to opt out"), channels stay email-only; disclosure v1 seeded.

Test plan

  • Gates: lint 0 · type-check 0 (app+api) · db:check EQUIVALENT · unit 1606 · web 361 · workers 33 · build ✓
  • Local Chrome E2E (standalone): multi-channel editor + persistence, Settings SMS section incl. tenant-slug inbound webhook URL, inspection-view consent attestation (not-recorded → granted, exercises auto-create), fail-closed "SMS not configured"
  • SaaS production smoke on app.inspectorhub.io after deploy

🤖 Generated with Claude Code

important-new and others added 24 commits June 6, 2026 21:51
The DB stores dates as ISO datetimes (e.g. '2026-05-29T09:00:00'). This
value was loaded verbatim into the form, causing two problems:
1. <input type='date'> could not parse it (showed empty placeholder)
2. sanitizeSettingsPatch bypassed the date→ISO conversion, so the API
   received a datetime without a timezone suffix, failing z.string().datetime()

Fix: strip the time component on load so the date picker gets 'YYYY-MM-DD',
which sanitizeSettingsPatch then correctly converts to a Z-suffixed ISO string.
Applied to closingDate too for consistency.
api.team.members returns {data:{members:[...]}} — the loader was reading
body.data (an object) and calling .filter() on it, causing a TypeError crash.
Align with the BFF sheet route which correctly reads data?.members.
…ings BFF

pageSize: "200" fails Zod refine (valid: 12/25/50/100); the .catch(() => null)
silently swallowed the 400 so the template dropdown was always empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lates & dashboard

- TemplateCombobox: new reusable combobox with debounced search, cursor
  pagination (25/page), keyboard nav, load-more; replaces <select> in
  InspectionSettingsSheet (also fixes pageSize 200->100 Zod refine bug)
- /resources/template-search BFF: token-relay loader for TemplateCombobox
- templates.tsx: upgraded from client-side useMemo to URL-based server-side
  search (?q=) with 350ms debounce; sort remains client-side
- server: listTemplates() gains optional q param with LIKE filter;
  listTemplatesRoute schema extended with q; exactOptionalPropertyTypes fix
- /resources/inspection-search BFF: full cross-all-inspections search
  via GET /api/inspections with search+cursor params
- dashboard.tsx: searchQuery now triggers server-side BFF fetch (300ms
  debounce) instead of client-side bucket filter; load-more for results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…review)

Zod's `.partial()` over a field carrying `.default()` still applies the
default and injects the key. UpdateAutomationSchema derived `channels`
(and `delayMinutes`) from CreateAutomationBase, so every partial PATCH
omitting `channels` parsed to `channels: ['email']`. The service gates
on `'channels' in data`, so any such update silently dropped a tenant's
enabled SMS channel (data loss). `delayMinutes` had the same hazard via
`{...rest}`, resetting a configured delay to 0.

Fix: drop `.default()` from the base fields; re-add the defaults only on
CreateAutomationSchema. Update keeps both optional with no default, so
omitting them leaves the keys absent and the service never rewrites them.

Regression covered at the schema-parse level (service tests bypass Zod).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n SMS runtime wiring

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…SMS (migration 0026)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_configs DDL

The workers tests hand-maintain an inline CREATE TABLE tenant_configs; Task 1
(sms_mode) and Task 7 (company_phone, migration 0026) added columns the Drizzle
apply path now SELECTs/writes, breaking test:workers (13/33). Mirror both columns
at their schema positions. test:workers 33/33 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent contact

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… inbound webhook, booking checkbox + email link

Task 8 (Track L):
- server/api/sms.ts: public opt-in resolve/confirm + two-shape Twilio inbound
  webhook (platform shared-number /sms/inbound + tenant-scoped /sms/inbound/:slug),
  signature-validated (APP_BASE_URL+path); admin attest/test-send/consent-status
  (requireRole owner/admin). Routers mounted in server/index.ts.
- Opt-in token (Step 0 decision): NO new table — self-describing sealed payload
  <tenantId>~sealToken(contactId) via lib/sms/optin-token.ts (reuses the
  config-crypto tier-2 envelope, same as agreement tokens).
- app/routes/public/sms-optin.tsx: BFF SSR double-opt-in page (DS tokens, dark-safe).
- Path A: booking-form unchecked SMS opt-in checkbox -> granted (booking_form).
- Path B: opt-in link injected into the booking-confirmation email at the renderer
  level (survives template overrides / rule disabling).
- audit: sms.consent.attest + sms.test_send; route tag 'sms' allowlisted;
  TWILIO_* added to request Bindings; typed BFF client surface registered.
- settings-automations.tsx: minimal channels[]/recipient interface alignment so the
  tree type-checks after the Task 6 rename (full multi-channel editor is Task 9).

NOTE: inbound non-command bodies are acknowledged but NOT persisted (automation_logs
automation_id/inspection_id are NOT NULL; an inbound reply binds neither; two-way
surfacing is out of scope per spec §10). STOP/START consent sync is fully handled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + consent UI (Task 9)

Track L Task 9 + carried-over Task 8 review fixes:

A. Parse channels on output — AutomationService.list/create/update now return
   `channels` as string[] (new private serializeRow) so the typed API surface
   (AutomationSchema.channels: string[]) is truthful end to end.

B. Editor multi-channel (settings-automations.tsx) — channel checkboxes
   (Email/SMS) replacing the disabled select; conditional SMS body textarea with
   a live char counter + ~N segments hint; email subject/body section shown when
   Email is checked; >=1 channel enforced client-side (Save disabled + hint);
   save reads form.getAll("channels") + smsBody (nulled when SMS off); per-rule
   + run-log channel badge; run-log renders l.recipient. SMS placeholder palette.

C. Residual recipientEmail rename — verified no automation-log read still uses
   recipientEmail (only renderer is settings-automations, already on l.recipient;
   inspection-hub:737 is an unrelated payment-modal prop).

D. Settings -> Communication SMS section — sms_mode toggle + companyPhone via
   extended PATCH /api/admin/tenant-config (schema+handler+GET); effective-source
   line from new GET /api/admin/sms/config (resolveTwilioSource, no secrets); 3
   Twilio SecretFields via existing PUT /api/admin/secrets; A2P 10DLC guidance;
   inbound webhook URL (own/standalone only); Send-test-SMS.

E. Inspection-view consent + attest (inspection-hub.tsx) — ClientSmsConsent shows
   granted/revoked/not-recorded via GET /sms/consent; inline "I confirm" attest
   posts POST /sms/attest. BFF only.

Tests: extended settings-automations.spec (channels/recipient fixtures + sms
serialization), new settings-communication-sms + inspection-hub-sms-consent web
specs, resolveTwilioSource + list()-parse unit cases. All gates green:
unit 1604, web 361, workers 33, type-check 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@important-new important-new merged commit 15b8e31 into InspectorHub:main Jun 8, 2026
1 check passed
important-new added a commit to important-new/OpenInspection that referenced this pull request Jun 9, 2026
…nspectorHub#134/InspectorHub#136)

- inspection-hub.tsx: keep reinspectCandidates loader + destructure (Track K InspectorHub#119);
  remove duplicate attestSms/attesting declarations introduced by merge
- _journal.json: keep migrations 0027 + 0028 (Track K)
- automation-flush-sms.spec.ts: keep today-date flakiness fix from upstream/main
- cmd-consumer.spec.ts + cmd-fixtures.spec.ts: keep reinspection_statuses column (Track K)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant