Files
MySMSAPio/docs/superpowers/plans/2026-07-28-ntfy-notifications-integration.md
2026-07-28 03:04:27 +08:00

43 KiB

ntfy Notifications Integration Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add self-hosted ntfy push notifications to MySMSAPio so admins receive real-time alerts on their phone for gateway online/offline, SMS failed, SMS delivered, and API key revoked events.

Architecture: A per-admin ntfy topic stored on AdminUser. An Ntfy::Publisher service builds JSON payloads (title, message, priority, tags, click URL) and POSTs via HTTParty to a self-hosted ntfy server. A SendNtfyNotificationJob runs async with retry. An NtfyDispatcher concern is included in models/jobs that need to fire notifications, calling the publisher for each admin with ntfy configured. Admin UI under /admin/notifications lets each admin set their topic + access token and send a test notification. ntfy runs as a Kamal accessory container.

Tech Stack: Rails 8, HTTParty (already in Gemfile), Sidekiq/ActiveJob, Tailwind v4, PostgreSQL, Docker/Kamal, ntfy (Go binary, binwiederhier/ntfy image).

Decisions locked from brainstorm:

  • Self-hosted ntfy via Kamal accessory container
  • Per-admin ntfy topic (each AdminUser has their own topic + token)
  • ntfy only — do NOT touch the existing webhook system
  • Events to wire: gateway_offline, gateway_online, sms_failed, sms_delivered, api_key_revoked

File Structure

New files:

File Responsibility
db/migrate/<ts>_add_ntfy_fields_to_admin_users.rb Migration: add ntfy_topic, ntfy_token, ntfy_enabled, ntfy_server_url to admin_users
lib/my_smsa_pio/notifications/ntfy/publisher.rb Service: builds payload, HTTParty POST to ntfy server. Single #publish method.
app/jobs/send_ntfy_notification_job.rb ActiveJob: async dispatch with retry. Calls Ntfy::Publisher.
app/models/concerns/ntfy_dispatchable.rb Concern: #dispatch_ntfy(event, payload) method that enqueues SendNtfyNotificationJob for each enabled admin.
app/controllers/admin/notifications_controller.rb Admin controller: show/edit ntfy config + send test notification.
app/views/admin/notifications/show.html.erb Admin UI: ntfy settings form + test button.
config/initializers/ntfy.rb Default ntfy server URL from ENV.
test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb Unit tests for publisher.
test/jobs/send_ntfy_notification_job_test.rb Job tests.
test/integration/admin/notifications_flow_test.rb Integration test for admin UI + test notification.
test/models/concerns/ntfy_dispatchable_test.rb Concern dispatch tests.

Modified files:

File Change
app/models/admin_user.rb Add ntfy fields, ntfy_configured? method
app/models/gateway.rb Include NtfyDispatchable; dispatch in heartbeat! and mark_offline!
app/models/sms_message.rb Include NtfyDispatchable; dispatch in mark_delivered! and mark_failed!
app/models/api_key.rb Include NtfyDispatchable; dispatch in revoke!
app/jobs/check_gateway_health_job.rb Dispatch gateway_offline for each gateway marked offline (after update_all)
app/views/layouts/admin.html.erb Add "Notifications" nav link
config/routes.rb Add admin/notifications routes
config/deploy.yml Add ntfy accessory container
db/seeds.rb Update admin seed with ntfy fields

Task 1: Migration — add ntfy fields to admin_users

Files:

  • Create: db/migrate/<timestamp>_add_ntfy_fields_to_admin_users.rb

  • Test: test/models/admin_user_test.rb (modify)

  • Step 1: Generate the migration

Run:

bin/rails generate migration AddNtfyFieldsToAdminUsers ntfy_topic:string ntfy_token:string ntfy_enabled:boolean ntfy_server_url:string
  • Step 2: Edit the generated migration to set defaults

Open the generated file (e.g. db/migrate/2026XXXX_add_ntfy_fields_to_admin_users.rb) and ensure it reads:

class AddNtfyFieldsToAdminUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :admin_users, :ntfy_topic, :string
    add_column :admin_users, :ntfy_token, :string
    add_column :admin_users, :ntfy_enabled, :boolean, default: false, null: false
    add_column :admin_users, :ntfy_server_url, :string
  end
end
  • Step 3: Run the migration

Run:

bin/rails db:migrate

Expected: migration applies, schema.rb updates with the new columns.

  • Step 4: Add test for ntfy_configured? to AdminUserTest

Add this test to test/models/admin_user_test.rb (inside the class, after existing tests):

  test "ntfy_configured? returns true only when topic and token and enabled are set" do
    admin = AdminUser.new(name: "N", email: "n@e.com", password: "password123")
    assert_not admin.ntfy_configured?

    admin.ntfy_enabled = true
    assert_not admin.ntfy_configured?

    admin.ntfy_topic = "my-topic"
    assert_not admin.ntfy_configured?

    admin.ntfy_token = "tk_abc"
    assert admin.ntfy_configured?
  end
  • Step 5: Add ntfy_configured? method to AdminUser

In app/models/admin_user.rb, add this method (before the private keyword):

  def ntfy_configured?
    ntfy_enabled? && ntfy_topic.present? && ntfy_token.present?
  end
  • Step 6: Run tests to verify they pass

