Skip to content

Track L — SMS notifications (#123)#136

Merged
important-new merged 27 commits into
InspectorHub:mainfrom
important-new:track-l-sms
Jun 9, 2026
Merged

Track L — SMS notifications (#123)#136
important-new merged 27 commits into
InspectorHub:mainfrom
important-new:track-l-sms

Conversation

@important-new

Copy link
Copy Markdown
Contributor

Track L — SMS notifications (#123)

Multi-channel automations engine with SMS via Twilio, consent ledger, and reminder delivery.

  • channels[] / sms_body on the automation engine; channel-aware address resolution + fan-out
  • Twilio REST send + request-signature validation; per-tenant explicit mode toggle (mirrors email)
  • append-only SMS consent ledger (booking checkbox / opt-in link / inspector attestation) + ensureClientContact
  • tenant-routed inbound webhook (STOP/START); reminder due-time derived live (no cached send_at)
  • E.164 phone normalization; {{company_phone}} from tenant_configs
  • migrations 0025 (channels/sms_body, recipient rename, consent tables, sms_mode, Twilio secret keys) + 0026 (company_phone)
  • editor multi-channel UI + Settings SMS config + consent UI

Gates green (unit 1606 / web 361 / workers 33) + Chrome E2E. Closes #123.

🤖 Generated with Claude Code

important-new and others added 27 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>
…:00 UTC flakiness

The previous test anchored on tomorrow's date, making the derived due-time
(tomorrow@09:00Z - 1440min = today@09:00Z) only in the past after 09:00 UTC.
CI jobs running before that hour would see the reminder as not-yet-due and
the status would stay 'pending' instead of 'sent'.

Fix: use today's date so derived due = yesterday@09:00Z, always in the past.
@important-new important-new merged commit 213daa8 into InspectorHub:main Jun 9, 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)
@important-new important-new deleted the track-l-sms branch June 9, 2026 02:52
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.

SMS notifications (bring-your-own Twilio)

1 participant