From 74b91c8832edb9b2ba08b2e689bb45597198d0d8 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 25 Feb 2026 17:08:11 +0100 Subject: [PATCH 01/56] Add "show label" and "main action" attributes --- .../initializers/bridge/bridge_element.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 7ddeb1b33..29aa52549 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -1,10 +1,14 @@ import { BridgeElement } from "@hotwired/hotwire-native-bridge" BridgeElement.prototype.getButton = function() { - return { + const button = { title: this.title, - icon: this.getIcon() + icon: this.getIcon(), + showLabel: this.getShowLabel(), + isMainAction: this.getIsMainAction() } + console.log(button) + return button } BridgeElement.prototype.getIcon = function() { @@ -16,3 +20,11 @@ BridgeElement.prototype.getIcon = function() { return null } + +BridgeElement.prototype.getShowLabel = function() { + return !!this.bridgeAttribute(`show-label`) +} + +BridgeElement.prototype.getIsMainAction = function() { + return !!this.bridgeAttribute(`main-action`) +} From feea5289b6ba89c78507d0d40cd68b3097e5e6f1 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 25 Feb 2026 17:08:41 +0100 Subject: [PATCH 02/56] Apply to add/done actions --- app/views/cards/container/_closure_buttons.html.erb | 2 +- app/views/columns/show/_add_card_button.html.erb | 2 +- app/views/events/index/_add_board_button.html.erb | 2 +- app/views/events/index/_add_card_button.html.erb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 990a17982..decc954b8 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -7,7 +7,7 @@ <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark as Done", bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_show_label: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done diff --git a/app/views/columns/show/_add_card_button.html.erb b/app/views/columns/show/_add_card_button.html.erb index 040fd55d3..2d82bbb7e 100644 --- a/app/views/columns/show/_add_card_button.html.erb +++ b/app/views/columns/show/_add_card_button.html.erb @@ -1,7 +1,7 @@
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } }, - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_show_label: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add", class: "show-on-touch" %> Add a card C diff --git a/app/views/events/index/_add_board_button.html.erb b/app/views/events/index/_add_board_button.html.erb index a534626d4..42b882a12 100644 --- a/app/views/events/index/_add_board_button.html.erb +++ b/app/views/events/index/_add_board_button.html.erb @@ -1,7 +1,7 @@
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile", - data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a board", bridge_icon_url: bridge_icon("board") } do %> + data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add board", bridge_show_label: true, bridge_icon_url: bridge_icon("board") } do %> <%= icon_tag "board" %> Add a board B diff --git a/app/views/events/index/_add_card_button.html.erb b/app/views/events/index/_add_card_button.html.erb index f865e5403..0a4ecf1d8 100644 --- a/app/views/events/index/_add_card_button.html.erb +++ b/app/views/events/index/_add_card_button.html.erb @@ -1,7 +1,7 @@
<% if board = user_filtering.single_board_or_first %> <%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile", - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a card", bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_show_label: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add" %> Add a card C From 7f058a7cea3262f6c47b6658f4a0a7830689750c Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 25 Feb 2026 17:55:15 +0100 Subject: [PATCH 03/56] Make add card and mark done main actions --- app/views/cards/container/_closure_buttons.html.erb | 2 +- app/views/columns/show/_add_card_button.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index decc954b8..ce2c57aa5 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -7,7 +7,7 @@ <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_show_label: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_main_action: "true", bridge_show_label: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done diff --git a/app/views/columns/show/_add_card_button.html.erb b/app/views/columns/show/_add_card_button.html.erb index 2d82bbb7e..a2f9d9b44 100644 --- a/app/views/columns/show/_add_card_button.html.erb +++ b/app/views/columns/show/_add_card_button.html.erb @@ -1,7 +1,7 @@
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } }, - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_show_label: true, bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_show_label: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add", class: "show-on-touch" %> Add a card C From 16abe6b38bc1a313a781240fe31f2fb2fd3a3465 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 25 Feb 2026 12:30:39 -0500 Subject: [PATCH 04/56] Remove dead HTML branch from boards#index (#2613) See #2528 for context --- app/controllers/boards_controller.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index a8284b309..191bb72fd 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -5,12 +5,8 @@ class BoardsController < ApplicationController before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] def index - if request.format.json? - set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed - fresh_when etag: @page.records - else - set_page_and_extract_portion_from Current.user.boards - end + set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed + fresh_when etag: @page.records end def show From 446f22a292845424f3e34a18bb5d2c61f3be1c50 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Wed, 25 Feb 2026 20:10:56 +0200 Subject: [PATCH 05/56] Fix N+1 queries on identity in UsersController and BoardsController (#2365) * Fix N+1 queries on identity in UsersController and BoardsController - Add .includes(:identity) to UsersController#index - Add .includes(creator: :identity) to BoardsController#index - Add N+1 regression tests for both controllers * Simplify controller N+1 tests with query assertions --------- Co-authored-by: Mike Dalessio --- app/controllers/boards_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- test/controllers/boards_controller_test.rb | 14 ++++++++++++++ test/controllers/users_controller_test.rb | 12 ++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 191bb72fd..604938deb 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -5,7 +5,7 @@ class BoardsController < ApplicationController before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] def index - set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed + set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity) fresh_when etag: @page.records end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 54c18704c..7062b85e5 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,7 +3,7 @@ class UsersController < ApplicationController before_action :ensure_permission_to_change_user, only: %i[ update destroy ] def index - set_page_and_extract_portion_from Current.account.users.active.alphabetically + set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity) end def show diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 8a426f00e..6c171b6b2 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -278,6 +278,20 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_response :no_content end + test "index avoids N+1 queries on creator and identity" do + assert_queries_match(/FROM [`"]users[`"].* IN \(/, count: 1) do + assert_queries_match(/FROM [`"]identities[`"].* IN \(/, count: 1) do + get boards_path, as: :json + assert_response :success + end + end + + json = @response.parsed_body + first_board = json.first + assert first_board["creator"].present? + assert first_board["creator"]["email_address"].present? + end + private def next_page_from_link_header(link_header) url = link_header&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index ab91a4ab6..094a2310c 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -144,4 +144,16 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_response :no_content end + + test "index avoids N+1 queries on identity" do + sign_in_as :kevin + + assert_queries_match(/FROM [`"]identities[`"].* IN \(/, count: 1) do + get users_path, as: :json + assert_response :success + end + + json = @response.parsed_body + assert json.first["email_address"].present? + end end From 3c54cd84fc1c3bf50a06157a590dc4e25293632e Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Wed, 14 Jan 2026 14:38:09 -0600 Subject: [PATCH 06/56] 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 c29eaeb79..39aa49a71 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 b18d2fa8b..0b58ab02e 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 d6425e561..1b50142bd 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -12,18 +12,22 @@ class NotificationPusher 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 @@ class NotificationPusher } 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 3e6b8b84d..dc43bafd8 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 000000000..aa577ef76 --- /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 c8ce19e07..ab087f4b2 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -69,6 +69,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 000000000..a78bbe40f --- /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 000000000..5db7811c2 --- /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 000000000..41fe881f7 --- /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 000000000..237697f92 --- /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 000000000..25bd6e4b2 --- /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 000000000..a3e95b392 --- /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 000000000..4a9a02486 --- /dev/null +++ b/saas/app/views/users/devices/index.html.erb @@ -0,0 +1,16 @@ +

Registered Devices

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

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

+<% end %> diff --git a/saas/config/push.yml b/saas/config/push.yml new file mode 100644 index 000000000..916277b30 --- /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 3d20f9df8..4acf30e57 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -23,6 +23,10 @@ module Fizzy 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 @@ module Fizzy namespace :stripe do resource :webhooks, only: :create end + + namespace :users do + resources :devices, only: [ :index, :create, :destroy ] + end end end @@ -148,6 +156,8 @@ module Fizzy 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 000000000..46da8ce7f --- /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 000000000..0494d2a97 --- /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 000000000..2c628b416 --- /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 000000000..979194b3f --- /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 000000000..24355edf7 --- /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 b1f813761..b8e96bf97 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -37,6 +37,10 @@ VCR.configure do |config| } 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 @@ module ActiveSupport 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 07/56] 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 05032e7c3..cd8101659 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 000000000..86d4183fa --- /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 aa577ef76..e2742dae7 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 @@ class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] 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 20cb0d161..c3405d9f9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -69,6 +69,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 423ef11fb..bf70f829c 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 46d7abfbc..5b4bb4b12 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 31979d170..a7330e157 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 a78bbe40f..3b2f8752a 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 41fe881f7..a0b5e3ee5 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 916277b30..309f41470 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 000000000..02bd28ad1 --- /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 1368ef63c..de690f5d6 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 4acf30e57..fa782414b 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -23,8 +23,8 @@ module Fizzy 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 @@ module Fizzy ::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 08/56] 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 309f41470..99c119670 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 02bd28ad1..e2ad0147f 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 09/56] 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 39aa49a71..28b3f9b11 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 0b58ab02e..ce398e00d 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 86d4183fa..000000000 --- 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 10/56] 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 e2742dae7..5ef4be322 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 @@ class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] 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 c3405d9f9..9ff5edb0c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -77,8 +77,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 3b2f8752a..935502408 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 @@ class Users::DevicesController < ApplicationController 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 fa782414b..3dc1902eb 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -45,7 +45,9 @@ module Fizzy 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 46da8ce7f..08383c3fe 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 0494d2a97..7601d5284 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 11/56] 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 237697f92..cb03eec43 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -35,6 +35,8 @@ module NotificationPusher::Native 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 12/56] 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 1b50142bd..12b4965bd 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -45,7 +45,7 @@ class NotificationPusher base_payload = { title: card_notification_title(card), - path: card_path(card) + url: card_url(card) } case event.action @@ -53,7 +53,7 @@ class NotificationPusher 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 @@ class NotificationPusher { 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 @@ class NotificationPusher { 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 @@ class NotificationPusher 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 4c873fb48..c01caa953 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 @@ class WebPush::Notification 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 cb03eec43..e5f83e40a 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -37,7 +37,7 @@ module NotificationPusher::Native .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 13/56] 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 000000000..fd4b7ed83 --- /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 000000000..6547ec206 --- /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 25bd6e4b2..df198df16 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 4a9a02486..bb7d74871 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 a3e95b392..9931f822f 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 3dc1902eb..0c394ed3a 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -44,10 +44,8 @@ module Fizzy 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 08383c3fe..331c2f722 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 2c628b416..70a9f90ef 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 979194b3f..554315818 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 14/56] 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 28b3f9b11..913dad385 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 ce398e00d..0a56a7f67 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 fd4b7ed83..081c322b8 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 @@ class DevicesController < ApplicationController 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 935502408..000000000 --- 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 0c394ed3a..e2e86c033 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -44,9 +44,7 @@ module Fizzy 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 331c2f722..0cfbbd612 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 15/56] 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 081c322b8..6913da0c5 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 @@ class DevicesController < ApplicationController 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 df198df16..ce7eec457 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 e5f83e40a..2fc38ed72 100644 --- a/saas/app/models/notification_pusher/native.rb +++ b/saas/app/models/notification_pusher/native.rb @@ -12,11 +12,11 @@ module NotificationPusher::Native 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 9931f822f..ea9e9987d 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 e2e86c033..31fadee33 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -154,7 +154,7 @@ module Fizzy 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 0cfbbd612..0434a29cd 100644 --- a/saas/test/controllers/devices_controller_test.rb +++ b/saas/test/controllers/devices_controller_test.rb @@ -2,12 +2,12 @@ require "test_helper" 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 70a9f90ef..ef7fcd0d5 100644 --- a/saas/test/models/notification_pusher_test.rb +++ b/saas/test/models/notification_pusher_test.rb @@ -3,6 +3,7 @@ require "test_helper" 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 16/56] 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} | 156 ++++++++---------- 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, 321 insertions(+), 187 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 000000000..cd30e3581 --- /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 c912e141d..000000000 --- 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 4bf9b575d..000000000 --- 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 417a2f22d..a5eab3ccd 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 12b4965bd..c94f121f1 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 @@ class NotificationPusher 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 @@ class NotificationPusher def build_event_payload event = notification.source - card = event.card base_payload = { title: card_notification_title(card), @@ -80,7 +75,6 @@ class NotificationPusher def build_mention_payload mention = notification.source - card = mention.card { title: "#{mention.mentioner.first_name} mentioned you", @@ -93,19 +87,10 @@ class NotificationPusher { 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 @@ class NotificationPusher 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 000000000..33d34e3e7 --- /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 000000000..a4f1a56c6 --- /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 000000000..fa82a46a7 --- /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 000000000..c6f08f840 --- /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 2fc38ed72..0030cf4b5 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 @@ module NotificationPusher::Native 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 31fadee33..a582718b8 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -154,12 +154,14 @@ module Fizzy 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 @@ module Fizzy ::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 ef7fcd0d5..8ba0521a5 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") - assert_equal "assignment", pusher.send(:notification_category) + push = Notification::Push::Native.new(notification) + + 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") - assert_equal "mention", pusher.send(:notification_category) + push = Notification::Push::Native.new(notification) + + 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") - assert_equal "card", pusher.send(:notification_category) + push = Notification::Push::Native.new(@notification) + + 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) + push = Notification::Push::Native.new(notification) + + assert_equal "time-sensitive", push.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 - - 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?) + push = Notification::Push::Native.new(@notification) + + assert_equal "active", push.send(:interruption_level) 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 - - 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?) - 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 000000000..4c81d6719 --- /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 000000000..2f99ac2c5 --- /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 53380effe..000000000 --- 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 17/56] 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 000000000..acffb1365 --- /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 000000000..3cc02dc34 --- /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 000000000..4649f38f3 --- /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 c94f121f1..cf2adce44 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 @@ class Notification::Push def build_payload case notification.source_type when "Event" - build_event_payload + Notification::EventPayload.new(notification).to_h when "Mention" - build_mention_payload + Notification::MentionPayload.new(notification).to_h else - build_default_payload + Notification::DefaultPayload.new(notification).to_h 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}" - ) - else - base_payload.merge( - body: event.creator.name - ) - 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 18/56] 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 | 24 +++---- app/models/notification/event_payload.rb | 70 +++++++++---------- app/models/notification/mention_payload.rb | 24 +++---- 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, 87 insertions(+), 92 deletions(-) diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb index acffb1365..fc2d5290a 100644 --- a/app/models/notification/default_payload.rb +++ b/app/models/notification/default_payload.rb @@ -11,19 +11,19 @@ class Notification::DefaultPayload { title: title, body: body, url: url } end + def title + "New notification" + end + + def body + "You have a new notification" + end + + def url + notifications_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 diff --git a/app/models/notification/event_payload.rb b/app/models/notification/event_payload.rb index 3cc02dc34..ebdb807b9 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 + 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 + 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 diff --git a/app/models/notification/mention_payload.rb b/app/models/notification/mention_payload.rb index 4649f38f3..f07cbf20f 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 + 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 + 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 diff --git a/app/models/notification/push.rb b/app/models/notification/push.rb index cf2adce44..5def39e5b 100644 --- a/app/models/notification/push.rb +++ b/app/models/notification/push.rb @@ -21,15 +21,4 @@ class Notification::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 33d34e3e7..664473101 100644 --- a/app/models/notification/push/web.rb +++ b/app/models/notification/push/web.rb @@ -9,7 +9,7 @@ class Notification::Push::Web < Notification::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 a4f1a56c6..a780b11c5 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -33,4 +33,13 @@ module Notification::Pushable 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 0030cf4b5..2d0836b85 100644 --- a/saas/app/models/notification/push/native.rb +++ b/saas/app/models/notification/push/native.rb @@ -9,14 +9,18 @@ class Notification::Push::Native < Notification::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 @@ class Notification::Push::Native < Notification::Push 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 @@ class Notification::Push::Native < Notification::Push 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 8ba0521a5..0cff2336a 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 19/56] 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 cd30e3581..3fb83a513 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 5def39e5b..26fd2b263 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 664473101..fe9b72587 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 a780b11c5..8b90a36ba 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -16,10 +16,9 @@ module Notification::Pushable 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 c6f08f840..e06fc18d2 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 2d0836b85..2eb4e5ef2 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 0cff2336a..b684ec11a 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 4c81d6719..abf95f386 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 2f99ac2c5..898061b51 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 20/56] 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 000000000..e255f2eb3 --- /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 9ff5edb0c..c9da93d98 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -75,10 +75,12 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 6913da0c5..fc49b14b0 100644 --- a/saas/app/controllers/devices_controller.rb +++ b/saas/app/controllers/devices_controller.rb @@ -6,7 +6,7 @@ class DevicesController < ApplicationController 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 6547ec206..7d9aad4e7 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 000000000..c159ada72 --- /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 a582718b8..40be7fa22 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -155,6 +155,7 @@ module Fizzy ::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 000000000..00874895d --- /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 21/56] 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 000000000..3c1392b44 --- /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 c9da93d98..1b562554d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -79,7 +79,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 22/56] 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 5ef4be322..4985c8636 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 @@ class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] 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 e255f2eb3..000000000 --- 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 3c1392b44..000000000 --- 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 23/56] 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 4985c8636..c696a35bc 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 @@ class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] 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 1b562554d..19ee667a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -853,6 +853,4 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 24/56] 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 bbbf2a60c..6ba11a28d 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -37,6 +37,10 @@ class Event < ApplicationRecord 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 5491bfd9f..66a02428a 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -18,6 +18,10 @@ class Mention < ApplicationRecord 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 2eb4e5ef2..d2e86ead4 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -49,7 +49,7 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 b684ec11a..93fe546c5 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 25/56] 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 fc49b14b0..e6b6b1d19 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 @@ class DevicesController < ApplicationController 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 bb7d74871..9e467731a 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 ece9fef17..3c5a7fb98 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 40be7fa22..b733d7a41 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -43,8 +43,6 @@ module Fizzy 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 0434a29cd..b05ca5ed2 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 26/56] 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 24449909b..6efc361eb 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 913dad385..175b50347 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 0a56a7f67..b93c672a7 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 19ee667a3..20cb0d161 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -69,19 +69,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 ab087f4b2..c8ce19e07 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -69,19 +69,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 980e952ac..debc08b19 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -53,6 +53,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_26_230838) do 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 b733d7a41..b90113544 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -10,6 +10,11 @@ module Fizzy # 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 27/56] 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 6ba11a28d..bbbf2a60c 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -37,10 +37,6 @@ class Event < ApplicationRecord 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 66a02428a..5491bfd9f 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -18,10 +18,6 @@ class Mention < ApplicationRecord 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 d2e86ead4..38f3e5eb4 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -73,7 +73,11 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 28/56] 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 fc2d5290a..9cabc4988 100644 --- a/app/models/notification/default_payload.rb +++ b/app/models/notification/default_payload.rb @@ -23,6 +23,14 @@ class Notification::DefaultPayload 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 ebdb807b9..fba3a6cb2 100644 --- a/app/models/notification/event_payload.rb +++ b/app/models/notification/event_payload.rb @@ -36,6 +36,18 @@ class Notification::EventPayload < Notification::DefaultPayload 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 f07cbf20f..480771c15 100644 --- a/app/models/notification/mention_payload.rb +++ b/app/models/notification/mention_payload.rb @@ -13,6 +13,14 @@ class Notification::MentionPayload < Notification::DefaultPayload 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 38f3e5eb4..4a4e77be3 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -24,7 +24,7 @@ class Notification::PushTarget::Native < Notification::PushTarget ApplicationPushNotification .with_apple( aps: { - category: notification_category, + category: payload.category, "mutable-content": 1, "interruption-level": interruption_level } @@ -41,7 +41,7 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 93fe546c5..162b7a32c 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 29/56] 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 000000000..233762b37 --- /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 3fb83a513..000000000 --- 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 fe9b72587..68c971d8f 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 8b90a36ba..c8519951a 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -24,8 +24,12 @@ module Notification::Pushable 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 e06fc18d2..000000000 --- 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 4a4e77be3..28727ff0c 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 162b7a32c..2a098ebac 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 abf95f386..627f15357 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 898061b51..bdc3f2034 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 30/56] 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 26fd2b263..da6c26fdc 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 c8519951a..0f5ae2ed6 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -16,9 +16,10 @@ module Notification::Pushable 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 @@ module Notification::Pushable 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 @@ module Notification::Pushable 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 2a098ebac..a68e6517b 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 627f15357..7182bd08f 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 bdc3f2034..1b8e191b5 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 31/56] 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 da6c26fdc..1fc250259 100644 --- a/app/models/notification/push_target.rb +++ b/app/models/notification/push_target.rb @@ -12,17 +12,6 @@ class Notification::PushTarget 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 68c971d8f..9bf839906 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 0f5ae2ed6..4d5d14d0d 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -29,11 +29,9 @@ module Notification::Pushable 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 @@ module Notification::Pushable 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 28727ff0c..d4dce6168 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 a68e6517b..5e038fa87 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 7182bd08f..b492b48dd 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 1b8e191b5..7c54b0036 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 32/56] 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 bf70f829c..a8ac2ba4a 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 5b4bb4b12..27b99b9f1 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 a7330e157..31979d170 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 99c119670..4d4e9e236 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 33/56] 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 175b50347..39aa49a71 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 b93c672a7..5dfc9adc6 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 34/56] 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 1216c550d..91903e735 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 2cf8a6c7c..649e27b75 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 35/56] 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 5e038fa87..59182fb6c 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 554315818..000000000 --- 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 24355edf7..000000000 --- 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 000000000..b2c850144 --- /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 b492b48dd..da71d0db1 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 b8e96bf97..b1f813761 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -37,10 +37,6 @@ VCR.configure do |config| } 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 @@ module ActiveSupport 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 36/56] 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 000000000..862564927 --- /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 37/56] 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 cd8101659..24923069d 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 e2ad0147f..000000000 --- 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 000000000..85f1f2b29 --- /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 de690f5d6..7cc1f90a8 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 38/56] 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 4d4e9e236..4db9ae2c9 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 39/56] 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 a8ac2ba4a..2e955054b 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 27b99b9f1..1c6577dc8 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 85f1f2b29..cac878fae 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 40/56] 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 69697ded4..b71afff08 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 41/56] 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 9cabc4988..65ca068e2 100644 --- a/app/models/notification/default_payload.rb +++ b/app/models/notification/default_payload.rb @@ -31,6 +31,10 @@ class Notification::DefaultPayload 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 d4dce6168..94d8f4086 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -31,7 +31,7 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 42/56] 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 8ae4346ac..d80685349 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 c62d87e09..970104d6f 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 @@ module User::Avatar 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 94d8f4086..2e08aa485 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -34,7 +34,10 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 43/56] 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 2e08aa485..86212176f 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -36,6 +36,7 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 44/56] 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 5dfc9adc6..fc88e7aec 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 4d5d14d0d..e02f3a8de 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 debc08b19..298cc033a 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -43,16 +43,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_26_230838) do 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 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_26_230838) do 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 59182fb6c..0418d96c2 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 b2c850144..f581fb5e9 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 f6d7ed26d..000000000 --- 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 da71d0db1..94628fb82 100644 --- a/test/models/notification/push_target/web_test.rb +++ b/test/models/notification/push_target/web_test.rb @@ -3,10 +3,7 @@ require "test_helper" 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 7c54b0036..9036453bd 100644 --- a/test/models/notification/pushable_test.rb +++ b/test/models/notification/pushable_test.rb @@ -3,10 +3,7 @@ require "test_helper" 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 - @user.notifications.create!( - source: events(:logo_published), - creator: users(:jason) - ) + 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 + + 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 45/56] 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 ea9e9987d..a0ddbed13 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 b05ca5ed2..8b37a0164 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 46/56] 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 d5fce9d32..ddb67e16c 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 eadb66e7f..7865b95a6 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 47/56] 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 86212176f..5366ed032 100644 --- a/saas/app/models/notification/push_target/native.rb +++ b/saas/app/models/notification/push_target/native.rb @@ -30,7 +30,9 @@ class Notification::PushTarget::Native < Notification::PushTarget 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 0418d96c2..fe06f83f7 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 48/56] 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 65ca068e2..98f104856 100644 --- a/app/models/notification/default_payload.rb +++ b/app/models/notification/default_payload.rb @@ -31,6 +31,10 @@ class Notification::DefaultPayload 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 fe06f83f7..c6e1c9d4f 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 From fc0e477141750a3de48f44556611cf632a4ff84e Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 20:09:57 +0100 Subject: [PATCH 49/56] Include path in web push payload for backwards compatibility Old service workers cached in browsers still read `data.path` from the push notification payload. Include it alongside `data.url` so notification clicks keep working until those service workers update. Currently we only register the service worker in the notification settings screen, so this won't happen very often. Once we introduce offline mode, we'll register the service worker in more places, so we should be able to remove this. --- lib/web_push/notification.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb index c01caa953..0d2e54c8d 100644 --- a/lib/web_push/notification.rb +++ b/lib/web_push/notification.rb @@ -20,7 +20,7 @@ class WebPush::Notification end def encoded_message - JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { url: @url, badge: @badge } } + JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { url: @url, path: @url, badge: @badge } } end def icon_path From 158e95de42f9df05d38a3c607c726a4251710330 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 26 Feb 2026 11:12:01 +0100 Subject: [PATCH 50/56] Rename show-label to display-title --- app/javascript/initializers/bridge/bridge_element.js | 6 +++--- app/views/cards/container/_closure_buttons.html.erb | 2 +- app/views/columns/show/_add_card_button.html.erb | 2 +- app/views/events/index/_add_board_button.html.erb | 2 +- app/views/events/index/_add_card_button.html.erb | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 29aa52549..c29b4d9a1 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -4,7 +4,7 @@ BridgeElement.prototype.getButton = function() { const button = { title: this.title, icon: this.getIcon(), - showLabel: this.getShowLabel(), + displayTitle: this.getDisplayTitle(), isMainAction: this.getIsMainAction() } console.log(button) @@ -21,8 +21,8 @@ BridgeElement.prototype.getIcon = function() { return null } -BridgeElement.prototype.getShowLabel = function() { - return !!this.bridgeAttribute(`show-label`) +BridgeElement.prototype.getDisplayTitle = function() { + return !!this.bridgeAttribute(`display-title`) } BridgeElement.prototype.getIsMainAction = function() { diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index ce2c57aa5..2872238c7 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -7,7 +7,7 @@ <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_main_action: "true", bridge_show_label: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_main_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done diff --git a/app/views/columns/show/_add_card_button.html.erb b/app/views/columns/show/_add_card_button.html.erb index a2f9d9b44..bbb42e3ec 100644 --- a/app/views/columns/show/_add_card_button.html.erb +++ b/app/views/columns/show/_add_card_button.html.erb @@ -1,7 +1,7 @@

