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

1396 lines
43 KiB
Markdown

# 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:
```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
<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):
```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 %>
<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`:
```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` |