1396 lines
43 KiB
Markdown
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` |
|