From 3c54cd84fc1c3bf50a06157a590dc4e25293632e Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 14:38:09 -0600 Subject: [PATCH 01/43] Add native push notification infrastructure - Add action_push_native gem and SaaS configuration - Add device registration API and UI - Add User::Devices concern - Add NotificationPusher::Native for push delivery - Add tests and fixtures for native push --- Gemfile.saas | 3 + Gemfile.saas.lock | 38 +++ app/models/notification_pusher.rb | 10 +- .../notifications/settings/show.html.erb | 1 + ...03313_create_action_push_native_devices.rb | 15 ++ db/schema_sqlite.rb | 13 + .../controllers/users/devices_controller.rb | 29 +++ .../jobs/application_push_notification_job.rb | 2 + .../models/application_push_notification.rb | 4 + saas/app/models/notification_pusher/native.rb | 87 +++++++ saas/app/models/user/devices.rb | 7 + .../settings/_native_devices.html.erb | 14 ++ saas/app/views/users/devices/index.html.erb | 16 ++ saas/config/push.yml | 11 + saas/lib/fizzy/saas/engine.rb | 10 + .../users/devices_controller_test.rb | 234 ++++++++++++++++++ .../fixtures/action_push_native/devices.yml | 20 ++ saas/test/models/notification_pusher_test.rb | 221 +++++++++++++++++ saas/test/models/push_config_test.rb | 21 ++ .../push_notification_test_helper.rb | 33 +++ test/test_helper.rb | 5 + 21 files changed, 791 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260114203313_create_action_push_native_devices.rb create mode 100644 saas/app/controllers/users/devices_controller.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/app/models/notification_pusher/native.rb create mode 100644 saas/app/models/user/devices.rb create mode 100644 saas/app/views/notifications/settings/_native_devices.html.erb create mode 100644 saas/app/views/users/devices/index.html.erb create mode 100644 saas/config/push.yml create mode 100644 saas/test/controllers/users/devices_controller_test.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/models/push_config_test.rb create mode 100644 saas/test/test_helpers/push_notification_test_helper.rb diff --git a/Gemfile.saas b/Gemfile.saas index c29eaeb794..39aa49a71c 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -11,6 +11,9 @@ gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" +# 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 b18d2fa8b5..0b58ab02eb 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -195,6 +195,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 actionpack-xml_parser (2.0.1) @@ -279,6 +286,12 @@ GEM tzinfo faker (3.6.0) 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) @@ -296,7 +309,22 @@ GEM globalid (1.3.0) activesupport (>= 6.1) gvltools (0.4.0) + 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) @@ -369,6 +397,9 @@ GEM mocha (3.0.2) 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.3) @@ -403,6 +434,7 @@ GEM nokogiri (1.19.1-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) @@ -546,6 +578,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) @@ -664,6 +701,7 @@ PLATFORMS DEPENDENCIES actionpack-xml_parser + action_push_native activeresource audits1984! autotuner diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index d6425e561a..1b50142bdc 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -12,18 +12,22 @@ def push return unless should_push? build_payload.tap do |payload| - push_to_user(payload) + push_to_web(payload) end end 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" @@ -93,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/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 3e6b8b84d5..dc43bafd8c 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -16,6 +16,7 @@
<%= render "notifications/settings/push_notifications" %> + <%= render "notifications/settings/native_devices" if Fizzy.saas? %> <%= render "notifications/settings/email", settings: @settings %>
diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb new file mode 100644 index 0000000000..aa577ef760 --- /dev/null +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -0,0 +1,15 @@ +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 + t.belongs_to :owner, polymorphic: true + + t.timestamps + end + + 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 c8ce19e070..ab087f4b27 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -69,6 +69,19 @@ 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/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb new file mode 100644 index 0000000000..a78bbe40ff --- /dev/null +++ b/saas/app/controllers/users/devices_controller.rb @@ -0,0 +1,29 @@ +class Users::DevicesController < ApplicationController + def index + @devices = Current.user.devices.order(created_at: :desc) + end + + def create + attrs = device_params + 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 + end + + 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(: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 +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/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb new file mode 100644 index 0000000000..237697f920 --- /dev/null +++ b/saas/app/models/notification_pusher/native.rb @@ -0,0 +1,87 @@ +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 + end + + private + def push_destination? + notification.user.push_subscriptions.any? || notification.user.devices.any? + 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( + 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/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/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 %> +
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? %> + +<% else %> +

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

+<% 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) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 3d20f9df85..4acf30e57c 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -23,6 +23,10 @@ class Engine < ::Rails::Engine headers: app.config.public_file_server.headers 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 @@ -39,6 +43,10 @@ class Engine < ::Rails::Engine namespace :stripe do resource :webhooks, only: :create end + + namespace :users do + resources :devices, only: [ :index, :create, :destroy ] + end end end @@ -148,6 +156,8 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::User.include User::NotifiesAccountOfEmailChange + ::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 new file mode 100644 index 0000000000..46da8ce7f9 --- /dev/null +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -0,0 +1,234 @@ +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 + @user.devices.create!(uuid: "test-uuid", 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 + sign_out + + get users_devices_path + + assert_response :redirect + end + + # === 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" + }, as: :json + end + + 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 + assert_equal @user, device.owner + end + + test "creates android device" do + post users_devices_path, params: { + uuid: SecureRandom.uuid, + token: SecureRandom.hex(32), + 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 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" + ) + + # 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" + }, as: :json + end + + assert_response :created + + # 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" + }, as: :json + + 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 + + assert_response :bad_request + end + + test "create requires authentication" do + sign_out + + post users_devices_path, params: { + uuid: SecureRandom.uuid, + token: SecureRandom.hex(32), + platform: "apple" + }, as: :json + + assert_response :redirect + end + + # === Destroy (Web) === + + test "destroys device" do + device = @user.devices.create!( + uuid: "device-to-delete", + 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 + other_user = users(:kevin) + device = other_user.devices.create!( + uuid: "other-users-device", + 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 + device = @user.devices.create!( + uuid: "my-device", + 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..0494d2a973 --- /dev/null +++ b/saas/test/fixtures/action_push_native/devices.yml @@ -0,0 +1,20 @@ +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 + 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..2c628b4166 --- /dev/null +++ b/saas/test/models/notification_pusher_test.rb @@ -0,0 +1,221 @@ +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 "push_destination returns true when user has native devices" do + @user.devices.create!(uuid: SecureRandom.uuid, 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", + p256dh_key: "test_p256dh", + auth_key: "test_auth" + ) + + assert @pusher.send(:push_destination?) + end + + test "push_destination returns false when user has neither" do + @user.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!(uuid: SecureRandom.uuid, 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!(uuid: SecureRandom.uuid, 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!(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 + 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 + @user.devices.create!(uuid: SecureRandom.uuid, 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!(uuid: SecureRandom.uuid, 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!(uuid: SecureRandom.uuid, 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!(uuid: SecureRandom.uuid, 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!(uuid: SecureRandom.uuid, 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!(uuid: SecureRandom.uuid, 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!(uuid: SecureRandom.uuid, 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/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 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 b1f8137610..b8e96bf972 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -37,6 +37,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" @@ -47,6 +51,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 55336873b2c95fa32c50af8577dbf8aefcbfc534 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 15 Jan 2026 19:07:15 -0600 Subject: [PATCH 02/43] Fix database issues and add APNS configuration - Fix owner_id type to UUID in devices migration - Fix NOT NULL crash in device registration - Add APNS config and 1Password integration - Add --apns flag to bin/dev for local development - Clean up devices controller --- bin/dev | 18 ++++++++++ config/push.yml | 7 ++++ ...03313_create_action_push_native_devices.rb | 2 +- db/schema.rb | 13 +++++++ saas/.kamal/secrets.beta | 6 +++- saas/.kamal/secrets.production | 6 +++- saas/.kamal/secrets.staging | 6 +++- .../controllers/users/devices_controller.rb | 25 ++++++------- .../models/application_push_notification.rb | 2 +- saas/config/push.yml | 9 +++-- saas/exe/apns-dev | 36 +++++++++++++++++++ saas/fizzy-saas.gemspec | 2 +- saas/lib/fizzy/saas/engine.rb | 6 ++-- 13 files changed, 112 insertions(+), 26 deletions(-) create mode 100644 config/push.yml create mode 100755 saas/exe/apns-dev diff --git a/bin/dev b/bin/dev index 05032e7c36..cd8101659f 100755 --- a/bin/dev +++ b/bin/dev @@ -2,13 +2,31 @@ 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 "Enabling SaaS mode for APNs..." + ./bin/rails saas:enable + fi + echo "Loading APNs credentials from 1Password..." + 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 export SOLID_QUEUE_IN_PUMA=false fi 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/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 diff --git a/db/schema.rb b/db/schema.rb index 20cb0d1614..c3405d9f99 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -69,6 +69,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 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) diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb index a78bbe40ff..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_create_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 diff --git a/saas/app/models/application_push_notification.rb b/saas/app/models/application_push_notification.rb index 41fe881f7e..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? + self.enabled = Fizzy.saas? && (!Rails.env.local? || ENV["ENABLE_NATIVE_PUSH"] == "true") end diff --git a/saas/config/push.yml b/saas/config/push.yml index 916277b30a..309f414706 100644 --- a/saas/config/push.yml +++ b/saas/config/push.yml @@ -1,11 +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"] || 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 + 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? %> 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) diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev new file mode 100755 index 0000000000..02bd28ad18 --- /dev/null +++ b/saas/exe/apns-dev @@ -0,0 +1,36 @@ +#!/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") +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" 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" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 4acf30e57c..fa782414b8 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -23,8 +23,8 @@ class Engine < ::Rails::Engine headers: app.config.public_file_server.headers 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| @@ -157,7 +157,7 @@ class Engine < ::Rails::Engine ::Account.include Account::Billing, Account::Limited ::User.include User::NotifiesAccountOfEmailChange ::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 7d04bb514fbcd24862bf358e78a1dc216138e455 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 00:00:47 -0600 Subject: [PATCH 03/43] Add Firebase configuration - Update local script to load Firebase key - Configure Firebase projectId in push.yml --- saas/config/push.yml | 2 +- saas/exe/apns-dev | 42 +++++++++++++++++++++++++++--------------- 2 files changed, 28 insertions(+), 16 deletions(-) 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 diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev index 02bd28ad18..e2ad0147f5 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_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" 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.gsub("'", "'\\\\''")}') 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 2121b6dc3df87bfd987c3cb7b7d2097c3bb17184 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 20 Jan 2026 20:52:37 +0100 Subject: [PATCH 04/43] 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 39aa49a71c..28b3f9b115 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -12,7 +12,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" # 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 0b58ab02eb..ce398e00d9 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -60,6 +60,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: 12e24eaf2f0a9613e015653f013dd131317d9bf5 @@ -195,13 +208,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 actionpack-xml_parser (2.0.1) @@ -701,7 +707,7 @@ PLATFORMS DEPENDENCIES actionpack-xml_parser - 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 29d3960a3ca41bd9e9a665eddb1043b6ead7b003 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 22:44:06 -0600 Subject: [PATCH 05/43] 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/schema.rb | 3 +- .../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 - 6 files changed, 81 insertions(+), 65 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/schema.rb b/db/schema.rb index c3405d9f99..9ff5edb0c9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -77,8 +77,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 fa782414b8..3dc1902eb2 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -45,7 +45,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 ade39b15ab3e167ff6aaf468e2eecc8980dd3d41 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 22:52:01 -0600 Subject: [PATCH 06/43] 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 7e0470a692ab0377a5a19c1186e8e5371cc1fdac Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Tue, 20 Jan 2026 23:59:15 -0600 Subject: [PATCH 07/43] 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 cb03eec435..e5f83e40a4 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -37,7 +37,7 @@ def native_notification(payload) .with_data( title: payload[:title], body: payload[:body], - path: payload[:path], + url: payload[:url], account_id: notification.account.external_account_id, avatar_url: creator_avatar_url, card_id: card&.id, From 9d32f3e8457809d5750c2949edf6a83413f8b9d5 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 20 Jan 2026 21:40:25 +0100 Subject: [PATCH 08/43] 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 3dc1902eb2..0c394ed3a7 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -44,10 +44,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 d3cc79a5bcb4c0b41f7edfe2cea9345e8848c47a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 13:20:14 +0100 Subject: [PATCH 09/43] 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 | 11 +++--- 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, 30 insertions(+), 77 deletions(-) delete mode 100644 saas/app/controllers/users/devices_controller.rb diff --git a/Gemfile.saas b/Gemfile.saas index 28b3f9b115..913dad3854 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -12,7 +12,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" # 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 ce398e00d9..0a56a7f67f 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,8 +62,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) @@ -221,8 +220,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) @@ -319,7 +318,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) @@ -495,7 +494,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) 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 0c394ed3a7..e2e86c0339 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -44,9 +44,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 555132bbef8eef9896c2dfce22c864519ad6aa1d Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 15:33:17 +0100 Subject: [PATCH 10/43] 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 Fix reference to `user.devices`, left-over from the identity switch --- saas/app/controllers/devices_controller.rb | 6 +- saas/app/models/{user => identity}/devices.rb | 2 +- saas/app/models/notification_pusher/native.rb | 4 +- .../settings/_native_devices.html.erb | 4 +- saas/lib/fizzy/saas/engine.rb | 2 +- .../controllers/devices_controller_test.rb | 88 +++++++++---------- saas/test/models/notification_pusher_test.rb | 51 ++++------- 7 files changed, 71 insertions(+), 86 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/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 %> diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index e2e86c0339..31fadee331 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -154,7 +154,7 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::User.include User::NotifiesAccountOfEmailChange - ::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 05819f84a259c0683294a11a3376e44b1261c238 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 18:50:53 +0100 Subject: [PATCH 11/43] 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 Tidy up saas engine a bit more --- 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 | 36 ++++ 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 | 11 +- .../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 | 29 ---- 15 files changed, 320 insertions(+), 186 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 4bf9b575dd..0000000000 --- a/app/models/concerns/push_notifiable.rb +++ /dev/null @@ -1,12 +0,0 @@ -module PushNotifiable - extend ActiveSupport::Concern - - included do - after_save_commit :push_notification_later, if: :source_id_previously_changed? - 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 417a2f22d2..a5eab3ccde 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 @@ -27,6 +27,7 @@ class Notification < ApplicationRecord after_destroy_commit -> { broadcast_remove_to user, :notifications } delegate :notifiable_target, to: :source + delegate :identity, to: :user class << self def read_all 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..a4f1a56c68 --- /dev/null +++ b/app/models/notification/pushable.rb @@ -0,0 +1,36 @@ +module Notification::Pushable + extend ActiveSupport::Concern + + included do + class_attribute :push_targets, default: [] + + after_create_commit :push_later + after_update_commit :push_later, if: :source_id_previously_changed? + 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 31fadee331..a582718b8b 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -154,12 +154,14 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::User.include User::NotifiesAccountOfEmailChange - ::Identity.include Identity::Devices - ::NotificationPusher.prepend NotificationPusher::Native - ::Signup.prepend Fizzy::Saas::Signup + ::Identity.include Authorization::Identity, Identity::Devices + ::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 @@ -170,9 +172,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 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 53380effe6..0000000000 --- a/test/models/notification_pusher_test.rb +++ /dev/null @@ -1,29 +0,0 @@ -require "test_helper" - -class NotificationPusherTest < ActiveSupport::TestCase - setup do - @user = users(:david) - @notification = notifications(:logo_mentioned_david) - @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 1d2ed91e89478f65ccbc9eecdfbed382fce46132 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 19:07:04 +0100 Subject: [PATCH 12/43] 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 06c9ece058ebfa835b72e7cd91f8218bd8a07765 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 19:31:19 +0100 Subject: [PATCH 13/43] 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 a4f1a56c68..a780b11c5b 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -33,4 +33,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 b5205ce6b2a0301414d4a63211041c7724b51eff Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 19:41:34 +0100 Subject: [PATCH 14/43] 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 | 5 +-- 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, 37 insertions(+), 38 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 a780b11c5b..8b90a36ba6 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -16,10 +16,9 @@ def register_push_target(target) private def resolve_push_target(target) - if target.is_a?(Symbol) - "Notification::Push::#{target.to_s.classify}".constantize + if target.is_a?(Notification::PushTarget) then target else - target + "Notification::PushTarget::#{target.to_s.classify}".constantize end end 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 ab6bc256ebbc9400cd49b2c1a6ee6e2ad464b719 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:02:14 +0100 Subject: [PATCH 15/43] 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 | 4 ++ 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, 63 insertions(+), 4 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 9ff5edb0c9..c9da93d983 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -75,10 +75,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| @@ -852,4 +854,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 a582718b8b..40be7fa224 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -155,6 +155,7 @@ class Engine < ::Rails::Engine ::Account.include Account::Billing, Account::Limited ::User.include User::NotifiesAccountOfEmailChange ::Identity.include Authorization::Identity, Identity::Devices + ::Session.include Session::Devices ::Signup.prepend Signup ApplicationController.include Authorization::Controller 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 92f283c4d91db2cabec902ea1b37e96559475335 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:45:13 +0100 Subject: [PATCH 16/43] 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 | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) 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 c9da93d983..1b562554d5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -79,7 +79,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 8a77fd7966a3d0ef77b7bb40508bad8587132806 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 20:50:57 +0100 Subject: [PATCH 17/43] 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 ----- 3 files changed, 2 insertions(+), 11 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 From 2f9e41d356432cd9e326189d4384153a3b1ded77 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 21 Jan 2026 21:35:57 +0100 Subject: [PATCH 18/43] 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 1b562554d5..19ee667a34 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -853,6 +853,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 c7d0559191e68609678a73f899b769c47747a5b6 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 21 Jan 2026 21:38:07 -0600 Subject: [PATCH 19/43] 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 1b53396050c0e76d4ee529b848875b92e11cb035 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 11:38:32 +0100 Subject: [PATCH 20/43] 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 40be7fa224..b733d7a419 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -43,8 +43,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 34fd62faf1a723b9efd80962b36497dadc354c5b Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 13:01:21 +0100 Subject: [PATCH 21/43] 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 | 6 +++--- Gemfile.saas | 2 +- Gemfile.saas.lock | 3 ++- db/schema.rb | 13 ------------- db/schema_sqlite.rb | 13 ------------- ...60114203313_create_action_push_native_devices.rb | 0 saas/db/saas_schema.rb | 13 +++++++++++++ saas/lib/fizzy/saas/engine.rb | 5 +++++ .../devices.yml => application_push_devices.yml} | 0 9 files changed, 24 insertions(+), 31 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 24449909ba..6efc361eb2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -127,8 +127,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) @@ -337,7 +337,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) diff --git a/Gemfile.saas b/Gemfile.saas index 913dad3854..175b50347d 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -12,7 +12,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" # 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 0a56a7f67f..b93c672a72 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,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 19ee667a34..20cb0d1614 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -69,19 +69,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 ab087f4b27..c8ce19e070 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -69,19 +69,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 980e952ac5..debc08b199 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -53,6 +53,19 @@ t.index ["token_digest"], name: "index_audits1984_auditor_tokens_on_token_digest", 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 b733d7a419..b901135440 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -10,6 +10,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 9299300dbf44c10f2d3518f0dfa8e88c6d1f5c84 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 13:12:53 +0100 Subject: [PATCH 22/43] 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 4f19c429583c8837fbe4cd95306dcf148ce4a3b9 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 13:19:18 +0100 Subject: [PATCH 23/43] 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 3621df8f0fc01b83e393430109fe14d337e518b6 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 17:19:21 +0100 Subject: [PATCH 24/43] 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 8b90a36ba6..c8519951a0 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -24,8 +24,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 0948533da7d76cbd4f994c2f1de3f7fc36a2746f Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 18:02:25 +0100 Subject: [PATCH 25/43] 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 | 13 ++++++++----- .../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, 27 insertions(+), 23 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 c8519951a0..0f5ae2ed6e 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -16,9 +16,10 @@ def register_push_target(target) private def resolve_push_target(target) - if target.is_a?(Notification::PushTarget) then target - else + if target.is_a?(Symbol) "Notification::PushTarget::#{target.to_s.classify}".constantize + else + target end end end @@ -28,9 +29,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? @@ -42,6 +41,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 7864748be9503357eef2052652c601432e823f18 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 22 Jan 2026 18:32:49 +0100 Subject: [PATCH 26/43] 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 0f5ae2ed6e..4d5d14d0db 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -29,11 +29,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 @@ -41,6 +39,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 6b1598eebba41eb17431c8c2a2053ab3f9fd9980 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 18:49:21 +0100 Subject: [PATCH 27/43] Configure APNS and FCM encryption keys in Kamal secrets Move encryption keys to base64 password fields for easier Kamal secret management, and organize them at the root level. --- saas/.kamal/secrets.beta | 8 ++++---- saas/.kamal/secrets.production | 6 +++--- saas/.kamal/secrets.staging | 6 +----- saas/config/push.yml | 10 +++++----- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index bf70f829c6..a8ac2ba4a2 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_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,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_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) +APNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS) APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) -APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) +APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $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 5b4bb4b121..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_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_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) -APNS_TOPIC=$(kamal secrets extract APNS_TOPIC $SECRETS) +FCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $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..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" %> - topic: <%= ENV["APNS_TOPIC"] || Rails.application.credentials.dig(:action_push_native, :apns, :topic) || "do.fizzy.app.ios" %> + 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 155fa2da979973b0ea4aed6fd87930d2e7f189e1 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 18:02:03 +0100 Subject: [PATCH 28/43] 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 175b50347d..39aa49a71c 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -12,7 +12,7 @@ gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" # 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 b93c672a72..5dfc9adc66 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -60,19 +60,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: 12e24eaf2f0a9613e015653f013dd131317d9bf5 @@ -208,6 +195,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 actionpack-xml_parser (2.0.1) @@ -329,7 +323,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) @@ -707,7 +701,7 @@ PLATFORMS DEPENDENCIES actionpack-xml_parser - action_push_native! + action_push_native activeresource audits1984! autotuner From 1a0d8e25011640e84673a67959c969c33ac84ee5 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 13:52:28 +0100 Subject: [PATCH 29/43] 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. Also fix WebPush::Notification test to use url instead of path Co-Authored-By: Claude Opus 4.5 --- app/views/pwa/service_worker.js | 2 +- test/lib/web_push/persistent_request_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js index 1216c550d9..91903e7359 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)) }) diff --git a/test/lib/web_push/persistent_request_test.rb b/test/lib/web_push/persistent_request_test.rb index 2cf8a6c7c7..649e27b759 100644 --- a/test/lib/web_push/persistent_request_test.rb +++ b/test/lib/web_push/persistent_request_test.rb @@ -12,7 +12,7 @@ class WebPush::PersistentRequestTest < ActiveSupport::TestCase notification = WebPush::Notification.new( title: "Test", body: "Test notification", - path: "/test", + url: "/test", badge: 0, endpoint: ENDPOINT, endpoint_ip: PUBLIC_TEST_IP, From 126ccb5e3a400168ad0e7b5d32b9ad9d0aca8600 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 21:44:43 +0100 Subject: [PATCH 30/43] Add integration test for notification delivery and simplify test helpers - Add comprehensive integration test covering card assignment, comments, mentions, email bundling, and edge cases (system user, inactive user) - Inline push notification test helpers into the only test that uses them - Remove separate PushNotificationTestHelper module Co-Authored-By: Claude Opus 4.5 --- .../notification/push_target/native_test.rb | 16 ++ saas/test/models/push_config_test.rb | 21 --- .../push_notification_test_helper.rb | 33 ---- test/integration/notifications_test.rb | 171 ++++++++++++++++++ .../notification/push_target/web_test.rb | 1 - test/test_helper.rb | 5 - 6 files changed, 187 insertions(+), 60 deletions(-) delete mode 100644 saas/test/models/push_config_test.rb delete mode 100644 saas/test/test_helpers/push_notification_test_helper.rb create mode 100644 test/integration/notifications_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 5e038fa878..59182fb6cf 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -189,4 +189,20 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert_equal @notification.creator.name, native.data[:creator_name] end + private + def assert_native_push_delivery(count: 1, &block) + assert_enqueued_jobs count, only: ApplicationPushNotificationJob do + perform_enqueued_jobs only: Notification::PushJob, &block + end + end + + def assert_no_native_push_delivery(&block) + assert_enqueued_jobs 0, only: ApplicationPushNotificationJob do + perform_enqueued_jobs only: Notification::PushJob, &block + end + end + + def stub_push_services + ActionPushNative.stubs(:service_for).returns(stub(push: true)) + end end diff --git a/saas/test/models/push_config_test.rb b/saas/test/models/push_config_test.rb deleted file mode 100644 index 554315818b..0000000000 --- a/saas/test/models/push_config_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -require "test_helper" - -class PushConfigTest < ActiveSupport::TestCase - test "loads push config from the saas engine" do - skip unless Fizzy.saas? - - 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) - - 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 diff --git a/saas/test/test_helpers/push_notification_test_helper.rb b/saas/test/test_helpers/push_notification_test_helper.rb deleted file mode 100644 index 24355edf7e..0000000000 --- a/saas/test/test_helpers/push_notification_test_helper.rb +++ /dev/null @@ -1,33 +0,0 @@ -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/integration/notifications_test.rb b/test/integration/notifications_test.rb new file mode 100644 index 0000000000..b2c8501448 --- /dev/null +++ b/test/integration/notifications_test.rb @@ -0,0 +1,171 @@ +require "test_helper" + +class NotificationDeliveryTest < ActiveSupport::TestCase + setup do + @assigner = users(:david) + @assignee = users(:kevin) + @card = cards(:logo) + + @card.assignments.destroy_all + + stub_web_push_pool + + @original_targets = Notification.push_targets.dup + Notification.push_targets = [] + Notification.register_push_target(:web) + Notification.register_push_target(push_target_with_tracking) + + # Give assignee a web push subscription + @assignee.push_subscriptions.create!( + endpoint: "https://fcm.googleapis.com/fcm/send/test123", + p256dh_key: "test_key", + auth_key: "test_auth" + ) + + Current.user = @assigner + end + + teardown do + Notification.push_targets = @original_targets + @assignee.push_subscriptions.delete_all + end + + test "card assignment creates notification and triggers push" do + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.toggle_assignment(@assignee) + end + end + + notification = Notification.last + assert_equal @assignee, notification.user + assert_equal @assigner, notification.creator + assert_equal "card_assigned", notification.source.action + + assert_push_delivered_for notification + assert_web_push_delivered + end + + test "card assignment notification is bundled for email delivery when bundling enabled" do + @assignee.settings.update!(bundle_email_frequency: :every_few_hours) + + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: NotifyRecipientsJob do + @card.toggle_assignment(@assignee) + end + end + + notification = @assignee.notifications.last + assert_not_nil notification, "Notification should be created for assignee" + + bundle = @assignee.notification_bundles.pending.last + assert_not_nil bundle, "Bundle should be created when bundling is enabled" + assert_includes bundle.notifications, notification + end + + test "comment creates notification for card watchers and triggers push" do + @card.watch_by(@assignee) + + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.comments.create!(body: "Great work on this!", creator: @assigner) + end + end + + notification = Notification.last + assert_equal @assignee, notification.user + assert_equal "comment_created", notification.source.action + + assert_push_delivered + assert_web_push_delivered + end + + test "mention creates notification and triggers push" do + mention_html = ActionText::Attachment.from_attachable(@assignee).to_html + + perform_enqueued_jobs only: [ Mention::CreateJob, NotifyRecipientsJob, Notification::PushJob ] do + @card.comments.create!( + body: "#{mention_html} check this out", + creator: @assigner + ) + end + + mention_notification = @assignee.notifications.find_by(source_type: "Mention") + assert_not_nil mention_notification + + assert_push_delivered_for mention_notification + assert_web_push_delivered + end + + test "system user actions do not create notifications" do + Current.user = users(:system) + + assert_no_difference -> { Notification.count } do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.toggle_assignment(@assignee) + end + end + + assert_no_push_delivered + assert_no_web_push_delivered + end + + test "notifications for inactive users are created but do not trigger push" do + @assignee.deactivate + + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.toggle_assignment(@assignee) + end + end + + assert_no_push_delivered + assert_no_web_push_delivered + end + + private + def stub_web_push_pool + @web_push_calls = [] + web_push_pool = stub("web_push_pool") + web_push_pool.stubs(:queue).with do |payload, subs| + @web_push_calls << { payload: payload, subscriptions: subs } + end + + Rails.configuration.x.stubs(:web_push_pool).returns(web_push_pool) + end + + def push_target_with_tracking + @push_target_calls = [] + fake_push_target = Class.new(Notification::PushTarget) do + class << self + attr_accessor :calls + end + + def self.process(notification) + calls << notification + end + end + + fake_push_target.tap { it.calls = @push_target_calls } + end + + def assert_push_delivered + assert_not_empty @push_target_calls, "Expected push to be delivered" + end + + def assert_push_delivered_for(notification) + assert_includes @push_target_calls, notification, "Expected push to be delivered for notification" + end + + def assert_no_push_delivered + assert_empty @push_target_calls, "Expected no push to be delivered" + end + + def assert_web_push_delivered + assert_not_empty @web_push_calls, "Expected web push to be delivered" + end + + def assert_no_web_push_delivered + assert_empty @web_push_calls, "Expected no web push to be delivered" + end +end diff --git a/test/models/notification/push_target/web_test.rb b/test/models/notification/push_target/web_test.rb index b492b48dd5..da71d0db13 100644 --- a/test/models/notification/push_target/web_test.rb +++ b/test/models/notification/push_target/web_test.rb @@ -77,5 +77,4 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase Notification::PushTarget::Web.new(notification).process end - end diff --git a/test/test_helper.rb b/test/test_helper.rb index b8e96bf972..b1f8137610 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -37,10 +37,6 @@ } 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" @@ -51,7 +47,6 @@ 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 dbfb141b6ff1dfe76df6c1eaa8799e52f857425c Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 22:04:23 +0100 Subject: [PATCH 31/43] Restore PushNotificationJob as shim for in-flight jobs during deploy In-flight jobs queued before deploy would fail if the old class is missing. This shim delegates to the new notification.push method. Co-Authored-By: Claude Opus 4.5 --- app/jobs/push_notification_job.rb | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/jobs/push_notification_job.rb diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb new file mode 100644 index 0000000000..8625649272 --- /dev/null +++ b/app/jobs/push_notification_job.rb @@ -0,0 +1,7 @@ +class PushNotificationJob < ApplicationJob + discard_on ActiveJob::DeserializationError + + def perform(notification) + notification.push + end +end From d1500ad4eccf8dbb40bcab03076f5e9f9065d923 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sat, 24 Jan 2026 13:35:57 +0100 Subject: [PATCH 32/43] Rename apns-dev to push-dev and unify 1Password credentials - Rename script from apns-dev to push-dev (handles both APNs and FCM) - Fetch credentials from Deploy/Fizzy Production (same as Kamal) - Use _B64 env vars to avoid escaping issues with multiline keys - Update bin/dev flag from --apns to --push Co-Authored-By: Claude Opus 4.5 --- bin/dev | 18 ++++++++-------- saas/exe/apns-dev | 48 ----------------------------------------- saas/exe/push-dev | 44 +++++++++++++++++++++++++++++++++++++ saas/fizzy-saas.gemspec | 2 +- 4 files changed, 54 insertions(+), 58 deletions(-) delete mode 100755 saas/exe/apns-dev create mode 100755 saas/exe/push-dev diff --git a/bin/dev b/bin/dev index cd8101659f..24923069d8 100755 --- a/bin/dev +++ b/bin/dev @@ -2,27 +2,27 @@ PORT=3006 USE_TAILSCALE=0 -USE_APNS=0 +USE_PUSH=0 for arg in "$@"; do case $arg in --tailscale) USE_TAILSCALE=1 ;; - --apns) USE_APNS=1 ;; + --push) USE_PUSH=1 ;; esac done -if [ "$USE_APNS" = "1" ]; then +if [ "$USE_PUSH" = "1" ]; then if [ ! -f tmp/saas.txt ]; then - echo "Enabling SaaS mode for APNs..." + echo "Enabling SaaS mode for push notifications..." ./bin/rails saas:enable fi - echo "Loading APNs credentials from 1Password..." - if ! eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec apns-dev)"; then - echo "Error: failed to load APNs credentials. Are you signed into 1Password?" >&2 + echo "Loading push credentials from 1Password..." + if ! eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec push-dev)"; then + echo "Error: failed to load push 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 + if [ -z "$APNS_ENCRYPTION_KEY_B64" ] || [ -z "$APNS_KEY_ID" ]; then + echo "Error: Push credentials not set. Missing APNS_ENCRYPTION_KEY_B64 or APNS_KEY_ID." >&2 exit 1 fi fi diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev deleted file mode 100755 index e2ad0147f5..0000000000 --- a/saas/exe/apns-dev +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env ruby -# -# Fetches APNs and FCM development environment variables from 1Password. -# -# Usage: eval "$(bundle exec apns-dev)" - -OP_ACCOUNT = "23QPQDKZC5BKBIIG7UGT5GR5RM" -OP_VAULT = "Mobile" -OP_APNS_ITEM = "37signals Push Notifications key" -OP_FCM_ITEM = "Fizzy Firebase Push Notification Private Key" - -def op_read(item, field) - `op read "op://#{OP_VAULT}/#{item}/#{field}" --account #{OP_ACCOUNT} 2>/dev/null`.strip -end - -# 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") - -# FCM credentials (JSON file attachment) -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" - warn "Make sure you're signed in: op signin --account #{OP_ACCOUNT}" - exit 1 -end - -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.gsub("'", "'\\\\''")}') -puts %Q(export ENABLE_NATIVE_PUSH="true") - -warn "" -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" diff --git a/saas/exe/push-dev b/saas/exe/push-dev new file mode 100755 index 0000000000..85f1f2b29e --- /dev/null +++ b/saas/exe/push-dev @@ -0,0 +1,44 @@ +#!/usr/bin/env ruby +# +# Fetches push notification credentials from 1Password for development. +# Uses the same 1Password items as production (Deploy/Fizzy Production). +# +# Usage: eval "$(bundle exec push-dev)" + +OP_ACCOUNT = "23QPQDKZC5BKBIIG7UGT5GR5RM" +OP_VAULT = "Deploy" +OP_ITEM = "Fizzy" + +def op_read(field) + `op read "op://#{OP_VAULT}/#{OP_ITEM}/Production/#{field}" --account #{OP_ACCOUNT} 2>/dev/null`.strip +end + +apns_key_id = op_read("APNS_KEY_ID") +apns_team_id = op_read("APNS_TEAM_ID") +apns_encryption_key_b64 = op_read("APNS_ENCRYPTION_KEY_B64") +fcm_encryption_key_b64 = op_read("FCM_ENCRYPTION_KEY_B64") + +if apns_key_id.empty? || apns_encryption_key_b64.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 + +if fcm_encryption_key_b64.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_B64="#{apns_encryption_key_b64}") +puts %Q(export FCM_ENCRYPTION_KEY_B64="#{fcm_encryption_key_b64}") +puts %Q(export ENABLE_NATIVE_PUSH="true") + +warn "" +warn "Push notification credentials loaded for development" +warn " APNs Key ID: #{apns_key_id}" +warn " APNs Team ID: #{apns_team_id}" +warn " APNs: #{apns_encryption_key_b64.empty? ? "not configured" : "configured"}" +warn " FCM: #{fcm_encryption_key_b64.empty? ? "not configured" : "configured"}" +warn " Native push: enabled" diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index de690f5d60..7cc1f90a84 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 = [ "apns-dev", "stripe-dev" ] + spec.executables = [ "push-dev", "stripe-dev" ] spec.add_dependency "rails", ">= 8.1.0.beta1" spec.add_dependency "queenbee" From 5ad1e8cee7833f21a73a5a2ea572d14e9e8b5d3b Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sat, 24 Jan 2026 13:45:22 +0100 Subject: [PATCH 33/43] Simplify push.yml to only use B64 encryption keys Co-Authored-By: Claude Opus 4.5 --- saas/config/push.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/config/push.yml b/saas/config/push.yml index 4d4e9e236a..4db9ae2c9e 100644 --- a/saas/config/push.yml +++ b/saas/config/push.yml @@ -1,10 +1,10 @@ shared: apple: 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 %> + encryption_key: <%= Base64.decode64(ENV["APNS_ENCRYPTION_KEY_B64"] || "").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_B64"] ? Base64.decode64(ENV["FCM_ENCRYPTION_KEY_B64"]) : ENV["FCM_ENCRYPTION_KEY"])&.dump %> + encryption_key: <%= Base64.decode64(ENV["FCM_ENCRYPTION_KEY_B64"] || "").dump %> project_id: fizzy-a148c From 9c165e1e89ca1b8a277ba4675a438e9b34cfcb10 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sat, 24 Jan 2026 13:48:14 +0100 Subject: [PATCH 34/43] Don't read APNS_TEAM_ID from 1Password It's a public value like the APNs topic, so just fallback to this value in push.yml. In this way we have one fewer field we need to maintain consistently across multiple 1Password items. --- saas/.kamal/secrets.beta | 5 ++--- saas/.kamal/secrets.production | 5 ++--- saas/exe/push-dev | 2 -- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index a8ac2ba4a2..2e955054b5 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 Beta/APNS_ENCRYPTION_KEY_B64 Beta/FCM_ENCRYPTION_KEY_B64) +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_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,7 +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_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS) -APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS) APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) +APNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $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 27b99b9f1c..1c6577dc89 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 Production/APNS_ENCRYPTION_KEY_B64 Production/FCM_ENCRYPTION_KEY_B64) +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_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,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_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) +APNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS) FCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS) diff --git a/saas/exe/push-dev b/saas/exe/push-dev index 85f1f2b29e..cac878fae3 100755 --- a/saas/exe/push-dev +++ b/saas/exe/push-dev @@ -14,7 +14,6 @@ def op_read(field) end apns_key_id = op_read("APNS_KEY_ID") -apns_team_id = op_read("APNS_TEAM_ID") apns_encryption_key_b64 = op_read("APNS_ENCRYPTION_KEY_B64") fcm_encryption_key_b64 = op_read("FCM_ENCRYPTION_KEY_B64") @@ -38,7 +37,6 @@ puts %Q(export ENABLE_NATIVE_PUSH="true") warn "" warn "Push notification credentials loaded for development" warn " APNs Key ID: #{apns_key_id}" -warn " APNs Team ID: #{apns_team_id}" warn " APNs: #{apns_encryption_key_b64.empty? ? "not configured" : "configured"}" warn " FCM: #{fcm_encryption_key_b64.empty? ? "not configured" : "configured"}" warn " Native push: enabled" From 6ec3cc6f2cdf9d372a35aabb67496f74ffaa07b7 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sat, 24 Jan 2026 13:52:02 +0100 Subject: [PATCH 35/43] Document --push flag for testing native push notifications Co-Authored-By: Claude Opus 4.5 --- saas/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/saas/README.md b/saas/README.md index 69697ded44..b71afff08d 100644 --- a/saas/README.md +++ b/saas/README.md @@ -46,6 +46,16 @@ This will ask for your 1password authorization to read and set the environment v * [Staging](https://dashboard.stripe.com/acct_1SdTbuRvb8txnPBR/test/dashboard) * [Production](https://dashboard.stripe.com/acct_1SNy97RwChFE4it8/dashboard) +## Working with Push Notifications + +To test native push notifications (APNs and FCM) locally, start the dev server with the `--push` flag: + +```sh +bin/dev --push +``` + +This will ask for your 1Password authorization to fetch the push credentials. Note that this loads the **production** APNs and FCM credentials into your environment. + ## Environments Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. From 113e61d418601286200d52a8285764e44e8a8e42 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Sun, 25 Jan 2026 21:23:57 -0600 Subject: [PATCH 36/43] 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 b1cdb723813c33a4029c99be032cbc89b092d07c Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Sun, 25 Jan 2026 22:22:25 -0600 Subject: [PATCH 37/43] 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 cefc20612a59878a15c1ecd7c3362e2b94100273 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Sun, 25 Jan 2026 23:26:31 -0600 Subject: [PATCH 38/43] 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 From 92cb749e16f805614a1df70081d80fa570cf3cb4 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 18:38:43 +0100 Subject: [PATCH 39/43] Fix tests for renamed fixtures and new stacked notifications Clear assignee's existing notifications in setup since Notifier now uses create_or_find_by instead of create, and reload the association to avoid caching after destroy_all. Update fixture references after rebase: use `logo_assignment_kevin` as base notification and `logo_mentioned_david` for mention tests. Update source to `logo_published` in tests that need generic card events. Co-Authored-By: Claude Opus 4.5 --- Gemfile.saas.lock | 4 +-- app/models/notification/pushable.rb | 3 +- saas/db/saas_schema.rb | 20 ++++++------- .../notification/push_target/native_test.rb | 12 +++++--- test/integration/notifications_test.rb | 3 +- test/models/concerns/push_notifiable_test.rb | 28 ------------------- .../notification/push_target/web_test.rb | 17 ++++------- test/models/notification/pushable_test.rb | 27 ++++++++++++------ 8 files changed, 47 insertions(+), 67 deletions(-) delete mode 100644 test/models/concerns/push_notifiable_test.rb diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 5dfc9adc66..fc88e7aec5 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -308,7 +308,6 @@ GEM addressable (>= 2.5.0) globalid (1.3.0) activesupport (>= 6.1) - gvltools (0.4.0) google-cloud-env (2.3.1) base64 (~> 0.2) faraday (>= 1.0, < 3.a) @@ -321,6 +320,7 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) + gvltools (0.4.0) hashdiff (1.2.1) http-2 (1.1.1) httpx (1.7.1) @@ -700,8 +700,8 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - actionpack-xml_parser action_push_native + actionpack-xml_parser activeresource audits1984! autotuner diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb index 4d5d14d0db..e02f3a8de3 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -4,8 +4,7 @@ module Notification::Pushable included do class_attribute :push_targets, default: [] - after_create_commit :push_later - after_update_commit :push_later, if: :source_id_previously_changed? + after_save_commit :push_later, if: :source_id_previously_changed? end class_methods do diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index debc08b199..298cc033af 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -43,16 +43,6 @@ t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true end - create_table "audits1984_auditor_tokens", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.uuid "auditor_id", null: false - t.datetime "created_at", null: false - t.datetime "expires_at", null: false - t.string "token_digest", null: false - t.datetime "updated_at", null: false - t.index ["auditor_id"], name: "index_audits1984_auditor_tokens_on_auditor_id", unique: true - t.index ["token_digest"], name: "index_audits1984_auditor_tokens_on_token_digest", 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" @@ -66,6 +56,16 @@ t.index ["session_id"], name: "index_action_push_native_devices_on_session_id" end + create_table "audits1984_auditor_tokens", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "auditor_id", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "token_digest", null: false + t.datetime "updated_at", null: false + t.index ["auditor_id"], name: "index_audits1984_auditor_tokens_on_auditor_id", unique: true + t.index ["token_digest"], name: "index_audits1984_auditor_tokens_on_token_digest", unique: true + 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/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index 59182fb6cf..0418d96c2b 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -4,7 +4,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase setup do @user = users(:kevin) @identity = @user.identity - @notification = notifications(:logo_published_kevin) + @notification = notifications(:logo_assignment_kevin) # Ensure user has no web push subscriptions (we want to test native push independently) @user.push_subscriptions.delete_all @@ -23,12 +23,14 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase end test "payload category returns mention for mentions" do - notification = notifications(:logo_card_david_mention_by_jz) + notification = notifications(:logo_mentioned_david) assert_equal "mention", notification.payload.category end test "payload category returns card for other card events" do + @notification.update!(source: events(:logo_published)) + assert_equal "card", @notification.payload.category end @@ -92,7 +94,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase end test "native notification sets high_priority for mentions" do - notification = notifications(:logo_card_david_mention_by_jz) + notification = notifications(:logo_mentioned_david) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) @@ -112,6 +114,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase end test "native notification sets normal priority for other card events" do + @notification.update!(source: events(:logo_published)) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) @@ -141,7 +144,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase end test "native notification sets time-sensitive interruption level for mentions" do - notification = notifications(:logo_card_david_mention_by_jz) + notification = notifications(:logo_mentioned_david) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) @@ -161,6 +164,7 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase end test "native notification sets active interruption level for other card events" do + @notification.update!(source: events(:logo_published)) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) diff --git a/test/integration/notifications_test.rb b/test/integration/notifications_test.rb index b2c8501448..f581fb5e94 100644 --- a/test/integration/notifications_test.rb +++ b/test/integration/notifications_test.rb @@ -7,6 +7,7 @@ class NotificationDeliveryTest < ActiveSupport::TestCase @card = cards(:logo) @card.assignments.destroy_all + @assignee.notifications.destroy_all stub_web_push_pool @@ -55,7 +56,7 @@ class NotificationDeliveryTest < ActiveSupport::TestCase end end - notification = @assignee.notifications.last + notification = @assignee.notifications.reload.last assert_not_nil notification, "Notification should be created for assignee" bundle = @assignee.notification_bundles.pending.last diff --git a/test/models/concerns/push_notifiable_test.rb b/test/models/concerns/push_notifiable_test.rb deleted file mode 100644 index f6d7ed26d2..0000000000 --- a/test/models/concerns/push_notifiable_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -require "test_helper" - -class PushNotifiableTest < ActiveSupport::TestCase - test "enqueues push notification job when notification is created" do - assert_enqueued_with(job: PushNotificationJob) do - users(:david).notifications.create!( - source: events(:layout_published), - creator: users(:jason) - ) - end - end - - test "enqueues push notification job when notification source changes" do - notification = notifications(:logo_mentioned_david) - - assert_enqueued_with(job: PushNotificationJob) do - notification.update!(source: events(:logo_published)) - end - end - - test "does not enqueue push notification job for other updates" do - notification = notifications(:logo_mentioned_david) - - assert_no_enqueued_jobs only: PushNotificationJob do - notification.update!(unread_count: 5) - end - end -end diff --git a/test/models/notification/push_target/web_test.rb b/test/models/notification/push_target/web_test.rb index da71d0db13..94628fb828 100644 --- a/test/models/notification/push_target/web_test.rb +++ b/test/models/notification/push_target/web_test.rb @@ -3,10 +3,7 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase setup do @user = users(:david) - @notification = @user.notifications.create!( - source: events(:logo_published), - creator: users(:jason) - ) + @notification = notifications(:logo_mentioned_david) @user.push_subscriptions.create!( endpoint: "https://fcm.googleapis.com/fcm/send/test123", @@ -38,6 +35,8 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase end test "payload includes card title for card events" do + @notification.update!(source: events(:logo_published)) + @web_push_pool.expects(:queue).once.with do |payload, _| payload[:title] == @notification.card.title end @@ -57,24 +56,20 @@ class Notification::PushTarget::WebTest < ActiveSupport::TestCase end test "payload for assignment includes assigned message" do - event = events(:logo_assignment_david) - notification = @user.notifications.create!(source: event, creator: event.creator) + @notification.update!(source: events(:logo_assignment_david)) @web_push_pool.expects(:queue).once.with do |payload, _| payload[:body].include?("Assigned to you") end - Notification::PushTarget::Web.new(notification).process + Notification::PushTarget::Web.new(@notification).process 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::PushTarget::Web.new(notification).process + 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 7c54b0036e..9036453bdf 100644 --- a/test/models/notification/pushable_test.rb +++ b/test/models/notification/pushable_test.rb @@ -3,10 +3,7 @@ class Notification::PushableTest < ActiveSupport::TestCase setup do @user = users(:david) - @notification = @user.notifications.create!( - source: events(:logo_published), - creator: users(:jason) - ) + @notification = notifications(:logo_mentioned_david) end test "push_later enqueues Notification::PushJob" do @@ -28,12 +25,24 @@ class Notification::PushableTest < ActiveSupport::TestCase end test "push_later is called after notification is created" do - Notification.any_instance.expects(:push_later) + assert_enqueued_with(job: Notification::PushJob) do + @user.notifications.create!( + source: events(:layout_published), + creator: users(:jason) + ) + end + end + + test "push_later is called when notification source changes" do + assert_enqueued_with(job: Notification::PushJob) do + @notification.update!(source: events(:logo_published)) + end + end - @user.notifications.create!( - source: events(:logo_published), - creator: users(:jason) - ) + test "push_later is not called for other updates" do + assert_no_enqueued_jobs only: Notification::PushJob do + @notification.update!(unread_count: 5) + end end test "register_push_target accepts symbols" do From abef50c5036797311baa6feef7ecd716df452f53 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sat, 21 Feb 2026 09:29:32 +0100 Subject: [PATCH 40/43] Fix devices_path route helper in native devices partial The manage devices link used `devices_path` but the route is defined in the saas engine, so it needs `saas.devices_path`. Co-Authored-By: Claude Opus 4.6 --- .../views/notifications/settings/_native_devices.html.erb | 2 +- saas/test/controllers/devices_controller_test.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/saas/app/views/notifications/settings/_native_devices.html.erb b/saas/app/views/notifications/settings/_native_devices.html.erb index ea9e9987d1..a0ddbed130 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.identity.devices.count, "mobile device") %> registered for push notifications.

- <%= link_to "Manage devices", devices_path, class: "btn txt-small" %> + <%= link_to "Manage devices", saas.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/test/controllers/devices_controller_test.rb b/saas/test/controllers/devices_controller_test.rb index b05ca5ed29..8b37a0164a 100644 --- a/saas/test/controllers/devices_controller_test.rb +++ b/saas/test/controllers/devices_controller_test.rb @@ -25,6 +25,14 @@ class DevicesControllerTest < ActionDispatch::IntegrationTest assert_select "p", /No devices registered/ end + test "show notification settings with registered devices" do + @identity.devices.create!(token: "test_token", platform: "apple", name: "iPhone 15 Pro") + + get notifications_settings_path + + assert_response :success + end + test "index requires authentication" do sign_out From 5bea9633d0c489cc2e0edc92e84c04b2cce5b732 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 23 Feb 2026 15:45:52 +0100 Subject: [PATCH 41/43] Make APNS and FCM env vars available To beta and production deploys. --- saas/config/deploy.beta.yml | 3 +++ saas/config/deploy.production.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index d5fce9d320..ddb67e16cb 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -106,6 +106,9 @@ env: - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET + - APNS_KEY_ID + - APNS_ENCRYPTION_KEY_B64 + - FCM_ENCRYPTION_KEY_B64 tags: df_iad: PRIMARY_DATACENTER: true diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index eadb66e7f4..7865b95a62 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -55,6 +55,9 @@ env: - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET + - APNS_KEY_ID + - APNS_ENCRYPTION_KEY_B64 + - FCM_ENCRYPTION_KEY_B64 tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com From 6fb24118ea2500bcee200e89eafc80197c9c5256 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 24 Feb 2026 20:43:21 +0100 Subject: [PATCH 42/43] Use internal account ID as `account_id` and add `account_slug` In native push payload, to be consistent about how we refer to this in other endpoints. --- saas/app/models/notification/push_target/native.rb | 4 +++- saas/test/models/notification/push_target/native_test.rb | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb index 86212176f8..5366ed0322 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -30,7 +30,9 @@ def native_notification title: payload.title, body: payload.body, url: payload.url, - account_id: notification.account.external_account_id, + base_url: payload.base_url, + account_id: notification.account.id, + account_slug: notification.account.slug, avatar_url: payload.avatar_url, card_id: card&.id, card_title: card&.title, diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index 0418d96c2b..fe06f83f7f 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -189,7 +189,8 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase native = push.send(:native_notification) assert_not_nil native.data[:url] - assert_equal @notification.account.external_account_id, native.data[:account_id] + assert_equal @notification.account.id, native.data[:account_id] + assert_equal @notification.account.slug, native.data[:account_slug] assert_equal @notification.creator.name, native.data[:creator_name] end From 13268ef668bd6de4a246bb9360058e05a4f0b5ed Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 13:57:25 +0100 Subject: [PATCH 43/43] Add base_url to native push notification payload Include the server's base URL so native apps can identify which Fizzy instance (SaaS or self-hosted) a notification originated from. Co-Authored-By: Claude Opus 4.6 --- app/models/notification/default_payload.rb | 4 ++++ saas/test/models/notification/push_target/native_test.rb | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb index 65ca068e26..98f104856e 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 base_url + Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp("/") + end + def avatar_url Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options) end diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb index fe06f83f7f..c6e1c9d4f2 100644 --- a/saas/test/models/notification/push_target/native_test.rb +++ b/saas/test/models/notification/push_target/native_test.rb @@ -194,6 +194,15 @@ class Notification::PushTarget::NativeTest < ActiveSupport::TestCase assert_equal @notification.creator.name, native.data[:creator_name] end + test "native notification includes base_url without account slug" do + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(@notification) + native = push.send(:native_notification) + + assert_equal "http://example.com", native.data[:base_url] + end + private def assert_native_push_delivery(count: 1, &block) assert_enqueued_jobs count, only: ApplicationPushNotificationJob do