# 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/_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/_add_ntfy_fields_to_admin_users.rb` - Test: `test/models/admin_user_test.rb` (modify) - [ ] **Step 1: Generate the migration** Run: ```bash 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: ```ruby 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: ```bash 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): ```ruby 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): ```ruby def ntfy_configured? ntfy_enabled? && ntfy_topic.present? && ntfy_token.present? end ``` - [ ] **Step 6: Run tests to verify they pass** Run: ```bash bin/rails test test/models/admin_user_test.rb ``` Expected: all tests PASS. - [ ] **Step 7: Commit** ```bash 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`: ```ruby 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: ```bash 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`: ```ruby 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: ```bash bin/rails test test/lib/my_smsa_pio/notifications/ntfy/publisher_test.rb ``` Expected: 5 tests PASS. - [ ] **Step 5: Commit** ```bash 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`: ```ruby 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: ```bash 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`: ```ruby 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: ```bash bin/rails test test/jobs/send_ntfy_notification_job_test.rb ``` Expected: 3 tests PASS. - [ ] **Step 5: Commit** ```bash 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`: ```ruby 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: ```bash 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`: ```ruby 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: ```bash bin/rails test test/models/concerns/ntfy_dispatchable_test.rb ``` Expected: 3 tests PASS. - [ ] **Step 5: Commit** ```bash 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`: ```ruby 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: ```bash 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`): ```ruby include NtfyDispatchable ``` Replace the `heartbeat!` method with: ```ruby 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: ```ruby 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: ```bash bin/rails test test/models/gateway_test.rb ``` Expected: 3 tests PASS. - [ ] **Step 5: Commit (model part)** ```bash 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`: ```ruby 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: ```bash 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: ```ruby 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: ```bash bin/rails test test/jobs/check_gateway_health_job_test.rb ``` Expected: 2 tests PASS. - [ ] **Step 10: Commit** ```bash 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`: ```ruby 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: ```bash 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`: ```ruby include NtfyDispatchable ``` Replace `mark_delivered!` with: ```ruby 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: ```ruby 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: ```bash bin/rails test test/models/sms_message_test.rb ``` Expected: 2 tests PASS. - [ ] **Step 5: Commit** ```bash 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`: ```ruby 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: ```bash 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`: ```ruby include NtfyDispatchable ``` Replace `revoke!` with: ```ruby 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: ```bash bin/rails test test/models/api_key_test.rb ``` Expected: 1 test PASS. - [ ] **Step 5: Commit** ```bash 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: ```ruby 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: ```ruby resource :notifications, only: [:show, :update] do post :test, on: :member end ``` Actually for a singleton resource, use `collection`-style via `post :test` directly: ```ruby resource :notifications, only: [:show, :update] do post :test end ``` This creates: `GET /admin/notifications` → `show`, `PATCH /admin/notifications` → `update`, `POST /admin/notifications/test` → `test`. - [ ] **Step 2: Write the NotificationsController** Create `app/controllers/admin/notifications_controller.rb`: ```ruby 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`: ```erb

Notification Settings

Configure ntfy push notifications for your phone.

ntfy Configuration

Install the ntfy app on your phone, subscribe to your topic, and enter the details below.

<%= form_with model: @admin, url: admin_notifications_path, method: :patch, local: true, class: "space-y-6" do |f| %>
<%= 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" %>
<%= f.label :ntfy_topic, "ntfy Topic", class: "block text-sm font-medium text-gray-700" %>

The topic name you subscribe to in the ntfy app. Treat this like a password.

<%= 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" %>
<%= f.label :ntfy_token, "Access Token (optional)", class: "block text-sm font-medium text-gray-700" %>

Required only if your ntfy server has access control enabled.

<%= 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_..." %>
<%= f.label :ntfy_server_url, "ntfy Server URL", class: "block text-sm font-medium text-gray-700" %>

Leave blank to use the default (from NTFY_SERVER_URL env var or ntfy.sh).

<%= 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" %>
<%= 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" %>
<% end %>

Send Test Notification

Sends a test push to your configured ntfy topic right now.

<%= 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 %> Send Test Notification <% end %>

How ntfy works

  • Self-hosted ntfy server runs alongside this app in production.
  • Each admin gets their own topic — subscribe in the ntfy app to receive pushes.
  • Events: gateway offline/online, SMS failed/delivered, API key revoked.
  • Notifications are sent asynchronously and retried on failure.
``` - [ ] **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 `` of that nav list): ```erb <%= 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 %> Notifications <% end %> ``` - [ ] **Step 5: Write integration test** Create `test/integration/admin/notifications_flow_test.rb`: ```ruby 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: ```bash bin/rails test test/integration/admin/notifications_flow_test.rb ``` Expected: 4 tests PASS. - [ ] **Step 7: Commit** ```bash 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`: ```ruby # 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: ```yaml 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: ```yaml 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): ```ruby 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** ```bash 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: ```bash bin/rails test ``` Expected: all tests PASS, zero failures, zero errors. - [ ] **Step 2: Run RuboCop on all new/modified Ruby files** Run: ```bash 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** ```bash 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` |