<%= button_to board_cards_path(board), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } }, - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_show_label: true, bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add", class: "show-on-touch" %> Add a card C diff --git a/app/views/events/index/_add_board_button.html.erb b/app/views/events/index/_add_board_button.html.erb index 42b882a12..b8134266a 100644 --- a/app/views/events/index/_add_board_button.html.erb +++ b/app/views/events/index/_add_board_button.html.erb @@ -1,7 +1,7 @@
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile", - data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add board", bridge_show_label: true, bridge_icon_url: bridge_icon("board") } do %> + data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add board", bridge_display_title: true, bridge_icon_url: bridge_icon("board") } do %> <%= icon_tag "board" %> Add a board B diff --git a/app/views/events/index/_add_card_button.html.erb b/app/views/events/index/_add_card_button.html.erb index 0a4ecf1d8..7d3b62c33 100644 --- a/app/views/events/index/_add_card_button.html.erb +++ b/app/views/events/index/_add_card_button.html.erb @@ -1,7 +1,7 @@
<% if board = user_filtering.single_board_or_first %> <%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile", - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_show_label: true, bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add" %> Add a card C From fc1ee616c870966e6ab9c9f38ade4a57ea499685 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 26 Feb 2026 11:13:39 +0100 Subject: [PATCH 51/56] Remove debug line --- app/javascript/initializers/bridge/bridge_element.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index c29b4d9a1..53a474a9b 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -7,7 +7,6 @@ BridgeElement.prototype.getButton = function() { displayTitle: this.getDisplayTitle(), isMainAction: this.getIsMainAction() } - console.log(button) return button } From 047e8efad337161424b382e91d4c32c5f2f93d73 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 26 Feb 2026 11:33:24 +0100 Subject: [PATCH 52/56] Rename is-main-action to display-as-primary-action --- app/javascript/initializers/bridge/bridge_element.js | 6 +++--- app/views/cards/container/_closure_buttons.html.erb | 2 +- app/views/columns/show/_add_card_button.html.erb | 2 +- app/views/events/index/_add_card_button.html.erb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 53a474a9b..0923f9512 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -5,7 +5,7 @@ BridgeElement.prototype.getButton = function() { title: this.title, icon: this.getIcon(), displayTitle: this.getDisplayTitle(), - isMainAction: this.getIsMainAction() + displayAsPrimaryAction: this.getDisplayAsPrimaryAction() } return button } @@ -24,6 +24,6 @@ BridgeElement.prototype.getDisplayTitle = function() { return !!this.bridgeAttribute(`display-title`) } -BridgeElement.prototype.getIsMainAction = function() { - return !!this.bridgeAttribute(`main-action`) +BridgeElement.prototype.getDisplayAsPrimaryAction = function() { + return !!this.bridgeAttribute(`display-as-primary-action`) } diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 2872238c7..458631244 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -7,7 +7,7 @@ <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_main_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done diff --git a/app/views/columns/show/_add_card_button.html.erb b/app/views/columns/show/_add_card_button.html.erb index bbb42e3ec..1adceae73 100644 --- a/app/views/columns/show/_add_card_button.html.erb +++ b/app/views/columns/show/_add_card_button.html.erb @@ -1,7 +1,7 @@
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } }, - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add", class: "show-on-touch" %> Add a card C diff --git a/app/views/events/index/_add_card_button.html.erb b/app/views/events/index/_add_card_button.html.erb index 7d3b62c33..1ca879755 100644 --- a/app/views/events/index/_add_card_button.html.erb +++ b/app/views/events/index/_add_card_button.html.erb @@ -1,7 +1,7 @@
<% if board = user_filtering.single_board_or_first %> <%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile", - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_main_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add" %> Add a card C From a62defb570a5fe6b36959ad32cc2063537e657e6 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 26 Feb 2026 11:48:01 +0100 Subject: [PATCH 53/56] Just return the object --- app/javascript/initializers/bridge/bridge_element.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 0923f9512..438cf22e1 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -1,13 +1,12 @@ import { BridgeElement } from "@hotwired/hotwire-native-bridge" BridgeElement.prototype.getButton = function() { - const button = { + return { title: this.title, icon: this.getIcon(), displayTitle: this.getDisplayTitle(), displayAsPrimaryAction: this.getDisplayAsPrimaryAction() } - return button } BridgeElement.prototype.getIcon = function() { From 3f7b33889bc487fa7e8a310f005ba70b0e332203 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 26 Feb 2026 12:14:13 +0100 Subject: [PATCH 54/56] Update app/views/events/index/_add_card_button.html.erb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/views/events/index/_add_card_button.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/events/index/_add_card_button.html.erb b/app/views/events/index/_add_card_button.html.erb index 1ca879755..31caef555 100644 --- a/app/views/events/index/_add_card_button.html.erb +++ b/app/views/events/index/_add_card_button.html.erb @@ -1,7 +1,7 @@
<% if board = user_filtering.single_board_or_first %> <%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile", - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add" %> Add a card C From 7f6351ea41208f28d3ca954d709551c3c34d399d Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 26 Feb 2026 12:14:28 +0100 Subject: [PATCH 55/56] Update app/views/columns/show/_add_card_button.html.erb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/views/columns/show/_add_card_button.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/columns/show/_add_card_button.html.erb b/app/views/columns/show/_add_card_button.html.erb index 1adceae73..ee2f87196 100644 --- a/app/views/columns/show/_add_card_button.html.erb +++ b/app/views/columns/show/_add_card_button.html.erb @@ -1,7 +1,7 @@
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } }, - data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> + data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add", class: "show-on-touch" %> Add a card C From c4cea3d5dbcf70c3f8e44adec6d83b79e6dba4d9 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 26 Feb 2026 12:14:37 +0100 Subject: [PATCH 56/56] Update app/views/cards/container/_closure_buttons.html.erb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/views/cards/container/_closure_buttons.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 458631244..e1ea75ec7 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -7,7 +7,7 @@ <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: "true", bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done