diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index ff876a6..b0d868e 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -1,6 +1,7 @@
module Admin
class BaseController < ActionController::Base
include Pagy::Backend
+ include AdminAuthentication
# Enable session and flash for admin controllers
# (needed because the app is in API-only mode)
@@ -9,22 +10,12 @@ module Admin
layout "admin"
before_action :require_admin
+ helper_method :current_admin, :logged_in?
+
private
- def current_admin
- @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
- end
- helper_method :current_admin
-
- def logged_in?
- current_admin.present?
- end
- helper_method :logged_in?
-
def require_admin
- unless logged_in?
- redirect_to admin_login_path, alert: "Please log in to continue"
- end
+ redirect_to admin_login_path, alert: "Please log in to continue" unless logged_in?
end
end
end
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 0eafbbc..770d0fd 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -1,11 +1,15 @@
module Admin
class SessionsController < ActionController::Base
+ include AdminAuthentication
+
layout "admin"
# CSRF protection is enabled by default in ActionController::Base
# We need it for the create action but not for the new (GET) action
protect_from_forgery with: :exception
+ helper_method :current_admin, :logged_in?
+
def new
redirect_to admin_dashboard_path if current_admin
end
@@ -27,12 +31,5 @@ module Admin
session.delete(:admin_id)
redirect_to admin_login_path, notice: "You have been logged out"
end
-
- private
-
- def current_admin
- @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
- end
- helper_method :current_admin
end
end
diff --git a/app/controllers/concerns/admin_authentication.rb b/app/controllers/concerns/admin_authentication.rb
new file mode 100644
index 0000000..7a0f4e2
--- /dev/null
+++ b/app/controllers/concerns/admin_authentication.rb
@@ -0,0 +1,11 @@
+module AdminAuthentication
+ extend ActiveSupport::Concern
+
+ def current_admin
+ @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
+ end
+
+ def logged_in?
+ current_admin.present?
+ end
+end
diff --git a/app/helpers/admin_helper.rb b/app/helpers/admin_helper.rb
index b7b8929..d5c6d35 100644
--- a/app/helpers/admin_helper.rb
+++ b/app/helpers/admin_helper.rb
@@ -1,9 +1,2 @@
module AdminHelper
- def current_admin
- @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
- end
-
- def logged_in?
- current_admin.present?
- end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 6fe8ed4..ed6a048 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,12 +1,3 @@
module ApplicationHelper
include Pagy::Frontend
-
- # Admin authentication helpers
- def current_admin
- @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
- end
-
- def logged_in?
- current_admin.present?
- end
end
diff --git a/app/jobs/check_gateway_health_job.rb b/app/jobs/check_gateway_health_job.rb
index 79b270c..51aebbe 100644
--- a/app/jobs/check_gateway_health_job.rb
+++ b/app/jobs/check_gateway_health_job.rb
@@ -18,7 +18,7 @@ class CheckGatewayHealthJob < ApplicationJob
title: "Gateway offline",
message: "#{name} (#{device_id}) went offline — no heartbeat for 2+ minutes",
priority: 4,
- tags: ["rotating_light"])
+ tags: [ "rotating_light" ])
end
end
end
diff --git a/app/models/api_key.rb b/app/models/api_key.rb
index fd8142a..9326214 100644
--- a/app/models/api_key.rb
+++ b/app/models/api_key.rb
@@ -63,7 +63,7 @@ class ApiKey < ApplicationRecord
title: "API key revoked",
message: "API key '#{name}' (#{key_prefix}...) was revoked",
priority: 3,
- tags: ["key", "no_entry"])
+ tags: [ "key", "no_entry" ])
end
# Deactivate expired keys
diff --git a/app/models/gateway.rb b/app/models/gateway.rb
index d20acc4..ca3328c 100644
--- a/app/models/gateway.rb
+++ b/app/models/gateway.rb
@@ -45,7 +45,7 @@ class Gateway < ApplicationRecord
title: "Gateway online",
message: "#{name} (#{device_id}) came back online",
priority: 3,
- tags: ["white_check_mark"],
+ tags: [ "white_check_mark" ],
click: nil)
end
end
@@ -60,7 +60,7 @@ class Gateway < ApplicationRecord
title: "Gateway offline",
message: "#{name} (#{device_id}) went offline",
priority: 4,
- tags: ["rotating_light"])
+ tags: [ "rotating_light" ])
end
end
diff --git a/app/models/sms_message.rb b/app/models/sms_message.rb
index e3a67ec..6483223 100644
--- a/app/models/sms_message.rb
+++ b/app/models/sms_message.rb
@@ -60,7 +60,7 @@ class SmsMessage < ApplicationRecord
title: "SMS delivered",
message: "Message to #{phone_number} (#{message_id}) was delivered",
priority: 2,
- tags: ["white_check_mark"])
+ tags: [ "white_check_mark" ])
end
# Mark message as failed
@@ -71,7 +71,7 @@ class SmsMessage < ApplicationRecord
title: "SMS failed",
message: "Message to #{phone_number} (#{message_id}) failed#{error_msg ? ": #{error_msg}" : ''}",
priority: 5,
- tags: ["x", "rotating_light"])
+ tags: [ "x", "rotating_light" ])
end
# Increment retry counter
diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb
index fb3abb8..7dcf01d 100644
--- a/app/views/admin/sessions/new.html.erb
+++ b/app/views/admin/sessions/new.html.erb
@@ -1,22 +1,27 @@
-
+
-
-
+
-
MySMSAPio Admin
-
Sign in to your admin account
+
+
Admin Console
-
- <%= form_with url: admin_login_path, method: :post, local: true, class: "space-y-6" do |f| %>
+
+ <%= form_with url: admin_login_path, method: :post, local: true, class: "space-y-5" do |f| %>
- <%= label_tag :email, "Email address", class: "block text-sm font-medium text-gray-700" %>
-
-
-
+ <%= label_tag :email, "Email address", class: "font-mono-tech block text-[11px] uppercase tracking-wider text-slate-400" %>
+
+
+
<%= email_field_tag :email, params[:email],
- class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
+ class: "field-input block w-full rounded-xl pl-11 pr-3 py-3 text-sm",
placeholder: "admin@example.com",
autofocus: true,
required: true %>
@@ -24,26 +29,27 @@
- <%= label_tag :password, "Password", class: "block text-sm font-medium text-gray-700" %>
-
-
-
+ <%= label_tag :password, "Password", class: "font-mono-tech block text-[11px] uppercase tracking-wider text-slate-400" %>
+
+
+
<%= password_field_tag :password, nil,
- class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
- placeholder: "Enter your password",
+ class: "field-input block w-full rounded-xl pl-11 pr-3 py-3 text-sm",
+ placeholder: "••••••••••••",
required: true %>
-
+
<%= submit_tag "Sign in",
- class: "flex w-full justify-center rounded-lg bg-blue-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %>
+ class: "btn-accent flex w-full justify-center rounded-xl px-4 py-3.5 text-sm font-semibold text-slate-950" %>
<% end %>
-
-
Secure Admin Access
+
+
+ Secure · Encrypted · Local
diff --git a/docs/superpowers/plans/2026-07-28-ntfy-notifications-integration.md b/docs/superpowers/plans/2026-07-28-ntfy-notifications-integration.md
new file mode 100644
index 0000000..79997a7
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-28-ntfy-notifications-integration.md
@@ -0,0 +1,1395 @@
+# 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` |
diff --git a/test/fixtures/admin_users.yml b/test/fixtures/admin_users.yml
index c28319e..e7954eb 100644
--- a/test/fixtures/admin_users.yml
+++ b/test/fixtures/admin_users.yml
@@ -1,13 +1,13 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
- email: MyString
- password_digest: MyString
- name: MyString
+ email: one@example.com
+ password_digest: <%= BCrypt::Password.create("password123") %>
+ name: Admin One
last_login_at: 2025-10-20 10:51:35
two:
- email: MyString
- password_digest: MyString
- name: MyString
+ email: two@example.com
+ password_digest: <%= BCrypt::Password.create("password123") %>
+ name: Admin Two
last_login_at: 2025-10-20 10:51:35
diff --git a/test/integration/admin/sessions_flow_test.rb b/test/integration/admin/sessions_flow_test.rb
new file mode 100644
index 0000000..bfa3219
--- /dev/null
+++ b/test/integration/admin/sessions_flow_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class AdminSessionsFlowTest < ActionDispatch::IntegrationTest
+ def setup
+ @admin = AdminUser.create!(name: "Site Admin", email: "admin@example.com", password: "supersecret123")
+ end
+
+ test "login page renders the sign-in form" do
+ get admin_login_path
+ assert_response :success
+ assert_match "MySMSAPio", response.body
+ assert_match "Sign in", response.body
+ end
+
+ test "login page displays the auth error message on failed login" do
+ post admin_login_path, params: { email: "admin@example.com", password: "wrong-password" }
+ assert_response :unprocessable_entity
+ # The critical regression check: the alert must be present in the rendered HTML
+ assert_match "Invalid email or password", response.body
+ end
+
+ test "successful login redirects to the dashboard and shows welcome notice" do
+ post admin_login_path, params: { email: "admin@example.com", password: "supersecret123" }
+ assert_redirected_to admin_dashboard_path
+ follow_redirect!
+ assert_match "Welcome back", response.body
+ end
+
+ test "login is case-insensitive for email" do
+ post admin_login_path, params: { email: "ADMIN@example.com", password: "supersecret123" }
+ assert_redirected_to admin_dashboard_path
+ end
+
+ test "logging out clears the session and shows notice on the login page" do
+ post admin_login_path, params: { email: "admin@example.com", password: "supersecret123" }
+ delete admin_logout_path
+ assert_redirected_to admin_login_path
+ follow_redirect!
+ assert_match "logged out", response.body
+ end
+
+ test "protected admin page redirects to login with an alert" do
+ get admin_dashboard_path
+ assert_redirected_to admin_login_path
+ follow_redirect!
+ assert_match "Please log in to continue", response.body
+ end
+end