Kulcare Docs
DeveloperAuthentication DocumentationNotifications

SMS Dispatcher Wiring (Channels::Sms::EVENTHANDLERS)

Last Updated: 2026-10-15

Why this guide exists

The unified notification dispatcher (Shared::Notifications::Dispatcher) routes domain events to channel adapters. The SMS adapter (Shared::Notifications::Channels::Sms) maps each dispatcher event key to:

  1. A handler class (e.g. Shared::Notifications::Sms::SmsAppointment).
  2. A message_type string that the handler's message_types registry resolves to a specific subtype renderer (e.g. Sms::Appointment::FutureAppointmentReminder).

When either side of the map drifts — typo in the event key, wrong message_type string, or a handler subtype that doesn't accept the string — sends silently no-op. The dispatcher logs nothing because the handler's message_types.find { ... } returns nil and the send returns without raising.

This guide documents the contract that prevents that silent drop and the process for adding or fixing entries.

The wiring contract

Every entry in Channels::Sms::EVENT_HANDLERS must satisfy:

klass = mapping[:handler].constantize
klass.message_types.find { |m| m.match?(mapping[:message_type]) }.present?

This is enforced by backend/domains/shared/test/services/notifications/channels/sms_event_handlers_test.rb, which iterates the full table and fails if any entry breaks the invariant.

A handler subtype that no entry reaches must be listed in UNWIRED_SUBTYPES in the same test. Each entry cites its callers as path#method (or is dead:), and the test checks that the file defines the method and names a message key that the subtype matches, so a citation cannot drift (#2709).

Historical incidents

  • #1566 — 'reminder_patient' (legacy class name) mistagged as the message_type after the port. No Sms::Teleconsultation::PatientReminder subtype accepted that string. Caught in production after silent SMS drops.
  • #1569 — added the wiring contract spec scoped to the 15 newly-mapped events.
  • #1587 — widened the spec to iterate the full EVENT_HANDLERS map after renaming 5 pre-existing entries with mismatched message_type strings (one appointment.reminder, one public_appointment.confirmed, three doctor_appointment.*) and removing 2 orphan entries with no handler at all (appointment_queue.checked_in, public_appointment.created).

How to add a new dispatcher event

  1. Decide the event key — namespace by domain: <aggregate>.<verb> (e.g. appointment.rescheduled, clinic_doctor.id_verification). Lowercase, dot- separated, snake_case after the dot.

  2. Find or create the handler subtype under backend/domains/shared/services/notifications/sms/<family>/<subtype>.rb. The subtype must implement:

    def match?(input)
      input == 'your_message_type_string'  # or a set of strings
    end
    
    def get_message_text(message_params, basic_language = nil)
      I18n.with_locale(basic_language || :en) do
        I18n.t('sms.your_family.your_subtype', name: message_params[:name].to_s)
      end
    end

    The text lives in config/locales/en.yml under sms.*; the class holds no words of its own (text_in_i18n_test.rb, #2709).

  3. Register the subtype in the handler class's message_types array (Shared::Notifications::Sms::Sms<Family>).

  4. Add the entry to Channels::Sms::EVENT_HANDLERS:

    'your_aggregate.your_verb' => {
      handler: 'Shared::Notifications::Sms::SmsYourFamily',
      message_type: 'your_message_type_string'
    }
  5. (Optional, recommended) Seed the corresponding Shared::MsgDetail row in backend/db/seeds/reference/ so the DLT registration is tracked.

  6. (Optional) If the event should be gated by a per-facility toggle (e.g. appt_confirm_sms), add the entry to Shared::Notifications::SmsPolicy::EVENT_TOGGLES and WhatsappPolicy::EVENT_TOGGLES (if WhatsApp also routes this event). Declare the same event on the message class (gated_by event: 'your_aggregate.your_verb'), so a direct call to the handler with facility: applies the same toggle (#2711, see notifications.md).

  7. Run the contract spec:

    RAILS_ENV=test bin/rails test domains/shared/test/services/notifications/channels/sms_event_handlers_test.rb

    It will iterate every entry and verify the new one resolves.

How to fix a mismatched entry

Run the spec above. The failure message tells you which event broke and the configured message_type it couldn't resolve:

handler Shared::Notifications::Sms::SmsAppointment has no message_type
accepting "appointment_reminder" for event "appointment.reminder".

Pick one of:

Option A — Change the dispatcher's message_type to match the handler

Look up what match? actually accepts on each subtype:

grep -A2 "def match?" backend/domains/shared/services/notifications/sms/<family>/*.rb

Update the EVENT_HANDLERS entry. Preferred when: only the dispatcher references this string. Faster, no ripple effect.

Option B — Widen the handler subtype's match? to accept the dispatcher's string

def match?(input)
  %w[old_string dispatcher_string].include?(input)
end

This is the pattern Sms::Teleconsultation::PatientReminder uses (accepts both 'patient_10min' and 'patient_reminder'). Use when: the existing acceptance string is referenced by non-dispatcher callers (e.g. legacy parts/ code still calling the handler directly) and renaming would break them.

How to remove a dead entry

An entry is dead if:

  • No handler subtype accepts its message_type (the spec fails), AND
  • No caller invokes Dispatcher.call(event: 'this_event', ...) (grep -rn the codebase), AND
  • No MsgDetail seed references the event, AND
  • No policy map references the event.

In that case, remove the entry from EVENT_HANDLERS. Also remove any corresponding entries from SmsPolicy::EVENT_TOGGLES and WhatsappPolicy::EVENT_TOGGLES — the policy maps are independent and won't fail loudly if they reference a non-existent dispatcher event.

Document the removal in the file with a comment explaining what the canonical event is now (if any) so callers searching for the removed key land on the replacement. Example from channels/sms.rb after #1587:

# `appointment_queue.checked_in` was an orphan entry pointing at
# `'checkin_confirmation'` — no `AppointmentQueue` subtype matched
# that string, and grep finds zero callers and zero policy or seed
# entries. The "checkin happened, notify queue position" notification
# is what `appointment_queue.position_update` (above) covers.
# Removed in #1587.

Reference: handler families

Handler classSubtypes live inCommon message_type pattern
SmsAppointmentsms/appointment/booking_confirmation, reschedule_appointment, future_appointment_reminder
SmsAppointmentQueuesms/appointment_queue/new_after_checkin, update_after_checkin, prescription_attached
SmsPublicAppointmentsms/public_appointment/save_details, appointment_confirmed, reminder
SmsNotifyDoctorApptsms/notify_doctor_appt/consultant_appointment_booked, doctor_appointment_rescheduled
SmsTeleconsultationsms/teleconsultation/patient_reminder, reminder_doctor, patient_joined
SmsCodesms/code/verification, two_factor, reset_password

Run the contract spec after any change to either side.

On this page