Run:

bin/rails test test/models/admin_user_test.rb

Expected: all tests PASS.

  • Step 7: Commit
git add db/migrate/*add_ntfy* db/schema.rb app/models/admin_user.rb test/models/admin_user_test.rb
git commit -m "feat(ntfy): add ntfy fields to admin_users and ntfy_configured? method"

Task 2: Ntfy::Publisher service (TDD)

Files:

  • Create: lib/my_smsa_pio/notifications/ntfy/publisher.rb

  • Create: test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb

  • Step 1: Write failing tests for the publisher

Create test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb:

require "test_helper"

module MySmsaPio
  module Notifications
    module Ntfy
      class PublisherTest < ActiveSupport::TestCase
        setup do
          @admin = AdminUser.create!(
            name: "Notifier", email: "notif@example.com",
            password: "password123",
            ntfy_topic: "mysmsa-test-topic",
            ntfy_token: "tk_test_token",
            ntfy_enabled: true,
            ntfy_server_url: "https://ntfy.example.com"
          )
        end

        test "publish sends POST to the correct ntfy URL with JSON body and auth header" do
          stubbed = stub_request(:post, "https://ntfy.example.com/mysmsa-test-topic")
            .with(
              headers: {
                "Authorization" => "Bearer tk_test_token",
                "Content-Type" => "application/json"
              },
              body: hash_including(
                "topic" => "mysmsa-test-topic",
                "title" => "Gateway offline",
                "message" => /gw-001 went offline/,
                "priority" => 4,
                "tags" => array_including("rotating_light")
              )
            )
            .to_return(status: 200, body: "", headers: {})

          result = Publisher.new(@admin).publish(
            title: "Gateway offline",
            message: "gw-001 went offline at #{Time.current}",
            priority: 4,
            tags: ["rotating_light"]
          )

          assert result
          assert_requested stubbed
        end

        test "publish returns false on HTTP failure without raising" do
          stub_request(:post, "https://ntfy.example.com/mysmsa-test-topic")
            .to_return(status: 500, body: "error")

          result = Publisher.new(@admin).publish(
            title: "Test",
            message: "body",
            priority: 3,
            tags: []
          )

          assert_not result
        end

        test "publish returns false if admin not ntfy_configured?" do
          unconfigured = AdminUser.create!(
            name: "No Ntfy", email: "none@example.com",
            password: "password123"
          )

          result = Publisher.new(unconfigured).publish(
            title: "X", message: "Y", priority: 3, tags: []
          )

          assert_not result
        end

        test "publish uses default server URL from ENV when ntfy_server_url is nil" do
          ENV["NTFY_SERVER_URL"] = "https://default-ntfy.example.com"
          admin = AdminUser.create!(
            name: "Default", email: "default@example.com",
            password: "password123",
            ntfy_topic: "def-topic", ntfy_token: "tk_def",
            ntfy_enabled: true
          )

          stub = stub_request(:post, "https://default-ntfy.example.com/def-topic")
            .to_return(status: 200, body: "", headers: {})

          Publisher.new(admin).publish(title: "T", message: "M", priority: 3, tags: [])
          assert_requested stub
        ensure
          ENV.delete("NTFY_SERVER_URL")
        end

        test "publish includes click URL when provided" do
          stub = stub_request(:post, "https://ntfy.example.com/mysmsa-test-topic")
            .with(body: hash_including("click" => "https://app.example.com/admin/gateways"))
            .to_return(status: 200, body: "", headers: {})

          Publisher.new(@admin).publish(
            title: "T", message: "M", priority: 3, tags: [],
            click: "https://app.example.com/admin/gateways"
          )
          assert_requested stub
        end
      end
    end
  end
end
  • Step 2: Run test to verify it fails

Run:

bin/rails test test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb

Expected: FAIL — NameError: uninitialized constant MySmsaPio::Notifications::Ntfy::Publisher (class does not exist yet).

  • Step 3: Write the Publisher implementation

Create lib/my_smsa_pio/notifications/ntfy/publisher.rb:

require "httparty"

module MySmsaPio
  module Notifications
    module Ntfy
      class Publisher
        def initialize(admin)
          @admin = admin
        end

        def publish(title:, message:, priority: 3, tags: [], click: nil)
          return false unless @admin.ntfy_configured?

          body = {
            topic: @admin.ntfy_topic,
            title: title,
            message: message,
            priority: priority,
            tags: Array(tags)
          }
          body[:click] = click if click.present?

          url = "#{server_url}/#{@admin.ntfy_topic}"

          response = HTTParty.post(
            url,
            body: body.to_json,
            headers: {
              "Content-Type" => "application/json",
              "Authorization" => "Bearer #{@admin.ntfy_token}"
            },
            timeout: 10
          )

          response.success?
        rescue StandardError => e
          Rails.logger.error("ntfy publish failed for admin #{@admin.id}: #{e.message}")
          false
        end

        private

        def server_url
          @admin.ntfy_server_url.presence || ENV["NTFY_SERVER_URL"] || "https://ntfy.sh"
        end
      end
    end
  end
end
  • Step 4: Run test to verify it passes

Run:

bin/rails test test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb

Expected: 5 tests PASS.

  • Step 5: Commit
git add lib/my_smsa_pio/notifications/ntfy/publisher.rb test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb
git commit -m "feat(ntfy): add Ntfy::Publisher service with HTTP POST + auth"

Task 3: SendNtfyNotificationJob (TDD)

Files:

  • Create: app/jobs/send_ntfy_notification_job.rb

  • Create: test/jobs/send_ntfy_notification_job_test.rb

  • Step 1: Write failing tests for the job

Create test/jobs/send_ntfy_notification_job_test.rb:

require "test_helper"

class SendNtfyNotificationJobTest < ActiveJob::TestCase
  setup do
    @admin = AdminUser.create!(
      name: "Job Admin", email: "jobadmin@example.com",
      password: "password123",
      ntfy_topic: "job-topic", ntfy_token: "tk_job",
      ntfy_enabled: true,
      ntfy_server_url: "https://ntfy.example.com"
    )

    stub_request(:post, "https://ntfy.example.com/job-topic")
      .to_return(status: 200, body: "", headers: {})
  end

  test "perform calls Publisher with provided params" do
    SendNtfyNotificationJob.perform_now(
      @admin.id,
      "gateway_offline",
      title: "Gateway offline",
      message: "gw-001 went offline",
      priority: 4,
      tags: ["rotating_light"],
      click: "https://app.example.com/admin/gateways"
    )

    assert_requested :post, "https://ntfy.example.com/job-topic",
      body: hash_including("title" => "Gateway offline", "priority" => 4)
  end

  test "perform does nothing if admin not found" do
    assert_nothing_raised do
      SendNtfyNotificationJob.perform_now(999999, "gateway_offline",
        title: "X", message: "Y", priority: 3, tags: [])
    end
  end

  test "perform does nothing if admin ntfy not configured" do
    unconfigured = AdminUser.create!(
      name: "UC", email: "uc@example.com", password: "password123"
    )

    SendNtfyNotificationJob.perform_now(unconfigured.id, "gateway_offline",
      title: "X", message: "Y", priority: 3, tags: [])

    assert_not_requested :post, "https://ntfy.example.com/job-topic"
  end
end
  • Step 2: Run test to verify it fails

Run:

bin/rails test test/jobs/send_ntfy_notification_job_test.rb

Expected: FAIL — NameError: uninitialized constant SendNtfyNotificationJob.

  • Step 3: Write the job implementation

Create app/jobs/send_ntfy_notification_job.rb:

require "my_smsa_pio/notifications/ntfy/publisher"

class SendNtfyNotificationJob < ApplicationJob
  queue_as :notifications
  retry_on StandardError, wait: :exponentially_longer, attempts: 3

  def perform(admin_id, event_type, title:, message:, priority: 3, tags: [], click: nil)
    admin = AdminUser.find_by(id: admin_id)
    return unless admin
    return unless admin.ntfy_configured?

    MySmsaPio::Notifications::Ntfy::Publisher.new(admin).publish(
      title: title,
      message: message,
      priority: priority,
      tags: tags,
      click: click
    )
  rescue StandardError => e
    Rails.logger.error("SendNtfyNotificationJob failed for admin #{admin_id}: #{e.message}")
    raise
  end
end
  • Step 4: Run test to verify it passes

Run:

bin/rails test test/jobs/send_ntfy_notification_job_test.rb

Expected: 3 tests PASS.

  • Step 5: Commit
git add app/jobs/send_ntfy_notification_job.rb test/jobs/send_ntfy_notification_job_test.rb
git commit -m "feat(ntfy): add SendNtfyNotificationJob for async dispatch with retry"

Task 4: NtfyDispatchable concern (TDD)

Files:

  • Create: app/models/concerns/ntfy_dispatchable.rb

  • Create: test/models/concerns/ntfy_dispatchable_test.rb

  • Step 1: Write failing tests for the concern

Create test/models/concerns/ntfy_dispatchable_test.rb:

require "test_helper"

class NtfyDispatchableTest < ActiveSupport::TestCase
  setup do
    @admin = AdminUser.create!(
      name: "Dispatch Admin", email: "dispatch@example.com",
      password: "password123",
      ntfy_topic: "dispatch-topic", ntfy_token: "tk_dispatch",
      ntfy_enabled: true,
      ntfy_server_url: "https://ntfy.example.com"
    )

    stub_request(:post, "https://ntfy.example.com/dispatch-topic")
      .to_return(status: 200, body: "", headers: {})
  end

  test "dispatch_ntfy enqueues a SendNtfyNotificationJob per configured admin" do
    assert_enqueued_jobs 1 do
      TestModel.dispatch_ntfy("sms_failed",
        title: "SMS failed",
        message: "msg_abc failed",
        priority: 5,
        tags: ["x"],
        click: "https://app/admin/logs"
      )
    end
  end

  test "dispatch_ntfy skips admins without ntfy configured" do
    AdminUser.create!(name: "No Ntfy", email: "none@example.com", password: "password123")

    assert_enqueued_jobs 1 do
      TestModel.dispatch_ntfy("sms_failed", title: "T", message: "M", priority: 3, tags: [])
    end
  end

  test "dispatch_ntfy enqueues zero jobs if no admins configured" do
    @admin.update!(ntfy_enabled: false)

    assert_enqueued_jobs 0 do
      TestModel.dispatch_ntfy("sms_failed", title: "T", message: "M", priority: 3, tags: [])
    end
  end
end

class TestModel
  extend NtfyDispatchable
end
  • Step 2: Run test to verify it fails

Run:

bin/rails test test/models/concerns/ntfy_dispatchable_test.rb

Expected: FAIL — NameError: uninitialized constant NtfyDispatchable.

  • Step 3: Write the concern implementation

Create app/models/concerns/ntfy_dispatchable.rb:

module NtfyDispatchable
  extend ActiveSupport::Concern

  class_methods do
    def dispatch_ntfy(event_type, title:, message:, priority: 3, tags: [], click: nil)
      AdminUser.where(ntfy_enabled: true).find_each do |admin|
        next unless admin.ntfy_configured?

        SendNtfyNotificationJob.perform_later(
          admin.id,
          event_type,
          title: title,
          message: message,
          priority: priority,
          tags: tags,
          click: click
        )
      end
    end
  end
end
  • Step 4: Run test to verify it passes

Run:

bin/rails test test/models/concerns/ntfy_dispatchable_test.rb

Expected: 3 tests PASS.

  • Step 5: Commit
git add app/models/concerns/ntfy_dispatchable.rb test/models/concerns/ntfy_dispatchable_test.rb
git commit -m "feat(ntfy): add NtfyDispatchable concern for fanning out to admins"

Task 5: Wire gateway_online and gateway_offline notifications

Files:

  • Modify: app/models/gateway.rb
  • Modify: app/jobs/check_gateway_health_job.rb
  • Test: test/models/gateway_test.rb (create), test/jobs/check_gateway_health_job_test.rb (create)

5a: Gateway model — heartbeat! and mark_offline!

  • Step 1: Write failing test for gateway_online notification

Create test/models/gateway_test.rb:

require "test_helper"

class GatewayTest < ActiveSupport::TestCase
  setup do
    @admin = AdminUser.create!(
      name: "GW Admin", email: "gw@example.com",
      password: "password123",
      ntfy_topic: "gw-topic", ntfy_token: "tk_gw",
      ntfy_enabled: true, ntfy_server_url: "https://ntfy.example.com"
    )
    stub_request(:post, "https://ntfy.example.com/gw-topic").to_return(status: 200)
  end

  test "heartbeat! dispatches gateway_online notification when transitioning from offline" do
    gateway = Gateway.create!(
      device_id: "dev-online", name: "GW",
      api_key_digest: "x" * 64, status: "offline"
    )

    assert_enqueued_jobs 1 do
      gateway.heartbeat!
    end
  end

  test "heartbeat! does not dispatch if already online" do
    gateway = Gateway.create!(
      device_id: "dev-stay", name: "GW",
      api_key_digest: "y" * 64, status: "online",
      last_heartbeat_at: 1.minute.ago
    )

    assert_enqueued_jobs 0 do
      gateway.heartbeat!
    end
  end

  test "mark_offline! dispatches gateway_offline notification when transitioning from online" do
    gateway = Gateway.create!(
      device_id: "dev-off", name: "GW",
      api_key_digest: "z" * 64, status: "online",
      last_heartbeat_at: 1.minute.ago
    )

    assert_enqueued_jobs 1 do
      gateway.mark_offline!
    end
  end
end
  • Step 2: Run test to verify it fails

Run:

bin/rails test test/models/gateway_test.rb

Expected: FAIL — gateways don't dispatch notifications yet.

  • Step 3: Modify Gateway model to include concern and dispatch

In app/models/gateway.rb, add at the top of the class (after class Gateway < ApplicationRecord):

  include NtfyDispatchable

Replace the heartbeat! method with:

  def heartbeat!
    was_offline = status == "offline"
    update!(status: "online", last_heartbeat_at: Time.current)

    if was_offline
      self.class.dispatch_ntfy("gateway_online",
        title: "Gateway online",
        message: "#{name} (#{device_id}) came back online",
        priority: 3,
        tags: ["white_check_mark"],
        click: nil)
    end
  end

Replace the mark_offline! method with:

  def mark_offline!
    was_online = status == "online"
    update!(status: "offline")

    if was_online
      self.class.dispatch_ntfy("gateway_offline",
        title: "Gateway offline",
        message: "#{name} (#{device_id}) went offline",
        priority: 4,
        tags: ["rotating_light"])
    end
  end
  • Step 4: Run test to verify it passes

Run:

bin/rails test test/models/gateway_test.rb

Expected: 3 tests PASS.

  • Step 5: Commit (model part)
git add app/models/gateway.rb test/models/gateway_test.rb
git commit -m "feat(ntfy): dispatch gateway_online/gateway_offline from model"

5b: CheckGatewayHealthJob — gateway_offline for bulk-updated gateways

  • Step 6: Write failing test for CheckGatewayHealthJob dispatch

Create test/jobs/check_gateway_health_job_test.rb:

require "test_helper"

class CheckGatewayHealthJobTest < ActiveJob::TestCase
  setup do
    @admin = AdminUser.create!(
      name: "Health Admin", email: "health@example.com",
      password: "password123",
      ntfy_topic: "health-topic", ntfy_token: "tk_health",
      ntfy_enabled: true, ntfy_server_url: "https://ntfy.example.com"
    )
    stub_request(:post, "https://ntfy.example.com/health-topic").to_return(status: 200)
  end

  test "dispatches gateway_offline for each stale gateway marked offline" do
    stale_gw = Gateway.create!(
      device_id: "stale-1", name: "Stale One",
      api_key_digest: "a" * 64, status: "online",
      last_heartbeat_at: 5.minutes.ago
    )
    Gateway.create!(
      device_id: "fresh-1", name: "Fresh",
      api_key_digest: "b" * 64, status: "online",
      last_heartbeat_at: 30.seconds.ago
    )

    assert_enqueued_jobs 1 do
      CheckGatewayHealthJob.perform_now
    end

    assert_equal "offline", stale_gw.reload.status
  end

  test "does not dispatch when no gateways go stale" do
    Gateway.create!(
      device_id: "fresh-2", name: "Fresh Two",
      api_key_digest: "c" * 64, status: "online",
      last_heartbeat_at: 10.seconds.ago
    )

    assert_enqueued_jobs 0 do
      CheckGatewayHealthJob.perform_now
    end
  end
end
  • Step 7: Run test to verify it fails

Run:

bin/rails test test/jobs/check_gateway_health_job_test.rb

Expected: FAIL — job does not dispatch notifications yet.

  • Step 8: Modify CheckGatewayHealthJob to dispatch after update_all

Replace app/jobs/check_gateway_health_job.rb entirely with:

class CheckGatewayHealthJob < ApplicationJob
  queue_as :default

  def perform
    # Capture stale gateway details BEFORE update_all (update_all bypasses callbacks
    # and would change the scope, so we snapshot the rows first)
    stale_gateway_details = Gateway.where("last_heartbeat_at < ?", 2.minutes.ago)
                                   .where.not(status: "offline")
                                   .pluck(:id, :name, :device_id)

    offline_count = Gateway.where(id: stale_gateway_details.map(&:first)).update_all(status: "offline")

    if offline_count > 0
      Rails.logger.warn("Marked #{offline_count} gateways as offline due to missing heartbeat")

      stale_gateway_details.each do |_id, name, device_id|
        Gateway.dispatch_ntfy("gateway_offline",
          title: "Gateway offline",
          message: "#{name} (#{device_id}) went offline — no heartbeat for 2+ minutes",
          priority: 4,
          tags: ["rotating_light"])
      end
    end
  end
end
  • Step 9: Run test to verify it passes

Run:

bin/rails test test/jobs/check_gateway_health_job_test.rb

Expected: 2 tests PASS.

  • Step 10: Commit
git add app/jobs/check_gateway_health_job.rb test/jobs/check_gateway_health_job_test.rb
git commit -m "feat(ntfy): dispatch gateway_offline from CheckGatewayHealthJob after bulk update"

Task 6: Wire sms_delivered and sms_failed notifications

Files:

  • Modify: app/models/sms_message.rb

  • Test: test/models/sms_message_test.rb (create)

  • Step 1: Write failing test for sms_delivered and sms_failed dispatch

Create test/models/sms_message_test.rb:

require "test_helper"

class SmsMessageTest < ActiveSupport::TestCase
  setup do
    @admin = AdminUser.create!(
      name: "SMS Admin", email: "sms@example.com",
      password: "password123",
      ntfy_topic: "sms-topic", ntfy_token: "tk_sms",
      ntfy_enabled: true, ntfy_server_url: "https://ntfy.example.com"
    )
    stub_request(:post, "https://ntfy.example.com/sms-topic").to_return(status: 200)

    @gateway = Gateway.create!(
      device_id: "sms-gw", name: "SMS GW",
      api_key_digest: "d" * 64, status: "online",
      last_heartbeat_at: 1.second.ago
    )
  end

  test "mark_delivered! dispatches sms_delivered notification" do
    sms = SmsMessage.create!(
      direction: "outbound", phone_number: "+15551234567",
      message_body: "Hello", status: "sent",
      gateway: @gateway, sent_at: 1.minute.ago
    )

    assert_enqueued_jobs 1 do
      sms.mark_delivered!
    end
  end

  test "mark_failed! dispatches sms_failed notification with urgent priority" do
    sms = SmsMessage.create!(
      direction: "outbound", phone_number: "+15551234567",
      message_body: "Hello", status: "sent",
      gateway: @gateway, sent_at: 1.minute.ago
    )

    assert_enqueued_jobs 1 do
      sms.mark_failed!("Network timeout")
    end
  end
end
  • Step 2: Run test to verify it fails

Run:

bin/rails test test/models/sms_message_test.rb

Expected: FAIL — mark_delivered! and mark_failed! don't dispatch.

  • Step 3: Modify SmsMessage model

In app/models/sms_message.rb, add include NtfyDispatchable after class SmsMessage < ApplicationRecord:

  include NtfyDispatchable

Replace mark_delivered! with:

  def mark_delivered!
    update!(status: "delivered", delivered_at: Time.current)

    self.class.dispatch_ntfy("sms_delivered",
      title: "SMS delivered",
      message: "Message to #{phone_number} (#{message_id}) was delivered",
      priority: 2,
      tags: ["white_check_mark"])
  end

Replace mark_failed! with:

  def mark_failed!(error_msg = nil)
    update!(status: "failed", failed_at: Time.current, error_message: error_msg)

    self.class.dispatch_ntfy("sms_failed",
      title: "SMS failed",
      message: "Message to #{phone_number} (#{message_id}) failed#{error_msg ? ": #{error_msg}" : ''}",
      priority: 5,
      tags: ["x", "rotating_light"])
  end
  • Step 4: Run test to verify it passes

Run:

bin/rails test test/models/sms_message_test.rb

Expected: 2 tests PASS.

  • Step 5: Commit
git add app/models/sms_message.rb test/models/sms_message_test.rb
git commit -m "feat(ntfy): dispatch sms_delivered and sms_failed from SmsMessage"

Task 7: Wire api_key_revoked notification

Files:

  • Modify: app/models/api_key.rb

  • Test: test/models/api_key_test.rb (create)

  • Step 1: Write failing test for api_key_revoked dispatch

Create test/models/api_key_test.rb:

require "test_helper"

class ApiKeyTest < ActiveSupport::TestCase
  setup do
    @admin = AdminUser.create!(
      name: "Key Admin", email: "key@example.com",
      password: "password123",
      ntfy_topic: "key-topic", ntfy_token: "tk_key",
      ntfy_enabled: true, ntfy_server_url: "https://ntfy.example.com"
    )
    stub_request(:post, "https://ntfy.example.com/key-topic").to_return(status: 200)
  end

  test "revoke! dispatches api_key_revoked notification" do
    api_key = ApiKey.create!(
      name: "Test Key",
      key_digest: "e" * 64,
      key_prefix: "api_live_ab",
      permissions: {}, active: true
    )

    assert_enqueued_jobs 1 do
      api_key.revoke!
    end

    assert_not api_key.reload.active
  end
end
  • Step 2: Run test to verify it fails

Run:

bin/rails test test/models/api_key_test.rb

Expected: FAIL — revoke! doesn't dispatch.

  • Step 3: Modify ApiKey model

In app/models/api_key.rb, add include NtfyDispatchable after class ApiKey < ApplicationRecord:

  include NtfyDispatchable

Replace revoke! with:

  def revoke!
    update!(active: false)

    self.class.dispatch_ntfy("api_key_revoked",
      title: "API key revoked",
      message: "API key '#{name}' (#{key_prefix}...) was revoked",
      priority: 3,
      tags: ["key", "no_entry"])
  end
  • Step 4: Run test to verify it passes

Run:

bin/rails test test/models/api_key_test.rb

Expected: 1 test PASS.

  • Step 5: Commit
git add app/models/api_key.rb test/models/api_key_test.rb
git commit -m "feat(ntfy): dispatch api_key_revoked from ApiKey#revoke!"

Task 8: Admin Notifications controller + routes + UI

Files:

  • Create: app/controllers/admin/notifications_controller.rb

  • Create: app/views/admin/notifications/show.html.erb

  • Modify: config/routes.rb

  • Modify: app/views/layouts/admin.html.erb

  • Test: test/integration/admin/notifications_flow_test.rb

  • Step 1: Add routes

In config/routes.rb, inside the namespace :admin block (after the api_tester line), add:

  resource :notifications, only: [:show, :update], controller: "notifications" do
    member { post :test }
  end

Wait — resource (singular) doesn't take a member block the same way. Correct syntax for a singular resource with a custom action:

  resource :notifications, only: [:show, :update] do
    post :test, on: :member
  end

Actually for a singleton resource, use collection-style via post :test directly:

  resource :notifications, only: [:show, :update] do
    post :test
  end

This creates: GET /admin/notificationsshow, PATCH /admin/notificationsupdate, POST /admin/notifications/testtest.

  • Step 2: Write the NotificationsController

Create app/controllers/admin/notifications_controller.rb:

module Admin
  class NotificationsController < BaseController
    def show
      @admin = current_admin
    end

    def update
      @admin = current_admin
      if @admin.update(admin_params)
        redirect_to admin_notifications_path, notice: "Notification settings saved"
      else
        flash.now[:alert] = "Failed to save settings"
        render :show, status: :unprocessable_entity
      end
    end

    def test
      admin = current_admin

      if admin.ntfy_configured?
        SendNtfyNotificationJob.perform_now(
          admin.id,
          "test_notification",
          title: "Test notification from MySMSAPio",
          message: "If you can read this, your ntfy setup is working! Sent at #{Time.current.strftime('%H:%M:%S')}.",
          priority: 3,
          tags: ["tada", "white_check_mark"]
        )
        redirect_to admin_notifications_path, notice: "Test notification sent"
      else
        redirect_to admin_notifications_path, alert: "Configure ntfy topic and token first"
      end
    end

    private

    def admin_params
      params.require(:admin_user).permit(:ntfy_enabled, :ntfy_topic, :ntfy_token, :ntfy_server_url)
    end
  end
end
  • Step 3: Write the admin notifications view

Create app/views/admin/notifications/show.html.erb:

<div class="space-y-6">
  <div class="sm:flex sm:items-center sm:justify-between">
    <div>
      <h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Notification Settings</h1>
      <p class="mt-2 text-sm text-gray-600">Configure ntfy push notifications for your phone.</p>
    </div>
  </div>

  <div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
    <div class="px-6 py-5 border-b border-gray-200">
      <h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
        <i class="fas fa-bell text-blue-500"></i>
        ntfy Configuration
      </h2>
      <p class="mt-1 text-sm text-gray-500">
        Install the <a href="https://ntfy.sh" class="text-blue-600 hover:underline" target="_blank" rel="noopener">ntfy app</a>
        on your phone, subscribe to your topic, and enter the details below.
      </p>
    </div>

    <div class="px-6 py-6">
      <%= form_with model: @admin, url: admin_notifications_path, method: :patch, local: true, class: "space-y-6" do |f| %>
        <div class="flex items-center gap-3">
          <%= f.check_box :ntfy_enabled, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
          <%= f.label :ntfy_enabled, "Enable ntfy notifications", class: "text-sm font-medium text-gray-700" %>
        </div>

        <div>
          <%= f.label :ntfy_topic, "ntfy Topic", class: "block text-sm font-medium text-gray-700" %>
          <p class="mt-1 text-xs text-gray-500">The topic name you subscribe to in the ntfy app. Treat this like a password.</p>
          <%= f.text_field :ntfy_topic,
              class: "mt-2 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-2.5",
              placeholder: "mysmsa-pio-your-secret-topic" %>
        </div>

        <div>
          <%= f.label :ntfy_token, "Access Token (optional)", class: "block text-sm font-medium text-gray-700" %>
          <p class="mt-1 text-xs text-gray-500">Required only if your ntfy server has access control enabled.</p>
          <%= f.text_field :ntfy_token,
              class: "mt-2 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-2.5",
              placeholder: "tk_..." %>
        </div>

        <div>
          <%= f.label :ntfy_server_url, "ntfy Server URL", class: "block text-sm font-medium text-gray-700" %>
          <p class="mt-1 text-xs text-gray-500">Leave blank to use the default (from NTFY_SERVER_URL env var or ntfy.sh).</p>
          <%= f.text_field :ntfy_server_url,
              class: "mt-2 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-2.5",
              placeholder: "https://ntfy.yourdomain.com" %>
        </div>

        <div class="pt-2">
          <%= f.submit "Save Settings",
              class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all" %>
        </div>
      <% end %>
    </div>
  </div>

  <div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
    <div class="px-6 py-5 border-b border-gray-200">
      <h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
        <i class="fas fa-paper-plane text-green-500"></i>
        Send Test Notification
      </h2>
      <p class="mt-1 text-sm text-gray-500">Sends a test push to your configured ntfy topic right now.</p>
    </div>
    <div class="px-6 py-6">
      <%= button_to admin_notifications_test_path, method: :post,
          class: "inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-green-500 transition-all" do %>
        <i class="fas fa-paper-plane"></i>
        Send Test Notification
      <% end %>
    </div>
  </div>

  <div class="rounded-xl bg-blue-50 p-6 ring-1 ring-blue-900/5">
    <h3 class="text-sm font-semibold text-blue-900 flex items-center gap-2">
      <i class="fas fa-info-circle"></i>
      How ntfy works
    </h3>
    <ul class="mt-3 space-y-2 text-sm text-blue-800">
      <li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Self-hosted ntfy server runs alongside this app in production.</li>
      <li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Each admin gets their own topic — subscribe in the ntfy app to receive pushes.</li>
      <li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Events: gateway offline/online, SMS failed/delivered, API key revoked.</li>
      <li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Notifications are sent asynchronously and retried on failure.</li>
    </ul>
  </div>
</div>
  • Step 4: Add nav link to admin layout

In app/views/layouts/admin.html.erb, find the API Tester nav link (the <%= link_to admin_api_tester_path ... %> block) and add this immediately after it (before the closing </ul> of that nav list):


                  <%= link_to admin_notifications_path, class: "group flex gap-x-3 rounded-md p-3 text-sm leading-6 font-semibold transition-all duration-200 #{current_page?(admin_notifications_path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:text-white hover:bg-gray-700'}" do %>
                    <i class="fas fa-bell w-6 h-6 shrink-0 flex items-center justify-center"></i>
                    Notifications
                  <% end %>
  • Step 5: Write integration test

Create test/integration/admin/notifications_flow_test.rb:

require "test_helper"

class AdminNotificationsFlowTest < ActionDispatch::IntegrationTest
  setup do
    @admin = AdminUser.create!(name: "Flow Admin", email: "flow@example.com", password: "password123")
    post admin_login_path, params: { email: "flow@example.com", password: "password123" }
  end

  test "can view notification settings page" do
    get admin_notifications_path
    assert_response :success
    assert_match "Notification Settings", response.body
    assert_match "ntfy Topic", response.body
  end

  test "can update ntfy settings" do
    patch admin_notifications_path, params: {
      admin_user: {
        ntfy_enabled: "1",
        ntfy_topic: "flow-topic",
        ntfy_token: "tk_flow",
        ntfy_server_url: "https://ntfy.example.com"
      }
    }

    assert_redirected_to admin_notifications_path
    @admin.reload
    assert_equal true, @admin.ntfy_enabled
    assert_equal "flow-topic", @admin.ntfy_topic
    assert_equal "tk_flow", @admin.ntfy_token
  end

  test "test notification redirects with alert when not configured" do
    post admin_notifications_test_path
    assert_redirected_to admin_notifications_path
    follow_redirect!
    assert_match "Configure ntfy", response.body
  end

  test "test notification sends and redirects with notice when configured" do
    @admin.update!(ntfy_enabled: true, ntfy_topic: "t", ntfy_token: "tk", ntfy_server_url: "https://ntfy.example.com")
    stub_request(:post, "https://ntfy.example.com/t").to_return(status: 200)

    post admin_notifications_test_path
    assert_redirected_to admin_notifications_path
    follow_redirect!
    assert_match "Test notification sent", response.body
  end
end
  • Step 6: Run integration test to verify it passes

Run:

bin/rails test test/integration/admin/notifications_flow_test.rb

Expected: 4 tests PASS.

  • Step 7: Commit
git add config/routes.rb app/controllers/admin/notifications_controller.rb app/views/admin/notifications/ app/views/layouts/admin.html.erb test/integration/admin/notifications_flow_test.rb
git commit -m "feat(ntfy): add admin Notifications settings page with test button"

Task 9: ntfy initializer + deploy.yml accessory + seeds

Files:

  • Create: config/initializers/ntfy.rb

  • Modify: config/deploy.yml

  • Modify: db/seeds.rb

  • Step 1: Create ntfy initializer

Create config/initializers/ntfy.rb:

# ntfy notification server configuration
# Default server URL used when AdminUser.ntfy_server_url is blank.
# In production, set this to your self-hosted ntfy instance.
NTFY_SERVER_URL = ENV.fetch("NTFY_SERVER_URL", "https://ntfy.sh")
  • Step 2: Add ntfy accessory to deploy.yml

In config/deploy.yml, uncomment/replace the accessories section to add ntfy:

accessories:
  ntfy:
    image: binwiederhier/ntfy:latest
    host: 192.168.0.1
    port: "127.0.0.1:8090:80"
    cmd: serve
    env:
      clear:
        NTFY_BASE_URL: "https://ntfy.app.example.com"
        NTFY_LISTEN_HTTP: ":80"
      secret:
        - NTFY_AUTH_FILE
    volumes:
      - "ntfy_data:/var/lib/ntfy"

Also add NTFY_SERVER_URL and NTFY_AUTH_FILE to the main app's env.secret list:

env:
  secret:
    - RAILS_MASTER_KEY
    - NTFY_AUTH_FILE
  clear:
    # ... existing ...
    NTFY_SERVER_URL: "http://192.168.0.1:8090"
  • Step 3: Update seeds.rb admin user with ntfy fields

In db/seeds.rb, find the AdminUser.find_or_create_by! block for the default admin and add ntfy fields (all disabled by default):

AdminUser.find_or_create_by!(email: "admin@example.com") do |admin|
  admin.name = "Administrator"
  admin.password = "password123"
  admin.ntfy_enabled = false
  admin.ntfy_topic = nil
  admin.ntfy_token = nil
  admin.ntfy_server_url = nil
end
  • Step 4: Commit
git add config/initializers/ntfy.rb config/deploy.yml db/seeds.rb
git commit -m "feat(ntfy): add initializer, Kamal accessory container, seed defaults"

Task 10: Final verification — full test suite + lint

  • Step 1: Run the full test suite

Run:

bin/rails test

Expected: all tests PASS, zero failures, zero errors.

  • Step 2: Run RuboCop on all new/modified Ruby files

Run:

bin/rubocop app/models/concerns/ntfy_dispatchable.rb app/jobs/send_ntfy_notification_job.rb app/controllers/admin/notifications_controller.rb lib/my_smsa_pio/notifications/ntfy/publisher.rb app/models/gateway.rb app/models/sms_message.rb app/models/api_key.rb app/models/admin_user.rb app/jobs/check_gateway_health_job.rb test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb test/jobs/send_ntfy_notification_job_test.rb test/models/concerns/ntfy_dispatchable_test.rb test/models/gateway_test.rb test/models/sms_message_test.rb test/models/api_key_test.rb test/integration/admin/notifications_flow_test.rb config/initializers/ntfy.rb

Expected: no offenses. (If any appear, run bin/rubocop -a on the flagged files and re-run.)

  • Step 3: Verify the admin UI loads in the browser

Run bin/dev (or bin/rails server), log in at /admin/login, and confirm:

  • "Notifications" appears in the sidebar nav

  • /admin/notifications shows the settings form

  • Saving settings works

  • "Send Test Notification" button works (with ntfy configured)

  • Step 4: Final commit if any fixes were needed

git add -A
git commit -m "chore(ntfy): fix lint/test issues from final verification"

Event payload summary (reference)

Event Title Priority Tags Triggered from
gateway_online Gateway online 3 (default) white_check_mark Gateway#heartbeat! (only on offline→online)
gateway_offline (model) Gateway offline 4 (high) rotating_light Gateway#mark_offline! (only on online→offline)
gateway_offline (job) Gateway offline 4 (high) rotating_light CheckGatewayHealthJob (stale heartbeat)
sms_delivered SMS delivered 2 (low) white_check_mark SmsMessage#mark_delivered!
sms_failed SMS failed 5 (urgent) x, rotating_light SmsMessage#mark_failed!
api_key_revoked API key revoked 3 (default) key, no_entry ApiKey#revoke!
test_notification Test notification 3 (default) tada, white_check_mark Admin::NotificationsController#test