From d87f6ed9da26b9f6a231162d5803c70d6b273bab Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 14:38:09 -0600 Subject: [PATCH 01/72] Add action_push_native gem and core SaaS infrastructure Phase 1 of native push notifications implementation: - Add action_push_native gem to Gemfile.saas for SaaS-only native push - Add migration for action_push_native_devices table - Create ApplicationPushNotification model in saas/app/models/ - Create ApplicationPushNotificationJob in saas/app/jobs/ - Create push.yml config in saas/config/ with APNs/FCM settings The migration needs MySQL running to execute (SaaS mode uses MySQL). Config placeholders (team_id, topic, project_id) need to be filled in. Co-Authored-By: Claude Opus 4.5 --- Gemfile.saas | 3 ++ Gemfile.saas.lock | 38 +++++++++++++++++++ ...n_push_native_device.action_push_native.rb | 13 +++++++ .../jobs/application_push_notification_job.rb | 2 + .../models/application_push_notification.rb | 4 ++ saas/config/push.yml | 11 ++++++ 6 files changed, 71 insertions(+) create mode 100644 db/migrate/20260114203313_create_action_push_native_device.action_push_native.rb create mode 100644 saas/app/jobs/application_push_notification_job.rb create mode 100644 saas/app/models/application_push_notification.rb create mode 100644 saas/config/push.yml diff --git a/Gemfile.saas b/Gemfile.saas index 05e6bbabdf..88eb2b4248 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -10,6 +10,9 @@ gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" +# Native push notifications (iOS/Android) +gem "action_push_native" + # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" gem "sentry-ruby" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f340b4ae4d..f1e3813362 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -184,6 +184,13 @@ PATH GEM remote: https://rubygems.org/ specs: + action_push_native (0.3.0) + activejob (>= 8.0) + activerecord (>= 8.0) + googleauth (~> 1.14) + httpx (~> 1.6) + jwt (>= 2) + railties (>= 8.0) action_text-trix (2.1.16) railties activemodel-serializers-xml (1.0.3) @@ -265,6 +272,12 @@ GEM tzinfo faker (3.5.3) i18n (>= 1.8.11, < 2) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -280,7 +293,22 @@ GEM addressable (>= 2.5.0) globalid (1.3.0) activesupport (>= 6.1) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-logging-utils (0.2.0) + googleauth (1.16.0) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) hashdiff (1.2.1) + http-2 (1.1.1) + httpx (1.7.0) + http-2 (>= 1.0.0) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -352,6 +380,9 @@ GEM mocha (3.0.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_json (1.19.1) + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) net-imap (0.6.2) @@ -384,6 +415,7 @@ GEM nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) + os (1.1.4) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -523,6 +555,11 @@ GEM sentry-ruby (6.2.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) sniffer (0.5.0) anyway_config (>= 1.0) dry-initializer (~> 3) @@ -639,6 +676,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + action_push_native activeresource audits1984! autotuner diff --git a/db/migrate/20260114203313_create_action_push_native_device.action_push_native.rb b/db/migrate/20260114203313_create_action_push_native_device.action_push_native.rb new file mode 100644 index 0000000000..9f87d43348 --- /dev/null +++ b/db/migrate/20260114203313_create_action_push_native_device.action_push_native.rb @@ -0,0 +1,13 @@ +# This migration comes from action_push_native (originally 20250610075650) +class CreateActionPushNativeDevice < ActiveRecord::Migration[8.0] + def change + create_table :action_push_native_devices do |t| + t.string :name + t.string :platform, null: false + t.string :token, null: false + t.belongs_to :owner, polymorphic: true + + t.timestamps + end + end +end diff --git a/saas/app/jobs/application_push_notification_job.rb b/saas/app/jobs/application_push_notification_job.rb new file mode 100644 index 0000000000..5db7811c20 --- /dev/null +++ b/saas/app/jobs/application_push_notification_job.rb @@ -0,0 +1,2 @@ +class ApplicationPushNotificationJob < ActionPushNative::NotificationJob +end diff --git a/saas/app/models/application_push_notification.rb b/saas/app/models/application_push_notification.rb new file mode 100644 index 0000000000..41fe881f7e --- /dev/null +++ b/saas/app/models/application_push_notification.rb @@ -0,0 +1,4 @@ +class ApplicationPushNotification < ActionPushNative::Notification + queue_as :default + self.enabled = !Rails.env.local? +end diff --git a/saas/config/push.yml b/saas/config/push.yml new file mode 100644 index 0000000000..916277b30a --- /dev/null +++ b/saas/config/push.yml @@ -0,0 +1,11 @@ +shared: + apple: + key_id: <%= ENV["APNS_KEY_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %> + encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :apns, :key))&.dump %> + team_id: YOUR_TEAM_ID # Your 10-character Apple Developer Team ID (not secret) + topic: com.yourcompany.fizzy # Your app's bundle identifier (not secret) + # Uncomment for local development with Xcode builds (uses APNs sandbox): + # connect_to_development_server: true + google: + encryption_key: <%= (ENV["FCM_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :fcm, :key))&.dump %> + project_id: your-firebase-project # Your Firebase project ID (not secret) From 6a72631e9b7762912862ee6552ba1033467ed234 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 14:41:41 -0600 Subject: [PATCH 02/72] Add User::Devices concern for native push notifications Include the devices association in User model via the SaaS engine to prepare for device registration functionality. Co-Authored-By: Claude Opus 4.5 --- saas/app/models/user/devices.rb | 7 +++++++ saas/lib/fizzy/saas/engine.rb | 1 + 2 files changed, 8 insertions(+) create mode 100644 saas/app/models/user/devices.rb diff --git a/saas/app/models/user/devices.rb b/saas/app/models/user/devices.rb new file mode 100644 index 0000000000..25bd6e4b26 --- /dev/null +++ b/saas/app/models/user/devices.rb @@ -0,0 +1,7 @@ +module User::Devices + extend ActiveSupport::Concern + + included do + has_many :devices, class_name: "ActionPushNative::Device", as: :owner, dependent: :destroy + end +end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 757de6bebc..373566c84b 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -133,6 +133,7 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited + ::User.include User::Devices ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) Cards::PublishesController.include(Card::LimitedPublishing) From 6c295b672dd09fdcf94674adf520f5c87917b7f2 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 14:45:54 -0600 Subject: [PATCH 03/72] Add device registration API and UI for native push - Add /users/devices routes for device registration - Create DevicesController with index, create, destroy actions - Add devices index view for managing registered devices - Add native_devices partial to notification settings (SaaS only) - Add skeleton controller tests for Phase 4 implementation Co-Authored-By: Claude Opus 4.5 --- .../settings/_native_devices.html.erb | 16 ++++++ .../notifications/settings/show.html.erb | 1 + .../controllers/users/devices_controller.rb | 30 +++++++++++ saas/app/views/users/devices/index.html.erb | 16 ++++++ saas/lib/fizzy/saas/engine.rb | 5 ++ .../users/devices_controller_test.rb | 50 +++++++++++++++++++ 6 files changed, 118 insertions(+) create mode 100644 app/views/notifications/settings/_native_devices.html.erb create mode 100644 saas/app/controllers/users/devices_controller.rb create mode 100644 saas/app/views/users/devices/index.html.erb create mode 100644 saas/test/controllers/users/devices_controller_test.rb diff --git a/app/views/notifications/settings/_native_devices.html.erb b/app/views/notifications/settings/_native_devices.html.erb new file mode 100644 index 0000000000..e20599f77d --- /dev/null +++ b/app/views/notifications/settings/_native_devices.html.erb @@ -0,0 +1,16 @@ +<% if Fizzy.saas? %> +
+

Mobile Devices

+ + <% if Current.user.devices.any? %> +

+ You have <%= pluralize(Current.user.devices.count, "mobile device") %> registered for push notifications. +

+ <%= link_to "Manage devices", users_devices_path, class: "btn txt-small" %> + <% else %> +

+ No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone. +

+ <% end %> +
+<% end %> diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 5972e7fdad..f3194f5c37 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -14,6 +14,7 @@
<%= render "notifications/settings/push_notifications" %> + <%= render "notifications/settings/native_devices" %> <%= render "notifications/settings/email", settings: @settings %>
diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb new file mode 100644 index 0000000000..271171cf2b --- /dev/null +++ b/saas/app/controllers/users/devices_controller.rb @@ -0,0 +1,30 @@ +class Users::DevicesController < ApplicationController + # GET /users/devices - Web only (view registered devices) + def index + @devices = Current.user.devices.order(created_at: :desc) + end + + # POST /users/devices - API only (mobile apps register tokens) + def create + device = Current.user.devices.find_or_initialize_by(token: device_params[:token]) + device.update!(device_params) + head :created + rescue ActiveRecord::RecordInvalid + head :unprocessable_entity + end + + # DELETE /users/devices/:id - Web only + def destroy + Current.user.devices.find_by(id: params[:id])&.destroy + redirect_to users_devices_path, notice: "Device removed" + end + + private + def device_params + params.permit(:token, :platform, :name).tap do |p| + p[:platform] = p[:platform].to_s.downcase + raise ActionController::BadRequest unless p[:platform].in?(%w[apple google]) + raise ActionController::BadRequest if p[:token].blank? + end + end +end diff --git a/saas/app/views/users/devices/index.html.erb b/saas/app/views/users/devices/index.html.erb new file mode 100644 index 0000000000..4a9a02486c --- /dev/null +++ b/saas/app/views/users/devices/index.html.erb @@ -0,0 +1,16 @@ +

Registered Devices

+ +<% if @devices.any? %> +
    + <% @devices.each do |device| %> +
  • + <%= device.name || "Unnamed device" %> + (<%= device.platform == "apple" ? "iOS" : "Android" %>) + Added <%= time_ago_in_words(device.created_at) %> ago + <%= button_to "Remove", users_device_path(device), method: :delete, data: { confirm: "Remove this device?" } %> +
  • + <% end %> +
+<% else %> +

No devices registered. Install the mobile app to receive push notifications.

+<% end %> diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 373566c84b..66dfbb0247 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -33,6 +33,11 @@ class Engine < ::Rails::Engine namespace :stripe do resource :webhooks, only: :create end + + # Native push notification device registration + namespace :users do + resources :devices, only: [ :index, :create, :destroy ] + end end end diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/users/devices_controller_test.rb new file mode 100644 index 0000000000..d391b1a6f4 --- /dev/null +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class Users::DevicesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:david) + sign_in_as @user + end + + # === Index (Web) === + + test "index shows user devices" do + skip "Implement in Phase 4" + end + + test "index requires authentication" do + skip "Implement in Phase 4" + end + + # === Create (API) === + + test "creates a new device via api" do + skip "Implement in Phase 4" + end + + test "updates existing device with same token" do + skip "Implement in Phase 4" + end + + test "rejects invalid platform" do + skip "Implement in Phase 4" + end + + test "create requires authentication" do + skip "Implement in Phase 4" + end + + # === Destroy (Web) === + + test "destroys device via web" do + skip "Implement in Phase 4" + end + + test "cannot destroy another user's device" do + skip "Implement in Phase 4" + end + + test "destroy requires authentication" do + skip "Implement in Phase 4" + end +end From 48cc4aeaeb8b034c5a09b52aa04030d683dd7757 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 14:49:34 -0600 Subject: [PATCH 04/72] Add NotificationPusher native push integration and tests - Create NotificationPusher::Native concern for sending to native devices - Prepend native concern via SaaS engine - Add device fixtures for testing - Add PushNotificationTestHelper for test assertions - Implement full controller tests for device registration - Add NotificationPusher model tests for native push logic Native push notifications now send alongside web push when users have registered mobile devices. Supports iOS (APNs) and Android (FCM) with platform-specific features like time-sensitive delivery and data-only messages. Co-Authored-By: Claude Opus 4.5 --- saas/app/models/notification_pusher/native.rb | 108 ++++++++++ saas/lib/fizzy/saas/engine.rb | 1 + .../users/devices_controller_test.rb | 149 ++++++++++++- .../fixtures/action_push_native/devices.yml | 17 ++ saas/test/models/notification_pusher_test.rb | 195 ++++++++++++++++++ .../push_notification_test_helper.rb | 33 +++ test/test_helper.rb | 5 + 7 files changed, 498 insertions(+), 10 deletions(-) create mode 100644 saas/app/models/notification_pusher/native.rb create mode 100644 saas/test/fixtures/action_push_native/devices.yml create mode 100644 saas/test/models/notification_pusher_test.rb create mode 100644 saas/test/test_helpers/push_notification_test_helper.rb diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb new file mode 100644 index 0000000000..bf98317d13 --- /dev/null +++ b/saas/app/models/notification_pusher/native.rb @@ -0,0 +1,108 @@ +module NotificationPusher::Native + extend ActiveSupport::Concern + + included do + # Alias the original method so we can extend it + alias_method :original_should_push?, :should_push? + end + + # Override should_push? to also check for native devices + def should_push? + has_any_push_destination? && + !notification.creator.system? && + notification.user.active? && + notification.account.active? + end + + # Override push to also send to native devices + def push + return unless should_push? + + build_payload.tap do |payload| + push_to_web(payload) + push_to_native(payload) + end + end + + private + def has_any_push_destination? + notification.user.push_subscriptions.any? || notification.user.devices.any? + end + + def push_to_web(payload) + subscriptions = notification.user.push_subscriptions + return if subscriptions.empty? + enqueue_payload_for_delivery(payload, subscriptions) + end + + def push_to_native(payload) + devices = notification.user.devices + return if devices.empty? + + native_notification(payload).deliver_later_to(devices) + end + + def native_notification(payload) + ApplicationPushNotification + .with_apple( + aps: { + category: notification_category, + "mutable-content": 1, + "interruption-level": interruption_level + } + ) + .with_google( + # Data-only message - Android app handles notification display + android: { notification: nil } + ) + .with_data( + path: payload[:path], + account_id: notification.account.external_account_id, + avatar_url: creator_avatar_url, + card_id: card&.id, + card_title: card&.title, + creator_name: notification.creator.name, + category: notification_category + ) + .new( + title: payload[:title], + body: payload[:body], + badge: notification.user.notifications.unread.count, + sound: "default", + thread_id: card&.id, + high_priority: assignment_notification? + ) + end + + def notification_category + case notification.source + when Event + case notification.source.action + when "card_assigned" then "assignment" + when "comment_created" then "comment" + else "card" + end + when Mention + "mention" + else + "default" + end + end + + def interruption_level + assignment_notification? ? "time-sensitive" : "active" + end + + def assignment_notification? + notification.source.is_a?(Event) && notification.source.action == "card_assigned" + end + + def creator_avatar_url + return unless notification.creator.respond_to?(:avatar) && notification.creator.avatar.attached? + Rails.application.routes.url_helpers.url_for(notification.creator.avatar) + end + + def card + @card ||= notification.card + end +end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 66dfbb0247..cfe2361331 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -139,6 +139,7 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::User.include User::Devices + ::NotificationPusher.include NotificationPusher::Native ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) Cards::PublishesController.include(Card::LimitedPublishing) diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/users/devices_controller_test.rb index d391b1a6f4..241ec23338 100644 --- a/saas/test/controllers/users/devices_controller_test.rb +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -9,42 +9,171 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Index (Web) === test "index shows user devices" do - skip "Implement in Phase 4" + @user.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") + + get users_devices_path + + assert_response :success + assert_select "strong", "iPhone 15 Pro" + assert_select "li", /iOS/ + end + + test "index shows empty state when no devices" do + @user.devices.delete_all + + get users_devices_path + + assert_response :success + assert_select "p", /No devices registered/ end test "index requires authentication" do - skip "Implement in Phase 4" + sign_out + + get users_devices_path + + assert_response :redirect end # === Create (API) === test "creates a new device via api" do - skip "Implement in Phase 4" + token = SecureRandom.hex(32) + + assert_difference "ActionPushNative::Device.count", 1 do + post users_devices_path, params: { + token: token, + platform: "apple", + name: "iPhone 15 Pro" + }, as: :json + end + + assert_response :created + + device = ActionPushNative::Device.last + assert_equal token, device.token + assert_equal "apple", device.platform + assert_equal "iPhone 15 Pro", device.name + assert_equal @user, device.owner + end + + test "creates android device" do + token = SecureRandom.hex(32) + + post users_devices_path, params: { + token: token, + platform: "google", + name: "Pixel 8" + }, as: :json + + assert_response :created + + device = ActionPushNative::Device.last + assert_equal "google", device.platform end test "updates existing device with same token" do - skip "Implement in Phase 4" + existing_device = @user.devices.create!( + token: "existing_token_123", + platform: "apple", + name: "Old iPhone" + ) + + assert_no_difference "ActionPushNative::Device.count" do + post users_devices_path, params: { + token: "existing_token_123", + platform: "apple", + name: "New iPhone" + }, as: :json + end + + assert_response :created + assert_equal "New iPhone", existing_device.reload.name end test "rejects invalid platform" do - skip "Implement in Phase 4" + post users_devices_path, params: { + token: SecureRandom.hex(32), + platform: "windows", + name: "Surface" + }, as: :json + + assert_response :bad_request + end + + test "rejects missing token" do + post users_devices_path, params: { + platform: "apple", + name: "iPhone" + }, as: :json + + assert_response :bad_request end test "create requires authentication" do - skip "Implement in Phase 4" + sign_out + + post users_devices_path, params: { + token: SecureRandom.hex(32), + platform: "apple" + }, as: :json + + assert_response :redirect end # === Destroy (Web) === - test "destroys device via web" do - skip "Implement in Phase 4" + test "destroys device" do + device = @user.devices.create!( + token: "token_to_delete", + platform: "apple", + name: "iPhone" + ) + + assert_difference "ActionPushNative::Device.count", -1 do + delete users_device_path(device) + end + + assert_redirected_to users_devices_path + assert_not ActionPushNative::Device.exists?(device.id) + end + + test "does nothing when device not found" do + assert_no_difference "ActionPushNative::Device.count" do + delete users_device_path(id: "nonexistent") + end + + assert_redirected_to users_devices_path end test "cannot destroy another user's device" do - skip "Implement in Phase 4" + other_user = users(:kevin) + device = other_user.devices.create!( + token: "other_users_token", + platform: "apple", + name: "Other iPhone" + ) + + assert_no_difference "ActionPushNative::Device.count" do + delete users_device_path(device) + end + + assert_redirected_to users_devices_path + assert ActionPushNative::Device.exists?(device.id) end test "destroy requires authentication" do - skip "Implement in Phase 4" + device = @user.devices.create!( + token: "my_token", + platform: "apple", + name: "iPhone" + ) + + sign_out + + delete users_device_path(device) + + assert_response :redirect + assert ActionPushNative::Device.exists?(device.id) end end diff --git a/saas/test/fixtures/action_push_native/devices.yml b/saas/test/fixtures/action_push_native/devices.yml new file mode 100644 index 0000000000..7601d52849 --- /dev/null +++ b/saas/test/fixtures/action_push_native/devices.yml @@ -0,0 +1,17 @@ +davids_iphone: + name: iPhone 15 Pro + token: abc123def456abc123def456abc123def456abc123def456abc123def456abcd + platform: apple + owner: david (User) + +davids_pixel: + name: Pixel 8 + token: def456abc123def456abc123def456abc123def456abc123def456abc123defg + platform: google + owner: david (User) + +kevins_iphone: + name: iPhone 14 + token: 789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7890 + platform: apple + owner: kevin (User) diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification_pusher_test.rb new file mode 100644 index 0000000000..35407840f0 --- /dev/null +++ b/saas/test/models/notification_pusher_test.rb @@ -0,0 +1,195 @@ +require "test_helper" + +class NotificationPusherNativeTest < ActiveSupport::TestCase + setup do + @user = users(:kevin) + @notification = notifications(:logo_published_kevin) + @pusher = NotificationPusher.new(@notification) + + # Ensure user has no web push subscriptions (we want to test native push independently) + @user.push_subscriptions.delete_all + end + + # === Notification Category === + + test "notification_category returns assignment for card_assigned" do + notification = notifications(:logo_assignment_kevin) + pusher = NotificationPusher.new(notification) + + assert_equal "assignment", pusher.send(:notification_category) + end + + test "notification_category returns comment for comment_created" do + notification = notifications(:layout_commented_kevin) + pusher = NotificationPusher.new(notification) + + assert_equal "comment", pusher.send(:notification_category) + end + + test "notification_category returns mention for mentions" do + notification = notifications(:logo_card_david_mention_by_jz) + pusher = NotificationPusher.new(notification) + + assert_equal "mention", pusher.send(:notification_category) + end + + test "notification_category returns card for other card events" do + notification = notifications(:logo_published_kevin) + pusher = NotificationPusher.new(notification) + + assert_equal "card", pusher.send(:notification_category) + end + + # === Interruption Level === + + test "interruption_level is time-sensitive for assignments" do + notification = notifications(:logo_assignment_kevin) + pusher = NotificationPusher.new(notification) + + assert_equal "time-sensitive", pusher.send(:interruption_level) + end + + test "interruption_level is active for non-assignments" do + notification = notifications(:logo_published_kevin) + pusher = NotificationPusher.new(notification) + + assert_equal "active", pusher.send(:interruption_level) + end + + # === Has Any Push Destination === + + test "has_any_push_destination returns true when user has native devices" do + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + assert @pusher.send(:has_any_push_destination?) + end + + test "has_any_push_destination returns true when user has web subscriptions" do + @user.push_subscriptions.create!( + endpoint: "https://example.com/push", + p256dh_key: "test_p256dh", + auth_key: "test_auth" + ) + + assert @pusher.send(:has_any_push_destination?) + end + + test "has_any_push_destination returns false when user has neither" do + @user.devices.delete_all + @user.push_subscriptions.delete_all + + assert_not @pusher.send(:has_any_push_destination?) + end + + # === Push Delivery === + + test "push delivers to native devices when user has devices" do + stub_push_services + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + assert_native_push_delivery(count: 1) do + @pusher.push + end + end + + test "push does not deliver to native when user has no devices" do + @user.devices.delete_all + + assert_no_native_push_delivery do + @pusher.push + end + end + + test "push does not deliver when creator is system user" do + stub_push_services + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @notification.update!(creator: users(:system)) + + result = @pusher.push + + assert_nil result + end + + test "push delivers to multiple devices" do + stub_push_services + @user.devices.create!(token: "token1", platform: "apple", name: "iPhone") + @user.devices.create!(token: "token2", platform: "google", name: "Pixel") + + assert_native_push_delivery(count: 1) do + @pusher.push + end + end + + # === Native Notification Building === + + test "native notification includes required fields" do + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + payload = @pusher.send(:build_payload) + native = @pusher.send(:native_notification, payload) + + assert_not_nil native.title + assert_not_nil native.body + assert_equal "default", native.sound + end + + test "native notification sets thread_id from card" do + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + payload = @pusher.send(:build_payload) + native = @pusher.send(:native_notification, payload) + + assert_equal @notification.card.id, native.thread_id + end + + test "native notification sets high_priority for assignments" do + notification = notifications(:logo_assignment_kevin) + notification.user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + pusher = NotificationPusher.new(notification) + + payload = pusher.send(:build_payload) + native = pusher.send(:native_notification, payload) + + assert native.high_priority + end + + test "native notification sets normal priority for non-assignments" do + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + payload = @pusher.send(:build_payload) + native = @pusher.send(:native_notification, payload) + + assert_not native.high_priority + end + + # === Apple-specific Payload === + + test "native notification includes apple-specific fields" do + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + payload = @pusher.send(:build_payload) + native = @pusher.send(:native_notification, payload) + + assert_equal 1, native.apple_data.dig(:aps, :"mutable-content") + assert_includes %w[active time-sensitive], native.apple_data.dig(:aps, :"interruption-level") + assert_not_nil native.apple_data.dig(:aps, :category) + end + + # === Google-specific Payload === + + test "native notification sets android notification to nil for data-only" do + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + payload = @pusher.send(:build_payload) + native = @pusher.send(:native_notification, payload) + + assert_nil native.google_data.dig(:android, :notification) + end + + # === Data Payload === + + test "native notification includes data payload" do + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + payload = @pusher.send(:build_payload) + native = @pusher.send(:native_notification, payload) + + assert_not_nil native.data[:path] + assert_equal @notification.account.external_account_id, native.data[:account_id] + assert_equal @notification.creator.name, native.data[:creator_name] + end +end diff --git a/saas/test/test_helpers/push_notification_test_helper.rb b/saas/test/test_helpers/push_notification_test_helper.rb new file mode 100644 index 0000000000..24355edf7e --- /dev/null +++ b/saas/test/test_helpers/push_notification_test_helper.rb @@ -0,0 +1,33 @@ +module PushNotificationTestHelper + # Assert native push notification is queued for delivery + def assert_native_push_delivery(count: 1, &block) + assert_enqueued_jobs count, only: ApplicationPushNotificationJob, &block + end + + # Assert no native push notifications are queued + def assert_no_native_push_delivery(&block) + assert_native_push_delivery(count: 0, &block) + end + + # Expect push notification to be delivered (using mocha) + def expect_native_push_delivery(count: 1) + ApplicationPushNotification.any_instance.expects(:deliver_later_to).times(count) + yield if block_given? + end + + # Expect no push notification delivery + def expect_no_native_push_delivery(&block) + expect_native_push_delivery(count: 0, &block) + end + + # Stub the push service to avoid actual API calls + def stub_push_services + ActionPushNative.stubs(:service_for).returns(stub(push: true)) + end + + # Stub push service to simulate token error (device should be deleted) + def stub_push_token_error + push_stub = stub.tap { |s| s.stubs(:push).raises(ActionPushNative::TokenError) } + ActionPushNative.stubs(:service_for).returns(push_stub) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 14893b030a..336ed05a31 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -36,6 +36,10 @@ } end +if Fizzy.saas? + require_relative "../saas/test/test_helpers/push_notification_test_helper" +end + module ActiveSupport class TestCase parallelize workers: :number_of_processors, work_stealing: ENV["WORK_STEALING"] != "false" @@ -46,6 +50,7 @@ class TestCase include ActiveJob::TestHelper include ActionTextTestHelper, CachingTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper include Turbo::Broadcastable::TestHelper + include PushNotificationTestHelper if Fizzy.saas? setup do Current.account = accounts("37s") From deabebd95c9f98e957aaecde81c5fe50c8f21509 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:01:00 -0600 Subject: [PATCH 05/72] Add action_push_native_devices table --- db/schema_sqlite.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index b76f998659..3618e1b9a9 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -68,6 +68,17 @@ t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end + create_table "action_push_native_devices", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name", limit: 255 + t.integer "owner_id" + t.string "owner_type", limit: 255 + t.string "platform", limit: 255, null: false + t.string "token", limit: 255, null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" + end + create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.text "body", limit: 4294967295 From 40cdb32d6fd1ab87d78808fea2271419778e0c2a Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:27:11 -0600 Subject: [PATCH 06/72] Update init so ActionNativePush picks up the config --- saas/lib/fizzy/saas/engine.rb | 4 ++++ saas/test/models/push_config_test.rb | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 saas/test/models/push_config_test.rb diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index cfe2361331..63e2f23cc2 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -17,6 +17,10 @@ class Engine < ::Rails::Engine app.config.assets.paths << root.join("app/assets/stylesheets") end + initializer "fizzy_saas.push_config", before: "action_push_native.config" do |app| + app.paths.add "config/push", with: root.join("config/push.yml") + end + initializer "fizzy.saas.routes", after: :add_routing_paths do |app| # Routes that rely on the implicit account tenant should go here instead of in +routes.rb+. app.routes.prepend do diff --git a/saas/test/models/push_config_test.rb b/saas/test/models/push_config_test.rb new file mode 100644 index 0000000000..979194b3f4 --- /dev/null +++ b/saas/test/models/push_config_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class PushConfigTest < ActiveSupport::TestCase + test "loads push config from the saas engine" do + skip unless Fizzy.saas? + + config = Rails.application.config_for(:push) + + apple_team_id = config.dig("apple", "team_id") + apple_topic = config.dig("apple", "topic") + google_project_id = config.dig("google", "project_id") + + skip "Update test once APNS team_id is configured" if apple_team_id == "YOUR_TEAM_ID" + skip "Update test once APNS topic is configured" if apple_topic == "com.yourcompany.fizzy" + skip "Update test once FCM project_id is configured" if google_project_id == "your-firebase-project" + + assert apple_team_id.present? + assert apple_topic.present? + assert google_project_id.present? + end +end From 98683cb1e3aa98ce82c9f95dab6dcbb7b1f96f1d Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:33:08 -0600 Subject: [PATCH 07/72] Update to add a global identifier to device tokens --- ...dex_to_action_push_native_devices_token.rb | 5 +++++ db/schema_sqlite.rb | 1 + .../controllers/users/devices_controller.rb | 13 ++++++++++-- .../users/devices_controller_test.rb | 21 +++++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb diff --git a/db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb b/db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb new file mode 100644 index 0000000000..616e5742e1 --- /dev/null +++ b/db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb @@ -0,0 +1,5 @@ +class AddUniqueIndexToActionPushNativeDevicesToken < ActiveRecord::Migration[8.0] + def change + add_index :action_push_native_devices, :token, unique: true + end +end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 3618e1b9a9..dfa10613d3 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -77,6 +77,7 @@ t.string "token", limit: 255, null: false t.datetime "updated_at", null: false t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" + t.index ["token"], name: "index_action_push_native_devices_on_token", unique: true end create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index 271171cf2b..0b7d96dd6e 100644 --- a/saas/app/controllers/users/devices_controller.rb +++ b/saas/app/controllers/users/devices_controller.rb @@ -6,8 +6,17 @@ def index # POST /users/devices - API only (mobile apps register tokens) def create - device = Current.user.devices.find_or_initialize_by(token: device_params[:token]) - device.update!(device_params) + attrs = device_params + device = ActionPushNative::Device.find_or_initialize_by(token: attrs[:token]) + device.owner = Current.user + device.update!(attrs) + head :created + rescue ActiveRecord::RecordNotUnique + device = ActionPushNative::Device.find_by(token: attrs[:token]) + raise unless device + + device.owner = Current.user + device.update!(attrs) head :created rescue ActiveRecord::RecordInvalid head :unprocessable_entity diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/users/devices_controller_test.rb index 241ec23338..c947e690ee 100644 --- a/saas/test/controllers/users/devices_controller_test.rb +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -91,6 +91,27 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_equal "New iPhone", existing_device.reload.name end + test "reassigns device token from another user" do + other_user = users(:kevin) + device = other_user.devices.create!( + token: "shared_token_123", + platform: "apple", + name: "Other iPhone" + ) + + assert_no_difference "ActionPushNative::Device.count" do + post users_devices_path, params: { + token: "shared_token_123", + platform: "apple", + name: "My iPhone" + }, as: :json + end + + assert_response :created + assert_equal @user, device.reload.owner + assert_equal "My iPhone", device.name + end + test "rejects invalid platform" do post users_devices_path, params: { token: SecureRandom.hex(32), From 7653effae4cf7aa63ab5abc2da7fef6e8d0fc142 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:35:26 -0600 Subject: [PATCH 08/72] Update push destination --- app/models/notification_pusher.rb | 6 +++++- saas/app/models/notification_pusher/native.rb | 15 +-------------- saas/test/models/notification_pusher_test.rb | 12 ++++++------ 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index d6425e561a..9c89937a3e 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -18,12 +18,16 @@ def push private def should_push? - notification.user.push_subscriptions.any? && + push_destination? && !notification.creator.system? && notification.user.active? && notification.account.active? end + def push_destination? + notification.user.push_subscriptions.any? + end + def build_payload case notification.source_type when "Event" diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb index bf98317d13..115df497d5 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -1,19 +1,6 @@ module NotificationPusher::Native extend ActiveSupport::Concern - included do - # Alias the original method so we can extend it - alias_method :original_should_push?, :should_push? - end - - # Override should_push? to also check for native devices - def should_push? - has_any_push_destination? && - !notification.creator.system? && - notification.user.active? && - notification.account.active? - end - # Override push to also send to native devices def push return unless should_push? @@ -25,7 +12,7 @@ def push end private - def has_any_push_destination? + def push_destination? notification.user.push_subscriptions.any? || notification.user.devices.any? end diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification_pusher_test.rb index 35407840f0..bea03e6cef 100644 --- a/saas/test/models/notification_pusher_test.rb +++ b/saas/test/models/notification_pusher_test.rb @@ -58,27 +58,27 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Has Any Push Destination === - test "has_any_push_destination returns true when user has native devices" do + test "push_destination returns true when user has native devices" do @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - assert @pusher.send(:has_any_push_destination?) + assert @pusher.send(:push_destination?) end - test "has_any_push_destination returns true when user has web subscriptions" do + test "push_destination returns true when user has web subscriptions" do @user.push_subscriptions.create!( endpoint: "https://example.com/push", p256dh_key: "test_p256dh", auth_key: "test_auth" ) - assert @pusher.send(:has_any_push_destination?) + assert @pusher.send(:push_destination?) end - test "has_any_push_destination returns false when user has neither" do + test "push_destination returns false when user has neither" do @user.devices.delete_all @user.push_subscriptions.delete_all - assert_not @pusher.send(:has_any_push_destination?) + assert_not @pusher.send(:push_destination?) end # === Push Delivery === From 27b78298ee2eaefe4d2592c6187843b0eb361f88 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:43:07 -0600 Subject: [PATCH 09/72] Update schema version to match latest migration The schema version was set to the first migration's timestamp (2026_01_14_203313) but already included the unique index from the second migration (2026_01_15_000000). This updates the version to correctly reflect all applied migrations. Co-Authored-By: Claude Opus 4.5 --- db/schema_sqlite.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index dfa10613d3..adaf813135 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do +ActiveRecord::Schema[8.2].define(version: 2026_01_15_000000) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false From 5207214e61d8461358a924568b9aade1c8b164cb Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:45:43 -0600 Subject: [PATCH 10/72] Reuse push_to_user instead of duplicating web push logic Remove the push_to_web method which duplicated the base class's push_to_user logic. Now directly calls push_to_user with a guard for empty subscriptions, keeping the optimization while reducing code duplication. Co-Authored-By: Claude Opus 4.5 --- saas/app/models/notification_pusher/native.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb index 115df497d5..4106a858ef 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -6,7 +6,7 @@ def push return unless should_push? build_payload.tap do |payload| - push_to_web(payload) + push_to_user(payload) if notification.user.push_subscriptions.any? push_to_native(payload) end end @@ -16,12 +16,6 @@ def push_destination? notification.user.push_subscriptions.any? || notification.user.devices.any? end - def push_to_web(payload) - subscriptions = notification.user.push_subscriptions - return if subscriptions.empty? - enqueue_payload_for_delivery(payload, subscriptions) - end - def push_to_native(payload) devices = notification.user.devices return if devices.empty? From 8ae9540ff4a9f3ae6108bac2a9664007f4ccc323 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:52:31 -0600 Subject: [PATCH 11/72] Scope device tokens to users for better security Devices are now identified by (owner, uuid) instead of token alone. This allows multiple users to have device records with the same push token, with notifications correctly routed to the authenticated user. Key changes: - Add uuid column to action_push_native_devices - Replace unique index on token with composite (owner_type, owner_id, uuid) - Controller now looks up by user + uuid, updates token on registration - Require uuid parameter in device registration API This prevents potential token hijacking where one user could take over another user's push notifications by registering their token. Co-Authored-By: Claude Opus 4.5 --- ..._add_uuid_to_action_push_native_devices.rb | 8 +++ db/schema_sqlite.rb | 5 +- .../controllers/users/devices_controller.rb | 15 ++-- .../users/devices_controller_test.rb | 68 ++++++++++++++----- .../fixtures/action_push_native/devices.yml | 3 + saas/test/models/notification_pusher_test.rb | 24 +++---- 6 files changed, 81 insertions(+), 42 deletions(-) create mode 100644 db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb diff --git a/db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb b/db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb new file mode 100644 index 0000000000..9d46f63067 --- /dev/null +++ b/db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb @@ -0,0 +1,8 @@ +class AddUuidToActionPushNativeDevices < ActiveRecord::Migration[8.0] + def change + add_column :action_push_native_devices, :uuid, :string, null: false + + remove_index :action_push_native_devices, :token + add_index :action_push_native_devices, [ :owner_type, :owner_id, :uuid ], unique: true + end +end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index adaf813135..5a266284be 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_15_000000) do +ActiveRecord::Schema[8.2].define(version: 2026_01_15_100000) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -76,8 +76,9 @@ t.string "platform", limit: 255, null: false t.string "token", limit: 255, null: false t.datetime "updated_at", null: false + t.string "uuid", limit: 255, null: false + t.index ["owner_type", "owner_id", "uuid"], name: "idx_on_owner_type_owner_id_uuid_a42e3920d5", unique: true t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" - t.index ["token"], name: "index_action_push_native_devices_on_token", unique: true end create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index 0b7d96dd6e..bd83275fd5 100644 --- a/saas/app/controllers/users/devices_controller.rb +++ b/saas/app/controllers/users/devices_controller.rb @@ -7,16 +7,8 @@ def index # POST /users/devices - API only (mobile apps register tokens) def create attrs = device_params - device = ActionPushNative::Device.find_or_initialize_by(token: attrs[:token]) - device.owner = Current.user - device.update!(attrs) - head :created - rescue ActiveRecord::RecordNotUnique - device = ActionPushNative::Device.find_by(token: attrs[:token]) - raise unless device - - device.owner = Current.user - device.update!(attrs) + device = Current.user.devices.find_or_create_by(uuid: attrs[:uuid]) + device.update!(token: attrs[:token], name: attrs[:name], platform: attrs[:platform]) head :created rescue ActiveRecord::RecordInvalid head :unprocessable_entity @@ -30,9 +22,10 @@ def destroy private def device_params - params.permit(:token, :platform, :name).tap do |p| + params.permit(:uuid, :token, :platform, :name).tap do |p| p[:platform] = p[:platform].to_s.downcase raise ActionController::BadRequest unless p[:platform].in?(%w[apple google]) + raise ActionController::BadRequest if p[:uuid].blank? raise ActionController::BadRequest if p[:token].blank? end end diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/users/devices_controller_test.rb index c947e690ee..46da8ce7f9 100644 --- a/saas/test/controllers/users/devices_controller_test.rb +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -9,7 +9,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Index (Web) === test "index shows user devices" do - @user.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") + @user.devices.create!(uuid: "test-uuid", token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") get users_devices_path @@ -38,10 +38,12 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Create (API) === test "creates a new device via api" do + uuid = SecureRandom.uuid token = SecureRandom.hex(32) assert_difference "ActionPushNative::Device.count", 1 do post users_devices_path, params: { + uuid: uuid, token: token, platform: "apple", name: "iPhone 15 Pro" @@ -51,6 +53,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :created device = ActionPushNative::Device.last + assert_equal uuid, device.uuid assert_equal token, device.token assert_equal "apple", device.platform assert_equal "iPhone 15 Pro", device.name @@ -58,10 +61,9 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest end test "creates android device" do - token = SecureRandom.hex(32) - post users_devices_path, params: { - token: token, + uuid: SecureRandom.uuid, + token: SecureRandom.hex(32), platform: "google", name: "Pixel 8" }, as: :json @@ -72,48 +74,65 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_equal "google", device.platform end - test "updates existing device with same token" do + test "updates existing device with same uuid" do existing_device = @user.devices.create!( - token: "existing_token_123", + uuid: "my-device-uuid", + token: "old_token", platform: "apple", name: "Old iPhone" ) assert_no_difference "ActionPushNative::Device.count" do post users_devices_path, params: { - token: "existing_token_123", + uuid: "my-device-uuid", + token: "new_token", platform: "apple", name: "New iPhone" }, as: :json end assert_response :created - assert_equal "New iPhone", existing_device.reload.name + existing_device.reload + assert_equal "new_token", existing_device.token + assert_equal "New iPhone", existing_device.name end - test "reassigns device token from another user" do + test "same token can be registered by multiple users" do + shared_token = "shared_push_token_123" other_user = users(:kevin) - device = other_user.devices.create!( - token: "shared_token_123", + + # Other user registers the token first + other_device = other_user.devices.create!( + uuid: "kevins-device-uuid", + token: shared_token, platform: "apple", - name: "Other iPhone" + name: "Kevin's iPhone" ) - assert_no_difference "ActionPushNative::Device.count" do + # Current user registers the same token with their own device + assert_difference "ActionPushNative::Device.count", 1 do post users_devices_path, params: { - token: "shared_token_123", + uuid: "davids-device-uuid", + token: shared_token, platform: "apple", - name: "My iPhone" + name: "David's iPhone" }, as: :json end assert_response :created - assert_equal @user, device.reload.owner - assert_equal "My iPhone", device.name + + # Both users have their own device records + assert_equal shared_token, other_device.reload.token + assert_equal other_user, other_device.owner + + davids_device = @user.devices.find_by(uuid: "davids-device-uuid") + assert_equal shared_token, davids_device.token + assert_equal @user, davids_device.owner end test "rejects invalid platform" do post users_devices_path, params: { + uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "windows", name: "Surface" @@ -122,8 +141,19 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :bad_request end + test "rejects missing uuid" do + post users_devices_path, params: { + token: SecureRandom.hex(32), + platform: "apple", + name: "iPhone" + }, as: :json + + assert_response :bad_request + end + test "rejects missing token" do post users_devices_path, params: { + uuid: SecureRandom.uuid, platform: "apple", name: "iPhone" }, as: :json @@ -135,6 +165,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest sign_out post users_devices_path, params: { + uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "apple" }, as: :json @@ -146,6 +177,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "destroys device" do device = @user.devices.create!( + uuid: "device-to-delete", token: "token_to_delete", platform: "apple", name: "iPhone" @@ -170,6 +202,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "cannot destroy another user's device" do other_user = users(:kevin) device = other_user.devices.create!( + uuid: "other-users-device", token: "other_users_token", platform: "apple", name: "Other iPhone" @@ -185,6 +218,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "destroy requires authentication" do device = @user.devices.create!( + uuid: "my-device", token: "my_token", platform: "apple", name: "iPhone" diff --git a/saas/test/fixtures/action_push_native/devices.yml b/saas/test/fixtures/action_push_native/devices.yml index 7601d52849..0494d2a973 100644 --- a/saas/test/fixtures/action_push_native/devices.yml +++ b/saas/test/fixtures/action_push_native/devices.yml @@ -1,16 +1,19 @@ davids_iphone: + uuid: device-uuid-davids-iphone name: iPhone 15 Pro token: abc123def456abc123def456abc123def456abc123def456abc123def456abcd platform: apple owner: david (User) davids_pixel: + uuid: device-uuid-davids-pixel name: Pixel 8 token: def456abc123def456abc123def456abc123def456abc123def456abc123defg platform: google owner: david (User) kevins_iphone: + uuid: device-uuid-kevins-iphone name: iPhone 14 token: 789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7890 platform: apple diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification_pusher_test.rb index bea03e6cef..74c7b5dd81 100644 --- a/saas/test/models/notification_pusher_test.rb +++ b/saas/test/models/notification_pusher_test.rb @@ -59,7 +59,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Has Any Push Destination === test "push_destination returns true when user has native devices" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") assert @pusher.send(:push_destination?) end @@ -85,7 +85,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push delivers to native devices when user has devices" do stub_push_services - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") assert_native_push_delivery(count: 1) do @pusher.push @@ -102,7 +102,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push does not deliver when creator is system user" do stub_push_services - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") @notification.update!(creator: users(:system)) result = @pusher.push @@ -112,8 +112,8 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push delivers to multiple devices" do stub_push_services - @user.devices.create!(token: "token1", platform: "apple", name: "iPhone") - @user.devices.create!(token: "token2", platform: "google", name: "Pixel") + @user.devices.create!(uuid: SecureRandom.uuid, token: "token1", platform: "apple", name: "iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "token2", platform: "google", name: "Pixel") assert_native_push_delivery(count: 1) do @pusher.push @@ -123,7 +123,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Native Notification Building === test "native notification includes required fields" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -133,7 +133,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "native notification sets thread_id from card" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -142,7 +142,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "native notification sets high_priority for assignments" do notification = notifications(:logo_assignment_kevin) - notification.user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + notification.user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") pusher = NotificationPusher.new(notification) payload = pusher.send(:build_payload) @@ -152,7 +152,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "native notification sets normal priority for non-assignments" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -162,7 +162,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Apple-specific Payload === test "native notification includes apple-specific fields" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -174,7 +174,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Google-specific Payload === test "native notification sets android notification to nil for data-only" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -184,7 +184,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Data Payload === test "native notification includes data payload" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) From 9424e915e12f1f90c5fc65411de0c0185dac3c50 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 18:55:44 -0600 Subject: [PATCH 12/72] Add test for dual web and native push delivery Verifies that when a user has both web push subscriptions and native devices registered, notifications are delivered to both channels. Co-Authored-By: Claude Opus 4.5 --- saas/test/models/notification_pusher_test.rb | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification_pusher_test.rb index 74c7b5dd81..2c628b4166 100644 --- a/saas/test/models/notification_pusher_test.rb +++ b/saas/test/models/notification_pusher_test.rb @@ -120,6 +120,32 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end end + test "push delivers to both web and native when user has both" do + stub_push_services + + # Set up web push subscription + @user.push_subscriptions.create!( + endpoint: "https://fcm.googleapis.com/fcm/send/test", + p256dh_key: "test_p256dh_key", + auth_key: "test_auth_key" + ) + + # Set up native device + @user.devices.create!(uuid: SecureRandom.uuid, token: "native_token", platform: "apple", name: "iPhone") + + # Mock web push pool to verify it receives the payload + web_push_pool = mock("web_push_pool") + web_push_pool.expects(:queue).once.with do |payload, subscriptions| + payload.is_a?(Hash) && subscriptions.count == 1 + end + Rails.configuration.x.stubs(:web_push_pool).returns(web_push_pool) + + # Verify native push is also delivered + assert_native_push_delivery(count: 1) do + @pusher.push + end + end + # === Native Notification Building === test "native notification includes required fields" do From 8860044b664f8d5a5a2108777b1d59016dcd81b2 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 19:02:42 -0600 Subject: [PATCH 13/72] Consolidate device migrations into single migration Replace three separate migrations with one that creates the action_push_native_devices table with the final schema: - uuid column (not null) - Composite unique index on (owner_type, owner_id, uuid) This avoids unnecessary intermediate schema changes. Co-Authored-By: Claude Opus 4.5 --- ...> 20260114203313_create_action_push_native_devices.rb} | 5 ++++- ...dd_unique_index_to_action_push_native_devices_token.rb | 5 ----- ...260115100000_add_uuid_to_action_push_native_devices.rb | 8 -------- db/schema_sqlite.rb | 2 +- 4 files changed, 5 insertions(+), 15 deletions(-) rename db/migrate/{20260114203313_create_action_push_native_device.action_push_native.rb => 20260114203313_create_action_push_native_devices.rb} (62%) delete mode 100644 db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb delete mode 100644 db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb diff --git a/db/migrate/20260114203313_create_action_push_native_device.action_push_native.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb similarity index 62% rename from db/migrate/20260114203313_create_action_push_native_device.action_push_native.rb rename to db/migrate/20260114203313_create_action_push_native_devices.rb index 9f87d43348..97ab59c8a5 100644 --- a/db/migrate/20260114203313_create_action_push_native_device.action_push_native.rb +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -1,7 +1,8 @@ # This migration comes from action_push_native (originally 20250610075650) -class CreateActionPushNativeDevice < ActiveRecord::Migration[8.0] +class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] def change create_table :action_push_native_devices do |t| + t.string :uuid, null: false t.string :name t.string :platform, null: false t.string :token, null: false @@ -9,5 +10,7 @@ def change t.timestamps end + + add_index :action_push_native_devices, [ :owner_type, :owner_id, :uuid ], unique: true end end diff --git a/db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb b/db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb deleted file mode 100644 index 616e5742e1..0000000000 --- a/db/migrate/20260115000000_add_unique_index_to_action_push_native_devices_token.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddUniqueIndexToActionPushNativeDevicesToken < ActiveRecord::Migration[8.0] - def change - add_index :action_push_native_devices, :token, unique: true - end -end diff --git a/db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb b/db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb deleted file mode 100644 index 9d46f63067..0000000000 --- a/db/migrate/20260115100000_add_uuid_to_action_push_native_devices.rb +++ /dev/null @@ -1,8 +0,0 @@ -class AddUuidToActionPushNativeDevices < ActiveRecord::Migration[8.0] - def change - add_column :action_push_native_devices, :uuid, :string, null: false - - remove_index :action_push_native_devices, :token - add_index :action_push_native_devices, [ :owner_type, :owner_id, :uuid ], unique: true - end -end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 5a266284be..b04576934c 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_15_100000) do +ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false From 2746e4d3f787b3a99bd53d93d84b6ed19270d434 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 19:14:43 -0600 Subject: [PATCH 14/72] Move native devices partial to SaaS engine The _native_devices partial now lives in the SaaS engine and is only rendered when Fizzy.saas? is true. This keeps SaaS-specific views out of the self-hosted codebase entirely. In self-hosted mode, the partial doesn't exist and the render call is skipped via the conditional. If someone tried to render it directly, Rails would raise ActionView::MissingTemplate. Co-Authored-By: Claude Opus 4.5 --- .../settings/_native_devices.html.erb | 16 ---------------- app/views/notifications/settings/show.html.erb | 2 +- .../settings/_native_devices.html.erb | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 17 deletions(-) delete mode 100644 app/views/notifications/settings/_native_devices.html.erb create mode 100644 saas/app/views/notifications/settings/_native_devices.html.erb diff --git a/app/views/notifications/settings/_native_devices.html.erb b/app/views/notifications/settings/_native_devices.html.erb deleted file mode 100644 index e20599f77d..0000000000 --- a/app/views/notifications/settings/_native_devices.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -<% if Fizzy.saas? %> -
-

