Kulcare Docs
Feature QA checklists

SMS Event Handler Wiring QA Guide

Scope: Manual verification that 5 pre-existing Channels::Sms::EVENT_HANDLERS entries renamed in #1587 now actually send (previously silently dropped), the 2 removed orphan entries don't break any caller flow, and the same-class bug in Clinical::Appointments::NotificationService (which bypassed the dispatcher and used the broken keys directly) now sends correctly too.

Prerequisites: Backend running on dev or staging, Rails console access, a +91 test phone number registered for SMS in dev or a staging number. SMS dispatch is gated by SmsPolicy — verify the test facility has the relevant per-clinic toggles ON (appt_confirm_sms, appt_reminder_sms, send_doctor_appt_sms) before testing.

Related: Wiring contract spec · SMS Dispatcher Wiring Guide · Issue #1587

What changed in #1587

The dispatcher map at backend/domains/shared/services/notifications/channels/sms.rb had 7 broken entries — 5 with mismatched message_type strings (handler silently dropped the send because no subtype's match? accepted the configured string) and 2 orphan entries pointing at non-existent handler subtypes.

Separately, Clinical::Appointments::NotificationService invoked the same SMS handlers DIRECTLY (bypassing the dispatcher) with the OLD pre-#1587 strings — so doctor-side appointment SMSes from CMS also silently dropped. Those 3 direct calls now use the same renamed strings; 3 other branches in that service that pointed at non-existent handler subtypes ('patient_checked_in', 'new_appointment', 'patient_arrived') were changed to log a skip instead of pretending to send.

Renamed (now send correctly):

Event keyBefore (silent drop)After (correct subtype)
appointment.reminderappointment_reminderfuture_appointment_reminder
public_appointment.confirmedconfirmappointment_confirmed
doctor_appointment.bookedappointment_bookedconsultant_appointment_booked
doctor_appointment.rescheduledappointment_rescheduleddoctor_appointment_rescheduled
doctor_appointment.cancelledappointment_cancelleddoctor_appointment_cancelled

(doctor_appointment.teleconsult_booked was already correct.)

Removed (orphans with no handler):

  • appointment_queue.checked_in — use appointment_queue.position_update instead.
  • public_appointment.created — use public_appointment.save_details or .confirmed instead.

SmsPolicy::EVENT_TOGGLES and WhatsappPolicy::EVENT_TOGGLES have the corresponding public_appointment.created entries removed.

Verification — automated (run first)

cd backend
bundle exec rspec domains/shared/spec/services/notifications/channels/sms_event_handlers_spec.rb

Expect: 164 examples, 0 failures. This iterates the full EVENT_HANDLERS map and asserts every entry's message_type resolves to a handler subtype. If this fails, the wiring guide is wrong — stop and fix before manual QA.

Also run the policy specs (they reference the same event keys):

bundle exec rspec domains/shared/spec/services/notifications/sms_policy_spec.rb \
                  domains/shared/spec/services/notifications/whatsapp_policy_spec.rb

Manual flow checks (5 scenarios)

For each scenario:

  1. Trigger the flow from the UI (or Rails console as documented).
  2. Confirm a row appears in shared.notification_logs with the expected channel: 'sms', event:, and recipient.
  3. (Staging only) Confirm the SMS arrives on the test number.
  4. Tail logs for [Channels::Sms] suppression messages and [Sms<Family>] delivery confirmations.

Scenario 1 — Future appointment reminder (appointment.reminder)

Trigger: Schedule an appointment 4+ hours in the future, then run the appointment-reminder cron / job that fires future-dated SMSes. In Rails console:

# `Identity::Patient#phone_number` stores the number WITHOUT the
# country code; the country code lives on `phone_country_code`. Use
# the right column for the lookup, then call `phone_full` for the
# concatenated string the SMS sender expects.
patient = Identity::Patient.find_by(phone_country_code: '91', phone_number: 'XXXXXXXXXX')

Shared::Notifications::Dispatcher.call(
  event: 'appointment.reminder',
  context: { patient_name: 'Test', appointment_time: 1.day.from_now.iso8601 },
  recipient: patient,
  channels: [:sms],
  facility: Identity::Facility.find(<facility_id>),
  tenant_id: Identity::Tenant.find(<tenant_id>).id
)

Expect:

  • shared.notification_logs row with event: 'appointment.reminder', channel: 'sms', status: 'sent' (or 'queued' if Sidekiq deferred).
  • SMS body templated from Sms::Appointment::FutureAppointmentReminder.
  • Before the fix: this would log nothing and return silently.

Scenario 2 — Public appointment confirmation (public_appointment.confirmed)

Trigger: From the patient app, book a public appointment and click the "Confirm" CTA in the booking flow.

Expect:

  • shared.notification_logs row with event: 'public_appointment.confirmed'.
  • SMS body templated from Sms::PublicAppointment::AppointmentConfirmed.

Scenario 3 — Doctor-side appointment notifications (3 events)

Trigger: From CMS, perform each action against an appointment whose attending consultant is a different user than the booking clinic owner:

  1. Book: create a new appointment → fires doctor_appointment.booked.
  2. Reschedule: edit time → fires doctor_appointment.rescheduled.
  3. Cancel: cancel the appointment → fires doctor_appointment.cancelled.

Expect:

  • 3 separate notification_logs rows (one per action), each with recipient set to the doctor's phone (not the patient).
  • SMS bodies templated from NotifyDoctorAppt::AppointmentBooked, ::AppointmentRescheduled, ::AppointmentCancelled.

Scenario 4 — Removed event: appointment_queue.checked_in

This event no longer routes to SMS. The replacement is appointment_queue.position_update, which is what the queue notification UI should already be calling.

Trigger: From CMS queue management, mark a patient as checked in.

Expect:

  • shared.notification_logs row with event: 'appointment_queue.position_update' (NOT checked_in).
  • SMS body templated from Sms::AppointmentQueue::NewAfterCheckin.
  • If you see a row with event: 'appointment_queue.checked_in': a caller in the code is still using the removed key. File a bug and tag #1587.

Scenario 5 — Removed event: public_appointment.created

This event no longer routes to SMS. The patient-app booking flow's "saved your details" notification is public_appointment.save_details, and the booking confirmation is public_appointment.confirmed.

Trigger: Book a public appointment from the patient app (do not confirm).

Expect:

  • shared.notification_logs row with event: 'public_appointment.save_details'.
  • No row with event: 'public_appointment.created'.

Edge cases to check

  1. Toggle off: Set appt_reminder_sms = false on the test facility's FacilitySettings. Trigger scenario 1. Expect the dispatcher to log [Channels::Sms] suppressed by SmsPolicy event=appointment.reminder ... and NOT create a notification row.

  2. DLT not registered: If the test environment doesn't have a MsgDetail row for the relevant template + language, the send will fail at the SmsService.call level (not at the dispatcher). The dispatcher will still write the notification log row with status: 'failed'.

  3. Pre-auth flow: Some SMS events fire before a user is logged in (e.g. OTP). These pass facility: nil to the dispatcher and bypass the policy check. Out of scope for this QA pass (covered by the auth flow QA).

What "done" looks like

  • Wiring spec passes locally (164 examples, 0 failures).
  • Each of the 5 scenarios delivers an SMS or shows the corresponding notification_logs row in dev/staging.
  • No [LegacyDeprecation] warn lines for the renamed events (those callers should be going through the new dispatcher path).
  • No errors in the staging error tracker on the dispatcher code path for 7 days post-deploy.

Rollback

If a renamed event causes regressions in production (e.g. some legacy caller was relying on the broken-but-recoverable old behavior, or a handler subtype has a bug surfaced only now that the send actually fires), revert the specific entry in channels/sms.rb back to the broken message_type string. The dispatcher will go back to silently dropping that one event, restoring previous "send doesn't happen" behavior while the underlying handler issue is fixed.

For the removed orphan entries (appointment_queue.checked_in, public_appointment.created), rollback is straightforward: re-add the entry to EVENT_HANDLERS and EVENT_TOGGLES. Since they were silently broken before #1587, the rollback restores the pre-#1587 silent-drop behavior, not working sends.

On this page