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
AdminUserhas 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/notifications → show, PATCH /admin/notifications → update, POST /admin/notifications/test → test.
- 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/notificationsshows 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 |