Mobile Devices

- - <% if Current.user.devices.any? %> -

- You have <%= pluralize(Current.user.devices.count, "mobile device") %> registered for push notifications. -

- <%= link_to "Manage devices", users_devices_path, class: "btn txt-small" %> - <% else %> -

- No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone. -

- <% end %> -
-<% end %> diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index f3194f5c37..4dfdf396e0 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -14,7 +14,7 @@
<%= render "notifications/settings/push_notifications" %> - <%= render "notifications/settings/native_devices" %> + <%= render "notifications/settings/native_devices" if Fizzy.saas? %> <%= render "notifications/settings/email", settings: @settings %>
diff --git a/saas/app/views/notifications/settings/_native_devices.html.erb b/saas/app/views/notifications/settings/_native_devices.html.erb new file mode 100644 index 0000000000..a3e95b392b --- /dev/null +++ b/saas/app/views/notifications/settings/_native_devices.html.erb @@ -0,0 +1,14 @@ +
+

Mobile Devices

+ + <% if Current.user.devices.any? %> +

+ You have <%= pluralize(Current.user.devices.count, "mobile device") %> registered for push notifications. +

+ <%= link_to "Manage devices", users_devices_path, class: "btn txt-small" %> + <% else %> +

+ No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone. +

+ <% end %> +
From fe560bbb6fb14c89768dcb09f678c20a7a82e54c Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 12:49:05 -0600 Subject: [PATCH 15/72] Remove code comments Co-Authored-By: Claude Opus 4.5 --- db/migrate/20260114203313_create_action_push_native_devices.rb | 1 - saas/app/controllers/users/devices_controller.rb | 3 --- saas/app/models/notification_pusher/native.rb | 2 -- saas/lib/fizzy/saas/engine.rb | 1 - 4 files changed, 7 deletions(-) diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb index 97ab59c8a5..aa577ef760 100644 --- a/db/migrate/20260114203313_create_action_push_native_devices.rb +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -1,4 +1,3 @@ -# This migration comes from action_push_native (originally 20250610075650) class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] def change create_table :action_push_native_devices do |t| diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index bd83275fd5..a78bbe40ff 100644 --- a/saas/app/controllers/users/devices_controller.rb +++ b/saas/app/controllers/users/devices_controller.rb @@ -1,10 +1,8 @@ class Users::DevicesController < ApplicationController - # GET /users/devices - Web only (view registered devices) def index @devices = Current.user.devices.order(created_at: :desc) end - # POST /users/devices - API only (mobile apps register tokens) def create attrs = device_params device = Current.user.devices.find_or_create_by(uuid: attrs[:uuid]) @@ -14,7 +12,6 @@ def create head :unprocessable_entity end - # DELETE /users/devices/:id - Web only def destroy Current.user.devices.find_by(id: params[:id])&.destroy redirect_to users_devices_path, notice: "Device removed" diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb index 4106a858ef..681ef7920c 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -1,7 +1,6 @@ module NotificationPusher::Native extend ActiveSupport::Concern - # Override push to also send to native devices def push return unless should_push? @@ -33,7 +32,6 @@ def native_notification(payload) } ) .with_google( - # Data-only message - Android app handles notification display android: { notification: nil } ) .with_data( diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 63e2f23cc2..7f56f2c387 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -38,7 +38,6 @@ class Engine < ::Rails::Engine resource :webhooks, only: :create end - # Native push notification device registration namespace :users do resources :devices, only: [ :index, :create, :destroy ] end From 5f5956be9998f72112e955b20cabf996d017b2a1 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 12:54:31 -0600 Subject: [PATCH 16/72] Rename push_to_user to push_to_web Clearer naming now that we have both web and native push delivery. Co-Authored-By: Claude Opus 4.5 --- app/models/notification_pusher.rb | 4 ++-- saas/app/models/notification_pusher/native.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index 9c89937a3e..1b50142bdc 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -12,7 +12,7 @@ def push return unless should_push? build_payload.tap do |payload| - push_to_user(payload) + push_to_web(payload) end end @@ -97,7 +97,7 @@ def build_default_payload } end - def push_to_user(payload) + def push_to_web(payload) subscriptions = notification.user.push_subscriptions enqueue_payload_for_delivery(payload, subscriptions) end diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb index 681ef7920c..237697f920 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -5,7 +5,7 @@ def push return unless should_push? build_payload.tap do |payload| - push_to_user(payload) if notification.user.push_subscriptions.any? + push_to_web(payload) if notification.user.push_subscriptions.any? push_to_native(payload) end end From f6f1e7e38e78c898d1e88e94c2cc37577ba04c27 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 19:07:15 -0600 Subject: [PATCH 17/72] Update with team identifiers --- saas/config/push.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/saas/config/push.yml b/saas/config/push.yml index 916277b30a..fcc9e2ffb9 100644 --- a/saas/config/push.yml +++ b/saas/config/push.yml @@ -2,10 +2,9 @@ shared: apple: key_id: <%= ENV["APNS_KEY_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %> encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :apns, :key))&.dump %> - team_id: YOUR_TEAM_ID # Your 10-character Apple Developer Team ID (not secret) - topic: com.yourcompany.fizzy # Your app's bundle identifier (not secret) - # Uncomment for local development with Xcode builds (uses APNs sandbox): - # connect_to_development_server: true + team_id: <%= ENV["APNS_TEAM_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :team_id) || "2WNYUYRS7G" %> + topic: <%= ENV["APNS_TOPIC"] || Rails.application.credentials.dig(:action_push_native, :apns, :topic) || "do.fizzy.app.ios" %> + connect_to_development_server: <%= Rails.env.local? %> google: encryption_key: <%= (ENV["FCM_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :fcm, :key))&.dump %> project_id: your-firebase-project # Your Firebase project ID (not secret) From 517745eeb590366598e2c02c507a7238b95dbd4b Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 19:24:03 -0600 Subject: [PATCH 18/72] Add script to load certificate from 1Password --- saas/exe/apns-dev | 34 ++++++++++++++++++++++++++++++++++ saas/fizzy-saas.gemspec | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100755 saas/exe/apns-dev diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev new file mode 100755 index 0000000000..9ce2e71f0a --- /dev/null +++ b/saas/exe/apns-dev @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# +# Fetches APNs development environment variables from 1Password. +# +# Usage: eval "$(bundle exec apns-dev)" + +OP_ACCOUNT = "23QPQDKZC5BKBIIG7UGT5GR5RM" +OP_VAULT = "Mobile" +OP_ITEM = "37signals Push Notifications key" + +def op_read(field) + `op read "op://#{OP_VAULT}/#{OP_ITEM}/#{field}" --account #{OP_ACCOUNT} 2>/dev/null`.strip +end + +key_id = op_read("key ID") +team_id = op_read("team ID") +encryption_key = op_read("AuthKey_3CR5J2W8Q6.p8") + +if key_id.empty? || encryption_key.empty? + warn "Error: Could not fetch APNs credentials from 1Password" + warn "Make sure you're signed in: op signin --account #{OP_ACCOUNT}" + exit 1 +end + +puts %Q(export APNS_KEY_ID="#{key_id}") +puts %Q(export APNS_TEAM_ID="#{team_id}") +puts %Q(export APNS_ENCRYPTION_KEY="#{encryption_key.gsub("\n", "\\n")}") +puts %Q(export APNS_TOPIC="do.fizzy.app.ios") + +warn "" +warn "APNs credentials loaded for development" +warn " Key ID: #{key_id}" +warn " Team ID: #{team_id}" +warn " Topic: do.fizzy.app.ios" diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 1368ef63c3..de690f5d60 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |spec| end spec.bindir = "exe" - spec.executables = [ "stripe-dev" ] + spec.executables = [ "apns-dev", "stripe-dev" ] spec.add_dependency "rails", ">= 8.1.0.beta1" spec.add_dependency "queenbee" From 872aa2d98e43850b1f8a8b447dd00afa0cc1e295 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 19:39:55 -0600 Subject: [PATCH 19/72] Fix NOT NULL crash in device registration Use find_or_initialize_by instead of find_or_create_by to avoid inserting a record with only uuid before all required fields are set. Co-Authored-By: Claude Opus 4.5 --- saas/app/controllers/users/devices_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index a78bbe40ff..6b49cf7c31 100644 --- a/saas/app/controllers/users/devices_controller.rb +++ b/saas/app/controllers/users/devices_controller.rb @@ -5,7 +5,7 @@ def index def create attrs = device_params - device = Current.user.devices.find_or_create_by(uuid: attrs[:uuid]) + device = Current.user.devices.find_or_initialize_by(uuid: attrs[:uuid]) device.update!(token: attrs[:token], name: attrs[:name], platform: attrs[:platform]) head :created rescue ActiveRecord::RecordInvalid From 58e670c611fcb682e80dced44f796581fcf8f5fa Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 19:41:54 -0600 Subject: [PATCH 20/72] Fix owner_id type to UUID in devices migration Fizzy uses UUID primary keys, so the polymorphic owner reference needs to specify type: :uuid to match. Co-Authored-By: Claude Opus 4.5 --- db/migrate/20260114203313_create_action_push_native_devices.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb index aa577ef760..e2742dae7a 100644 --- a/db/migrate/20260114203313_create_action_push_native_devices.rb +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -5,7 +5,7 @@ def change t.string :name t.string :platform, null: false t.string :token, null: false - t.belongs_to :owner, polymorphic: true + t.belongs_to :owner, polymorphic: true, type: :uuid t.timestamps end From a1e4b025c4944b017649811e26cf9b1a083aab9a Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 19:42:19 -0600 Subject: [PATCH 21/72] Allow enabling native push in dev via ENABLE_NATIVE_PUSH env var Native push is disabled in local environments by default, but can now be enabled by setting ENABLE_NATIVE_PUSH=true for testing the full push notification flow. Co-Authored-By: Claude Opus 4.5 --- saas/app/models/application_push_notification.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/application_push_notification.rb b/saas/app/models/application_push_notification.rb index 41fe881f7e..7c0594c50a 100644 --- a/saas/app/models/application_push_notification.rb +++ b/saas/app/models/application_push_notification.rb @@ -1,4 +1,4 @@ class ApplicationPushNotification < ActionPushNative::Notification queue_as :default - self.enabled = !Rails.env.local? + self.enabled = !Rails.env.local? || ENV["ENABLE_NATIVE_PUSH"] == "true" end From 2f0bf24b326d1a89693b577281af2f0e53c1467f Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 20:44:33 -0600 Subject: [PATCH 22/72] Add --apns flag to bin/dev for loading APNs credentials Usage: bin/dev --apns This loads APNs credentials from 1Password and enables native push delivery in development. Requires SaaS mode to be enabled. Also updates apns-dev to export ENABLE_NATIVE_PUSH=true so that loading credentials automatically enables push delivery. Co-Authored-By: Claude Opus 4.5 --- bin/dev | 11 +++++++++++ saas/exe/apns-dev | 2 ++ 2 files changed, 13 insertions(+) diff --git a/bin/dev b/bin/dev index 05032e7c36..328fac2e6c 100755 --- a/bin/dev +++ b/bin/dev @@ -2,13 +2,24 @@ PORT=3006 USE_TAILSCALE=0 +USE_APNS=0 for arg in "$@"; do case $arg in --tailscale) USE_TAILSCALE=1 ;; + --apns) USE_APNS=1 ;; esac done +if [ "$USE_APNS" = "1" ]; then + if [ ! -f tmp/saas.txt ]; then + echo "Error: --apns requires SaaS mode. Run: bin/rails saas:enable" >&2 + exit 1 + fi + echo "Loading APNs credentials from 1Password..." + eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec apns-dev)" +fi + if [ ! -f tmp/solid-queue.txt ]; then export SOLID_QUEUE_IN_PUMA=false fi diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev index 9ce2e71f0a..02bd28ad18 100755 --- a/saas/exe/apns-dev +++ b/saas/exe/apns-dev @@ -26,9 +26,11 @@ puts %Q(export APNS_KEY_ID="#{key_id}") puts %Q(export APNS_TEAM_ID="#{team_id}") puts %Q(export APNS_ENCRYPTION_KEY="#{encryption_key.gsub("\n", "\\n")}") puts %Q(export APNS_TOPIC="do.fizzy.app.ios") +puts %Q(export ENABLE_NATIVE_PUSH="true") warn "" warn "APNs credentials loaded for development" warn " Key ID: #{key_id}" warn " Team ID: #{team_id}" warn " Topic: do.fizzy.app.ios" +warn " Native push: enabled" From 3b1e00223919e5ea730936fe14b29d5be49ce619 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 20:45:24 -0600 Subject: [PATCH 23/72] Auto-enable SaaS mode when running bin/dev --apns Native push notifications require SaaS mode, so automatically enable it when the --apns flag is used instead of showing an error. Co-Authored-By: Claude Opus 4.5 --- bin/dev | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/dev b/bin/dev index 328fac2e6c..80d413eb61 100755 --- a/bin/dev +++ b/bin/dev @@ -13,8 +13,8 @@ done if [ "$USE_APNS" = "1" ]; then if [ ! -f tmp/saas.txt ]; then - echo "Error: --apns requires SaaS mode. Run: bin/rails saas:enable" >&2 - exit 1 + echo "Enabling SaaS mode for APNs..." + ./bin/rails saas:enable fi echo "Loading APNs credentials from 1Password..." eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec apns-dev)" From 1824d2e2b9e22524d068f5158b9a5f831dc6e5bf Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 21:42:08 -0600 Subject: [PATCH 24/72] Update bin/dev to correctly load apns --- bin/dev | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bin/dev b/bin/dev index 80d413eb61..cd8101659f 100755 --- a/bin/dev +++ b/bin/dev @@ -17,7 +17,14 @@ if [ "$USE_APNS" = "1" ]; then ./bin/rails saas:enable fi echo "Loading APNs credentials from 1Password..." - eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec apns-dev)" + if ! eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec apns-dev)"; then + echo "Error: failed to load APNs credentials. Are you signed into 1Password?" >&2 + exit 1 + fi + if [ -z "$APNS_ENCRYPTION_KEY" ] || [ -z "$APNS_KEY_ID" ]; then + echo "Error: APNs credentials not set. Missing APNS_ENCRYPTION_KEY or APNS_KEY_ID." >&2 + exit 1 + fi fi if [ ! -f tmp/solid-queue.txt ]; then From db28aabd9efa71e0a308c11f284bb389ac70a57d Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 22:08:50 -0600 Subject: [PATCH 25/72] Use prepend to override functions and update initializer for fizzy-saas --- saas/lib/fizzy/saas/engine.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 7f56f2c387..4c0b1361cb 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -17,8 +17,8 @@ class Engine < ::Rails::Engine app.config.assets.paths << root.join("app/assets/stylesheets") end - initializer "fizzy_saas.push_config", before: "action_push_native.config" do |app| - app.paths.add "config/push", with: root.join("config/push.yml") + initializer "fizzy_saas.push_config", after: "action_push_native.config" do |app| + app.paths["config/push"].unshift(root.join("config/push.yml").to_s) end initializer "fizzy.saas.routes", after: :add_routing_paths do |app| @@ -142,7 +142,7 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::User.include User::Devices - ::NotificationPusher.include NotificationPusher::Native + ::NotificationPusher.prepend NotificationPusher::Native ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) Cards::PublishesController.include(Card::LimitedPublishing) From eb6c53f66b2fb75339ee888c89c3b5644ac3f52a Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 22:09:15 -0600 Subject: [PATCH 26/72] Add missing table --- db/schema.rb | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 86b9f29375..4942a5da0c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -68,6 +68,19 @@ t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end + create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.uuid "owner_id" + t.string "owner_type" + t.string "platform", null: false + t.string "token", null: false + t.datetime "updated_at", null: false + t.string "uuid", null: false + t.index ["owner_type", "owner_id", "uuid"], name: "idx_on_owner_type_owner_id_uuid_a42e3920d5", unique: true + t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" + end + create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.text "body", size: :long From a0d6cb88b697178c0210555a228cff9868526e43 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 22:13:32 -0600 Subject: [PATCH 27/72] Update the parsing of the certificate via 1Password --- saas/config/push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/config/push.yml b/saas/config/push.yml index fcc9e2ffb9..309f414706 100644 --- a/saas/config/push.yml +++ b/saas/config/push.yml @@ -1,7 +1,7 @@ shared: apple: key_id: <%= ENV["APNS_KEY_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %> - encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :apns, :key))&.dump %> + encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY"]&.gsub("\\n", "\n") || Rails.application.credentials.dig(:action_push_native, :apns, :key))&.dump %> team_id: <%= ENV["APNS_TEAM_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :team_id) || "2WNYUYRS7G" %> topic: <%= ENV["APNS_TOPIC"] || Rails.application.credentials.dig(:action_push_native, :apns, :topic) || "do.fizzy.app.ios" %> connect_to_development_server: <%= Rails.env.local? %> From 77f56388d9fc43e89f823a023b359602bbec6931 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 22:19:42 -0600 Subject: [PATCH 28/72] Update config/push.yml --- config/push.yml | 7 +++++++ saas/app/models/application_push_notification.rb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 config/push.yml diff --git a/config/push.yml b/config/push.yml new file mode 100644 index 0000000000..86d4183fa3 --- /dev/null +++ b/config/push.yml @@ -0,0 +1,7 @@ +<% if Fizzy.saas? %> +<%= ERB.new(File.read(Rails.root.join("saas/config/push.yml"))).result %> +<% else %> +shared: + apple: {} + google: {} +<% end %> diff --git a/saas/app/models/application_push_notification.rb b/saas/app/models/application_push_notification.rb index 7c0594c50a..a0b5e3ee59 100644 --- a/saas/app/models/application_push_notification.rb +++ b/saas/app/models/application_push_notification.rb @@ -1,4 +1,4 @@ class ApplicationPushNotification < ActionPushNative::Notification queue_as :default - self.enabled = !Rails.env.local? || ENV["ENABLE_NATIVE_PUSH"] == "true" + self.enabled = Fizzy.saas? && (!Rails.env.local? || ENV["ENABLE_NATIVE_PUSH"] == "true") end From 681827f0867174f33da8ab2cb5700e56e47b7f80 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 22:34:14 -0600 Subject: [PATCH 29/72] Update secrets to fetch APNS from 1Password --- saas/.kamal/secrets.beta | 6 +++++- saas/.kamal/secrets.production | 6 +++++- saas/.kamal/secrets.staging | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 423ef11fb5..bf70f829c6 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_ENCRYPTION_KEY Beta/APNS_KEY_ID Beta/APNS_TEAM_ID Beta/APNS_TOPIC) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,3 +25,7 @@ STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $S STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) +APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) +APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) +APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) +APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 46d7abfbcb..5b4bb4b121 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_ENCRYPTION_KEY Production/APNS_KEY_ID Production/APNS_TEAM_ID Production/APNS_TOPIC) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,3 +25,7 @@ STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $S STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) +APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) +APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) +APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) +APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 31979d1704..a7330e1572 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET Staging/APNS_ENCRYPTION_KEY Staging/APNS_KEY_ID Staging/APNS_TEAM_ID Staging/APNS_TOPIC) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,3 +25,7 @@ STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $S STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) +APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) +APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) +APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) +APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) From 04bc368d25637aaee0902d0432d7fe712254edc4 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 16 Jan 2026 10:39:53 -0600 Subject: [PATCH 30/72] Clean up devices controller --- .../controllers/users/devices_controller.rb | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index 6b49cf7c31..3b2f8752a7 100644 --- a/saas/app/controllers/users/devices_controller.rb +++ b/saas/app/controllers/users/devices_controller.rb @@ -1,29 +1,30 @@ class Users::DevicesController < ApplicationController + before_action :set_devices + def index - @devices = Current.user.devices.order(created_at: :desc) end def create - attrs = device_params - device = Current.user.devices.find_or_initialize_by(uuid: attrs[:uuid]) - device.update!(token: attrs[:token], name: attrs[:name], platform: attrs[:platform]) + device = @devices.find_or_initialize_by(uuid: params.require(:uuid)) + device.update!(device_params) head :created - rescue ActiveRecord::RecordInvalid - head :unprocessable_entity + rescue ArgumentError + head :bad_request end def destroy - Current.user.devices.find_by(id: params[:id])&.destroy + @devices.destroy_by(id: params[:id]) redirect_to users_devices_path, notice: "Device removed" end private + def set_devices + @devices = Current.user.devices.order(created_at: :desc) + end + def device_params - params.permit(:uuid, :token, :platform, :name).tap do |p| - p[:platform] = p[:platform].to_s.downcase - raise ActionController::BadRequest unless p[:platform].in?(%w[apple google]) - raise ActionController::BadRequest if p[:uuid].blank? - raise ActionController::BadRequest if p[:token].blank? + params.permit(:token, :platform, :name).tap do |permitted| + permitted[:platform] = permitted[:platform].to_s.downcase if permitted[:platform].present? end end end From 6153a0e01f3068ddcfe4e7a6e9a3023fa2a63b35 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 00:00:47 -0600 Subject: [PATCH 31/72] Update local script to load Firebase key --- saas/exe/apns-dev | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev index 02bd28ad18..a864249ad0 100755 --- a/saas/exe/apns-dev +++ b/saas/exe/apns-dev @@ -1,36 +1,48 @@ #!/usr/bin/env ruby # -# Fetches APNs development environment variables from 1Password. +# Fetches APNs and FCM development environment variables from 1Password. # # Usage: eval "$(bundle exec apns-dev)" OP_ACCOUNT = "23QPQDKZC5BKBIIG7UGT5GR5RM" OP_VAULT = "Mobile" -OP_ITEM = "37signals Push Notifications key" +OP_APNS_ITEM = "37signals Push Notifications key" +OP_FCM_ITEM = "Fizzy Firebase Push Notification Private Key" -def op_read(field) - `op read "op://#{OP_VAULT}/#{OP_ITEM}/#{field}" --account #{OP_ACCOUNT} 2>/dev/null`.strip +def op_read(item, field) + `op read "op://#{OP_VAULT}/#{item}/#{field}" --account #{OP_ACCOUNT} 2>/dev/null`.strip end -key_id = op_read("key ID") -team_id = op_read("team ID") -encryption_key = op_read("AuthKey_3CR5J2W8Q6.p8") +# APNs credentials +apns_key_id = op_read(OP_APNS_ITEM, "key ID") +apns_team_id = op_read(OP_APNS_ITEM, "team ID") +apns_encryption_key = op_read(OP_APNS_ITEM, "AuthKey_3CR5J2W8Q6.p8") -if key_id.empty? || encryption_key.empty? +# FCM credentials (JSON file attachment) +fcm_encryption_key = `op document get "#{OP_FCM_ITEM}" --vault "#{OP_VAULT}" --account #{OP_ACCOUNT} 2>/dev/null`.strip + +if apns_key_id.empty? || apns_encryption_key.empty? warn "Error: Could not fetch APNs credentials from 1Password" warn "Make sure you're signed in: op signin --account #{OP_ACCOUNT}" exit 1 end -puts %Q(export APNS_KEY_ID="#{key_id}") -puts %Q(export APNS_TEAM_ID="#{team_id}") -puts %Q(export APNS_ENCRYPTION_KEY="#{encryption_key.gsub("\n", "\\n")}") +if fcm_encryption_key.empty? + warn "Warning: Could not fetch FCM credentials from 1Password" + warn "Android push notifications will not work" +end + +puts %Q(export APNS_KEY_ID="#{apns_key_id}") +puts %Q(export APNS_TEAM_ID="#{apns_team_id}") +puts %Q(export APNS_ENCRYPTION_KEY="#{apns_encryption_key.gsub("\n", "\\n")}") puts %Q(export APNS_TOPIC="do.fizzy.app.ios") +puts %Q(export FCM_ENCRYPTION_KEY='#{fcm_encryption_key}') puts %Q(export ENABLE_NATIVE_PUSH="true") warn "" -warn "APNs credentials loaded for development" -warn " Key ID: #{key_id}" -warn " Team ID: #{team_id}" -warn " Topic: do.fizzy.app.ios" +warn "Push notification credentials loaded for development" +warn " APNs Key ID: #{apns_key_id}" +warn " APNs Team ID: #{apns_team_id}" +warn " APNs Topic: do.fizzy.app.ios" +warn " FCM: #{fcm_encryption_key.empty? ? "not configured" : "configured"}" warn " Native push: enabled" From 55331c2c8bc6a975bc3efe980762c4c0165690e9 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 00:01:47 -0600 Subject: [PATCH 32/72] Update Firebase projectId in push.yml --- saas/config/push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/config/push.yml b/saas/config/push.yml index 309f414706..99c1196704 100644 --- a/saas/config/push.yml +++ b/saas/config/push.yml @@ -7,4 +7,4 @@ shared: connect_to_development_server: <%= Rails.env.local? %> google: encryption_key: <%= (ENV["FCM_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :fcm, :key))&.dump %> - project_id: your-firebase-project # Your Firebase project ID (not secret) + project_id: fizzy-a148c From 70aefca11a04e5de4ba705df3d85d3d9ccd26b04 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 00:09:40 -0600 Subject: [PATCH 33/72] Update name of the private key --- saas/exe/apns-dev | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev index a864249ad0..e2ad0147f5 100755 --- a/saas/exe/apns-dev +++ b/saas/exe/apns-dev @@ -19,7 +19,7 @@ apns_team_id = op_read(OP_APNS_ITEM, "team ID") apns_encryption_key = op_read(OP_APNS_ITEM, "AuthKey_3CR5J2W8Q6.p8") # FCM credentials (JSON file attachment) -fcm_encryption_key = `op document get "#{OP_FCM_ITEM}" --vault "#{OP_VAULT}" --account #{OP_ACCOUNT} 2>/dev/null`.strip +fcm_encryption_key = op_read(OP_FCM_ITEM, "fizzy-a148c-firebase-adminsdk-fbsvc-bdc640ce13.json") if apns_key_id.empty? || apns_encryption_key.empty? warn "Error: Could not fetch APNs credentials from 1Password" @@ -36,7 +36,7 @@ puts %Q(export APNS_KEY_ID="#{apns_key_id}") puts %Q(export APNS_TEAM_ID="#{apns_team_id}") puts %Q(export APNS_ENCRYPTION_KEY="#{apns_encryption_key.gsub("\n", "\\n")}") puts %Q(export APNS_TOPIC="do.fizzy.app.ios") -puts %Q(export FCM_ENCRYPTION_KEY='#{fcm_encryption_key}') +puts %Q(export FCM_ENCRYPTION_KEY='#{fcm_encryption_key.gsub("'", "'\\\\''")}') puts %Q(export ENABLE_NATIVE_PUSH="true") warn "" From d1e47ec8e6b3e93b59a6b0f648481d429aea2b83 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 20 Jan 2026 20:52:37 +0100 Subject: [PATCH 34/72] Use version of `action_push_native` with proper config paths support See https://github.com/rails/action_push_native/pull/89 Now we can delete the OSS version of `config/push.yml`, no longer needed. --- Gemfile.saas | 2 +- Gemfile.saas.lock | 22 ++++++++++++++-------- config/push.yml | 7 ------- 3 files changed, 15 insertions(+), 16 deletions(-) delete mode 100644 config/push.yml diff --git a/Gemfile.saas b/Gemfile.saas index 88eb2b4248..2f131d1e46 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -11,7 +11,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" # Native push notifications (iOS/Android) -gem "action_push_native" +gem "action_push_native", github: "rails/action_push_native", branch: "use-registered-config-path" # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 651cbde877..e021b7da56 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -58,6 +58,19 @@ GIT rails (>= 6.1) yabeda (~> 0.6) +GIT + remote: https://github.com/rails/action_push_native.git + revision: d5c44514e13faf919261ca7943fc43aedd8e992e + branch: use-registered-config-path + specs: + action_push_native (0.3.0) + activejob (>= 8.0) + activerecord (>= 8.0) + googleauth (~> 1.14) + httpx (~> 1.6) + jwt (>= 2) + railties (>= 8.0) + GIT remote: https://github.com/rails/rails.git revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8 @@ -184,13 +197,6 @@ PATH GEM remote: https://rubygems.org/ specs: - action_push_native (0.3.0) - activejob (>= 8.0) - activerecord (>= 8.0) - googleauth (~> 1.14) - httpx (~> 1.6) - jwt (>= 2) - railties (>= 8.0) action_text-trix (2.1.16) railties activemodel-serializers-xml (1.0.3) @@ -676,7 +682,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - action_push_native + action_push_native! activeresource audits1984! autotuner diff --git a/config/push.yml b/config/push.yml deleted file mode 100644 index 86d4183fa3..0000000000 --- a/config/push.yml +++ /dev/null @@ -1,7 +0,0 @@ -<% if Fizzy.saas? %> -<%= ERB.new(File.read(Rails.root.join("saas/config/push.yml"))).result %> -<% else %> -shared: - apple: {} - google: {} -<% end %> From d353f5cc0dc03eec2501a7ccd5d81aa4713173ee Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 22:44:06 -0600 Subject: [PATCH 35/72] Remove UUID requirement from push notification device registration - Make UUID column nullable in devices table - Update unique index from (owner, uuid) to (owner, token) - Simplify create action to just create device records - Add token-based unregister route for API clients - Consolidate error handling with rescue_from Devices are now identified by (owner, token) instead of UUID. This simplifies the client-side registration flow. Co-Authored-By: Claude Opus 4.5 --- ...03313_create_action_push_native_devices.rb | 4 +- db/queue_schema.rb | 1 - db/schema.rb | 6 +- .../controllers/users/devices_controller.rb | 20 +++- saas/lib/fizzy/saas/engine.rb | 4 +- .../users/devices_controller_test.rb | 113 ++++++++++-------- 6 files changed, 84 insertions(+), 64 deletions(-) diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb index e2742dae7a..408d0e3234 100644 --- a/db/migrate/20260114203313_create_action_push_native_devices.rb +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -1,7 +1,7 @@ class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] def change create_table :action_push_native_devices do |t| - t.string :uuid, null: false + t.string :uuid t.string :name t.string :platform, null: false t.string :token, null: false @@ -10,6 +10,6 @@ def change t.timestamps end - add_index :action_push_native_devices, [ :owner_type, :owner_id, :uuid ], unique: true + add_index :action_push_native_devices, [ :owner_type, :owner_id, :token ], unique: true end end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 84bc6b8a6f..c4713f8d13 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -132,7 +132,6 @@ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end - # If these FKs are removed, make sure to periodically run `RecurringExecution.clear_in_batches` add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb index 4942a5da0c..20530c0682 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do +ActiveRecord::Schema[8.2].define(version: 2026_01_21_044252) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -76,8 +76,8 @@ t.string "platform", null: false t.string "token", null: false t.datetime "updated_at", null: false - t.string "uuid", null: false - t.index ["owner_type", "owner_id", "uuid"], name: "idx_on_owner_type_owner_id_uuid_a42e3920d5", unique: true + t.string "uuid" + t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" end diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index 3b2f8752a7..9355024083 100644 --- a/saas/app/controllers/users/devices_controller.rb +++ b/saas/app/controllers/users/devices_controller.rb @@ -1,20 +1,24 @@ class Users::DevicesController < ApplicationController before_action :set_devices + rescue_from ActiveRecord::NotNullViolation, ArgumentError, with: :bad_request + def index end def create - device = @devices.find_or_initialize_by(uuid: params.require(:uuid)) - device.update!(device_params) + @devices.create!(device_params) head :created - rescue ArgumentError - head :bad_request end def destroy - @devices.destroy_by(id: params[:id]) - redirect_to users_devices_path, notice: "Device removed" + if params[:token].present? + @devices.destroy_by(token: params[:token]) + head :no_content + else + @devices.destroy_by(id: params[:id]) + redirect_to users_devices_path, notice: "Device removed" + end end private @@ -27,4 +31,8 @@ def device_params permitted[:platform] = permitted[:platform].to_s.downcase if permitted[:platform].present? end end + + def bad_request + head :bad_request + end end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 4c0b1361cb..fad1c35949 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -39,7 +39,9 @@ class Engine < ::Rails::Engine end namespace :users do - resources :devices, only: [ :index, :create, :destroy ] + resources :devices, only: [ :index, :create, :destroy ] do + delete :destroy, on: :collection, as: :unregister + end end end end diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/users/devices_controller_test.rb index 46da8ce7f9..08383c3fea 100644 --- a/saas/test/controllers/users/devices_controller_test.rb +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -9,7 +9,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Index (Web) === test "index shows user devices" do - @user.devices.create!(uuid: "test-uuid", token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") + @user.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") get users_devices_path @@ -38,12 +38,10 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Create (API) === test "creates a new device via api" do - uuid = SecureRandom.uuid token = SecureRandom.hex(32) assert_difference "ActionPushNative::Device.count", 1 do post users_devices_path, params: { - uuid: uuid, token: token, platform: "apple", name: "iPhone 15 Pro" @@ -53,7 +51,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :created device = ActionPushNative::Device.last - assert_equal uuid, device.uuid assert_equal token, device.token assert_equal "apple", device.platform assert_equal "iPhone 15 Pro", device.name @@ -62,7 +59,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "creates android device" do post users_devices_path, params: { - uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "google", name: "Pixel 8" @@ -74,36 +70,12 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_equal "google", device.platform end - test "updates existing device with same uuid" do - existing_device = @user.devices.create!( - uuid: "my-device-uuid", - token: "old_token", - platform: "apple", - name: "Old iPhone" - ) - - assert_no_difference "ActionPushNative::Device.count" do - post users_devices_path, params: { - uuid: "my-device-uuid", - token: "new_token", - platform: "apple", - name: "New iPhone" - }, as: :json - end - - assert_response :created - existing_device.reload - assert_equal "new_token", existing_device.token - assert_equal "New iPhone", existing_device.name - end - test "same token can be registered by multiple users" do shared_token = "shared_push_token_123" other_user = users(:kevin) # Other user registers the token first other_device = other_user.devices.create!( - uuid: "kevins-device-uuid", token: shared_token, platform: "apple", name: "Kevin's iPhone" @@ -112,7 +84,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # Current user registers the same token with their own device assert_difference "ActionPushNative::Device.count", 1 do post users_devices_path, params: { - uuid: "davids-device-uuid", token: shared_token, platform: "apple", name: "David's iPhone" @@ -125,14 +96,13 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_equal shared_token, other_device.reload.token assert_equal other_user, other_device.owner - davids_device = @user.devices.find_by(uuid: "davids-device-uuid") + davids_device = @user.devices.last assert_equal shared_token, davids_device.token assert_equal @user, davids_device.owner end test "rejects invalid platform" do post users_devices_path, params: { - uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "windows", name: "Surface" @@ -141,19 +111,8 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :bad_request end - test "rejects missing uuid" do - post users_devices_path, params: { - token: SecureRandom.hex(32), - platform: "apple", - name: "iPhone" - }, as: :json - - assert_response :bad_request - end - test "rejects missing token" do post users_devices_path, params: { - uuid: SecureRandom.uuid, platform: "apple", name: "iPhone" }, as: :json @@ -165,7 +124,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest sign_out post users_devices_path, params: { - uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "apple" }, as: :json @@ -175,9 +133,8 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Destroy (Web) === - test "destroys device" do + test "destroys device by id" do device = @user.devices.create!( - uuid: "device-to-delete", token: "token_to_delete", platform: "apple", name: "iPhone" @@ -191,7 +148,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_not ActionPushNative::Device.exists?(device.id) end - test "does nothing when device not found" do + test "does nothing when device not found by id" do assert_no_difference "ActionPushNative::Device.count" do delete users_device_path(id: "nonexistent") end @@ -199,10 +156,9 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to users_devices_path end - test "cannot destroy another user's device" do + test "cannot destroy another user's device by id" do other_user = users(:kevin) device = other_user.devices.create!( - uuid: "other-users-device", token: "other_users_token", platform: "apple", name: "Other iPhone" @@ -216,9 +172,8 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert ActionPushNative::Device.exists?(device.id) end - test "destroy requires authentication" do + test "destroy by id requires authentication" do device = @user.devices.create!( - uuid: "my-device", token: "my_token", platform: "apple", name: "iPhone" @@ -231,4 +186,60 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :redirect assert ActionPushNative::Device.exists?(device.id) end + + # === Destroy by Token (API) === + + test "destroys device by token" do + device = @user.devices.create!( + token: "token_to_unregister", + platform: "apple", + name: "iPhone" + ) + + assert_difference "ActionPushNative::Device.count", -1 do + delete unregister_users_devices_path, params: { token: "token_to_unregister" }, as: :json + end + + assert_response :no_content + assert_not ActionPushNative::Device.exists?(device.id) + end + + test "does nothing when device not found by token" do + assert_no_difference "ActionPushNative::Device.count" do + delete unregister_users_devices_path, params: { token: "nonexistent_token" }, as: :json + end + + assert_response :no_content + end + + test "cannot destroy another user's device by token" do + other_user = users(:kevin) + device = other_user.devices.create!( + token: "other_users_token", + platform: "apple", + name: "Other iPhone" + ) + + assert_no_difference "ActionPushNative::Device.count" do + delete unregister_users_devices_path, params: { token: "other_users_token" }, as: :json + end + + assert_response :no_content + assert ActionPushNative::Device.exists?(device.id) + end + + test "destroy by token requires authentication" do + device = @user.devices.create!( + token: "my_token", + platform: "apple", + name: "iPhone" + ) + + sign_out + + delete unregister_users_devices_path, params: { token: "my_token" }, as: :json + + assert_response :redirect + assert ActionPushNative::Device.exists?(device.id) + end end From f9afbecd2c47d2924aa2c8329fa73a308781c9ae Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 22:52:01 -0600 Subject: [PATCH 36/72] Add title/body to android notifications too --- saas/app/models/notification_pusher/native.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb index 237697f920..cb03eec435 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -35,6 +35,8 @@ def native_notification(payload) android: { notification: nil } ) .with_data( + title: payload[:title], + body: payload[:body], path: payload[:path], account_id: notification.account.external_account_id, avatar_url: creator_avatar_url, From cc2e7f8c87809ef76ee3f6c483b5264cb6c2030f Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 22:44:06 -0600 Subject: [PATCH 37/72] Remove UUID requirement from push notification device registration - Remove UUID column from devices table entirely - Update unique index from (owner, uuid) to (owner, token) - Simplify create action to just create device records - Add token-based unregister route for API clients - Consolidate error handling with rescue_from - Update fixtures to remove uuid references Devices are now identified by (owner, token) instead of UUID. This simplifies the client-side registration flow. Co-Authored-By: Claude Opus 4.5 --- ...03313_create_action_push_native_devices.rb | 3 +- db/queue_schema.rb | 1 - db/schema.rb | 5 +- .../controllers/users/devices_controller.rb | 20 +++- saas/lib/fizzy/saas/engine.rb | 4 +- .../users/devices_controller_test.rb | 113 ++++++++++-------- .../fixtures/action_push_native/devices.yml | 3 - 7 files changed, 82 insertions(+), 67 deletions(-) diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb index e2742dae7a..5ef4be322a 100644 --- a/db/migrate/20260114203313_create_action_push_native_devices.rb +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -1,7 +1,6 @@ class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] def change create_table :action_push_native_devices do |t| - t.string :uuid, null: false t.string :name t.string :platform, null: false t.string :token, null: false @@ -10,6 +9,6 @@ def change t.timestamps end - add_index :action_push_native_devices, [ :owner_type, :owner_id, :uuid ], unique: true + add_index :action_push_native_devices, [ :owner_type, :owner_id, :token ], unique: true end end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 84bc6b8a6f..c4713f8d13 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -132,7 +132,6 @@ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end - # If these FKs are removed, make sure to periodically run `RecurringExecution.clear_in_batches` add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb index 4942a5da0c..62f34756ef 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do +ActiveRecord::Schema[8.2].define(version: 2026_01_21_044252) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -76,8 +76,7 @@ t.string "platform", null: false t.string "token", null: false t.datetime "updated_at", null: false - t.string "uuid", null: false - t.index ["owner_type", "owner_id", "uuid"], name: "idx_on_owner_type_owner_id_uuid_a42e3920d5", unique: true + t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" end diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index 3b2f8752a7..9355024083 100644 --- a/saas/app/controllers/users/devices_controller.rb +++ b/saas/app/controllers/users/devices_controller.rb @@ -1,20 +1,24 @@ class Users::DevicesController < ApplicationController before_action :set_devices + rescue_from ActiveRecord::NotNullViolation, ArgumentError, with: :bad_request + def index end def create - device = @devices.find_or_initialize_by(uuid: params.require(:uuid)) - device.update!(device_params) + @devices.create!(device_params) head :created - rescue ArgumentError - head :bad_request end def destroy - @devices.destroy_by(id: params[:id]) - redirect_to users_devices_path, notice: "Device removed" + if params[:token].present? + @devices.destroy_by(token: params[:token]) + head :no_content + else + @devices.destroy_by(id: params[:id]) + redirect_to users_devices_path, notice: "Device removed" + end end private @@ -27,4 +31,8 @@ def device_params permitted[:platform] = permitted[:platform].to_s.downcase if permitted[:platform].present? end end + + def bad_request + head :bad_request + end end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 4c0b1361cb..fad1c35949 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -39,7 +39,9 @@ class Engine < ::Rails::Engine end namespace :users do - resources :devices, only: [ :index, :create, :destroy ] + resources :devices, only: [ :index, :create, :destroy ] do + delete :destroy, on: :collection, as: :unregister + end end end end diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/users/devices_controller_test.rb index 46da8ce7f9..08383c3fea 100644 --- a/saas/test/controllers/users/devices_controller_test.rb +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -9,7 +9,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Index (Web) === test "index shows user devices" do - @user.devices.create!(uuid: "test-uuid", token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") + @user.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") get users_devices_path @@ -38,12 +38,10 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Create (API) === test "creates a new device via api" do - uuid = SecureRandom.uuid token = SecureRandom.hex(32) assert_difference "ActionPushNative::Device.count", 1 do post users_devices_path, params: { - uuid: uuid, token: token, platform: "apple", name: "iPhone 15 Pro" @@ -53,7 +51,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :created device = ActionPushNative::Device.last - assert_equal uuid, device.uuid assert_equal token, device.token assert_equal "apple", device.platform assert_equal "iPhone 15 Pro", device.name @@ -62,7 +59,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "creates android device" do post users_devices_path, params: { - uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "google", name: "Pixel 8" @@ -74,36 +70,12 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_equal "google", device.platform end - test "updates existing device with same uuid" do - existing_device = @user.devices.create!( - uuid: "my-device-uuid", - token: "old_token", - platform: "apple", - name: "Old iPhone" - ) - - assert_no_difference "ActionPushNative::Device.count" do - post users_devices_path, params: { - uuid: "my-device-uuid", - token: "new_token", - platform: "apple", - name: "New iPhone" - }, as: :json - end - - assert_response :created - existing_device.reload - assert_equal "new_token", existing_device.token - assert_equal "New iPhone", existing_device.name - end - test "same token can be registered by multiple users" do shared_token = "shared_push_token_123" other_user = users(:kevin) # Other user registers the token first other_device = other_user.devices.create!( - uuid: "kevins-device-uuid", token: shared_token, platform: "apple", name: "Kevin's iPhone" @@ -112,7 +84,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # Current user registers the same token with their own device assert_difference "ActionPushNative::Device.count", 1 do post users_devices_path, params: { - uuid: "davids-device-uuid", token: shared_token, platform: "apple", name: "David's iPhone" @@ -125,14 +96,13 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_equal shared_token, other_device.reload.token assert_equal other_user, other_device.owner - davids_device = @user.devices.find_by(uuid: "davids-device-uuid") + davids_device = @user.devices.last assert_equal shared_token, davids_device.token assert_equal @user, davids_device.owner end test "rejects invalid platform" do post users_devices_path, params: { - uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "windows", name: "Surface" @@ -141,19 +111,8 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :bad_request end - test "rejects missing uuid" do - post users_devices_path, params: { - token: SecureRandom.hex(32), - platform: "apple", - name: "iPhone" - }, as: :json - - assert_response :bad_request - end - test "rejects missing token" do post users_devices_path, params: { - uuid: SecureRandom.uuid, platform: "apple", name: "iPhone" }, as: :json @@ -165,7 +124,6 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest sign_out post users_devices_path, params: { - uuid: SecureRandom.uuid, token: SecureRandom.hex(32), platform: "apple" }, as: :json @@ -175,9 +133,8 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # === Destroy (Web) === - test "destroys device" do + test "destroys device by id" do device = @user.devices.create!( - uuid: "device-to-delete", token: "token_to_delete", platform: "apple", name: "iPhone" @@ -191,7 +148,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_not ActionPushNative::Device.exists?(device.id) end - test "does nothing when device not found" do + test "does nothing when device not found by id" do assert_no_difference "ActionPushNative::Device.count" do delete users_device_path(id: "nonexistent") end @@ -199,10 +156,9 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to users_devices_path end - test "cannot destroy another user's device" do + test "cannot destroy another user's device by id" do other_user = users(:kevin) device = other_user.devices.create!( - uuid: "other-users-device", token: "other_users_token", platform: "apple", name: "Other iPhone" @@ -216,9 +172,8 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert ActionPushNative::Device.exists?(device.id) end - test "destroy requires authentication" do + test "destroy by id requires authentication" do device = @user.devices.create!( - uuid: "my-device", token: "my_token", platform: "apple", name: "iPhone" @@ -231,4 +186,60 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :redirect assert ActionPushNative::Device.exists?(device.id) end + + # === Destroy by Token (API) === + + test "destroys device by token" do + device = @user.devices.create!( + token: "token_to_unregister", + platform: "apple", + name: "iPhone" + ) + + assert_difference "ActionPushNative::Device.count", -1 do + delete unregister_users_devices_path, params: { token: "token_to_unregister" }, as: :json + end + + assert_response :no_content + assert_not ActionPushNative::Device.exists?(device.id) + end + + test "does nothing when device not found by token" do + assert_no_difference "ActionPushNative::Device.count" do + delete unregister_users_devices_path, params: { token: "nonexistent_token" }, as: :json + end + + assert_response :no_content + end + + test "cannot destroy another user's device by token" do + other_user = users(:kevin) + device = other_user.devices.create!( + token: "other_users_token", + platform: "apple", + name: "Other iPhone" + ) + + assert_no_difference "ActionPushNative::Device.count" do + delete unregister_users_devices_path, params: { token: "other_users_token" }, as: :json + end + + assert_response :no_content + assert ActionPushNative::Device.exists?(device.id) + end + + test "destroy by token requires authentication" do + device = @user.devices.create!( + token: "my_token", + platform: "apple", + name: "iPhone" + ) + + sign_out + + delete unregister_users_devices_path, params: { token: "my_token" }, as: :json + + assert_response :redirect + assert ActionPushNative::Device.exists?(device.id) + end end diff --git a/saas/test/fixtures/action_push_native/devices.yml b/saas/test/fixtures/action_push_native/devices.yml index 0494d2a973..7601d52849 100644 --- a/saas/test/fixtures/action_push_native/devices.yml +++ b/saas/test/fixtures/action_push_native/devices.yml @@ -1,19 +1,16 @@ davids_iphone: - uuid: device-uuid-davids-iphone name: iPhone 15 Pro token: abc123def456abc123def456abc123def456abc123def456abc123def456abcd platform: apple owner: david (User) davids_pixel: - uuid: device-uuid-davids-pixel name: Pixel 8 token: def456abc123def456abc123def456abc123def456abc123def456abc123defg platform: google owner: david (User) kevins_iphone: - uuid: device-uuid-kevins-iphone name: iPhone 14 token: 789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7890 platform: apple From 3cc758079e5114548e80b8e83f5d22a648e44ff5 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 23:59:15 -0600 Subject: [PATCH 38/72] Send the URL instead of path in notifications --- app/models/notification_pusher.rb | 25 ++++++++++++------- lib/web_push/notification.rb | 6 ++--- saas/app/models/notification_pusher/native.rb | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index 1b50142bdc..12b4965bdd 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -45,7 +45,7 @@ def build_event_payload base_payload = { title: card_notification_title(card), - path: card_path(card) + url: card_url(card) } case event.action @@ -53,7 +53,7 @@ def build_event_payload base_payload.merge( title: "RE: #{base_payload[:title]}", body: comment_notification_body(event), - path: card_path_with_comment_anchor(event.eventable) + url: card_url_with_comment_anchor(event.eventable) ) when "card_assigned" base_payload.merge( @@ -85,7 +85,7 @@ def build_mention_payload { title: "#{mention.mentioner.first_name} mentioned you", body: format_excerpt(mention.source.mentionable_content, length: 200), - path: card_path(card) + url: card_url(card) } end @@ -93,7 +93,7 @@ def build_default_payload { title: "New notification", body: "You have a new notification", - path: notifications_path(script_name: notification.account.slug) + url: notifications_url(**url_options) } end @@ -114,15 +114,22 @@ def comment_notification_body(event) format_excerpt(event.eventable.body, length: 200) end - def card_path(card) - Rails.application.routes.url_helpers.card_path(card, script_name: notification.account.slug) + def card_url(card) + Rails.application.routes.url_helpers.card_url(card, **url_options) end - def card_path_with_comment_anchor(comment) - Rails.application.routes.url_helpers.card_path( + def card_url_with_comment_anchor(comment) + Rails.application.routes.url_helpers.card_url( comment.card, anchor: ActionView::RecordIdentifier.dom_id(comment), - script_name: notification.account.slug + **url_options ) end + + def url_options + base_options = Rails.application.routes.default_url_options.presence || + Rails.application.config.action_mailer.default_url_options || + {} + base_options.merge(script_name: notification.account.slug) + end end diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb index 4c873fb48f..c01caa9535 100644 --- a/lib/web_push/notification.rb +++ b/lib/web_push/notification.rb @@ -1,6 +1,6 @@ class WebPush::Notification - def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:) - @title, @body, @path, @badge = title, body, path, badge + def initialize(title:, body:, url:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:) + @title, @body, @url, @badge = title, body, url, badge @endpoint, @endpoint_ip, @p256dh_key, @auth_key = endpoint, endpoint_ip, p256dh_key, auth_key end @@ -20,7 +20,7 @@ def vapid_identification end def encoded_message - JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } } + JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { url: @url, badge: @badge } } end def icon_path diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb index 237697f920..27e29397da 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -35,7 +35,7 @@ def native_notification(payload) android: { notification: nil } ) .with_data( - path: payload[:path], + url: payload[:url], account_id: notification.account.external_account_id, avatar_url: creator_avatar_url, card_id: card&.id, From ab1356bf8cda85a33bae7ae903694ce92e0156a4 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 20 Jan 2026 21:40:25 +0100 Subject: [PATCH 39/72] Refactor devices controller and extract registration to model - Remove Users namespace from DevicesController (now just DevicesController) - Create ApplicationPushDevice model extending ActionPushNative::Device - Move device registration logic (find_or_initialize + update) to model - Update User::Devices concern to use ApplicationPushDevice - Fix push notification tests (endpoint validation, job count expectations) - Update push_config_test to use ActionPushNative.config Co-Authored-By: Claude Opus 4.5 --- saas/app/controllers/devices_controller.rb | 28 +++++++++++++ saas/app/models/application_push_device.rb | 7 ++++ saas/app/models/user/devices.rb | 2 +- .../views/{users => }/devices/index.html.erb | 2 +- .../settings/_native_devices.html.erb | 2 +- saas/lib/fizzy/saas/engine.rb | 6 +-- .../{users => }/devices_controller_test.rb | 42 +++++++++---------- saas/test/models/notification_pusher_test.rb | 33 ++++++++------- saas/test/models/push_config_test.rb | 8 ++-- 9 files changed, 82 insertions(+), 48 deletions(-) create mode 100644 saas/app/controllers/devices_controller.rb create mode 100644 saas/app/models/application_push_device.rb rename saas/app/views/{users => }/devices/index.html.erb (78%) rename saas/test/controllers/{users => }/devices_controller_test.rb (82%) diff --git a/saas/app/controllers/devices_controller.rb b/saas/app/controllers/devices_controller.rb new file mode 100644 index 0000000000..fd4b7ed830 --- /dev/null +++ b/saas/app/controllers/devices_controller.rb @@ -0,0 +1,28 @@ +class DevicesController < ApplicationController + def index + @devices = Current.user.devices.order(created_at: :desc) + end + + def create + ApplicationPushDevice.register(owner: Current.user, **device_params) + head :created + rescue ArgumentError + head :bad_request + end + + def destroy + if params[:token].present? + Current.user.devices.destroy_by(token: params[:token]) + head :no_content + else + Current.user.devices.destroy_by(id: params[:id]) + redirect_to devices_path, notice: "Device removed" + end + end + + private + def device_params + params.require([ :token, :platform ]) + params.permit(:token, :platform, :name).to_h.symbolize_keys + end +end diff --git a/saas/app/models/application_push_device.rb b/saas/app/models/application_push_device.rb new file mode 100644 index 0000000000..6547ec2065 --- /dev/null +++ b/saas/app/models/application_push_device.rb @@ -0,0 +1,7 @@ +class ApplicationPushDevice < ActionPushNative::Device + def self.register(owner:, token:, platform:, name: nil) + owner.devices.find_or_initialize_by(token: token).tap do |device| + device.update!(platform: platform.downcase, name: name) + end + end +end diff --git a/saas/app/models/user/devices.rb b/saas/app/models/user/devices.rb index 25bd6e4b26..df198df169 100644 --- a/saas/app/models/user/devices.rb +++ b/saas/app/models/user/devices.rb @@ -2,6 +2,6 @@ module User::Devices extend ActiveSupport::Concern included do - has_many :devices, class_name: "ActionPushNative::Device", as: :owner, dependent: :destroy + has_many :devices, class_name: "ApplicationPushDevice", as: :owner, dependent: :destroy end end diff --git a/saas/app/views/users/devices/index.html.erb b/saas/app/views/devices/index.html.erb similarity index 78% rename from saas/app/views/users/devices/index.html.erb rename to saas/app/views/devices/index.html.erb index 4a9a02486c..bb7d74871c 100644 --- a/saas/app/views/users/devices/index.html.erb +++ b/saas/app/views/devices/index.html.erb @@ -7,7 +7,7 @@ <%= device.name || "Unnamed device" %> (<%= device.platform == "apple" ? "iOS" : "Android" %>) Added <%= time_ago_in_words(device.created_at) %> ago - <%= button_to "Remove", users_device_path(device), method: :delete, data: { confirm: "Remove this device?" } %> + <%= button_to "Remove", device_path(device), method: :delete, data: { confirm: "Remove this device?" } %> <% end %> diff --git a/saas/app/views/notifications/settings/_native_devices.html.erb b/saas/app/views/notifications/settings/_native_devices.html.erb index a3e95b392b..9931f822f4 100644 --- a/saas/app/views/notifications/settings/_native_devices.html.erb +++ b/saas/app/views/notifications/settings/_native_devices.html.erb @@ -5,7 +5,7 @@

You have <%= pluralize(Current.user.devices.count, "mobile device") %> registered for push notifications.

- <%= link_to "Manage devices", users_devices_path, class: "btn txt-small" %> + <%= link_to "Manage devices", devices_path, class: "btn txt-small" %> <% else %>

No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone. diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index fad1c35949..5f48b91f30 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -38,10 +38,8 @@ class Engine < ::Rails::Engine resource :webhooks, only: :create end - namespace :users do - resources :devices, only: [ :index, :create, :destroy ] do - delete :destroy, on: :collection, as: :unregister - end + resources :devices, only: [ :index, :create, :destroy ] do + delete :destroy, on: :collection, as: :unregister end end end diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/devices_controller_test.rb similarity index 82% rename from saas/test/controllers/users/devices_controller_test.rb rename to saas/test/controllers/devices_controller_test.rb index 08383c3fea..331c2f722e 100644 --- a/saas/test/controllers/users/devices_controller_test.rb +++ b/saas/test/controllers/devices_controller_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Users::DevicesControllerTest < ActionDispatch::IntegrationTest +class DevicesControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:david) sign_in_as @user @@ -11,7 +11,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "index shows user devices" do @user.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") - get users_devices_path + get devices_path assert_response :success assert_select "strong", "iPhone 15 Pro" @@ -21,7 +21,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "index shows empty state when no devices" do @user.devices.delete_all - get users_devices_path + get devices_path assert_response :success assert_select "p", /No devices registered/ @@ -30,7 +30,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "index requires authentication" do sign_out - get users_devices_path + get devices_path assert_response :redirect end @@ -41,7 +41,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest token = SecureRandom.hex(32) assert_difference "ActionPushNative::Device.count", 1 do - post users_devices_path, params: { + post devices_path, params: { token: token, platform: "apple", name: "iPhone 15 Pro" @@ -58,7 +58,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest end test "creates android device" do - post users_devices_path, params: { + post devices_path, params: { token: SecureRandom.hex(32), platform: "google", name: "Pixel 8" @@ -83,7 +83,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest # Current user registers the same token with their own device assert_difference "ActionPushNative::Device.count", 1 do - post users_devices_path, params: { + post devices_path, params: { token: shared_token, platform: "apple", name: "David's iPhone" @@ -102,7 +102,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest end test "rejects invalid platform" do - post users_devices_path, params: { + post devices_path, params: { token: SecureRandom.hex(32), platform: "windows", name: "Surface" @@ -112,7 +112,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest end test "rejects missing token" do - post users_devices_path, params: { + post devices_path, params: { platform: "apple", name: "iPhone" }, as: :json @@ -123,7 +123,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "create requires authentication" do sign_out - post users_devices_path, params: { + post devices_path, params: { token: SecureRandom.hex(32), platform: "apple" }, as: :json @@ -141,19 +141,19 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_difference "ActionPushNative::Device.count", -1 do - delete users_device_path(device) + delete device_path(device) end - assert_redirected_to users_devices_path + assert_redirected_to devices_path assert_not ActionPushNative::Device.exists?(device.id) end test "does nothing when device not found by id" do assert_no_difference "ActionPushNative::Device.count" do - delete users_device_path(id: "nonexistent") + delete device_path(id: "nonexistent") end - assert_redirected_to users_devices_path + assert_redirected_to devices_path end test "cannot destroy another user's device by id" do @@ -165,10 +165,10 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_no_difference "ActionPushNative::Device.count" do - delete users_device_path(device) + delete device_path(device) end - assert_redirected_to users_devices_path + assert_redirected_to devices_path assert ActionPushNative::Device.exists?(device.id) end @@ -181,7 +181,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest sign_out - delete users_device_path(device) + delete device_path(device) assert_response :redirect assert ActionPushNative::Device.exists?(device.id) @@ -197,7 +197,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_difference "ActionPushNative::Device.count", -1 do - delete unregister_users_devices_path, params: { token: "token_to_unregister" }, as: :json + delete unregister_devices_path, params: { token: "token_to_unregister" }, as: :json end assert_response :no_content @@ -206,7 +206,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest test "does nothing when device not found by token" do assert_no_difference "ActionPushNative::Device.count" do - delete unregister_users_devices_path, params: { token: "nonexistent_token" }, as: :json + delete unregister_devices_path, params: { token: "nonexistent_token" }, as: :json end assert_response :no_content @@ -221,7 +221,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_no_difference "ActionPushNative::Device.count" do - delete unregister_users_devices_path, params: { token: "other_users_token" }, as: :json + delete unregister_devices_path, params: { token: "other_users_token" }, as: :json end assert_response :no_content @@ -237,7 +237,7 @@ class Users::DevicesControllerTest < ActionDispatch::IntegrationTest sign_out - delete unregister_users_devices_path, params: { token: "my_token" }, as: :json + delete unregister_devices_path, params: { token: "my_token" }, as: :json assert_response :redirect assert ActionPushNative::Device.exists?(device.id) diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification_pusher_test.rb index 2c628b4166..70a9f90efb 100644 --- a/saas/test/models/notification_pusher_test.rb +++ b/saas/test/models/notification_pusher_test.rb @@ -59,14 +59,14 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Has Any Push Destination === test "push_destination returns true when user has native devices" do - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert @pusher.send(:push_destination?) end test "push_destination returns true when user has web subscriptions" do @user.push_subscriptions.create!( - endpoint: "https://example.com/push", + endpoint: "https://fcm.googleapis.com/fcm/send/test", p256dh_key: "test_p256dh", auth_key: "test_auth" ) @@ -85,7 +85,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push delivers to native devices when user has devices" do stub_push_services - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert_native_push_delivery(count: 1) do @pusher.push @@ -102,7 +102,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push does not deliver when creator is system user" do stub_push_services - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") @notification.update!(creator: users(:system)) result = @pusher.push @@ -112,10 +112,11 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push delivers to multiple devices" do stub_push_services - @user.devices.create!(uuid: SecureRandom.uuid, token: "token1", platform: "apple", name: "iPhone") - @user.devices.create!(uuid: SecureRandom.uuid, token: "token2", platform: "google", name: "Pixel") + @user.devices.delete_all + @user.devices.create!(token: "token1", platform: "apple", name: "iPhone") + @user.devices.create!(token: "token2", platform: "google", name: "Pixel") - assert_native_push_delivery(count: 1) do + assert_native_push_delivery(count: 2) do @pusher.push end end @@ -131,7 +132,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase ) # Set up native device - @user.devices.create!(uuid: SecureRandom.uuid, token: "native_token", platform: "apple", name: "iPhone") + @user.devices.create!(token: "native_token", platform: "apple", name: "iPhone") # Mock web push pool to verify it receives the payload web_push_pool = mock("web_push_pool") @@ -149,7 +150,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Native Notification Building === test "native notification includes required fields" do - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -159,7 +160,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "native notification sets thread_id from card" do - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -168,7 +169,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "native notification sets high_priority for assignments" do notification = notifications(:logo_assignment_kevin) - notification.user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + notification.user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") pusher = NotificationPusher.new(notification) payload = pusher.send(:build_payload) @@ -178,7 +179,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "native notification sets normal priority for non-assignments" do - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -188,7 +189,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Apple-specific Payload === test "native notification includes apple-specific fields" do - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -200,7 +201,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Google-specific Payload === test "native notification sets android notification to nil for data-only" do - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -210,11 +211,11 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase # === Data Payload === test "native notification includes data payload" do - @user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone") + @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) - assert_not_nil native.data[:path] + assert_not_nil native.data[:url] assert_equal @notification.account.external_account_id, native.data[:account_id] assert_equal @notification.creator.name, native.data[:creator_name] end diff --git a/saas/test/models/push_config_test.rb b/saas/test/models/push_config_test.rb index 979194b3f4..554315818b 100644 --- a/saas/test/models/push_config_test.rb +++ b/saas/test/models/push_config_test.rb @@ -4,11 +4,11 @@ class PushConfigTest < ActiveSupport::TestCase test "loads push config from the saas engine" do skip unless Fizzy.saas? - config = Rails.application.config_for(:push) + config = ActionPushNative.config - apple_team_id = config.dig("apple", "team_id") - apple_topic = config.dig("apple", "topic") - google_project_id = config.dig("google", "project_id") + apple_team_id = config.dig(:apple, :team_id) + apple_topic = config.dig(:apple, :topic) + google_project_id = config.dig(:google, :project_id) skip "Update test once APNS team_id is configured" if apple_team_id == "YOUR_TEAM_ID" skip "Update test once APNS topic is configured" if apple_topic == "com.yourcompany.fizzy" From 1ac09a2b9b0700c85aab79ec39f15c5c0505ae84 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 13:20:14 +0100 Subject: [PATCH 40/72] Simplify device routes and use ActiveRecord validations - Use RESTful DELETE /devices/:id where :id can be token or database ID - Remove redundant unregister collection route - Remove old Users::DevicesController - Return 404 when device not found instead of silently succeeding - Return 422 for invalid platform via ActiveRecord validation - Update action_push_native to main branch (includes validate: true on enum) Co-Authored-By: Claude Opus 4.5 --- Gemfile.saas | 2 +- Gemfile.saas.lock | 13 +++---- saas/app/controllers/devices_controller.rb | 18 +++++---- .../controllers/users/devices_controller.rb | 38 ------------------- saas/lib/fizzy/saas/engine.rb | 4 +- .../controllers/devices_controller_test.rb | 34 +++++++---------- 6 files changed, 31 insertions(+), 78 deletions(-) delete mode 100644 saas/app/controllers/users/devices_controller.rb diff --git a/Gemfile.saas b/Gemfile.saas index 2f131d1e46..f34cbb6df2 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -11,7 +11,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" # Native push notifications (iOS/Android) -gem "action_push_native", github: "rails/action_push_native", branch: "use-registered-config-path" +gem "action_push_native", github: "rails/action_push_native" # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index e021b7da56..ee46c850f2 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -60,8 +60,7 @@ GIT GIT remote: https://github.com/rails/action_push_native.git - revision: d5c44514e13faf919261ca7943fc43aedd8e992e - branch: use-registered-config-path + revision: 9fb4a2bfe54270b1a3508028f00aaa586e257655 specs: action_push_native (0.3.0) activejob (>= 8.0) @@ -207,8 +206,8 @@ GEM activemodel (>= 7.0) activemodel-serializers-xml (~> 1.0) activesupport (>= 7.0) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) anyway_config (2.7.2) ruby-next-core (~> 1.0) ast (2.4.3) @@ -303,7 +302,7 @@ GEM base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-logging-utils (0.2.0) - googleauth (1.16.0) + googleauth (1.16.1) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -471,7 +470,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) @@ -498,7 +497,7 @@ GEM rake-compiler-dock (1.9.1) rb_sys (0.9.117) rake-compiler-dock (= 1.9.1) - rdoc (7.0.3) + rdoc (7.1.0) erb psych (>= 4.0.0) tsort diff --git a/saas/app/controllers/devices_controller.rb b/saas/app/controllers/devices_controller.rb index fd4b7ed830..081c322b85 100644 --- a/saas/app/controllers/devices_controller.rb +++ b/saas/app/controllers/devices_controller.rb @@ -1,4 +1,6 @@ class DevicesController < ApplicationController + before_action :set_device, only: :destroy + def index @devices = Current.user.devices.order(created_at: :desc) end @@ -6,21 +8,21 @@ def index def create ApplicationPushDevice.register(owner: Current.user, **device_params) head :created - rescue ArgumentError - head :bad_request end def destroy - if params[:token].present? - Current.user.devices.destroy_by(token: params[:token]) - head :no_content - else - Current.user.devices.destroy_by(id: params[:id]) - redirect_to devices_path, notice: "Device removed" + @device.destroy + respond_to do |format| + format.html { redirect_to devices_path, notice: "Device removed" } + format.json { head :no_content } end end private + def set_device + @device = Current.user.devices.find_by(token: params[:id]) || Current.user.devices.find(params[:id]) + end + def device_params params.require([ :token, :platform ]) params.permit(:token, :platform, :name).to_h.symbolize_keys diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb deleted file mode 100644 index 9355024083..0000000000 --- a/saas/app/controllers/users/devices_controller.rb +++ /dev/null @@ -1,38 +0,0 @@ -class Users::DevicesController < ApplicationController - before_action :set_devices - - rescue_from ActiveRecord::NotNullViolation, ArgumentError, with: :bad_request - - def index - end - - def create - @devices.create!(device_params) - head :created - end - - def destroy - if params[:token].present? - @devices.destroy_by(token: params[:token]) - head :no_content - else - @devices.destroy_by(id: params[:id]) - redirect_to users_devices_path, notice: "Device removed" - end - end - - private - def set_devices - @devices = Current.user.devices.order(created_at: :desc) - end - - def device_params - params.permit(:token, :platform, :name).tap do |permitted| - permitted[:platform] = permitted[:platform].to_s.downcase if permitted[:platform].present? - end - end - - def bad_request - head :bad_request - end -end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 5f48b91f30..e04f029c8e 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -38,9 +38,7 @@ class Engine < ::Rails::Engine resource :webhooks, only: :create end - resources :devices, only: [ :index, :create, :destroy ] do - delete :destroy, on: :collection, as: :unregister - end + resources :devices, only: [ :index, :create, :destroy ] end end diff --git a/saas/test/controllers/devices_controller_test.rb b/saas/test/controllers/devices_controller_test.rb index 331c2f722e..0cfbbd612f 100644 --- a/saas/test/controllers/devices_controller_test.rb +++ b/saas/test/controllers/devices_controller_test.rb @@ -6,8 +6,6 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest sign_in_as @user end - # === Index (Web) === - test "index shows user devices" do @user.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") @@ -35,8 +33,6 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :redirect end - # === Create (API) === - test "creates a new device via api" do token = SecureRandom.hex(32) @@ -108,7 +104,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest name: "Surface" }, as: :json - assert_response :bad_request + assert_response :unprocessable_entity end test "rejects missing token" do @@ -131,8 +127,6 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :redirect end - # === Destroy (Web) === - test "destroys device by id" do device = @user.devices.create!( token: "token_to_delete", @@ -148,15 +142,15 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert_not ActionPushNative::Device.exists?(device.id) end - test "does nothing when device not found by id" do + test "returns not found when device not found by id" do assert_no_difference "ActionPushNative::Device.count" do delete device_path(id: "nonexistent") end - assert_redirected_to devices_path + assert_response :not_found end - test "cannot destroy another user's device by id" do + test "returns not found for another user's device by id" do other_user = users(:kevin) device = other_user.devices.create!( token: "other_users_token", @@ -168,7 +162,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest delete device_path(device) end - assert_redirected_to devices_path + assert_response :not_found assert ActionPushNative::Device.exists?(device.id) end @@ -187,8 +181,6 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert ActionPushNative::Device.exists?(device.id) end - # === Destroy by Token (API) === - test "destroys device by token" do device = @user.devices.create!( token: "token_to_unregister", @@ -197,22 +189,22 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_difference "ActionPushNative::Device.count", -1 do - delete unregister_devices_path, params: { token: "token_to_unregister" }, as: :json + delete device_path("token_to_unregister"), as: :json end assert_response :no_content assert_not ActionPushNative::Device.exists?(device.id) end - test "does nothing when device not found by token" do + test "returns not found when device not found by token" do assert_no_difference "ActionPushNative::Device.count" do - delete unregister_devices_path, params: { token: "nonexistent_token" }, as: :json + delete device_path("nonexistent_token"), as: :json end - assert_response :no_content + assert_response :not_found end - test "cannot destroy another user's device by token" do + test "returns not found for another user's device by token" do other_user = users(:kevin) device = other_user.devices.create!( token: "other_users_token", @@ -221,10 +213,10 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_no_difference "ActionPushNative::Device.count" do - delete unregister_devices_path, params: { token: "other_users_token" }, as: :json + delete device_path("other_users_token"), as: :json end - assert_response :no_content + assert_response :not_found assert ActionPushNative::Device.exists?(device.id) end @@ -237,7 +229,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest sign_out - delete unregister_devices_path, params: { token: "my_token" }, as: :json + delete device_path("my_token"), as: :json assert_response :redirect assert ActionPushNative::Device.exists?(device.id) From 47c360c3927b7baa7ccfa1ced8fe6a83ce099f89 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 15:33:17 +0100 Subject: [PATCH 41/72] Change device ownership from User to Identity Devices now belong to Identity instead of User, allowing a single device registration to work across all accounts an identity has access to. - Move User::Devices to Identity::Devices - Update DevicesController to use Current.identity - Update NotificationPusher::Native to use user.identity.devices - Clean up tests to use @identity directly Co-Authored-By: Claude Opus 4.5 --- saas/app/controllers/devices_controller.rb | 6 +- saas/app/models/{user => identity}/devices.rb | 2 +- saas/app/models/notification_pusher/native.rb | 4 +- saas/lib/fizzy/saas/engine.rb | 2 +- .../controllers/devices_controller_test.rb | 88 +++++++++---------- saas/test/models/notification_pusher_test.rb | 51 ++++------- 6 files changed, 69 insertions(+), 84 deletions(-) rename saas/app/models/{user => identity}/devices.rb (85%) diff --git a/saas/app/controllers/devices_controller.rb b/saas/app/controllers/devices_controller.rb index 081c322b85..6913da0c58 100644 --- a/saas/app/controllers/devices_controller.rb +++ b/saas/app/controllers/devices_controller.rb @@ -2,11 +2,11 @@ class DevicesController < ApplicationController before_action :set_device, only: :destroy def index - @devices = Current.user.devices.order(created_at: :desc) + @devices = Current.identity.devices.order(created_at: :desc) end def create - ApplicationPushDevice.register(owner: Current.user, **device_params) + ApplicationPushDevice.register(owner: Current.identity, **device_params) head :created end @@ -20,7 +20,7 @@ def destroy private def set_device - @device = Current.user.devices.find_by(token: params[:id]) || Current.user.devices.find(params[:id]) + @device = Current.identity.devices.find_by(token: params[:id]) || Current.identity.devices.find(params[:id]) end def device_params diff --git a/saas/app/models/user/devices.rb b/saas/app/models/identity/devices.rb similarity index 85% rename from saas/app/models/user/devices.rb rename to saas/app/models/identity/devices.rb index df198df169..ce7eec457e 100644 --- a/saas/app/models/user/devices.rb +++ b/saas/app/models/identity/devices.rb @@ -1,4 +1,4 @@ -module User::Devices +module Identity::Devices extend ActiveSupport::Concern included do diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb index e5f83e40a4..2fc38ed72e 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -12,11 +12,11 @@ def push private def push_destination? - notification.user.push_subscriptions.any? || notification.user.devices.any? + notification.user.push_subscriptions.any? || notification.user.identity.devices.any? end def push_to_native(payload) - devices = notification.user.devices + devices = notification.user.identity.devices return if devices.empty? native_notification(payload).deliver_later_to(devices) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index e04f029c8e..d0138f30ea 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -139,7 +139,7 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited - ::User.include User::Devices + ::Identity.include Identity::Devices ::NotificationPusher.prepend NotificationPusher::Native ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) diff --git a/saas/test/controllers/devices_controller_test.rb b/saas/test/controllers/devices_controller_test.rb index 0cfbbd612f..0434a29cd8 100644 --- a/saas/test/controllers/devices_controller_test.rb +++ b/saas/test/controllers/devices_controller_test.rb @@ -2,12 +2,12 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest setup do - @user = users(:david) - sign_in_as @user + @identity = identities(:david) + sign_in_as :david end - test "index shows user devices" do - @user.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") + test "index shows identity's devices" do + @identity.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") get devices_path @@ -17,7 +17,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest end test "index shows empty state when no devices" do - @user.devices.delete_all + @identity.devices.delete_all get devices_path @@ -36,7 +36,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest test "creates a new device via api" do token = SecureRandom.hex(32) - assert_difference "ActionPushNative::Device.count", 1 do + assert_difference -> { ApplicationPushDevice.count }, 1 do post devices_path, params: { token: token, platform: "apple", @@ -46,11 +46,11 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :created - device = ActionPushNative::Device.last + device = ApplicationPushDevice.last assert_equal token, device.token assert_equal "apple", device.platform assert_equal "iPhone 15 Pro", device.name - assert_equal @user, device.owner + assert_equal @identity, device.owner end test "creates android device" do @@ -62,23 +62,23 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :created - device = ActionPushNative::Device.last + device = ApplicationPushDevice.last assert_equal "google", device.platform end - test "same token can be registered by multiple users" do + test "same token can be registered by multiple identities" do shared_token = "shared_push_token_123" - other_user = users(:kevin) + other_identity = identities(:kevin) - # Other user registers the token first - other_device = other_user.devices.create!( + # Other identity registers the token first + other_device = other_identity.devices.create!( token: shared_token, platform: "apple", name: "Kevin's iPhone" ) - # Current user registers the same token with their own device - assert_difference "ActionPushNative::Device.count", 1 do + # Current identity registers the same token with their own device + assert_difference -> { ApplicationPushDevice.count }, 1 do post devices_path, params: { token: shared_token, platform: "apple", @@ -88,13 +88,13 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert_response :created - # Both users have their own device records + # Both identities have their own device records assert_equal shared_token, other_device.reload.token - assert_equal other_user, other_device.owner + assert_equal other_identity, other_device.owner - davids_device = @user.devices.last + davids_device = @identity.devices.last assert_equal shared_token, davids_device.token - assert_equal @user, davids_device.owner + assert_equal @identity, davids_device.owner end test "rejects invalid platform" do @@ -128,46 +128,46 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest end test "destroys device by id" do - device = @user.devices.create!( + device = @identity.devices.create!( token: "token_to_delete", platform: "apple", name: "iPhone" ) - assert_difference "ActionPushNative::Device.count", -1 do + assert_difference -> { ApplicationPushDevice.count }, -1 do delete device_path(device) end assert_redirected_to devices_path - assert_not ActionPushNative::Device.exists?(device.id) + assert_not ApplicationPushDevice.exists?(device.id) end test "returns not found when device not found by id" do - assert_no_difference "ActionPushNative::Device.count" do + assert_no_difference "ApplicationPushDevice.count" do delete device_path(id: "nonexistent") end assert_response :not_found end - test "returns not found for another user's device by id" do - other_user = users(:kevin) - device = other_user.devices.create!( - token: "other_users_token", + test "returns not found for another identity's device by id" do + other_identity = identities(:kevin) + device = other_identity.devices.create!( + token: "other_identity_token", platform: "apple", name: "Other iPhone" ) - assert_no_difference "ActionPushNative::Device.count" do + assert_no_difference "ApplicationPushDevice.count" do delete device_path(device) end assert_response :not_found - assert ActionPushNative::Device.exists?(device.id) + assert ApplicationPushDevice.exists?(device.id) end test "destroy by id requires authentication" do - device = @user.devices.create!( + device = @identity.devices.create!( token: "my_token", platform: "apple", name: "iPhone" @@ -178,50 +178,50 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest delete device_path(device) assert_response :redirect - assert ActionPushNative::Device.exists?(device.id) + assert ApplicationPushDevice.exists?(device.id) end test "destroys device by token" do - device = @user.devices.create!( + device = @identity.devices.create!( token: "token_to_unregister", platform: "apple", name: "iPhone" ) - assert_difference "ActionPushNative::Device.count", -1 do + assert_difference -> { ApplicationPushDevice.count }, -1 do delete device_path("token_to_unregister"), as: :json end assert_response :no_content - assert_not ActionPushNative::Device.exists?(device.id) + assert_not ApplicationPushDevice.exists?(device.id) end test "returns not found when device not found by token" do - assert_no_difference "ActionPushNative::Device.count" do + assert_no_difference "ApplicationPushDevice.count" do delete device_path("nonexistent_token"), as: :json end assert_response :not_found end - test "returns not found for another user's device by token" do - other_user = users(:kevin) - device = other_user.devices.create!( - token: "other_users_token", + test "returns not found for another identity's device by token" do + other_identity = identities(:kevin) + device = other_identity.devices.create!( + token: "other_identity_token", platform: "apple", name: "Other iPhone" ) - assert_no_difference "ActionPushNative::Device.count" do - delete device_path("other_users_token"), as: :json + assert_no_difference "ApplicationPushDevice.count" do + delete device_path("other_identity_token"), as: :json end assert_response :not_found - assert ActionPushNative::Device.exists?(device.id) + assert ApplicationPushDevice.exists?(device.id) end test "destroy by token requires authentication" do - device = @user.devices.create!( + device = @identity.devices.create!( token: "my_token", platform: "apple", name: "iPhone" @@ -232,6 +232,6 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest delete device_path("my_token"), as: :json assert_response :redirect - assert ActionPushNative::Device.exists?(device.id) + assert ApplicationPushDevice.exists?(device.id) end end diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification_pusher_test.rb index 70a9f90efb..ef7fcd0d51 100644 --- a/saas/test/models/notification_pusher_test.rb +++ b/saas/test/models/notification_pusher_test.rb @@ -3,6 +3,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase setup do @user = users(:kevin) + @identity = @user.identity @notification = notifications(:logo_published_kevin) @pusher = NotificationPusher.new(@notification) @@ -10,8 +11,6 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase @user.push_subscriptions.delete_all end - # === Notification Category === - test "notification_category returns assignment for card_assigned" do notification = notifications(:logo_assignment_kevin) pusher = NotificationPusher.new(notification) @@ -40,8 +39,6 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase assert_equal "card", pusher.send(:notification_category) end - # === Interruption Level === - test "interruption_level is time-sensitive for assignments" do notification = notifications(:logo_assignment_kevin) pusher = NotificationPusher.new(notification) @@ -56,10 +53,8 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase assert_equal "active", pusher.send(:interruption_level) end - # === Has Any Push Destination === - - test "push_destination returns true when user has native devices" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + test "push_destination returns true when identity has native devices" do + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert @pusher.send(:push_destination?) end @@ -75,17 +70,15 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "push_destination returns false when user has neither" do - @user.devices.delete_all + @identity.devices.delete_all @user.push_subscriptions.delete_all assert_not @pusher.send(:push_destination?) end - # === Push Delivery === - test "push delivers to native devices when user has devices" do stub_push_services - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert_native_push_delivery(count: 1) do @pusher.push @@ -93,7 +86,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "push does not deliver to native when user has no devices" do - @user.devices.delete_all + @identity.devices.delete_all assert_no_native_push_delivery do @pusher.push @@ -102,7 +95,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push does not deliver when creator is system user" do stub_push_services - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") @notification.update!(creator: users(:system)) result = @pusher.push @@ -112,9 +105,9 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "push delivers to multiple devices" do stub_push_services - @user.devices.delete_all - @user.devices.create!(token: "token1", platform: "apple", name: "iPhone") - @user.devices.create!(token: "token2", platform: "google", name: "Pixel") + @identity.devices.delete_all + @identity.devices.create!(token: "token1", platform: "apple", name: "iPhone") + @identity.devices.create!(token: "token2", platform: "google", name: "Pixel") assert_native_push_delivery(count: 2) do @pusher.push @@ -132,7 +125,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase ) # Set up native device - @user.devices.create!(token: "native_token", platform: "apple", name: "iPhone") + @identity.devices.create!(token: "native_token", platform: "apple", name: "iPhone") # Mock web push pool to verify it receives the payload web_push_pool = mock("web_push_pool") @@ -147,10 +140,8 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end end - # === Native Notification Building === - test "native notification includes required fields" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -160,7 +151,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "native notification sets thread_id from card" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -169,7 +160,7 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "native notification sets high_priority for assignments" do notification = notifications(:logo_assignment_kevin) - notification.user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") pusher = NotificationPusher.new(notification) payload = pusher.send(:build_payload) @@ -179,17 +170,15 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase end test "native notification sets normal priority for non-assignments" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) assert_not native.high_priority end - # === Apple-specific Payload === - test "native notification includes apple-specific fields" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) @@ -198,20 +187,16 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase assert_not_nil native.apple_data.dig(:aps, :category) end - # === Google-specific Payload === - test "native notification sets android notification to nil for data-only" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) assert_nil native.google_data.dig(:android, :notification) end - # === Data Payload === - test "native notification includes data payload" do - @user.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") payload = @pusher.send(:build_payload) native = @pusher.send(:native_notification, payload) From 8cd642733df985f3ec88bc1e50092e45352499e0 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 18:50:53 +0100 Subject: [PATCH 42/72] Refactor notification push system with registry pattern Replace NotificationPusher with a cleaner architecture: - Add Notification::Pushable concern with push target registry - Add Notification::Push base class with template methods - Add Notification::Push::Web for web push (OSS) - Add Notification::Push::Native for native push (SaaS) - Add Notification::WebPushJob and Notification::NativePushJob Key design: - Registry pattern: Notification.register_push_target(:web) - Template method: push calls should_push? then perform_push - Subclasses override should_push? (with super) and perform_push - Each target handles its own job enqueueing Also: - Add Notification#pushable? for checking push eligibility - Add Notification#identity delegation to user - Reorganize tests to match new class structure Co-Authored-By: Claude Opus 4.5 --- app/jobs/notification/web_push_job.rb | 5 + app/jobs/push_notification_job.rb | 7 - app/models/concerns/push_notifiable.rb | 12 -- app/models/notification.rb | 3 +- .../push.rb} | 35 ++-- app/models/notification/push/web.rb | 18 ++ app/models/notification/pushable.rb | 34 ++++ config/initializers/push_notifications.rb | 3 + saas/app/jobs/notification/native_push_job.rb | 5 + .../push}/native.rb | 30 ++-- saas/lib/fizzy/saas/engine.rb | 2 +- .../push/native_test.rb} | 154 ++++++++---------- test/models/notification/push/web_test.rb | 100 ++++++++++++ test/models/notification/pushable_test.rb | 58 +++++++ test/models/notification_pusher_test.rb | 32 ---- 15 files changed, 314 insertions(+), 184 deletions(-) create mode 100644 app/jobs/notification/web_push_job.rb delete mode 100644 app/jobs/push_notification_job.rb delete mode 100644 app/models/concerns/push_notifiable.rb rename app/models/{notification_pusher.rb => notification/push.rb} (78%) create mode 100644 app/models/notification/push/web.rb create mode 100644 app/models/notification/pushable.rb create mode 100644 config/initializers/push_notifications.rb create mode 100644 saas/app/jobs/notification/native_push_job.rb rename saas/app/models/{notification_pusher => notification/push}/native.rb (75%) rename saas/test/models/{notification_pusher_test.rb => notification/push/native_test.rb} (52%) create mode 100644 test/models/notification/push/web_test.rb create mode 100644 test/models/notification/pushable_test.rb delete mode 100644 test/models/notification_pusher_test.rb diff --git a/app/jobs/notification/web_push_job.rb b/app/jobs/notification/web_push_job.rb new file mode 100644 index 0000000000..cd30e3581e --- /dev/null +++ b/app/jobs/notification/web_push_job.rb @@ -0,0 +1,5 @@ +class Notification::WebPushJob < ApplicationJob + def perform(notification) + Notification::Push::Web.new(notification).push + end +end diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb deleted file mode 100644 index c912e141d8..0000000000 --- a/app/jobs/push_notification_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class PushNotificationJob < ApplicationJob - discard_on ActiveJob::DeserializationError - - def perform(notification) - NotificationPusher.new(notification).push - end -end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb deleted file mode 100644 index e497bc3fe7..0000000000 --- a/app/models/concerns/push_notifiable.rb +++ /dev/null @@ -1,12 +0,0 @@ -module PushNotifiable - extend ActiveSupport::Concern - - included do - after_create_commit :push_notification_later - end - - private - def push_notification_later - PushNotificationJob.perform_later(self) - end -end diff --git a/app/models/notification.rb b/app/models/notification.rb index 545f6ab75f..48a66b3e03 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,5 +1,5 @@ class Notification < ApplicationRecord - include PushNotifiable + include Notification::Pushable belongs_to :account, default: -> { user.account } belongs_to :user @@ -18,6 +18,7 @@ class Notification < ApplicationRecord delegate :notifiable_target, to: :source delegate :card, to: :source + delegate :identity, to: :user def self.read_all all.each { |notification| notification.read } diff --git a/app/models/notification_pusher.rb b/app/models/notification/push.rb similarity index 78% rename from app/models/notification_pusher.rb rename to app/models/notification/push.rb index 12b4965bdd..c94f121f13 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification/push.rb @@ -1,9 +1,10 @@ -class NotificationPusher - include Rails.application.routes.url_helpers +class Notification::Push include ExcerptHelper attr_reader :notification + delegate :card, to: :notification + def initialize(notification) @notification = notification end @@ -11,21 +12,16 @@ def initialize(notification) def push return unless should_push? - build_payload.tap do |payload| - push_to_web(payload) - end + perform_push end private def should_push? - push_destination? && - !notification.creator.system? && - notification.user.active? && - notification.account.active? + notification.pushable? end - def push_destination? - notification.user.push_subscriptions.any? + def perform_push + raise NotImplementedError end def build_payload @@ -41,7 +37,6 @@ def build_payload def build_event_payload event = notification.source - card = event.card base_payload = { title: card_notification_title(card), @@ -80,7 +75,6 @@ def build_event_payload def build_mention_payload mention = notification.source - card = mention.card { title: "#{mention.mentioner.first_name} mentioned you", @@ -93,19 +87,10 @@ def build_default_payload { title: "New notification", body: "You have a new notification", - url: notifications_url(**url_options) + url: notifications_url } end - def push_to_web(payload) - subscriptions = notification.user.push_subscriptions - enqueue_payload_for_delivery(payload, subscriptions) - end - - def enqueue_payload_for_delivery(payload, subscriptions) - Rails.configuration.x.web_push_pool.queue(payload, subscriptions) - end - def card_notification_title(card) card.title.presence || "Card #{card.number}" end @@ -118,6 +103,10 @@ def card_url(card) Rails.application.routes.url_helpers.card_url(card, **url_options) end + def notifications_url + Rails.application.routes.url_helpers.notifications_url(**url_options) + end + def card_url_with_comment_anchor(comment) Rails.application.routes.url_helpers.card_url( comment.card, diff --git a/app/models/notification/push/web.rb b/app/models/notification/push/web.rb new file mode 100644 index 0000000000..33d34e3e7e --- /dev/null +++ b/app/models/notification/push/web.rb @@ -0,0 +1,18 @@ +class Notification::Push::Web < Notification::Push + def self.push_later(notification) + Notification::WebPushJob.perform_later(notification) + end + + private + def should_push? + super && subscriptions.any? + end + + def perform_push + Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions) + end + + def subscriptions + @subscriptions ||= notification.user.push_subscriptions + end +end diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb new file mode 100644 index 0000000000..8137959e6e --- /dev/null +++ b/app/models/notification/pushable.rb @@ -0,0 +1,34 @@ +module Notification::Pushable + extend ActiveSupport::Concern + + included do + class_attribute :push_targets, default: [] + after_create_commit :push_later + end + + class_methods do + def register_push_target(target) + target = resolve_push_target(target) + push_targets << target unless push_targets.include?(target) + end + + private + def resolve_push_target(target) + if target.is_a?(Symbol) + "Notification::Push::#{target.to_s.classify}".constantize + else + target + end + end + end + + def push_later + self.class.push_targets.each do |target| + target.push_later(self) + end + end + + def pushable? + !creator.system? && user.active? && account.active? + end +end diff --git a/config/initializers/push_notifications.rb b/config/initializers/push_notifications.rb new file mode 100644 index 0000000000..fa82a46a71 --- /dev/null +++ b/config/initializers/push_notifications.rb @@ -0,0 +1,3 @@ +Rails.application.config.to_prepare do + Notification.register_push_target(:web) +end diff --git a/saas/app/jobs/notification/native_push_job.rb b/saas/app/jobs/notification/native_push_job.rb new file mode 100644 index 0000000000..c6f08f8406 --- /dev/null +++ b/saas/app/jobs/notification/native_push_job.rb @@ -0,0 +1,5 @@ +class Notification::NativePushJob < ApplicationJob + def perform(notification) + Notification::Push::Native.new(notification).push + end +end diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification/push/native.rb similarity index 75% rename from saas/app/models/notification_pusher/native.rb rename to saas/app/models/notification/push/native.rb index 2fc38ed72e..0030cf4b5f 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification/push/native.rb @@ -1,25 +1,19 @@ -module NotificationPusher::Native - extend ActiveSupport::Concern - - def push - return unless should_push? - - build_payload.tap do |payload| - push_to_web(payload) if notification.user.push_subscriptions.any? - push_to_native(payload) - end +class Notification::Push::Native < Notification::Push + def self.push_later(notification) + Notification::NativePushJob.perform_later(notification) end private - def push_destination? - notification.user.push_subscriptions.any? || notification.user.identity.devices.any? + def should_push? + super && devices.any? end - def push_to_native(payload) - devices = notification.user.identity.devices - return if devices.empty? + def perform_push + native_notification(build_payload).deliver_later_to(devices) + end - native_notification(payload).deliver_later_to(devices) + def devices + @devices ||= notification.identity.devices end def native_notification(payload) @@ -82,8 +76,4 @@ def creator_avatar_url return unless notification.creator.respond_to?(:avatar) && notification.creator.avatar.attached? Rails.application.routes.url_helpers.url_for(notification.creator.avatar) end - - def card - @card ||= notification.card - end end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index d0138f30ea..0fdf9c8780 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -140,7 +140,7 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::Identity.include Identity::Devices - ::NotificationPusher.prepend NotificationPusher::Native + ::Notification.register_push_target(:native) ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) Cards::PublishesController.include(Card::LimitedPublishing) diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification/push/native_test.rb similarity index 52% rename from saas/test/models/notification_pusher_test.rb rename to saas/test/models/notification/push/native_test.rb index ef7fcd0d51..8ba0521a56 100644 --- a/saas/test/models/notification_pusher_test.rb +++ b/saas/test/models/notification/push/native_test.rb @@ -1,11 +1,10 @@ require "test_helper" -class NotificationPusherNativeTest < ActiveSupport::TestCase +class Notification::Push::NativeTest < ActiveSupport::TestCase setup do @user = users(:kevin) @identity = @user.identity @notification = notifications(:logo_published_kevin) - @pusher = NotificationPusher.new(@notification) # Ensure user has no web push subscriptions (we want to test native push independently) @user.push_subscriptions.delete_all @@ -13,137 +12,100 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "notification_category returns assignment for card_assigned" do notification = notifications(:logo_assignment_kevin) - pusher = NotificationPusher.new(notification) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::Push::Native.new(notification) - assert_equal "assignment", pusher.send(:notification_category) + assert_equal "assignment", push.send(:notification_category) end test "notification_category returns comment for comment_created" do notification = notifications(:layout_commented_kevin) - pusher = NotificationPusher.new(notification) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - assert_equal "comment", pusher.send(:notification_category) + push = Notification::Push::Native.new(notification) + + assert_equal "comment", push.send(:notification_category) end test "notification_category returns mention for mentions" do notification = notifications(:logo_card_david_mention_by_jz) - pusher = NotificationPusher.new(notification) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::Push::Native.new(notification) - assert_equal "mention", pusher.send(:notification_category) + assert_equal "mention", push.send(:notification_category) end test "notification_category returns card for other card events" do - notification = notifications(:logo_published_kevin) - pusher = NotificationPusher.new(notification) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::Push::Native.new(@notification) - assert_equal "card", pusher.send(:notification_category) + assert_equal "card", push.send(:notification_category) end test "interruption_level is time-sensitive for assignments" do notification = notifications(:logo_assignment_kevin) - pusher = NotificationPusher.new(notification) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - assert_equal "time-sensitive", pusher.send(:interruption_level) - end + push = Notification::Push::Native.new(notification) - test "interruption_level is active for non-assignments" do - notification = notifications(:logo_published_kevin) - pusher = NotificationPusher.new(notification) - - assert_equal "active", pusher.send(:interruption_level) + assert_equal "time-sensitive", push.send(:interruption_level) end - test "push_destination returns true when identity has native devices" do + test "interruption_level is active for non-assignments" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - assert @pusher.send(:push_destination?) - end - - test "push_destination returns true when user has web subscriptions" do - @user.push_subscriptions.create!( - endpoint: "https://fcm.googleapis.com/fcm/send/test", - p256dh_key: "test_p256dh", - auth_key: "test_auth" - ) - - assert @pusher.send(:push_destination?) - end + push = Notification::Push::Native.new(@notification) - test "push_destination returns false when user has neither" do - @identity.devices.delete_all - @user.push_subscriptions.delete_all - - assert_not @pusher.send(:push_destination?) + assert_equal "active", push.send(:interruption_level) end - test "push delivers to native devices when user has devices" do + test "pushes to native devices when user has devices" do stub_push_services @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert_native_push_delivery(count: 1) do - @pusher.push + Notification::Push::Native.new(@notification).push end end - test "push does not deliver to native when user has no devices" do + test "does not push when user has no devices" do @identity.devices.delete_all assert_no_native_push_delivery do - @pusher.push + Notification::Push::Native.new(@notification).push end end - test "push does not deliver when creator is system user" do + test "does not push when creator is system user" do stub_push_services @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") @notification.update!(creator: users(:system)) - result = @pusher.push - - assert_nil result + assert_no_native_push_delivery do + Notification::Push::Native.new(@notification).push + end end - test "push delivers to multiple devices" do + test "pushes to multiple devices" do stub_push_services @identity.devices.delete_all @identity.devices.create!(token: "token1", platform: "apple", name: "iPhone") @identity.devices.create!(token: "token2", platform: "google", name: "Pixel") assert_native_push_delivery(count: 2) do - @pusher.push - end - end - - test "push delivers to both web and native when user has both" do - stub_push_services - - # Set up web push subscription - @user.push_subscriptions.create!( - endpoint: "https://fcm.googleapis.com/fcm/send/test", - p256dh_key: "test_p256dh_key", - auth_key: "test_auth_key" - ) - - # Set up native device - @identity.devices.create!(token: "native_token", platform: "apple", name: "iPhone") - - # Mock web push pool to verify it receives the payload - web_push_pool = mock("web_push_pool") - web_push_pool.expects(:queue).once.with do |payload, subscriptions| - payload.is_a?(Hash) && subscriptions.count == 1 - end - Rails.configuration.x.stubs(:web_push_pool).returns(web_push_pool) - - # Verify native push is also delivered - assert_native_push_delivery(count: 1) do - @pusher.push + Notification::Push::Native.new(@notification).push end end test "native notification includes required fields" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - payload = @pusher.send(:build_payload) - native = @pusher.send(:native_notification, payload) + + push = Notification::Push::Native.new(@notification) + payload = push.send(:build_payload) + native = push.send(:native_notification, payload) assert_not_nil native.title assert_not_nil native.body @@ -152,8 +114,10 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "native notification sets thread_id from card" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - payload = @pusher.send(:build_payload) - native = @pusher.send(:native_notification, payload) + + push = Notification::Push::Native.new(@notification) + payload = push.send(:build_payload) + native = push.send(:native_notification, payload) assert_equal @notification.card.id, native.thread_id end @@ -161,26 +125,30 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "native notification sets high_priority for assignments" do notification = notifications(:logo_assignment_kevin) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - pusher = NotificationPusher.new(notification) - payload = pusher.send(:build_payload) - native = pusher.send(:native_notification, payload) + push = Notification::Push::Native.new(notification) + payload = push.send(:build_payload) + native = push.send(:native_notification, payload) assert native.high_priority end test "native notification sets normal priority for non-assignments" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - payload = @pusher.send(:build_payload) - native = @pusher.send(:native_notification, payload) + + push = Notification::Push::Native.new(@notification) + payload = push.send(:build_payload) + native = push.send(:native_notification, payload) assert_not native.high_priority end test "native notification includes apple-specific fields" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - payload = @pusher.send(:build_payload) - native = @pusher.send(:native_notification, payload) + + push = Notification::Push::Native.new(@notification) + payload = push.send(:build_payload) + native = push.send(:native_notification, payload) assert_equal 1, native.apple_data.dig(:aps, :"mutable-content") assert_includes %w[active time-sensitive], native.apple_data.dig(:aps, :"interruption-level") @@ -189,19 +157,29 @@ class NotificationPusherNativeTest < ActiveSupport::TestCase test "native notification sets android notification to nil for data-only" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - payload = @pusher.send(:build_payload) - native = @pusher.send(:native_notification, payload) + + push = Notification::Push::Native.new(@notification) + payload = push.send(:build_payload) + native = push.send(:native_notification, payload) assert_nil native.google_data.dig(:android, :notification) end test "native notification includes data payload" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - payload = @pusher.send(:build_payload) - native = @pusher.send(:native_notification, payload) + + push = Notification::Push::Native.new(@notification) + payload = push.send(:build_payload) + native = push.send(:native_notification, payload) assert_not_nil native.data[:url] assert_equal @notification.account.external_account_id, native.data[:account_id] assert_equal @notification.creator.name, native.data[:creator_name] end + + test "push_later enqueues Notification::NativePushJob" do + assert_enqueued_with(job: Notification::NativePushJob, args: [ @notification ]) do + Notification::Push::Native.push_later(@notification) + end + end end diff --git a/test/models/notification/push/web_test.rb b/test/models/notification/push/web_test.rb new file mode 100644 index 0000000000..4c81d67196 --- /dev/null +++ b/test/models/notification/push/web_test.rb @@ -0,0 +1,100 @@ +require "test_helper" + +class Notification::Push::WebTest < ActiveSupport::TestCase + setup do + @user = users(:david) + @notification = @user.notifications.create!( + source: events(:logo_published), + creator: users(:jason) + ) + + @user.push_subscriptions.create!( + endpoint: "https://fcm.googleapis.com/fcm/send/test123", + p256dh_key: "test_key", + auth_key: "test_auth" + ) + + @web_push_pool = mock("web_push_pool") + Rails.configuration.x.stubs(:web_push_pool).returns(@web_push_pool) + end + + test "pushes to web when user has subscriptions" do + @web_push_pool.expects(:queue).once.with do |payload, subscriptions| + payload.is_a?(Hash) && + payload[:title].present? && + payload[:body].present? && + payload[:url].present? && + subscriptions.count == 1 + end + + Notification::Push::Web.new(@notification).push + end + + test "does not push when user has no subscriptions" do + @user.push_subscriptions.delete_all + @web_push_pool.expects(:queue).never + + Notification::Push::Web.new(@notification).push + end + + test "does not push for cancelled accounts" do + @user.account.cancel(initiated_by: @user) + @web_push_pool.expects(:queue).never + + Notification::Push::Web.new(@notification).push + end + + test "does not push when creator is system user" do + @notification.update!(creator: users(:system)) + @web_push_pool.expects(:queue).never + + Notification::Push::Web.new(@notification).push + end + + test "payload includes card title for card events" do + @web_push_pool.expects(:queue).once.with do |payload, _| + payload[:title] == @notification.card.title + end + + Notification::Push::Web.new(@notification).push + end + + test "payload for comment includes RE prefix" do + event = events(:layout_commented) + notification = @user.notifications.create!(source: event, creator: event.creator) + + @web_push_pool.expects(:queue).once.with do |payload, _| + payload[:title].start_with?("RE:") + end + + Notification::Push::Web.new(notification).push + end + + test "payload for assignment includes assigned message" do + event = events(:logo_assignment_david) + notification = @user.notifications.create!(source: event, creator: event.creator) + + @web_push_pool.expects(:queue).once.with do |payload, _| + payload[:body].include?("Assigned to you") + end + + Notification::Push::Web.new(notification).push + end + + test "payload for mention includes mentioner name" do + mention = mentions(:logo_card_david_mention_by_jz) + notification = @user.notifications.create!(source: mention, creator: users(:jz)) + + @web_push_pool.expects(:queue).once.with do |payload, _| + payload[:title].include?("mentioned you") + end + + Notification::Push::Web.new(notification).push + end + + test "push_later enqueues Notification::WebPushJob" do + assert_enqueued_with(job: Notification::WebPushJob, args: [ @notification ]) do + Notification::Push::Web.push_later(@notification) + end + end +end diff --git a/test/models/notification/pushable_test.rb b/test/models/notification/pushable_test.rb new file mode 100644 index 0000000000..2f99ac2c58 --- /dev/null +++ b/test/models/notification/pushable_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class Notification::PushableTest < ActiveSupport::TestCase + setup do + @user = users(:david) + @notification = @user.notifications.create!( + source: events(:logo_published), + creator: users(:jason) + ) + end + + test "push_later calls push_later on all registered targets" do + target = mock("push_target") + target.expects(:push_later).with(@notification) + + original_targets = Notification.push_targets + Notification.push_targets = [ target ] + + @notification.push_later + ensure + Notification.push_targets = original_targets + end + + test "push_later is called after notification is created" do + Notification.any_instance.expects(:push_later) + + @user.notifications.create!( + source: events(:logo_published), + creator: users(:jason) + ) + end + + test "register_push_target accepts symbols" do + original_targets = Notification.push_targets.dup + + Notification.register_push_target(:web) + + assert_includes Notification.push_targets, Notification::Push::Web + ensure + Notification.push_targets = original_targets + end + + test "pushable? returns true for normal notifications" do + assert @notification.pushable? + end + + test "pushable? returns false when creator is system user" do + @notification.update!(creator: users(:system)) + + assert_not @notification.pushable? + end + + test "pushable? returns false for cancelled accounts" do + @user.account.cancel(initiated_by: @user) + + assert_not @notification.pushable? + end +end diff --git a/test/models/notification_pusher_test.rb b/test/models/notification_pusher_test.rb deleted file mode 100644 index 21b9e202b4..0000000000 --- a/test/models/notification_pusher_test.rb +++ /dev/null @@ -1,32 +0,0 @@ -require "test_helper" - -class NotificationPusherTest < ActiveSupport::TestCase - setup do - @user = users(:david) - @notification = @user.notifications.create!( - source: events(:logo_published), - creator: users(:jason) - ) - @pusher = NotificationPusher.new(@notification) - - @user.push_subscriptions.create!( - endpoint: "https://fcm.googleapis.com/fcm/send/test123", - p256dh_key: "test_key", - auth_key: "test_auth" - ) - end - - test "push does not send notifications for cancelled accounts" do - @user.account.cancel(initiated_by: @user) - - result = @pusher.push - - assert_nil result, "Should not push notifications for cancelled accounts" - end - - test "push sends notifications for active accounts with subscriptions" do - result = @pusher.push - - assert_not_nil result, "Should push notifications for active accounts with subscriptions" - end -end From 5639e8686dae9639ed115b246fc48a48e0e0bc22 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 19:07:04 +0100 Subject: [PATCH 43/72] Extract payload building into dedicated classes Separates notification payload construction from push delivery by introducing DefaultPayload, EventPayload, and MentionPayload classes that encapsulate the title, body, and URL generation for each notification type. Co-Authored-By: Claude Opus 4.5 --- app/models/notification/default_payload.rb | 41 ++++++++++ app/models/notification/event_payload.rb | 55 +++++++++++++ app/models/notification/mention_payload.rb | 20 +++++ app/models/notification/push.rb | 95 +--------------------- 4 files changed, 119 insertions(+), 92 deletions(-) create mode 100644 app/models/notification/default_payload.rb create mode 100644 app/models/notification/event_payload.rb create mode 100644 app/models/notification/mention_payload.rb diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb new file mode 100644 index 0000000000..acffb13657 --- /dev/null +++ b/app/models/notification/default_payload.rb @@ -0,0 +1,41 @@ +class Notification::DefaultPayload + attr_reader :notification + + delegate :card, to: :notification + + def initialize(notification) + @notification = notification + end + + def to_h + { title: title, body: body, url: url } + end + + private + def title + "New notification" + end + + def body + "You have a new notification" + end + + def url + notifications_url + end + + def card_url(card) + Rails.application.routes.url_helpers.card_url(card, **url_options) + end + + def notifications_url + Rails.application.routes.url_helpers.notifications_url(**url_options) + end + + def url_options + base_options = Rails.application.routes.default_url_options.presence || + Rails.application.config.action_mailer.default_url_options || + {} + base_options.merge(script_name: notification.account.slug) + end +end diff --git a/app/models/notification/event_payload.rb b/app/models/notification/event_payload.rb new file mode 100644 index 0000000000..3cc02dc341 --- /dev/null +++ b/app/models/notification/event_payload.rb @@ -0,0 +1,55 @@ +class Notification::EventPayload < Notification::DefaultPayload + include ExcerptHelper + + private + def title + case event.action + when "comment_created" + "RE: #{card_title}" + else + card_title + end + end + + def body + case event.action + when "comment_created" + format_excerpt(event.eventable.body, length: 200) + when "card_assigned" + "Assigned to you by #{event.creator.name}" + when "card_published" + "Added by #{event.creator.name}" + when "card_closed" + card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}" + when "card_reopened" + "Reopened by #{event.creator.name}" + else + event.creator.name + end + end + + def url + case event.action + when "comment_created" + card_url_with_comment_anchor(event.eventable) + else + card_url(card) + end + end + + def event + notification.source + end + + def card_title + card.title.presence || "Card #{card.number}" + end + + def card_url_with_comment_anchor(comment) + Rails.application.routes.url_helpers.card_url( + comment.card, + anchor: ActionView::RecordIdentifier.dom_id(comment), + **url_options + ) + end +end diff --git a/app/models/notification/mention_payload.rb b/app/models/notification/mention_payload.rb new file mode 100644 index 0000000000..4649f38f3b --- /dev/null +++ b/app/models/notification/mention_payload.rb @@ -0,0 +1,20 @@ +class Notification::MentionPayload < Notification::DefaultPayload + include ExcerptHelper + + private + def title + "#{mention.mentioner.first_name} mentioned you" + end + + def body + format_excerpt(mention.source.mentionable_content, length: 200) + end + + def url + card_url(card) + end + + def mention + notification.source + end +end diff --git a/app/models/notification/push.rb b/app/models/notification/push.rb index c94f121f13..cf2adce440 100644 --- a/app/models/notification/push.rb +++ b/app/models/notification/push.rb @@ -1,6 +1,4 @@ class Notification::Push - include ExcerptHelper - attr_reader :notification delegate :card, to: :notification @@ -27,98 +25,11 @@ def perform_push def build_payload case notification.source_type when "Event" - build_event_payload + Notification::EventPayload.new(notification).to_h when "Mention" - build_mention_payload - else - build_default_payload - end - end - - def build_event_payload - event = notification.source - - base_payload = { - title: card_notification_title(card), - url: card_url(card) - } - - case event.action - when "comment_created" - base_payload.merge( - title: "RE: #{base_payload[:title]}", - body: comment_notification_body(event), - url: card_url_with_comment_anchor(event.eventable) - ) - when "card_assigned" - base_payload.merge( - body: "Assigned to you by #{event.creator.name}" - ) - when "card_published" - base_payload.merge( - body: "Added by #{event.creator.name}" - ) - when "card_closed" - base_payload.merge( - body: card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}" - ) - when "card_reopened" - base_payload.merge( - body: "Reopened by #{event.creator.name}" - ) + Notification::MentionPayload.new(notification).to_h else - base_payload.merge( - body: event.creator.name - ) + Notification::DefaultPayload.new(notification).to_h end end - - def build_mention_payload - mention = notification.source - - { - title: "#{mention.mentioner.first_name} mentioned you", - body: format_excerpt(mention.source.mentionable_content, length: 200), - url: card_url(card) - } - end - - def build_default_payload - { - title: "New notification", - body: "You have a new notification", - url: notifications_url - } - end - - def card_notification_title(card) - card.title.presence || "Card #{card.number}" - end - - def comment_notification_body(event) - format_excerpt(event.eventable.body, length: 200) - end - - def card_url(card) - Rails.application.routes.url_helpers.card_url(card, **url_options) - end - - def notifications_url - Rails.application.routes.url_helpers.notifications_url(**url_options) - end - - def card_url_with_comment_anchor(comment) - Rails.application.routes.url_helpers.card_url( - comment.card, - anchor: ActionView::RecordIdentifier.dom_id(comment), - **url_options - ) - end - - def url_options - base_options = Rails.application.routes.default_url_options.presence || - Rails.application.config.action_mailer.default_url_options || - {} - base_options.merge(script_name: notification.account.slug) - end end From 95a755c882e3c94cc8ea3aea90489e4c98eb142d Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 19:31:19 +0100 Subject: [PATCH 44/72] Move payload method to Notification and make accessors public The notification now owns its payload via #payload method in Pushable, allowing direct access like notification.payload.title. Push classes simply use the notification's payload rather than building it themselves. Co-Authored-By: Claude Opus 4.5 --- app/models/notification/default_payload.rb | 20 +++---- app/models/notification/event_payload.rb | 60 +++++++++---------- app/models/notification/mention_payload.rb | 20 +++---- app/models/notification/push.rb | 11 ---- app/models/notification/push/web.rb | 2 +- app/models/notification/pushable.rb | 9 +++ saas/app/models/notification/push/native.rb | 18 +++--- .../models/notification/push/native_test.rb | 21 +++---- 8 files changed, 78 insertions(+), 83 deletions(-) diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb index acffb13657..fc2d5290a2 100644 --- a/app/models/notification/default_payload.rb +++ b/app/models/notification/default_payload.rb @@ -11,19 +11,19 @@ def to_h { title: title, body: body, url: url } end - private - def title - "New notification" - end + def title + "New notification" + end - def body - "You have a new notification" - end + def body + "You have a new notification" + end - def url - notifications_url - end + def url + notifications_url + end + private def card_url(card) Rails.application.routes.url_helpers.card_url(card, **url_options) end diff --git a/app/models/notification/event_payload.rb b/app/models/notification/event_payload.rb index 3cc02dc341..ebdb807b90 100644 --- a/app/models/notification/event_payload.rb +++ b/app/models/notification/event_payload.rb @@ -1,42 +1,42 @@ class Notification::EventPayload < Notification::DefaultPayload include ExcerptHelper - private - def title - case event.action - when "comment_created" - "RE: #{card_title}" - else - card_title - end + def title + case event.action + when "comment_created" + "RE: #{card_title}" + else + card_title end + end - def body - case event.action - when "comment_created" - format_excerpt(event.eventable.body, length: 200) - when "card_assigned" - "Assigned to you by #{event.creator.name}" - when "card_published" - "Added by #{event.creator.name}" - when "card_closed" - card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}" - when "card_reopened" - "Reopened by #{event.creator.name}" - else - event.creator.name - end + def body + case event.action + when "comment_created" + format_excerpt(event.eventable.body, length: 200) + when "card_assigned" + "Assigned to you by #{event.creator.name}" + when "card_published" + "Added by #{event.creator.name}" + when "card_closed" + card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}" + when "card_reopened" + "Reopened by #{event.creator.name}" + else + event.creator.name end + end - def url - case event.action - when "comment_created" - card_url_with_comment_anchor(event.eventable) - else - card_url(card) - end + def url + case event.action + when "comment_created" + card_url_with_comment_anchor(event.eventable) + else + card_url(card) end + end + private def event notification.source end diff --git a/app/models/notification/mention_payload.rb b/app/models/notification/mention_payload.rb index 4649f38f3b..f07cbf20f0 100644 --- a/app/models/notification/mention_payload.rb +++ b/app/models/notification/mention_payload.rb @@ -1,19 +1,19 @@ class Notification::MentionPayload < Notification::DefaultPayload include ExcerptHelper - private - def title - "#{mention.mentioner.first_name} mentioned you" - end + def title + "#{mention.mentioner.first_name} mentioned you" + end - def body - format_excerpt(mention.source.mentionable_content, length: 200) - end + def body + format_excerpt(mention.source.mentionable_content, length: 200) + end - def url - card_url(card) - end + def url + card_url(card) + end + private def mention notification.source end diff --git a/app/models/notification/push.rb b/app/models/notification/push.rb index cf2adce440..5def39e5b9 100644 --- a/app/models/notification/push.rb +++ b/app/models/notification/push.rb @@ -21,15 +21,4 @@ def should_push? def perform_push raise NotImplementedError end - - def build_payload - case notification.source_type - when "Event" - Notification::EventPayload.new(notification).to_h - when "Mention" - Notification::MentionPayload.new(notification).to_h - else - Notification::DefaultPayload.new(notification).to_h - end - end end diff --git a/app/models/notification/push/web.rb b/app/models/notification/push/web.rb index 33d34e3e7e..6644731015 100644 --- a/app/models/notification/push/web.rb +++ b/app/models/notification/push/web.rb @@ -9,7 +9,7 @@ def should_push? end def perform_push - Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions) + Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions) end def subscriptions diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb index 8137959e6e..17c68137f0 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -31,4 +31,13 @@ def push_later def pushable? !creator.system? && user.active? && account.active? end + + def payload + "Notification::#{payload_type}Payload".constantize.new(self) + end + + private + def payload_type + source_type.presence_in(%w[ Event Mention ]) || "Default" + end end diff --git a/saas/app/models/notification/push/native.rb b/saas/app/models/notification/push/native.rb index 0030cf4b5f..2d0836b851 100644 --- a/saas/app/models/notification/push/native.rb +++ b/saas/app/models/notification/push/native.rb @@ -9,14 +9,18 @@ def should_push? end def perform_push - native_notification(build_payload).deliver_later_to(devices) + native_notification.deliver_later_to(devices) end def devices @devices ||= notification.identity.devices end - def native_notification(payload) + def payload + @payload ||= notification.payload + end + + def native_notification ApplicationPushNotification .with_apple( aps: { @@ -29,9 +33,9 @@ def native_notification(payload) android: { notification: nil } ) .with_data( - title: payload[:title], - body: payload[:body], - url: payload[:url], + title: payload.title, + body: payload.body, + url: payload.url, account_id: notification.account.external_account_id, avatar_url: creator_avatar_url, card_id: card&.id, @@ -40,8 +44,8 @@ def native_notification(payload) category: notification_category ) .new( - title: payload[:title], - body: payload[:body], + title: payload.title, + body: payload.body, badge: notification.user.notifications.unread.count, sound: "default", thread_id: card&.id, diff --git a/saas/test/models/notification/push/native_test.rb b/saas/test/models/notification/push/native_test.rb index 8ba0521a56..0cff2336a1 100644 --- a/saas/test/models/notification/push/native_test.rb +++ b/saas/test/models/notification/push/native_test.rb @@ -104,8 +104,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::Push::Native.new(@notification) - payload = push.send(:build_payload) - native = push.send(:native_notification, payload) + native = push.send(:native_notification) assert_not_nil native.title assert_not_nil native.body @@ -116,8 +115,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::Push::Native.new(@notification) - payload = push.send(:build_payload) - native = push.send(:native_notification, payload) + native = push.send(:native_notification) assert_equal @notification.card.id, native.thread_id end @@ -127,8 +125,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::Push::Native.new(notification) - payload = push.send(:build_payload) - native = push.send(:native_notification, payload) + native = push.send(:native_notification) assert native.high_priority end @@ -137,8 +134,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::Push::Native.new(@notification) - payload = push.send(:build_payload) - native = push.send(:native_notification, payload) + native = push.send(:native_notification) assert_not native.high_priority end @@ -147,8 +143,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::Push::Native.new(@notification) - payload = push.send(:build_payload) - native = push.send(:native_notification, payload) + native = push.send(:native_notification) assert_equal 1, native.apple_data.dig(:aps, :"mutable-content") assert_includes %w[active time-sensitive], native.apple_data.dig(:aps, :"interruption-level") @@ -159,8 +154,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::Push::Native.new(@notification) - payload = push.send(:build_payload) - native = push.send(:native_notification, payload) + native = push.send(:native_notification) assert_nil native.google_data.dig(:android, :notification) end @@ -169,8 +163,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::Push::Native.new(@notification) - payload = push.send(:build_payload) - native = push.send(:native_notification, payload) + native = push.send(:native_notification) assert_not_nil native.data[:url] assert_equal @notification.account.external_account_id, native.data[:account_id] From 5674abd4cca94fbebee958a9292416a47bf92c84 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 19:41:34 +0100 Subject: [PATCH 45/72] Rename Push to PushTarget for better readability PushTarget#push reads more naturally than Push#push. The push target is the thing that pushes notifications to a specific destination. Co-Authored-By: Claude Opus 4.5 --- app/jobs/notification/web_push_job.rb | 2 +- .../notification/{push.rb => push_target.rb} | 2 +- .../notification/{push => push_target}/web.rb | 2 +- app/models/notification/pushable.rb | 2 +- saas/app/jobs/notification/native_push_job.rb | 2 +- .../{push => push_target}/native.rb | 2 +- .../{push => push_target}/native_test.rb | 38 +++++++++---------- .../{push => push_target}/web_test.rb | 20 +++++----- test/models/notification/pushable_test.rb | 2 +- 9 files changed, 36 insertions(+), 36 deletions(-) rename app/models/notification/{push.rb => push_target.rb} (91%) rename app/models/notification/{push => push_target}/web.rb (86%) rename saas/app/models/notification/{push => push_target}/native.rb (97%) rename saas/test/models/notification/{push => push_target}/native_test.rb (81%) rename test/models/notification/{push => push_target}/web_test.rb (80%) diff --git a/app/jobs/notification/web_push_job.rb b/app/jobs/notification/web_push_job.rb index cd30e3581e..3fb83a5132 100644 --- a/app/jobs/notification/web_push_job.rb +++ b/app/jobs/notification/web_push_job.rb @@ -1,5 +1,5 @@ class Notification::WebPushJob < ApplicationJob def perform(notification) - Notification::Push::Web.new(notification).push + Notification::PushTarget::Web.new(notification).push end end diff --git a/app/models/notification/push.rb b/app/models/notification/push_target.rb similarity index 91% rename from app/models/notification/push.rb rename to app/models/notification/push_target.rb index 5def39e5b9..26fd2b2630 100644 --- a/app/models/notification/push.rb +++ b/app/models/notification/push_target.rb @@ -1,4 +1,4 @@ -class Notification::Push +class Notification::PushTarget attr_reader :notification delegate :card, to: :notification diff --git a/app/models/notification/push/web.rb b/app/models/notification/push_target/web.rb similarity index 86% rename from app/models/notification/push/web.rb rename to app/models/notification/push_target/web.rb index 6644731015..fe9b725873 100644 --- a/app/models/notification/push/web.rb +++ b/app/models/notification/push_target/web.rb @@ -1,4 +1,4 @@ -class Notification::Push::Web < Notification::Push +class Notification::PushTarget::Web < Notification::PushTarget def self.push_later(notification) Notification::WebPushJob.perform_later(notification) end diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb index 17c68137f0..0ad8897d68 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -15,7 +15,7 @@ def register_push_target(target) private def resolve_push_target(target) if target.is_a?(Symbol) - "Notification::Push::#{target.to_s.classify}".constantize + "Notification::PushTarget::#{target.to_s.classify}".constantize else target end diff --git a/saas/app/jobs/notification/native_push_job.rb b/saas/app/jobs/notification/native_push_job.rb index c6f08f8406..e06fc18d26 100644 --- a/saas/app/jobs/notification/native_push_job.rb +++ b/saas/app/jobs/notification/native_push_job.rb @@ -1,5 +1,5 @@ class Notification::NativePushJob < ApplicationJob def perform(notification) - Notification::Push::Native.new(notification).push + Notification::PushTarget::Native.new(notification).push end end diff --git a/saas/app/models/notification/push/native.rb b/saas/app/models/notification/push_target/native.rb similarity index 97% rename from saas/app/models/notification/push/native.rb rename to saas/app/models/notification/push_target/native.rb index 2d0836b851..2eb4e5ef2a 100644 --- a/saas/app/models/notification/push/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -1,4 +1,4 @@ -class Notification::Push::Native < Notification::Push +class Notification::PushTarget::Native < Notification::PushTarget def self.push_later(notification) Notification::NativePushJob.perform_later(notification) end diff --git a/saas/test/models/notification/push/native_test.rb b/saas/test/models/notification/push_target/native_test.rb similarity index 81% rename from saas/test/models/notification/push/native_test.rb rename to saas/test/models/notification/push_target/native_test.rb index 0cff2336a1..b684ec11ab 100644 --- a/saas/test/models/notification/push/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Notification::Push::NativeTest < ActiveSupport::TestCase +class Notification::PushTarget::NativeTest < ActiveSupport::TestCase setup do @user = users(:kevin) @identity = @user.identity @@ -14,7 +14,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase notification = notifications(:logo_assignment_kevin) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(notification) + push = Notification::PushTarget::Native.new(notification) assert_equal "assignment", push.send(:notification_category) end @@ -23,7 +23,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase notification = notifications(:layout_commented_kevin) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(notification) + push = Notification::PushTarget::Native.new(notification) assert_equal "comment", push.send(:notification_category) end @@ -32,7 +32,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase notification = notifications(:logo_card_david_mention_by_jz) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(notification) + push = Notification::PushTarget::Native.new(notification) assert_equal "mention", push.send(:notification_category) end @@ -40,7 +40,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "notification_category returns card for other card events" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) assert_equal "card", push.send(:notification_category) end @@ -49,7 +49,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase notification = notifications(:logo_assignment_kevin) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(notification) + push = Notification::PushTarget::Native.new(notification) assert_equal "time-sensitive", push.send(:interruption_level) end @@ -57,7 +57,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "interruption_level is active for non-assignments" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) assert_equal "active", push.send(:interruption_level) end @@ -67,7 +67,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert_native_push_delivery(count: 1) do - Notification::Push::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).push end end @@ -75,7 +75,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.delete_all assert_no_native_push_delivery do - Notification::Push::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).push end end @@ -85,7 +85,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @notification.update!(creator: users(:system)) assert_no_native_push_delivery do - Notification::Push::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).push end end @@ -96,14 +96,14 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "token2", platform: "google", name: "Pixel") assert_native_push_delivery(count: 2) do - Notification::Push::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).push end end test "native notification includes required fields" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_not_nil native.title @@ -114,7 +114,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "native notification sets thread_id from card" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_equal @notification.card.id, native.thread_id @@ -124,7 +124,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase notification = notifications(:logo_assignment_kevin) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(notification) + push = Notification::PushTarget::Native.new(notification) native = push.send(:native_notification) assert native.high_priority @@ -133,7 +133,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "native notification sets normal priority for non-assignments" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_not native.high_priority @@ -142,7 +142,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "native notification includes apple-specific fields" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_equal 1, native.apple_data.dig(:aps, :"mutable-content") @@ -153,7 +153,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "native notification sets android notification to nil for data-only" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_nil native.google_data.dig(:android, :notification) @@ -162,7 +162,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "native notification includes data payload" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::Push::Native.new(@notification) + push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_not_nil native.data[:url] @@ -172,7 +172,7 @@ class Notification::Push::NativeTest < ActiveSupport::TestCase test "push_later enqueues Notification::NativePushJob" do assert_enqueued_with(job: Notification::NativePushJob, args: [ @notification ]) do - Notification::Push::Native.push_later(@notification) + Notification::PushTarget::Native.push_later(@notification) end end end diff --git a/test/models/notification/push/web_test.rb b/test/models/notification/push_target/web_test.rb similarity index 80% rename from test/models/notification/push/web_test.rb rename to test/models/notification/push_target/web_test.rb index 4c81d67196..abf95f3860 100644 --- a/test/models/notification/push/web_test.rb +++ b/test/models/notification/push_target/web_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Notification::Push::WebTest < ActiveSupport::TestCase +class Notification::PushTarget::WebTest < ActiveSupport::TestCase setup do @user = users(:david) @notification = @user.notifications.create!( @@ -27,28 +27,28 @@ class Notification::Push::WebTest < ActiveSupport::TestCase subscriptions.count == 1 end - Notification::Push::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).push end test "does not push when user has no subscriptions" do @user.push_subscriptions.delete_all @web_push_pool.expects(:queue).never - Notification::Push::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).push end test "does not push for cancelled accounts" do @user.account.cancel(initiated_by: @user) @web_push_pool.expects(:queue).never - Notification::Push::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).push end test "does not push when creator is system user" do @notification.update!(creator: users(:system)) @web_push_pool.expects(:queue).never - Notification::Push::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).push end test "payload includes card title for card events" do @@ -56,7 +56,7 @@ class Notification::Push::WebTest < ActiveSupport::TestCase payload[:title] == @notification.card.title end - Notification::Push::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).push end test "payload for comment includes RE prefix" do @@ -67,7 +67,7 @@ class Notification::Push::WebTest < ActiveSupport::TestCase payload[:title].start_with?("RE:") end - Notification::Push::Web.new(notification).push + Notification::PushTarget::Web.new(notification).push end test "payload for assignment includes assigned message" do @@ -78,7 +78,7 @@ class Notification::Push::WebTest < ActiveSupport::TestCase payload[:body].include?("Assigned to you") end - Notification::Push::Web.new(notification).push + Notification::PushTarget::Web.new(notification).push end test "payload for mention includes mentioner name" do @@ -89,12 +89,12 @@ class Notification::Push::WebTest < ActiveSupport::TestCase payload[:title].include?("mentioned you") end - Notification::Push::Web.new(notification).push + Notification::PushTarget::Web.new(notification).push end test "push_later enqueues Notification::WebPushJob" do assert_enqueued_with(job: Notification::WebPushJob, args: [ @notification ]) do - Notification::Push::Web.push_later(@notification) + Notification::PushTarget::Web.push_later(@notification) end end end diff --git a/test/models/notification/pushable_test.rb b/test/models/notification/pushable_test.rb index 2f99ac2c58..898061b518 100644 --- a/test/models/notification/pushable_test.rb +++ b/test/models/notification/pushable_test.rb @@ -35,7 +35,7 @@ class Notification::PushableTest < ActiveSupport::TestCase Notification.register_push_target(:web) - assert_includes Notification.push_targets, Notification::Push::Web + assert_includes Notification.push_targets, Notification::PushTarget::Web ensure Notification.push_targets = original_targets end From e0107b09011067cf16782a69952564e60355d1d7 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:02:14 +0100 Subject: [PATCH 46/72] Link devices to sessions for automatic cleanup on logout When a session is destroyed (user logs out), all devices registered to that session are automatically destroyed, preventing push notifications from being sent to logged-out devices. Co-Authored-By: Claude Opus 4.5 --- ...d_session_to_action_push_native_devices.rb | 5 +++ db/schema.rb | 6 ++- saas/app/controllers/devices_controller.rb | 2 +- saas/app/models/application_push_device.rb | 8 ++-- saas/app/models/session/devices.rb | 7 ++++ saas/lib/fizzy/saas/engine.rb | 1 + saas/test/models/session/devices_test.rb | 40 +++++++++++++++++++ 7 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20260121184815_add_session_to_action_push_native_devices.rb create mode 100644 saas/app/models/session/devices.rb create mode 100644 saas/test/models/session/devices_test.rb diff --git a/db/migrate/20260121184815_add_session_to_action_push_native_devices.rb b/db/migrate/20260121184815_add_session_to_action_push_native_devices.rb new file mode 100644 index 0000000000..e255f2eb36 --- /dev/null +++ b/db/migrate/20260121184815_add_session_to_action_push_native_devices.rb @@ -0,0 +1,5 @@ +class AddSessionToActionPushNativeDevices < ActiveRecord::Migration[8.2] + def change + add_reference :action_push_native_devices, :session, foreign_key: true, type: :uuid + end +end diff --git a/db/schema.rb b/db/schema.rb index 62f34756ef..0c0c39b29c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_21_044252) do +ActiveRecord::Schema[8.2].define(version: 2026_01_21_184815) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -74,10 +74,12 @@ t.uuid "owner_id" t.string "owner_type" t.string "platform", null: false + t.uuid "session_id" t.string "token", null: false t.datetime "updated_at", null: false t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" + t.index ["session_id"], name: "index_action_push_native_devices_on_session_id" end create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -832,4 +834,6 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end + + add_foreign_key "action_push_native_devices", "sessions" end diff --git a/saas/app/controllers/devices_controller.rb b/saas/app/controllers/devices_controller.rb index 6913da0c58..fc49b14b09 100644 --- a/saas/app/controllers/devices_controller.rb +++ b/saas/app/controllers/devices_controller.rb @@ -6,7 +6,7 @@ def index end def create - ApplicationPushDevice.register(owner: Current.identity, **device_params) + ApplicationPushDevice.register(session: Current.session, **device_params) head :created end diff --git a/saas/app/models/application_push_device.rb b/saas/app/models/application_push_device.rb index 6547ec2065..7d9aad4e7f 100644 --- a/saas/app/models/application_push_device.rb +++ b/saas/app/models/application_push_device.rb @@ -1,7 +1,9 @@ class ApplicationPushDevice < ActionPushNative::Device - def self.register(owner:, token:, platform:, name: nil) - owner.devices.find_or_initialize_by(token: token).tap do |device| - device.update!(platform: platform.downcase, name: name) + belongs_to :session, optional: true + + def self.register(session:, token:, platform:, name: nil) + session.identity.devices.find_or_initialize_by(token: token).tap do |device| + device.update!(session: session, platform: platform.downcase, name: name) end end end diff --git a/saas/app/models/session/devices.rb b/saas/app/models/session/devices.rb new file mode 100644 index 0000000000..c159ada723 --- /dev/null +++ b/saas/app/models/session/devices.rb @@ -0,0 +1,7 @@ +module Session::Devices + extend ActiveSupport::Concern + + included do + has_many :devices, class_name: "ApplicationPushDevice", dependent: :destroy + end +end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 0fdf9c8780..ecc163cea2 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -140,6 +140,7 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::Identity.include Identity::Devices + ::Session.include Session::Devices ::Notification.register_push_target(:native) ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) diff --git a/saas/test/models/session/devices_test.rb b/saas/test/models/session/devices_test.rb new file mode 100644 index 0000000000..00874895de --- /dev/null +++ b/saas/test/models/session/devices_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Session::DevicesTest < ActiveSupport::TestCase + setup do + @session = sessions(:david) + @identity = @session.identity + end + + test "destroying session destroys associated devices" do + device = ApplicationPushDevice.register( + session: @session, + token: "test_token", + platform: "apple", + name: "Test iPhone" + ) + + assert_difference -> { ApplicationPushDevice.count }, -1 do + @session.destroy + end + + assert_nil ApplicationPushDevice.find_by(id: device.id) + end + + test "destroying session does not destroy devices from other sessions" do + other_session = sessions(:kevin) + + device = ApplicationPushDevice.register( + session: other_session, + token: "other_token", + platform: "apple", + name: "Other iPhone" + ) + + assert_no_difference -> { ApplicationPushDevice.count } do + @session.destroy + end + + assert ApplicationPushDevice.exists?(device.id) + end +end From f1d2fe41475e82205d9b8e8b55b69cf839bec754 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:26:47 +0100 Subject: [PATCH 47/72] Tidy up saas engine a bit more --- saas/lib/fizzy/saas/engine.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index ecc163cea2..1d5813bc3e 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -139,13 +139,16 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited - ::Identity.include Identity::Devices + ::Identity.include Authorization::Identity, Identity::Devices ::Session.include Session::Devices - ::Notification.register_push_target(:native) - ::Signup.prepend Fizzy::Saas::Signup + ::Signup.prepend Signup + + ApplicationController.include Authorization::Controller CardsController.include(Card::LimitedCreation) Cards::PublishesController.include(Card::LimitedPublishing) + Notification.register_push_target(:native) + Queenbee::Subscription.short_names = Subscription::SHORT_NAMES # Default to local dev QB token if not set @@ -156,9 +159,6 @@ class Engine < ::Rails::Engine ::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name) ::Object.const_set const_name, Subscription.const_get(short_name, false) end - - ::ApplicationController.include Fizzy::Saas::Authorization::Controller - ::Identity.include Fizzy::Saas::Authorization::Identity end end end From 06878ac82044a69abd26089e829dd3487f65bfaf Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:38:33 +0100 Subject: [PATCH 48/72] Fix reference to `user.devices`, left-over from the identity switch --- .../app/views/notifications/settings/_native_devices.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/views/notifications/settings/_native_devices.html.erb b/saas/app/views/notifications/settings/_native_devices.html.erb index 9931f822f4..ea9e9987d1 100644 --- a/saas/app/views/notifications/settings/_native_devices.html.erb +++ b/saas/app/views/notifications/settings/_native_devices.html.erb @@ -1,9 +1,9 @@

Mobile Devices

- <% if Current.user.devices.any? %> + <% if Current.identity.devices.any? %>

- You have <%= pluralize(Current.user.devices.count, "mobile device") %> registered for push notifications. + You have <%= pluralize(Current.identity.devices.count, "mobile device") %> registered for push notifications.

<%= link_to "Manage devices", devices_path, class: "btn txt-small" %> <% else %> From f7695d6aa1b46388b3a6208add3d0cdf0cc1b5a9 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:45:13 +0100 Subject: [PATCH 49/72] Remove redundant owner index from devices table The unique (owner_type, owner_id, token) index already serves queries filtering by (owner_type, owner_id) via its leftmost prefix. Co-Authored-By: Claude Opus 4.5 --- ..._redundant_owner_index_from_action_push_native_devices.rb | 5 +++++ db/schema.rb | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb diff --git a/db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb b/db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb new file mode 100644 index 0000000000..3c1392b446 --- /dev/null +++ b/db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb @@ -0,0 +1,5 @@ +class RemoveRedundantOwnerIndexFromActionPushNativeDevices < ActiveRecord::Migration[8.2] + def change + remove_index :action_push_native_devices, column: [ :owner_type, :owner_id ] + end +end diff --git a/db/schema.rb b/db/schema.rb index 0c0c39b29c..0b1cc1955d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_21_184815) do +ActiveRecord::Schema[8.2].define(version: 2026_01_21_194404) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -78,7 +78,6 @@ t.string "token", null: false t.datetime "updated_at", null: false t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true - t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" t.index ["session_id"], name: "index_action_push_native_devices_on_session_id" end From 1062bb4526a474c23b9c092a2f31bfe914b5aa74 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:50:57 +0100 Subject: [PATCH 50/72] Squash device migrations into single table creation Consolidates the session reference and index cleanup into the original CreateActionPushNativeDevices migration for a cleaner migration history. Co-Authored-By: Claude Opus 4.5 --- .../20260114203313_create_action_push_native_devices.rb | 3 ++- ...260121184815_add_session_to_action_push_native_devices.rb | 5 ----- ..._redundant_owner_index_from_action_push_native_devices.rb | 5 ----- db/schema.rb | 2 +- 4 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 db/migrate/20260121184815_add_session_to_action_push_native_devices.rb delete mode 100644 db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb index 5ef4be322a..4985c8636a 100644 --- a/db/migrate/20260114203313_create_action_push_native_devices.rb +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -4,7 +4,8 @@ def change t.string :name t.string :platform, null: false t.string :token, null: false - t.belongs_to :owner, polymorphic: true, type: :uuid + t.belongs_to :owner, polymorphic: true, type: :uuid, index: false + t.belongs_to :session, type: :uuid, foreign_key: true t.timestamps end diff --git a/db/migrate/20260121184815_add_session_to_action_push_native_devices.rb b/db/migrate/20260121184815_add_session_to_action_push_native_devices.rb deleted file mode 100644 index e255f2eb36..0000000000 --- a/db/migrate/20260121184815_add_session_to_action_push_native_devices.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddSessionToActionPushNativeDevices < ActiveRecord::Migration[8.2] - def change - add_reference :action_push_native_devices, :session, foreign_key: true, type: :uuid - end -end diff --git a/db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb b/db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb deleted file mode 100644 index 3c1392b446..0000000000 --- a/db/migrate/20260121194404_remove_redundant_owner_index_from_action_push_native_devices.rb +++ /dev/null @@ -1,5 +0,0 @@ -class RemoveRedundantOwnerIndexFromActionPushNativeDevices < ActiveRecord::Migration[8.2] - def change - remove_index :action_push_native_devices, column: [ :owner_type, :owner_id ] - end -end diff --git a/db/schema.rb b/db/schema.rb index 0b1cc1955d..0079916975 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_21_194404) do +ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false From ad9ff21dddc0aa65626de1baa0cd1194ba69b8b2 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 21:35:57 +0100 Subject: [PATCH 51/72] Remove foreign key constraint from devices to sessions Devices should persist independently of sessions - when a session is deleted, the device registration should remain valid. Co-Authored-By: Claude Opus 4.5 --- db/migrate/20260114203313_create_action_push_native_devices.rb | 2 +- db/schema.rb | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb index 4985c8636a..c696a35bc1 100644 --- a/db/migrate/20260114203313_create_action_push_native_devices.rb +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -5,7 +5,7 @@ def change t.string :platform, null: false t.string :token, null: false t.belongs_to :owner, polymorphic: true, type: :uuid, index: false - t.belongs_to :session, type: :uuid, foreign_key: true + t.belongs_to :session, type: :uuid t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 0079916975..ebd74089dc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -833,6 +833,4 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end - - add_foreign_key "action_push_native_devices", "sessions" end From 1ee47cdc17bd2737f1c58c5ea4d834a866e1d925 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 21 Jan 2026 21:38:07 -0600 Subject: [PATCH 52/72] Change priority notification level for mentions and assignments --- app/models/event.rb | 4 ++++ app/models/mention.rb | 4 ++++ saas/app/models/notification/push_target/native.rb | 8 ++++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index bbbf2a60c6..6ba11a28d3 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -37,6 +37,10 @@ def description_for(user) Event::Description.new(self, user) end + def high_priority_push? + action.card_assigned? + end + private def dispatch_webhooks Event::WebhookDispatchJob.perform_later(self) diff --git a/app/models/mention.rb b/app/models/mention.rb index 5491bfd9fe..66a02428a3 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -18,6 +18,10 @@ def notifiable_target source end + def high_priority_push? + true + end + private def watch_source_by_mentionee source.watch_by(mentionee) diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 2eb4e5ef2a..d2e86ead43 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -49,7 +49,7 @@ def native_notification badge: notification.user.notifications.unread.count, sound: "default", thread_id: card&.id, - high_priority: assignment_notification? + high_priority: high_priority_notification? ) end @@ -69,11 +69,11 @@ def notification_category end def interruption_level - assignment_notification? ? "time-sensitive" : "active" + high_priority_notification? ? "time-sensitive" : "active" end - def assignment_notification? - notification.source.is_a?(Event) && notification.source.action == "card_assigned" + def high_priority_notification? + notification.source.high_priority_push? end def creator_avatar_url From bab4ee02628c344983ba012e9ed449c68ff197ae Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 21 Jan 2026 21:38:16 -0600 Subject: [PATCH 53/72] Add unit tests --- .../notification/push_target/native_test.rb | 42 ++++++++++++++++++- test/models/event_test.rb | 27 ++++++++++++ test/models/mention_test.rb | 15 +++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 test/models/event_test.rb create mode 100644 test/models/mention_test.rb diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index b684ec11ab..037d99177e 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -54,7 +54,25 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert_equal "time-sensitive", push.send(:interruption_level) end - test "interruption_level is active for non-assignments" do + test "interruption_level is time-sensitive for mentions" do + notification = notifications(:logo_card_david_mention_by_jz) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + + assert_equal "time-sensitive", push.send(:interruption_level) + end + + test "interruption_level is active for comments" do + notification = notifications(:layout_commented_kevin) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + + assert_equal "active", push.send(:interruption_level) + end + + test "interruption_level is active for other card events" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) @@ -130,7 +148,27 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert native.high_priority end - test "native notification sets normal priority for non-assignments" do + test "native notification sets high_priority for mentions" do + notification = notifications(:logo_card_david_mention_by_jz) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert native.high_priority + end + + test "native notification sets normal priority for comments" do + notification = notifications(:layout_commented_kevin) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_not native.high_priority + end + + test "native notification sets normal priority for other card events" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) diff --git a/test/models/event_test.rb b/test/models/event_test.rb new file mode 100644 index 0000000000..fbe156fa91 --- /dev/null +++ b/test/models/event_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class EventTest < ActiveSupport::TestCase + test "high_priority_push? is true for card_assigned" do + event = events(:logo_assignment_jz) + + assert event.high_priority_push? + end + + test "high_priority_push? is false for comment_created" do + event = events(:layout_commented) + + assert_not event.high_priority_push? + end + + test "high_priority_push? is false for card_published" do + event = events(:logo_published) + + assert_not event.high_priority_push? + end + + test "high_priority_push? is false for card_closed" do + event = events(:shipping_closed) + + assert_not event.high_priority_push? + end +end diff --git a/test/models/mention_test.rb b/test/models/mention_test.rb new file mode 100644 index 0000000000..8260c00b1f --- /dev/null +++ b/test/models/mention_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class MentionTest < ActiveSupport::TestCase + test "high_priority_push? is always true" do + mention = mentions(:logo_card_david_mention_by_jz) + + assert mention.high_priority_push? + end + + test "high_priority_push? is true for comment mentions" do + mention = mentions(:logo_comment_david_mention_by_jz) + + assert mention.high_priority_push? + end +end From 549df0d4e0d2edeac6ca742e166c4c2b6ad6bbc9 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 21 Jan 2026 21:40:47 -0600 Subject: [PATCH 54/72] Update unit tests --- .../notification/push_target/native_test.rb | 74 ++++++++++--------- test/models/event_test.rb | 27 ------- test/models/mention_test.rb | 15 ---- 3 files changed, 39 insertions(+), 77 deletions(-) delete mode 100644 test/models/event_test.rb delete mode 100644 test/models/mention_test.rb diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index 037d99177e..93fe546c5c 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -45,40 +45,6 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert_equal "card", push.send(:notification_category) end - test "interruption_level is time-sensitive for assignments" do - notification = notifications(:logo_assignment_kevin) - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(notification) - - assert_equal "time-sensitive", push.send(:interruption_level) - end - - test "interruption_level is time-sensitive for mentions" do - notification = notifications(:logo_card_david_mention_by_jz) - notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(notification) - - assert_equal "time-sensitive", push.send(:interruption_level) - end - - test "interruption_level is active for comments" do - notification = notifications(:layout_commented_kevin) - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(notification) - - assert_equal "active", push.send(:interruption_level) - end - - test "interruption_level is active for other card events" do - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(@notification) - - assert_equal "active", push.send(:interruption_level) - end test "pushes to native devices when user has devices" do stub_push_services @@ -184,10 +150,48 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase native = push.send(:native_notification) assert_equal 1, native.apple_data.dig(:aps, :"mutable-content") - assert_includes %w[active time-sensitive], native.apple_data.dig(:aps, :"interruption-level") assert_not_nil native.apple_data.dig(:aps, :category) end + test "native notification sets time-sensitive interruption level for assignments" do + notification = notifications(:logo_assignment_kevin) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_equal "time-sensitive", native.apple_data.dig(:aps, :"interruption-level") + end + + test "native notification sets time-sensitive interruption level for mentions" do + notification = notifications(:logo_card_david_mention_by_jz) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_equal "time-sensitive", native.apple_data.dig(:aps, :"interruption-level") + end + + test "native notification sets active interruption level for comments" do + notification = notifications(:layout_commented_kevin) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_equal "active", native.apple_data.dig(:aps, :"interruption-level") + end + + test "native notification sets active interruption level for other card events" do + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(@notification) + native = push.send(:native_notification) + + assert_equal "active", native.apple_data.dig(:aps, :"interruption-level") + end + test "native notification sets android notification to nil for data-only" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") diff --git a/test/models/event_test.rb b/test/models/event_test.rb deleted file mode 100644 index fbe156fa91..0000000000 --- a/test/models/event_test.rb +++ /dev/null @@ -1,27 +0,0 @@ -require "test_helper" - -class EventTest < ActiveSupport::TestCase - test "high_priority_push? is true for card_assigned" do - event = events(:logo_assignment_jz) - - assert event.high_priority_push? - end - - test "high_priority_push? is false for comment_created" do - event = events(:layout_commented) - - assert_not event.high_priority_push? - end - - test "high_priority_push? is false for card_published" do - event = events(:logo_published) - - assert_not event.high_priority_push? - end - - test "high_priority_push? is false for card_closed" do - event = events(:shipping_closed) - - assert_not event.high_priority_push? - end -end diff --git a/test/models/mention_test.rb b/test/models/mention_test.rb deleted file mode 100644 index 8260c00b1f..0000000000 --- a/test/models/mention_test.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "test_helper" - -class MentionTest < ActiveSupport::TestCase - test "high_priority_push? is always true" do - mention = mentions(:logo_card_david_mention_by_jz) - - assert mention.high_priority_push? - end - - test "high_priority_push? is true for comment mentions" do - mention = mentions(:logo_comment_david_mention_by_jz) - - assert mention.high_priority_push? - end -end From 167776ac99367bfbfd1131ea5a51b5f416e4b6a6 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 21 Jan 2026 21:38:07 -0600 Subject: [PATCH 55/72] Change priority notification level for mentions and assignments --- app/models/event.rb | 4 + app/models/mention.rb | 4 + .../models/notification/push_target/native.rb | 8 +- .../notification/push_target/native_test.rb | 78 ++++++++++++++----- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index bbbf2a60c6..6ba11a28d3 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -37,6 +37,10 @@ def description_for(user) Event::Description.new(self, user) end + def high_priority_push? + action.card_assigned? + end + private def dispatch_webhooks Event::WebhookDispatchJob.perform_later(self) diff --git a/app/models/mention.rb b/app/models/mention.rb index 5491bfd9fe..66a02428a3 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -18,6 +18,10 @@ def notifiable_target source end + def high_priority_push? + true + end + private def watch_source_by_mentionee source.watch_by(mentionee) diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 2eb4e5ef2a..d2e86ead43 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -49,7 +49,7 @@ def native_notification badge: notification.user.notifications.unread.count, sound: "default", thread_id: card&.id, - high_priority: assignment_notification? + high_priority: high_priority_notification? ) end @@ -69,11 +69,11 @@ def notification_category end def interruption_level - assignment_notification? ? "time-sensitive" : "active" + high_priority_notification? ? "time-sensitive" : "active" end - def assignment_notification? - notification.source.is_a?(Event) && notification.source.action == "card_assigned" + def high_priority_notification? + notification.source.high_priority_push? end def creator_avatar_url diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index b684ec11ab..93fe546c5c 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -45,22 +45,6 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert_equal "card", push.send(:notification_category) end - test "interruption_level is time-sensitive for assignments" do - notification = notifications(:logo_assignment_kevin) - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(notification) - - assert_equal "time-sensitive", push.send(:interruption_level) - end - - test "interruption_level is active for non-assignments" do - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(@notification) - - assert_equal "active", push.send(:interruption_level) - end test "pushes to native devices when user has devices" do stub_push_services @@ -130,7 +114,27 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert native.high_priority end - test "native notification sets normal priority for non-assignments" do + test "native notification sets high_priority for mentions" do + notification = notifications(:logo_card_david_mention_by_jz) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert native.high_priority + end + + test "native notification sets normal priority for comments" do + notification = notifications(:layout_commented_kevin) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_not native.high_priority + end + + test "native notification sets normal priority for other card events" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) @@ -146,10 +150,48 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase native = push.send(:native_notification) assert_equal 1, native.apple_data.dig(:aps, :"mutable-content") - assert_includes %w[active time-sensitive], native.apple_data.dig(:aps, :"interruption-level") assert_not_nil native.apple_data.dig(:aps, :category) end + test "native notification sets time-sensitive interruption level for assignments" do + notification = notifications(:logo_assignment_kevin) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_equal "time-sensitive", native.apple_data.dig(:aps, :"interruption-level") + end + + test "native notification sets time-sensitive interruption level for mentions" do + notification = notifications(:logo_card_david_mention_by_jz) + notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_equal "time-sensitive", native.apple_data.dig(:aps, :"interruption-level") + end + + test "native notification sets active interruption level for comments" do + notification = notifications(:layout_commented_kevin) + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(notification) + native = push.send(:native_notification) + + assert_equal "active", native.apple_data.dig(:aps, :"interruption-level") + end + + test "native notification sets active interruption level for other card events" do + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(@notification) + native = push.send(:native_notification) + + assert_equal "active", native.apple_data.dig(:aps, :"interruption-level") + end + test "native notification sets android notification to nil for data-only" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") From ee05044922c8cfc6b8a3f35cde55867a1455b0c3 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 11:38:32 +0100 Subject: [PATCH 56/72] Make devices controller untenanted with engine routes - Add disallow_account_scope to skip tenant requirement - Move routes to saas/config/routes.rb (engine routes) - Use saas.devices_path/saas.device_path for engine route helpers - Update tests to work without tenant context Devices belong to Identity (global), not Account, so they don't need tenant context in the URL. Co-Authored-By: Claude Opus 4.5 --- saas/app/controllers/devices_controller.rb | 3 +- saas/app/views/devices/index.html.erb | 2 +- saas/config/routes.rb | 2 + saas/lib/fizzy/saas/engine.rb | 2 - .../controllers/devices_controller_test.rb | 92 +++++++++++-------- 5 files changed, 57 insertions(+), 44 deletions(-) diff --git a/saas/app/controllers/devices_controller.rb b/saas/app/controllers/devices_controller.rb index fc49b14b09..e6b6b1d197 100644 --- a/saas/app/controllers/devices_controller.rb +++ b/saas/app/controllers/devices_controller.rb @@ -1,4 +1,5 @@ class DevicesController < ApplicationController + disallow_account_scope before_action :set_device, only: :destroy def index @@ -13,7 +14,7 @@ def create def destroy @device.destroy respond_to do |format| - format.html { redirect_to devices_path, notice: "Device removed" } + format.html { redirect_to saas.devices_path, notice: "Device removed" } format.json { head :no_content } end end diff --git a/saas/app/views/devices/index.html.erb b/saas/app/views/devices/index.html.erb index bb7d74871c..9e467731a1 100644 --- a/saas/app/views/devices/index.html.erb +++ b/saas/app/views/devices/index.html.erb @@ -7,7 +7,7 @@ <%= device.name || "Unnamed device" %> (<%= device.platform == "apple" ? "iOS" : "Android" %>) Added <%= time_ago_in_words(device.created_at) %> ago - <%= button_to "Remove", device_path(device), method: :delete, data: { confirm: "Remove this device?" } %> + <%= button_to "Remove", saas.device_path(device), method: :delete, data: { confirm: "Remove this device?" } %> <% end %> diff --git a/saas/config/routes.rb b/saas/config/routes.rb index ece9fef17c..3c5a7fb983 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -1,6 +1,8 @@ Fizzy::Saas::Engine.routes.draw do Queenbee.routes(self) + resources :devices, only: [ :index, :create, :destroy ] + namespace :admin do mount Audits1984::Engine, at: "/console" get "stats", to: "stats#show" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 1d5813bc3e..296940386d 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -37,8 +37,6 @@ class Engine < ::Rails::Engine namespace :stripe do resource :webhooks, only: :create end - - resources :devices, only: [ :index, :create, :destroy ] end end diff --git a/saas/test/controllers/devices_controller_test.rb b/saas/test/controllers/devices_controller_test.rb index 0434a29cd8..b05ca5ed29 100644 --- a/saas/test/controllers/devices_controller_test.rb +++ b/saas/test/controllers/devices_controller_test.rb @@ -9,7 +9,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest test "index shows identity's devices" do @identity.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") - get devices_path + untenanted { get saas.devices_path } assert_response :success assert_select "strong", "iPhone 15 Pro" @@ -19,7 +19,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest test "index shows empty state when no devices" do @identity.devices.delete_all - get devices_path + untenanted { get saas.devices_path } assert_response :success assert_select "p", /No devices registered/ @@ -28,7 +28,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest test "index requires authentication" do sign_out - get devices_path + untenanted { get saas.devices_path } assert_response :redirect end @@ -37,11 +37,13 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest token = SecureRandom.hex(32) assert_difference -> { ApplicationPushDevice.count }, 1 do - post devices_path, params: { - token: token, - platform: "apple", - name: "iPhone 15 Pro" - }, as: :json + untenanted do + post saas.devices_path, params: { + token: token, + platform: "apple", + name: "iPhone 15 Pro" + }, as: :json + end end assert_response :created @@ -54,11 +56,13 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest end test "creates android device" do - post devices_path, params: { - token: SecureRandom.hex(32), - platform: "google", - name: "Pixel 8" - }, as: :json + untenanted do + post saas.devices_path, params: { + token: SecureRandom.hex(32), + platform: "google", + name: "Pixel 8" + }, as: :json + end assert_response :created @@ -79,11 +83,13 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest # Current identity registers the same token with their own device assert_difference -> { ApplicationPushDevice.count }, 1 do - post devices_path, params: { - token: shared_token, - platform: "apple", - name: "David's iPhone" - }, as: :json + untenanted do + post saas.devices_path, params: { + token: shared_token, + platform: "apple", + name: "David's iPhone" + }, as: :json + end end assert_response :created @@ -98,20 +104,24 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest end test "rejects invalid platform" do - post devices_path, params: { - token: SecureRandom.hex(32), - platform: "windows", - name: "Surface" - }, as: :json + untenanted do + post saas.devices_path, params: { + token: SecureRandom.hex(32), + platform: "windows", + name: "Surface" + }, as: :json + end assert_response :unprocessable_entity end test "rejects missing token" do - post devices_path, params: { - platform: "apple", - name: "iPhone" - }, as: :json + untenanted do + post saas.devices_path, params: { + platform: "apple", + name: "iPhone" + }, as: :json + end assert_response :bad_request end @@ -119,10 +129,12 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest test "create requires authentication" do sign_out - post devices_path, params: { - token: SecureRandom.hex(32), - platform: "apple" - }, as: :json + untenanted do + post saas.devices_path, params: { + token: SecureRandom.hex(32), + platform: "apple" + }, as: :json + end assert_response :redirect end @@ -135,16 +147,16 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_difference -> { ApplicationPushDevice.count }, -1 do - delete device_path(device) + untenanted { delete saas.device_path(device) } end - assert_redirected_to devices_path + assert_redirected_to saas.devices_path(script_name: nil) assert_not ApplicationPushDevice.exists?(device.id) end test "returns not found when device not found by id" do assert_no_difference "ApplicationPushDevice.count" do - delete device_path(id: "nonexistent") + untenanted { delete saas.device_path(id: "nonexistent") } end assert_response :not_found @@ -159,7 +171,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_no_difference "ApplicationPushDevice.count" do - delete device_path(device) + untenanted { delete saas.device_path(device) } end assert_response :not_found @@ -175,7 +187,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest sign_out - delete device_path(device) + untenanted { delete saas.device_path(device) } assert_response :redirect assert ApplicationPushDevice.exists?(device.id) @@ -189,7 +201,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_difference -> { ApplicationPushDevice.count }, -1 do - delete device_path("token_to_unregister"), as: :json + untenanted { delete saas.device_path("token_to_unregister"), as: :json } end assert_response :no_content @@ -198,7 +210,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest test "returns not found when device not found by token" do assert_no_difference "ApplicationPushDevice.count" do - delete device_path("nonexistent_token"), as: :json + untenanted { delete saas.device_path("nonexistent_token"), as: :json } end assert_response :not_found @@ -213,7 +225,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest ) assert_no_difference "ApplicationPushDevice.count" do - delete device_path("other_identity_token"), as: :json + untenanted { delete saas.device_path("other_identity_token"), as: :json } end assert_response :not_found @@ -229,7 +241,7 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest sign_out - delete device_path("my_token"), as: :json + untenanted { delete saas.device_path("my_token"), as: :json } assert_response :redirect assert ApplicationPushDevice.exists?(device.id) From 0eb1b1e83bf2690d731f0a27b1f4909fbd53df59 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 13:01:21 +0100 Subject: [PATCH 57/72] Move devices table to saas database Use ActionPushNative's new on_load hook to configure the database connection, following the same pattern as Active Storage and Action Text: ActiveSupport.on_load(:action_push_native_record) do connects_to database: { writing: :saas, reading: :saas } end This allows ApplicationPushDevice to inherit directly from ActionPushNative::Device without needing an intermediate abstract class. Co-Authored-By: Claude Opus 4.5 --- Gemfile.lock | 8 ++++---- Gemfile.saas | 2 +- Gemfile.saas.lock | 3 ++- db/schema.rb | 15 +-------------- db/schema_sqlite.rb | 15 +-------------- ...114203313_create_action_push_native_devices.rb | 0 saas/db/saas_schema.rb | 15 ++++++++++++++- saas/lib/fizzy/saas/engine.rb | 5 +++++ .../devices.yml => application_push_devices.yml} | 0 9 files changed, 28 insertions(+), 35 deletions(-) rename {db => saas/db}/migrate/20260114203313_create_action_push_native_devices.rb (100%) rename saas/test/fixtures/{action_push_native/devices.yml => application_push_devices.yml} (100%) diff --git a/Gemfile.lock b/Gemfile.lock index ea92feb395..d4b2d491ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,8 +118,8 @@ GEM specs: action_text-trix (2.1.16) railties - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -327,7 +327,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) @@ -351,7 +351,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (7.0.3) + rdoc (7.1.0) erb psych (>= 4.0.0) tsort diff --git a/Gemfile.saas b/Gemfile.saas index f34cbb6df2..e5fba52f89 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -11,7 +11,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" # Native push notifications (iOS/Android) -gem "action_push_native", github: "rails/action_push_native" +gem "action_push_native", github: "rails/action_push_native", branch: "add-abstract-record" # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index ee46c850f2..7c6d82001c 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -60,7 +60,8 @@ GIT GIT remote: https://github.com/rails/action_push_native.git - revision: 9fb4a2bfe54270b1a3508028f00aaa586e257655 + revision: 8ef7023a335e1f09ad1fe22a4b7b007b040528bd + branch: add-abstract-record specs: action_push_native (0.3.0) activejob (>= 8.0) diff --git a/db/schema.rb b/db/schema.rb index ebd74089dc..86b9f29375 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do +ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -68,19 +68,6 @@ t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end - create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.datetime "created_at", null: false - t.string "name" - t.uuid "owner_id" - t.string "owner_type" - t.string "platform", null: false - t.uuid "session_id" - t.string "token", null: false - t.datetime "updated_at", null: false - t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true - t.index ["session_id"], name: "index_action_push_native_devices_on_session_id" - end - create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.text "body", size: :long diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index b04576934c..b76f998659 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do +ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -68,19 +68,6 @@ t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end - create_table "action_push_native_devices", force: :cascade do |t| - t.datetime "created_at", null: false - t.string "name", limit: 255 - t.integer "owner_id" - t.string "owner_type", limit: 255 - t.string "platform", limit: 255, null: false - t.string "token", limit: 255, null: false - t.datetime "updated_at", null: false - t.string "uuid", limit: 255, null: false - t.index ["owner_type", "owner_id", "uuid"], name: "idx_on_owner_type_owner_id_uuid_a42e3920d5", unique: true - t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" - end - create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.text "body", limit: 4294967295 diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/saas/db/migrate/20260114203313_create_action_push_native_devices.rb similarity index 100% rename from db/migrate/20260114203313_create_action_push_native_devices.rb rename to saas/db/migrate/20260114203313_create_action_push_native_devices.rb diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 7e9b80b92d..2a4232d8bb 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_16_000000) do +ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false @@ -43,6 +43,19 @@ t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true end + create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.uuid "owner_id" + t.string "owner_type" + t.string "platform", null: false + t.uuid "session_id" + t.string "token", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true + t.index ["session_id"], name: "index_action_push_native_devices_on_session_id" + end + create_table "audits1984_audits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "auditor_id", null: false t.datetime "created_at", null: false diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 296940386d..67a6969166 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -9,6 +9,11 @@ class Engine < ::Rails::Engine # moved from config/initializers/queenbee.rb Queenbee.host_app = Fizzy + # Configure ActionPushNative to use the saas database + ActiveSupport.on_load(:action_push_native_record) do + connects_to database: { writing: :saas, reading: :saas } + end + initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app| app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com" end diff --git a/saas/test/fixtures/action_push_native/devices.yml b/saas/test/fixtures/application_push_devices.yml similarity index 100% rename from saas/test/fixtures/action_push_native/devices.yml rename to saas/test/fixtures/application_push_devices.yml From b63940c5003b33e7b8d3d4b188f13c4ef8f2afd9 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 13:12:53 +0100 Subject: [PATCH 58/72] Move push priority concerns from Event and Mention into Native push target --- app/models/event.rb | 4 ---- app/models/mention.rb | 4 ---- saas/app/models/notification/push_target/native.rb | 6 +++++- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index 6ba11a28d3..bbbf2a60c6 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -37,10 +37,6 @@ def description_for(user) Event::Description.new(self, user) end - def high_priority_push? - action.card_assigned? - end - private def dispatch_webhooks Event::WebhookDispatchJob.perform_later(self) diff --git a/app/models/mention.rb b/app/models/mention.rb index 66a02428a3..5491bfd9fe 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -18,10 +18,6 @@ def notifiable_target source end - def high_priority_push? - true - end - private def watch_source_by_mentionee source.watch_by(mentionee) diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index d2e86ead43..38f3e5eb40 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -73,7 +73,11 @@ def interruption_level end def high_priority_notification? - notification.source.high_priority_push? + case notification.source + when Event then notification.source.action.card_assigned? + when Mention then true + else false + end end def creator_avatar_url From 9029121777a55b19ae56ee0a063d1c6d0dda3b94 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 13:19:18 +0100 Subject: [PATCH 59/72] Move category and high_priority to payload classes Use polymorphism instead of case statements in Native push target: - DefaultPayload#category returns "default", #high_priority? returns false - EventPayload#category returns "assignment"/"comment"/"card" based on action - MentionPayload#category returns "mention", #high_priority? returns true This simplifies the Native push target by delegating source-specific logic to the appropriate payload classes. Co-Authored-By: Claude Opus 4.5 --- app/models/notification/default_payload.rb | 8 +++++ app/models/notification/event_payload.rb | 12 +++++++ app/models/notification/mention_payload.rb | 8 +++++ .../models/notification/push_target/native.rb | 36 ++++--------------- .../notification/push_target/native_test.rb | 29 +++++---------- 5 files changed, 43 insertions(+), 50 deletions(-) diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb index fc2d5290a2..9cabc49885 100644 --- a/app/models/notification/default_payload.rb +++ b/app/models/notification/default_payload.rb @@ -23,6 +23,14 @@ def url notifications_url end + def category + "default" + end + + def high_priority? + false + end + private def card_url(card) Rails.application.routes.url_helpers.card_url(card, **url_options) diff --git a/app/models/notification/event_payload.rb b/app/models/notification/event_payload.rb index ebdb807b90..fba3a6cb26 100644 --- a/app/models/notification/event_payload.rb +++ b/app/models/notification/event_payload.rb @@ -36,6 +36,18 @@ def url end end + def category + case event.action + when "card_assigned" then "assignment" + when "comment_created" then "comment" + else "card" + end + end + + def high_priority? + event.action.card_assigned? + end + private def event notification.source diff --git a/app/models/notification/mention_payload.rb b/app/models/notification/mention_payload.rb index f07cbf20f0..480771c153 100644 --- a/app/models/notification/mention_payload.rb +++ b/app/models/notification/mention_payload.rb @@ -13,6 +13,14 @@ def url card_url(card) end + def category + "mention" + end + + def high_priority? + true + end + private def mention notification.source diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 38f3e5eb40..4a4e77be3b 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -24,7 +24,7 @@ def native_notification ApplicationPushNotification .with_apple( aps: { - category: notification_category, + category: payload.category, "mutable-content": 1, "interruption-level": interruption_level } @@ -41,7 +41,7 @@ def native_notification card_id: card&.id, card_title: card&.title, creator_name: notification.creator.name, - category: notification_category + category: payload.category ) .new( title: payload.title, @@ -49,39 +49,17 @@ def native_notification badge: notification.user.notifications.unread.count, sound: "default", thread_id: card&.id, - high_priority: high_priority_notification? + high_priority: payload.high_priority? ) end - def notification_category - case notification.source - when Event - case notification.source.action - when "card_assigned" then "assignment" - when "comment_created" then "comment" - else "card" - end - when Mention - "mention" - else - "default" - end - end - def interruption_level - high_priority_notification? ? "time-sensitive" : "active" - end - - def high_priority_notification? - case notification.source - when Event then notification.source.action.card_assigned? - when Mention then true - else false - end + payload.high_priority? ? "time-sensitive" : "active" end def creator_avatar_url - return unless notification.creator.respond_to?(:avatar) && notification.creator.avatar.attached? - Rails.application.routes.url_helpers.url_for(notification.creator.avatar) + if notification.creator.respond_to?(:avatar) && notification.creator.avatar.attached? + Rails.application.routes.url_helpers.url_for(notification.creator.avatar) + end end end diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index 93fe546c5c..162b7a32c7 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -10,39 +10,26 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase @user.push_subscriptions.delete_all end - test "notification_category returns assignment for card_assigned" do + test "payload category returns assignment for card_assigned" do notification = notifications(:logo_assignment_kevin) - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(notification) - assert_equal "assignment", push.send(:notification_category) + assert_equal "assignment", notification.payload.category end - test "notification_category returns comment for comment_created" do + test "payload category returns comment for comment_created" do notification = notifications(:layout_commented_kevin) - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::PushTarget::Native.new(notification) - - assert_equal "comment", push.send(:notification_category) + assert_equal "comment", notification.payload.category end - test "notification_category returns mention for mentions" do + test "payload category returns mention for mentions" do notification = notifications(:logo_card_david_mention_by_jz) - notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - push = Notification::PushTarget::Native.new(notification) - - assert_equal "mention", push.send(:notification_category) + assert_equal "mention", notification.payload.category end - test "notification_category returns card for other card events" do - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - - push = Notification::PushTarget::Native.new(@notification) - - assert_equal "card", push.send(:notification_category) + test "payload category returns card for other card events" do + assert_equal "card", @notification.payload.category end From 7f51f99d053dd712237e8d4b2aa70014489962a8 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 17:19:21 +0100 Subject: [PATCH 60/72] Consolidate push jobs into single Notification::PushJob Replace separate WebPushJob and NativePushJob with a single PushJob that calls notification.push, which iterates over registered targets. Each target handles its own delivery - Web pushes synchronously via the pool, Native enqueues device-level jobs via deliver_later_to. Co-Authored-By: Claude Opus 4.5 --- app/jobs/notification/push_job.rb | 5 +++++ app/jobs/notification/web_push_job.rb | 5 ----- app/models/notification/push_target/web.rb | 4 ---- app/models/notification/pushable.rb | 6 +++++- saas/app/jobs/notification/native_push_job.rb | 5 ----- .../models/notification/push_target/native.rb | 4 ---- .../notification/push_target/native_test.rb | 5 ----- .../notification/push_target/web_test.rb | 5 ----- test/models/notification/pushable_test.rb | 19 ++++++++++++++----- 9 files changed, 24 insertions(+), 34 deletions(-) create mode 100644 app/jobs/notification/push_job.rb delete mode 100644 app/jobs/notification/web_push_job.rb delete mode 100644 saas/app/jobs/notification/native_push_job.rb diff --git a/app/jobs/notification/push_job.rb b/app/jobs/notification/push_job.rb new file mode 100644 index 0000000000..233762b372 --- /dev/null +++ b/app/jobs/notification/push_job.rb @@ -0,0 +1,5 @@ +class Notification::PushJob < ApplicationJob + def perform(notification) + notification.push + end +end diff --git a/app/jobs/notification/web_push_job.rb b/app/jobs/notification/web_push_job.rb deleted file mode 100644 index 3fb83a5132..0000000000 --- a/app/jobs/notification/web_push_job.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Notification::WebPushJob < ApplicationJob - def perform(notification) - Notification::PushTarget::Web.new(notification).push - end -end diff --git a/app/models/notification/push_target/web.rb b/app/models/notification/push_target/web.rb index fe9b725873..68c971d8ff 100644 --- a/app/models/notification/push_target/web.rb +++ b/app/models/notification/push_target/web.rb @@ -1,8 +1,4 @@ class Notification::PushTarget::Web < Notification::PushTarget - def self.push_later(notification) - Notification::WebPushJob.perform_later(notification) - end - private def should_push? super && subscriptions.any? diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb index 0ad8897d68..caf122d875 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -23,8 +23,12 @@ def resolve_push_target(target) end def push_later + Notification::PushJob.perform_later(self) + end + + def push self.class.push_targets.each do |target| - target.push_later(self) + target.new(self).push end end diff --git a/saas/app/jobs/notification/native_push_job.rb b/saas/app/jobs/notification/native_push_job.rb deleted file mode 100644 index e06fc18d26..0000000000 --- a/saas/app/jobs/notification/native_push_job.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Notification::NativePushJob < ApplicationJob - def perform(notification) - Notification::PushTarget::Native.new(notification).push - end -end diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 4a4e77be3b..28727ff0cb 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -1,8 +1,4 @@ class Notification::PushTarget::Native < Notification::PushTarget - def self.push_later(notification) - Notification::NativePushJob.perform_later(notification) - end - private def should_push? super && devices.any? diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index 162b7a32c7..2a098ebaca 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -199,9 +199,4 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert_equal @notification.creator.name, native.data[:creator_name] end - test "push_later enqueues Notification::NativePushJob" do - assert_enqueued_with(job: Notification::NativePushJob, args: [ @notification ]) do - Notification::PushTarget::Native.push_later(@notification) - end - end end diff --git a/test/models/notification/push_target/web_test.rb b/test/models/notification/push_target/web_test.rb index abf95f3860..627f15357b 100644 --- a/test/models/notification/push_target/web_test.rb +++ b/test/models/notification/push_target/web_test.rb @@ -92,9 +92,4 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase Notification::PushTarget::Web.new(notification).push end - test "push_later enqueues Notification::WebPushJob" do - assert_enqueued_with(job: Notification::WebPushJob, args: [ @notification ]) do - Notification::PushTarget::Web.push_later(@notification) - end - end end diff --git a/test/models/notification/pushable_test.rb b/test/models/notification/pushable_test.rb index 898061b518..bdc3f2034e 100644 --- a/test/models/notification/pushable_test.rb +++ b/test/models/notification/pushable_test.rb @@ -9,14 +9,23 @@ class Notification::PushableTest < ActiveSupport::TestCase ) end - test "push_later calls push_later on all registered targets" do - target = mock("push_target") - target.expects(:push_later).with(@notification) + test "push_later enqueues Notification::PushJob" do + assert_enqueued_with(job: Notification::PushJob, args: [ @notification ]) do + @notification.push_later + end + end + + test "push calls push on all registered targets" do + target_class = mock("push_target_class") + target_instance = mock("push_target_instance") + + target_class.expects(:new).with(@notification).returns(target_instance) + target_instance.expects(:push) original_targets = Notification.push_targets - Notification.push_targets = [ target ] + Notification.push_targets = [ target_class ] - @notification.push_later + @notification.push ensure Notification.push_targets = original_targets end From 0ac7da1fdeb42be408334dec032b247d34cc0efe Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 18:02:25 +0100 Subject: [PATCH 61/72] Rename push to process on PushTarget for clearer semantics "Push to target" reads naturally - we push the notification to the target. "Target processes" also makes sense - the target receives and handles the notification in its own way. - Add class method PushTarget.process(notification) that instantiates and calls the instance method - Rename instance method from push to process - Add private push_to helper in Pushable for readable iteration Co-Authored-By: Claude Opus 4.5 --- app/models/notification/push_target.rb | 6 +++++- app/models/notification/pushable.rb | 8 +++++--- .../notification/push_target/native_test.rb | 8 ++++---- test/models/notification/push_target/web_test.rb | 16 ++++++++-------- test/models/notification/pushable_test.rb | 7 ++----- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/app/models/notification/push_target.rb b/app/models/notification/push_target.rb index 26fd2b2630..da6c26fdcd 100644 --- a/app/models/notification/push_target.rb +++ b/app/models/notification/push_target.rb @@ -3,11 +3,15 @@ class Notification::PushTarget delegate :card, to: :notification + def self.process(notification) + new(notification).process + end + def initialize(notification) @notification = notification end - def push + def process return unless should_push? perform_push diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb index caf122d875..207e59cf2b 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -27,9 +27,7 @@ def push_later end def push - self.class.push_targets.each do |target| - target.new(self).push - end + self.class.push_targets.each { |target| push_to(target) } end def pushable? @@ -41,6 +39,10 @@ def payload end private + def push_to(target) + target.process(self) + end + def payload_type source_type.presence_in(%w[ Event Mention ]) || "Default" end diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index 2a098ebaca..a68e6517bb 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -38,7 +38,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert_native_push_delivery(count: 1) do - Notification::PushTarget::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).process end end @@ -46,7 +46,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase @identity.devices.delete_all assert_no_native_push_delivery do - Notification::PushTarget::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).process end end @@ -56,7 +56,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase @notification.update!(creator: users(:system)) assert_no_native_push_delivery do - Notification::PushTarget::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).process end end @@ -67,7 +67,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase @identity.devices.create!(token: "token2", platform: "google", name: "Pixel") assert_native_push_delivery(count: 2) do - Notification::PushTarget::Native.new(@notification).push + Notification::PushTarget::Native.new(@notification).process end end diff --git a/test/models/notification/push_target/web_test.rb b/test/models/notification/push_target/web_test.rb index 627f15357b..7182bd08fb 100644 --- a/test/models/notification/push_target/web_test.rb +++ b/test/models/notification/push_target/web_test.rb @@ -27,28 +27,28 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase subscriptions.count == 1 end - Notification::PushTarget::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).process end test "does not push when user has no subscriptions" do @user.push_subscriptions.delete_all @web_push_pool.expects(:queue).never - Notification::PushTarget::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).process end test "does not push for cancelled accounts" do @user.account.cancel(initiated_by: @user) @web_push_pool.expects(:queue).never - Notification::PushTarget::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).process end test "does not push when creator is system user" do @notification.update!(creator: users(:system)) @web_push_pool.expects(:queue).never - Notification::PushTarget::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).process end test "payload includes card title for card events" do @@ -56,7 +56,7 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase payload[:title] == @notification.card.title end - Notification::PushTarget::Web.new(@notification).push + Notification::PushTarget::Web.new(@notification).process end test "payload for comment includes RE prefix" do @@ -67,7 +67,7 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase payload[:title].start_with?("RE:") end - Notification::PushTarget::Web.new(notification).push + Notification::PushTarget::Web.new(notification).process end test "payload for assignment includes assigned message" do @@ -78,7 +78,7 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase payload[:body].include?("Assigned to you") end - Notification::PushTarget::Web.new(notification).push + Notification::PushTarget::Web.new(notification).process end test "payload for mention includes mentioner name" do @@ -89,7 +89,7 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase payload[:title].include?("mentioned you") end - Notification::PushTarget::Web.new(notification).push + Notification::PushTarget::Web.new(notification).process end end diff --git a/test/models/notification/pushable_test.rb b/test/models/notification/pushable_test.rb index bdc3f2034e..1b8e191b52 100644 --- a/test/models/notification/pushable_test.rb +++ b/test/models/notification/pushable_test.rb @@ -15,12 +15,9 @@ class Notification::PushableTest < ActiveSupport::TestCase end end - test "push calls push on all registered targets" do + test "push calls process on all registered targets" do target_class = mock("push_target_class") - target_instance = mock("push_target_instance") - - target_class.expects(:new).with(@notification).returns(target_instance) - target_instance.expects(:push) + target_class.expects(:process).with(@notification) original_targets = Notification.push_targets Notification.push_targets = [ target_class ] From 7d8b6ec89f08c63d918361738868cb480a7a4d67 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 18:32:49 +0100 Subject: [PATCH 62/72] Simplify PushTarget by removing template method pattern Each target now implements process directly with its own logic, rather than using processable?/perform_push hooks. The pushable? check is done once in Notification#push before iterating targets. Co-Authored-By: Claude Opus 4.5 --- app/models/notification/push_target.rb | 13 +------ app/models/notification/push_target/web.rb | 10 +++--- app/models/notification/pushable.rb | 10 +++--- .../models/notification/push_target/native.rb | 10 +++--- .../notification/push_target/native_test.rb | 10 ------ .../notification/push_target/web_test.rb | 14 -------- test/models/notification/pushable_test.rb | 36 +++++++++++++++---- 7 files changed, 45 insertions(+), 58 deletions(-) diff --git a/app/models/notification/push_target.rb b/app/models/notification/push_target.rb index da6c26fdcd..1fc2502592 100644 --- a/app/models/notification/push_target.rb +++ b/app/models/notification/push_target.rb @@ -12,17 +12,6 @@ def initialize(notification) end def process - return unless should_push? - - perform_push + raise NotImplementedError end - - private - def should_push? - notification.pushable? - end - - def perform_push - raise NotImplementedError - end end diff --git a/app/models/notification/push_target/web.rb b/app/models/notification/push_target/web.rb index 68c971d8ff..9bf8399063 100644 --- a/app/models/notification/push_target/web.rb +++ b/app/models/notification/push_target/web.rb @@ -1,13 +1,11 @@ class Notification::PushTarget::Web < Notification::PushTarget - private - def should_push? - super && subscriptions.any? - end - - def perform_push + def process + if subscriptions.any? Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions) end + end + private def subscriptions @subscriptions ||= notification.user.push_subscriptions end diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb index 207e59cf2b..ecc0138470 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -27,11 +27,9 @@ def push_later end def push - self.class.push_targets.each { |target| push_to(target) } - end + return unless pushable? - def pushable? - !creator.system? && user.active? && account.active? + self.class.push_targets.each { |target| push_to(target) } end def payload @@ -39,6 +37,10 @@ def payload end private + def pushable? + !creator.system? && user.active? && account.active? + end + def push_to(target) target.process(self) end diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 28727ff0cb..d4dce61683 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -1,13 +1,11 @@ class Notification::PushTarget::Native < Notification::PushTarget - private - def should_push? - super && devices.any? - end - - def perform_push + def process + if devices.any? native_notification.deliver_later_to(devices) end + end + private def devices @devices ||= notification.identity.devices end diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index a68e6517bb..5e038fa878 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -50,16 +50,6 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase end end - test "does not push when creator is system user" do - stub_push_services - @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") - @notification.update!(creator: users(:system)) - - assert_no_native_push_delivery do - Notification::PushTarget::Native.new(@notification).process - end - end - test "pushes to multiple devices" do stub_push_services @identity.devices.delete_all diff --git a/test/models/notification/push_target/web_test.rb b/test/models/notification/push_target/web_test.rb index 7182bd08fb..b492b48dd5 100644 --- a/test/models/notification/push_target/web_test.rb +++ b/test/models/notification/push_target/web_test.rb @@ -37,20 +37,6 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase Notification::PushTarget::Web.new(@notification).process end - test "does not push for cancelled accounts" do - @user.account.cancel(initiated_by: @user) - @web_push_pool.expects(:queue).never - - Notification::PushTarget::Web.new(@notification).process - end - - test "does not push when creator is system user" do - @notification.update!(creator: users(:system)) - @web_push_pool.expects(:queue).never - - Notification::PushTarget::Web.new(@notification).process - end - test "payload includes card title for card events" do @web_push_pool.expects(:queue).once.with do |payload, _| payload[:title] == @notification.card.title diff --git a/test/models/notification/pushable_test.rb b/test/models/notification/pushable_test.rb index 1b8e191b52..7c54b0036e 100644 --- a/test/models/notification/pushable_test.rb +++ b/test/models/notification/pushable_test.rb @@ -46,19 +46,43 @@ class Notification::PushableTest < ActiveSupport::TestCase Notification.push_targets = original_targets end - test "pushable? returns true for normal notifications" do - assert @notification.pushable? + test "push processes targets for normal notifications" do + target_class = mock("push_target_class") + target_class.expects(:process).with(@notification) + + original_targets = Notification.push_targets + Notification.push_targets = [ target_class ] + + @notification.push + ensure + Notification.push_targets = original_targets end - test "pushable? returns false when creator is system user" do + test "push skips targets when creator is system user" do @notification.update!(creator: users(:system)) - assert_not @notification.pushable? + target_class = mock("push_target_class") + target_class.expects(:process).never + + original_targets = Notification.push_targets + Notification.push_targets = [ target_class ] + + @notification.push + ensure + Notification.push_targets = original_targets end - test "pushable? returns false for cancelled accounts" do + test "push skips targets for cancelled accounts" do @user.account.cancel(initiated_by: @user) - assert_not @notification.pushable? + target_class = mock("push_target_class") + target_class.expects(:process).never + + original_targets = Notification.push_targets + Notification.push_targets = [ target_class ] + + @notification.push + ensure + Notification.push_targets = original_targets end end From a12bfd385948d014d01cab833c66778793dea89d Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 21:17:59 +0100 Subject: [PATCH 63/72] Add `FCM_ENCRYPTION_KEY` to Kamal secrets and organize them No need for push notification secrets in staging, as we won't allow push notifications there. Also, no need to store APNs topic in 1P, as it's not a secret. --- saas/.kamal/secrets.beta | 5 ++--- saas/.kamal/secrets.production | 4 ++-- saas/.kamal/secrets.staging | 6 +----- saas/config/push.yml | 2 +- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index bf70f829c6..e3e58ef645 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_ENCRYPTION_KEY Beta/APNS_KEY_ID Beta/APNS_TEAM_ID Beta/APNS_TOPIC) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_ENCRYPTION_KEY Beta/APNS_KEY_ID Beta/APNS_TEAM_ID Beta/FCM_ENCRYPTION_KEY) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -27,5 +27,4 @@ STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) -APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) -APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) +FCM_ENCRYPTION_KEY=$(kamal secrets extract FCM_ENCRYPTION_KEY $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 5b4bb4b121..2b7f817b48 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_ENCRYPTION_KEY Production/APNS_KEY_ID Production/APNS_TEAM_ID Production/APNS_TOPIC) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_ENCRYPTION_KEY Production/APNS_KEY_ID Production/APNS_TEAM_ID Production/FCM_ENCRYPTION_KEY) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -28,4 +28,4 @@ STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) -APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) +FCM_ENCRYPTION_KEY=$(kamal secrets extract FCM_ENCRYPTION_KEY $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index a7330e1572..31979d1704 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET Staging/APNS_ENCRYPTION_KEY Staging/APNS_KEY_ID Staging/APNS_TEAM_ID Staging/APNS_TOPIC) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,7 +25,3 @@ STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $S STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) -APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) -APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) -APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) -APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) diff --git a/saas/config/push.yml b/saas/config/push.yml index 99c1196704..ee04264126 100644 --- a/saas/config/push.yml +++ b/saas/config/push.yml @@ -3,7 +3,7 @@ shared: key_id: <%= ENV["APNS_KEY_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %> encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY"]&.gsub("\\n", "\n") || Rails.application.credentials.dig(:action_push_native, :apns, :key))&.dump %> team_id: <%= ENV["APNS_TEAM_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :team_id) || "2WNYUYRS7G" %> - topic: <%= ENV["APNS_TOPIC"] || Rails.application.credentials.dig(:action_push_native, :apns, :topic) || "do.fizzy.app.ios" %> + topic: <%= ENV["APNS_TOPIC"] || "do.fizzy.app.ios" %> connect_to_development_server: <%= Rails.env.local? %> google: encryption_key: <%= (ENV["FCM_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :fcm, :key))&.dump %> From 3629f940adda6f5ddf944ee0beaa861cbe27c80a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 21:34:30 +0100 Subject: [PATCH 64/72] Store `APNS_ENCRYPTION_KEY` and `FCM_ENCRYPTION_KEY` at the root Turns out, files can't be referenced within a field group in 1Password, they need to live at the root of the item. --- saas/.kamal/secrets.beta | 2 +- saas/.kamal/secrets.production | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index e3e58ef645..7394fdcd53 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_ENCRYPTION_KEY Beta/APNS_KEY_ID Beta/APNS_TEAM_ID Beta/FCM_ENCRYPTION_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_KEY_ID Beta/APNS_TEAM_ID APNS_ENCRYPTION_KEY FCM_ENCRYPTION_KEY) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 2b7f817b48..6bb3309af3 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_ENCRYPTION_KEY Production/APNS_KEY_ID Production/APNS_TEAM_ID Production/FCM_ENCRYPTION_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_KEY_ID Production/APNS_TEAM_ID APNS_ENCRYPTION_KEY FCM_ENCRYPTION_KEY) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) From eff16ccd7be4b100ee313a3c346b6add4bb0880a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 10:47:23 +0100 Subject: [PATCH 65/72] Move encryption keys to base64 password fields Easier than having Kamal support attached files as secrets. Also: remove Rails credentials fallback from config file, as we don't use that in the app, and don't have the fallbacks anywhere. --- saas/.kamal/secrets.beta | 6 +++--- saas/.kamal/secrets.production | 6 +++--- saas/config/push.yml | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 7394fdcd53..b180f2ff62 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_KEY_ID Beta/APNS_TEAM_ID APNS_ENCRYPTION_KEY FCM_ENCRYPTION_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_KEY_ID Beta/APNS_TEAM_ID Beta/APNS_ENCRYPTION_KEY_B64 Beta/FCM_ENCRYPTION_KEY_B64) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,6 +25,6 @@ STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $S STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) -APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) +APNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS) APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) -FCM_ENCRYPTION_KEY=$(kamal secrets extract FCM_ENCRYPTION_KEY $SECRETS) +FCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 6bb3309af3..27b99b9f1c 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_KEY_ID Production/APNS_TEAM_ID APNS_ENCRYPTION_KEY FCM_ENCRYPTION_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_KEY_ID Production/APNS_TEAM_ID Production/APNS_ENCRYPTION_KEY_B64 Production/FCM_ENCRYPTION_KEY_B64) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,7 +25,7 @@ STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $S STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) -APNS_ENCRYPTION_KEY=$(kamal secrets extract APNS_ENCRYPTION_KEY $SECRETS) +APNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS) APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) -FCM_ENCRYPTION_KEY=$(kamal secrets extract FCM_ENCRYPTION_KEY $SECRETS) +FCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS) diff --git a/saas/config/push.yml b/saas/config/push.yml index ee04264126..4d4e9e236a 100644 --- a/saas/config/push.yml +++ b/saas/config/push.yml @@ -1,10 +1,10 @@ shared: apple: - key_id: <%= ENV["APNS_KEY_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %> - encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY"]&.gsub("\\n", "\n") || Rails.application.credentials.dig(:action_push_native, :apns, :key))&.dump %> - team_id: <%= ENV["APNS_TEAM_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :team_id) || "2WNYUYRS7G" %> + key_id: <%= ENV["APNS_KEY_ID"] %> + encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY_B64"] ? Base64.decode64(ENV["APNS_ENCRYPTION_KEY_B64"]) : ENV["APNS_ENCRYPTION_KEY"]&.gsub("\\n", "\n"))&.dump %> + team_id: <%= ENV["APNS_TEAM_ID"] || "2WNYUYRS7G" %> topic: <%= ENV["APNS_TOPIC"] || "do.fizzy.app.ios" %> connect_to_development_server: <%= Rails.env.local? %> google: - encryption_key: <%= (ENV["FCM_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :fcm, :key))&.dump %> + encryption_key: <%= (ENV["FCM_ENCRYPTION_KEY_B64"] ? Base64.decode64(ENV["FCM_ENCRYPTION_KEY_B64"]) : ENV["FCM_ENCRYPTION_KEY"])&.dump %> project_id: fizzy-a148c From 2355aa280c4ae4925f62434a9df72420bc169abd Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 13:29:33 +0100 Subject: [PATCH 66/72] Fix stuck state when permission granted but no subscription When you had already granted notification permission but hadn't completed the subscription flow (no service worker or no push subscription), the UI showed neither the subscribe button nor the enabled state, leaving you stuck with no way to subscribe, and wrong instructions to fix it. Instead, let's just show the button to allow you to subscribe. --- app/javascript/controllers/notifications_controller.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 02fce86d7e..8b2eddf52d 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -20,6 +20,9 @@ export default class extends Controller { if (registration && subscription) { this.element.classList.add(this.enabledClass) + } else { + this.subscribeButtonTarget.hidden = false + this.explainerTarget.hidden = true } break } From 64ff217dabec66d7855cbfa1cfc239d7452d43a7 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 13:40:19 +0100 Subject: [PATCH 67/72] Wait for service worker to be active before subscribing The push subscription requires an active service worker. When registering a new service worker, we now wait for it to become active using navigator.serviceWorker.ready before attempting to subscribe to push notifications. Co-Authored-By: Claude Opus 4.5 --- app/javascript/controllers/notifications_controller.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 8b2eddf52d..cbd117b345 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -57,8 +57,9 @@ export default class extends Controller { return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" }) } - #registerServiceWorker() { - return navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + async #registerServiceWorker() { + await navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + return navigator.serviceWorker.ready } async #subscribe(registration) { From 2583be9234f4cab68948bda77358db9bed2b2abc Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 13:52:28 +0100 Subject: [PATCH 68/72] Fix notification click URL by using correct data property The web push payload sends the URL in data.url but the service worker was looking for data.path, resulting in undefined URLs. Co-Authored-By: Claude Opus 4.5 --- app/views/pwa/service_worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js index df284d5780..292d089918 100644 --- a/app/views/pwa/service_worker.js +++ b/app/views/pwa/service_worker.js @@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) { self.addEventListener("notificationclick", (event) => { event.notification.close() - const url = new URL(event.notification.data.path, self.location.origin).href + const url = new URL(event.notification.data.url, self.location.origin).href event.waitUntil(openURL(url)) }) From fd9f7a0be80b3f7f3a04c8199fdb3c8e525d4e67 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 18:02:03 +0100 Subject: [PATCH 69/72] Go back to RubyGems version of `action_push_native` After releasing the new version: https://github.com/rails/action_push_native/releases/tag/v0.3.1 --- Gemfile.saas | 2 +- Gemfile.saas.lock | 24 +++++++++--------------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Gemfile.saas b/Gemfile.saas index e5fba52f89..88eb2b4248 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -11,7 +11,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" # Native push notifications (iOS/Android) -gem "action_push_native", github: "rails/action_push_native", branch: "add-abstract-record" +gem "action_push_native" # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 7c6d82001c..272349d33a 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -58,19 +58,6 @@ GIT rails (>= 6.1) yabeda (~> 0.6) -GIT - remote: https://github.com/rails/action_push_native.git - revision: 8ef7023a335e1f09ad1fe22a4b7b007b040528bd - branch: add-abstract-record - specs: - action_push_native (0.3.0) - activejob (>= 8.0) - activerecord (>= 8.0) - googleauth (~> 1.14) - httpx (~> 1.6) - jwt (>= 2) - railties (>= 8.0) - GIT remote: https://github.com/rails/rails.git revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8 @@ -197,6 +184,13 @@ PATH GEM remote: https://rubygems.org/ specs: + action_push_native (0.3.1) + activejob (>= 8.0) + activerecord (>= 8.0) + googleauth (~> 1.14) + httpx (~> 1.6) + jwt (>= 2) + railties (>= 8.0) action_text-trix (2.1.16) railties activemodel-serializers-xml (1.0.3) @@ -313,7 +307,7 @@ GEM signet (>= 0.16, < 2.a) hashdiff (1.2.1) http-2 (1.1.1) - httpx (1.7.0) + httpx (1.7.1) http-2 (>= 1.0.0) i18n (1.14.8) concurrent-ruby (~> 1.0) @@ -682,7 +676,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - action_push_native! + action_push_native activeresource audits1984! autotuner From b8d5e51184a756e1f651f3f13ac2fea6e7137e76 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Sun, 25 Jan 2026 21:23:57 -0600 Subject: [PATCH 70/72] Add avatar_url so we always get an avatar even when the user hasn't set one --- app/models/notification/default_payload.rb | 4 ++++ saas/app/models/notification/push_target/native.rb | 8 +------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb index 9cabc49885..65ca068e26 100644 --- a/app/models/notification/default_payload.rb +++ b/app/models/notification/default_payload.rb @@ -31,6 +31,10 @@ def high_priority? false end + def avatar_url + Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options) + end + private def card_url(card) Rails.application.routes.url_helpers.card_url(card, **url_options) diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index d4dce61683..94d8f40865 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -31,7 +31,7 @@ def native_notification body: payload.body, url: payload.url, account_id: notification.account.external_account_id, - avatar_url: creator_avatar_url, + avatar_url: payload.avatar_url, card_id: card&.id, card_title: card&.title, creator_name: notification.creator.name, @@ -50,10 +50,4 @@ def native_notification def interruption_level payload.high_priority? ? "time-sensitive" : "active" end - - def creator_avatar_url - if notification.creator.respond_to?(:avatar) && notification.creator.avatar.attached? - Rails.application.routes.url_helpers.url_for(notification.creator.avatar) - end - end end From f460d8b1c6a7d7018a3b5824a2b9580fe63cea13 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Sun, 25 Jan 2026 22:22:25 -0600 Subject: [PATCH 71/72] Add creator initials and avatar color to push notification payload Move avatar_background_color logic from helper to User::Avatar concern so it can be accessed from models. Include creator_id, creator_initials, and creator_avatar_color in native push notifications for local avatar rendering on iOS. Co-Authored-By: Claude Opus 4.5 --- app/helpers/avatars_helper.rb | 9 +-------- app/models/user/avatar.rb | 10 ++++++++++ saas/app/models/notification/push_target/native.rb | 3 +++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 8ae4346ac3..d806853495 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -1,13 +1,6 @@ -require "zlib" - module AvatarsHelper - AVATAR_COLORS = %w[ - #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53 - #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E - ] - def avatar_background_color(user) - AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size] + user.avatar_background_color end def avatar_tag(user, hidden_for_screen_reader: false, **options) diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index c62d87e09f..970104d6f4 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -1,8 +1,14 @@ +require "zlib" + module User::Avatar extend ActiveSupport::Concern ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze + AVATAR_COLORS = %w[ + #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53 + #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E + ].freeze included do has_one_attached :avatar do |attachable| @@ -22,6 +28,10 @@ def avatar_thumbnail avatar.variable? ? avatar.variant(:thumb) : avatar end + def avatar_background_color + AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size] + end + # Avatars are always publicly accessible def publicly_accessible? true diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 94d8f40865..2e08aa485c 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -34,7 +34,10 @@ def native_notification avatar_url: payload.avatar_url, card_id: card&.id, card_title: card&.title, + creator_id: notification.creator.id, creator_name: notification.creator.name, + creator_initials: notification.creator.initials, + creator_avatar_color: notification.creator.avatar_background_color, category: payload.category ) .new( From d2da10831f340db794347ebe4b70abc4c57b8ba6 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Sun, 25 Jan 2026 23:26:31 -0600 Subject: [PATCH 72/72] Add creator_familiar_name to push notification payload Include the shortened familiar name format (e.g., "Salvador D.") for display in iOS notification titles. Co-Authored-By: Claude Opus 4.5 --- saas/app/models/notification/push_target/native.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 2e08aa485c..86212176f8 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -36,6 +36,7 @@ def native_notification card_title: card&.title, creator_id: notification.creator.id, creator_name: notification.creator.name, + creator_familiar_name: notification.creator.familiar_name, creator_initials: notification.creator.initials, creator_avatar_color: notification.creator.avatar_background_color, category: payload.category