From 394a5a00814df2764db90e1bb814477dd5215cc7 Mon Sep 17 00:00:00 2001 From: creslinux Date: Sat, 20 Dec 2025 00:07:55 -0800 Subject: [PATCH 001/237] Clarify triage endpoint works for moving cards between columns --- docs/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 3c1d91977..3e083b3c8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -709,7 +709,7 @@ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/triage` -Moves a card from triage into a column. +Moves a card into a column. Works for cards in any state - use this to move cards between columns, not just from triage. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| From 40b1bb769cc9a2264064f869ebf26a360aca9029 Mon Sep 17 00:00:00 2001 From: creslinux Date: Sat, 20 Dec 2025 00:15:30 -0800 Subject: [PATCH 002/237] Add CSP_CONNECT_SRC to S3 configuration docs --- docs/docker-deployment.md | 1 + docs/kamal-deployment.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index 03b4d9c8b..55ef9a65c 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -132,6 +132,7 @@ Then set the following as appropriate for your S3 bucket: - `S3_REGION` - `S3_ACCESS_KEY_ID` - `S3_SECRET_ACCESS_KEY` +- `CSP_CONNECT_SRC` If you're using a provider other than AWS, you will also need some of the following: diff --git a/docs/kamal-deployment.md b/docs/kamal-deployment.md index 19aa208c8..b9d0efc04 100644 --- a/docs/kamal-deployment.md +++ b/docs/kamal-deployment.md @@ -103,6 +103,7 @@ To use the included `s3` service, set: - `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`) - `S3_REGION` (defaults to `us-east-1`) - `S3_SECRET_ACCESS_KEY` +- `CSP_CONNECT_SRC` Optional for S3-compatible endpoints: From f6e9898dabc269a713db01e21d5ad85bc4716e37 Mon Sep 17 00:00:00 2001 From: Jean-Marie Porchet Date: Sat, 20 Dec 2025 11:48:07 +0100 Subject: [PATCH 003/237] docs: add signup info when email config is not available --- docs/docker-deployment.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index 03b4d9c8b..4a24e2dea 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -74,6 +74,8 @@ docker run --publish 80:80 --environment DISABLE_SSL=true ... Fizzy needs to be able to send email for its sign up/sign in flow, and for its regular summary emails. The easiest way to set this up is to use a 3rd-party email provider (such as Postmark, Sendgrid, and so on). +If email is not configured, you can still sign in by finding the 6-character verification code in your Docker container's logs. + You can then plug all your SMTP settings from that provider into Fizzy via the following environment variables: - `MAILER_FROM_ADDRESS` - the "from" address that Fizzy should use to send email From 64059e2351b42a85ffbcfc7c0067aa43d924448b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 15 Jan 2026 22:13:41 -0500 Subject: [PATCH 004/237] Add test for webhook URL with trailing whitespace Currently fails because URI.parse rejects URLs with trailing spaces. --- test/models/webhook_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/models/webhook_test.rb b/test/models/webhook_test.rb index 52c43383d..121c1a651 100644 --- a/test/models/webhook_test.rb +++ b/test/models/webhook_test.rb @@ -35,6 +35,10 @@ class WebhookTest < ActiveSupport::TestCase webhook = Webhook.new name: "HTTPS", board: boards(:writebook), url: "https://example.com/webhook" assert webhook.valid? + + webhook = Webhook.new name: "TRAILING SPACE", board: boards(:writebook), url: "https://example.com/webhook " + assert webhook.valid? + assert_equal "https://example.com/webhook", webhook.url end test "deactivate" do From d8b6ea8bbadb0467793f052c6b96d77492f6b637 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 15 Jan 2026 22:13:47 -0500 Subject: [PATCH 005/237] Strip whitespace from webhook URLs Prevents validation errors when users accidentally include trailing spaces in webhook URLs. --- app/models/webhook.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 3b34af041..f008aa934 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -36,6 +36,7 @@ class Webhook < ApplicationRecord after_create :create_delinquency_tracker! normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS } + normalizes :url, with: -> { it.strip } validates :name, presence: true validate :validate_url From f50b0bca5a0cd1bab1b703cad77b0f4ee006583e Mon Sep 17 00:00:00 2001 From: Henrik Nyh Date: Sun, 8 Feb 2026 19:51:41 +0000 Subject: [PATCH 006/237] billing.rb: destroy -> destroy! --- saas/app/models/account/billing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 02886581a..c124fd64c 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -27,7 +27,7 @@ module Account::Billing end def uncomp - billing_waiver&.destroy + billing_waiver&.destroy! reload_billing_waiver end From 608be1f15575105059263cb03ce9ac75693c6177 Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Thu, 12 Feb 2026 09:56:00 +0300 Subject: [PATCH 007/237] Fix boards JSON to return all accessible boards ordered by recently accessed. --- app/controllers/boards_controller.rb | 7 +++++- app/views/boards/index.json.jbuilder | 2 +- test/controllers/boards_controller_test.rb | 29 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 5b8763b71..1175c889b 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -5,7 +5,12 @@ 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 + if request.format.json? + @boards = Current.user.boards.ordered_by_recently_accessed + fresh_when @boards + else + set_page_and_extract_portion_from Current.user.boards + end end def show diff --git a/app/views/boards/index.json.jbuilder b/app/views/boards/index.json.jbuilder index 047401cff..4c8325d73 100644 --- a/app/views/boards/index.json.jbuilder +++ b/app/views/boards/index.json.jbuilder @@ -1 +1 @@ -json.array! @page.records, partial: "boards/board", as: :board +json.array!(@boards, partial: "boards/board", as: :board) diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 094cca56b..4b5744f83 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -207,6 +207,35 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_equal users(:kevin).boards.count, @response.parsed_body.count end + test "index as JSON includes all boards ordered by recently accessed" do + account = accounts("37s") + kevin = users(:kevin) + baseline_accessed_at = 3.days.ago.change(usec: 0) + + kevin.accesses.order(:id).each_with_index do |access, index| + access.update!(accessed_at: baseline_accessed_at + index.seconds) + end + + 35.times do |index| + board = Board.create!( + name: "Recent board #{index}", + creator: kevin, + account: account, + all_access: false + ) + board.access_for(kevin).update!(accessed_at: baseline_accessed_at + (index + 1).minutes) + end + + get boards_path, as: :json + + assert_response :success + + expected_ids = kevin.boards.ordered_by_recently_accessed.pluck(:id) + actual_ids = @response.parsed_body.map { |board| board["id"] } + + assert_equal expected_ids, actual_ids + end + test "show as JSON" do get board_path(boards(:writebook)), as: :json assert_response :success From 0b9869f3c99dff0bf4126dec4b2065ef9745351c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Mon, 19 Jan 2026 13:51:14 +0100 Subject: [PATCH 008/237] Add JSON response support for `notifications/tray`. --- app/views/notifications/trays/show.json.jbuilder | 1 + test/controllers/notifications/trays_controller_test.rb | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 app/views/notifications/trays/show.json.jbuilder diff --git a/app/views/notifications/trays/show.json.jbuilder b/app/views/notifications/trays/show.json.jbuilder new file mode 100644 index 000000000..0c2928cef --- /dev/null +++ b/app/views/notifications/trays/show.json.jbuilder @@ -0,0 +1 @@ +json.array! @notifications, partial: "notifications/notification", as: :notification diff --git a/test/controllers/notifications/trays_controller_test.rb b/test/controllers/notifications/trays_controller_test.rb index 07034fff0..d0916e305 100644 --- a/test/controllers/notifications/trays_controller_test.rb +++ b/test/controllers/notifications/trays_controller_test.rb @@ -11,4 +11,13 @@ class Notifications::TraysControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "div", text: /Layout is broken/ end + + test "show as JSON" do + expected_ids = users(:kevin).notifications.unread.ordered.limit(100).pluck(:id) + + get tray_notifications_path(format: :json) + + assert_response :success + assert_equal expected_ids, @response.parsed_body.map { |notification| notification["id"] } + end end From 3dca00a9abd8334ab28d5794f598f00da3e04b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Mon, 19 Jan 2026 14:00:26 +0100 Subject: [PATCH 009/237] Extend notification JSON payload. Add avatar url, board name, and column. --- app/views/notifications/_notification.json.jbuilder | 9 +++++++-- test/controllers/notifications_controller_test.rb | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index d2fadfa1b..2b0cb3549 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -6,11 +6,16 @@ json.cache! notification do json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification - json.creator notification.creator, partial: "users/user", as: :user + json.creator do + json.partial! "users/user", user: notification.creator + json.avatar_url user_avatar_url(notification.creator) + end json.card do - json.(notification.card, :id, :title, :status) + json.(notification.card, :id, :number, :title, :status) + json.board_name notification.card.board.name json.url card_url(notification.card) + json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column end json.url notification_url(notification) diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index 0d0565f24..aa9c283a5 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -22,5 +22,9 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest assert_not_nil notification["card"] assert_not_nil notification["creator"] assert_not_nil notification["unread_count"] + assert_not_nil notification.dig("creator", "avatar_url") + assert_not_nil notification.dig("card", "number") + assert_not_nil notification.dig("card", "board_name") + assert_not_nil notification.dig("card", "column") end end From af2efe6d76e4d959b0dce5f6986edac87c2f0e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Mon, 19 Jan 2026 16:57:12 +0100 Subject: [PATCH 010/237] Add `avatar_url` to the shared user JSON partial. --- app/views/users/_user.json.jbuilder | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/users/_user.json.jbuilder b/app/views/users/_user.json.jbuilder index 0884f225a..ed05bc67f 100644 --- a/app/views/users/_user.json.jbuilder +++ b/app/views/users/_user.json.jbuilder @@ -5,4 +5,5 @@ json.cache! user do json.created_at user.created_at.utc json.url user_url(user) + json.avatar_url user_avatar_url(user) end From 683547e310e2a9b2b25a7e04939189340563279f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Mon, 19 Jan 2026 17:00:14 +0100 Subject: [PATCH 011/237] Use the existing user partial on notifications. --- app/views/notifications/_notification.json.jbuilder | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index 2b0cb3549..5673097a7 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -6,10 +6,7 @@ json.cache! notification do json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification - json.creator do - json.partial! "users/user", user: notification.creator - json.avatar_url user_avatar_url(notification.creator) - end + json.creator notification.creator, partial: "users/user", as: :user json.card do json.(notification.card, :id, :number, :title, :status) From 009e431ad8a1822401561a76cb3f6f64c3f17d3e Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 22 Jan 2026 03:00:17 -0600 Subject: [PATCH 012/237] Add has_attachments to notification card JSON Co-Authored-By: Claude Opus 4.5 --- app/views/notifications/_notification.json.jbuilder | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index 5673097a7..ac85f837f 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -11,6 +11,7 @@ json.cache! notification do json.card do json.(notification.card, :id, :number, :title, :status) json.board_name notification.card.board.name + json.has_attachments notification.card.has_attachments? json.url card_url(notification.card) json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column end From 6809804d3a6d952933c549c1ae441c45c14abc82 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 22 Jan 2026 03:13:31 -0600 Subject: [PATCH 013/237] Add card assignees to notification JSON Includes assignee users in the card object for display in notification UI. Co-Authored-By: Claude Opus 4.5 --- app/views/notifications/_notification.json.jbuilder | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index ac85f837f..7d269d46d 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -12,6 +12,7 @@ json.cache! notification do json.(notification.card, :id, :number, :title, :status) json.board_name notification.card.board.name json.has_attachments notification.card.has_attachments? + json.assignees notification.card.assignees, partial: "users/user", as: :user json.url card_url(notification.card) json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column end From a844353f1e0c7af0f415763f75e5531a19af32c2 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 22 Jan 2026 03:31:23 -0600 Subject: [PATCH 014/237] Remove unused has_attachments and assignees from notification JSON These fields were added but are not used by the iOS client. Co-Authored-By: Claude Opus 4.5 --- app/views/notifications/_notification.json.jbuilder | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index 7d269d46d..5673097a7 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -11,8 +11,6 @@ json.cache! notification do json.card do json.(notification.card, :id, :number, :title, :status) json.board_name notification.card.board.name - json.has_attachments notification.card.has_attachments? - json.assignees notification.card.assignees, partial: "users/user", as: :user json.url card_url(notification.card) json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column end From 80546a6b477b8ada4ed4349cc018b6d8891e9bdd Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Wed, 28 Jan 2026 16:43:39 +0300 Subject: [PATCH 015/237] Include read notifications in the tray API response --- .../notifications/trays_controller.rb | 20 ++++++++++++++++++- .../notifications/trays_controller_test.rb | 13 +++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb index 0adc0e17b..980d1ba1f 100644 --- a/app/controllers/notifications/trays_controller.rb +++ b/app/controllers/notifications/trays_controller.rb @@ -1,9 +1,27 @@ class Notifications::TraysController < ApplicationController + MAX_ENTRIES_LIMIT = 100 + def show - @notifications = Current.user.notifications.unread.preloaded.ordered.limit(100) + @notifications = unread_notifications + if include_unread? + @notifications += read_notifications + end # Invalidate on the whole set instead of the unread set since the max updated at in the unread set # can stay the same when reading old notifications. fresh_when Current.user.notifications end + + private + def unread_notifications + Current.user.notifications.unread.preloaded.ordered.limit(MAX_ENTRIES_LIMIT) + end + + def read_notifications + Current.user.notifications.read.preloaded.ordered.limit(MAX_ENTRIES_LIMIT) + end + + def include_unread? + ActiveModel::Type::Boolean.new.cast(params[:include_unread]) + end end diff --git a/test/controllers/notifications/trays_controller_test.rb b/test/controllers/notifications/trays_controller_test.rb index d0916e305..89dc42c6f 100644 --- a/test/controllers/notifications/trays_controller_test.rb +++ b/test/controllers/notifications/trays_controller_test.rb @@ -18,6 +18,17 @@ class Notifications::TraysControllerTest < ActionDispatch::IntegrationTest get tray_notifications_path(format: :json) assert_response :success - assert_equal expected_ids, @response.parsed_body.map { |notification| notification["id"] } + assert_equal expected_ids, @response.parsed_body.map { |s| s["id"] } + end + + test "show as JSON with include_unread includes read notifications" do + notifications = users(:kevin).notifications + expected_ids = notifications.unread.ordered.limit(100).pluck(:id) + + notifications.read.ordered.limit(100).pluck(:id) + + get tray_notifications_path(format: :json, include_unread: true) + + assert_response :success + assert_equal expected_ids, @response.parsed_body.map { |s| s["id"] } end end From b8793bfec36beb44633bc6a453b77a4198822431 Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Thu, 29 Jan 2026 10:18:20 +0300 Subject: [PATCH 016/237] Add include_unread param to tray API and vary cache --- app/controllers/notifications/trays_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb index 980d1ba1f..fac108370 100644 --- a/app/controllers/notifications/trays_controller.rb +++ b/app/controllers/notifications/trays_controller.rb @@ -9,7 +9,7 @@ class Notifications::TraysController < ApplicationController # Invalidate on the whole set instead of the unread set since the max updated at in the unread set # can stay the same when reading old notifications. - fresh_when Current.user.notifications + fresh_when etag: [ Current.user.notifications, include_unread? ] end private From a6eb331710e128f1585b08d64467fdc034f08f13 Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Tue, 3 Feb 2026 11:51:19 +0300 Subject: [PATCH 017/237] Fix query parameter name --- app/controllers/notifications/trays_controller.rb | 8 ++++---- test/controllers/notifications/trays_controller_test.rb | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb index fac108370..46dc445cf 100644 --- a/app/controllers/notifications/trays_controller.rb +++ b/app/controllers/notifications/trays_controller.rb @@ -3,13 +3,13 @@ class Notifications::TraysController < ApplicationController def show @notifications = unread_notifications - if include_unread? + if include_read? @notifications += read_notifications end # Invalidate on the whole set instead of the unread set since the max updated at in the unread set # can stay the same when reading old notifications. - fresh_when etag: [ Current.user.notifications, include_unread? ] + fresh_when etag: [ Current.user.notifications, include_read? ] end private @@ -21,7 +21,7 @@ class Notifications::TraysController < ApplicationController Current.user.notifications.read.preloaded.ordered.limit(MAX_ENTRIES_LIMIT) end - def include_unread? - ActiveModel::Type::Boolean.new.cast(params[:include_unread]) + def include_read? + ActiveModel::Type::Boolean.new.cast(params[:include_read]) end end diff --git a/test/controllers/notifications/trays_controller_test.rb b/test/controllers/notifications/trays_controller_test.rb index 89dc42c6f..b6efa200b 100644 --- a/test/controllers/notifications/trays_controller_test.rb +++ b/test/controllers/notifications/trays_controller_test.rb @@ -21,12 +21,12 @@ class Notifications::TraysControllerTest < ActionDispatch::IntegrationTest assert_equal expected_ids, @response.parsed_body.map { |s| s["id"] } end - test "show as JSON with include_unread includes read notifications" do + test "show as JSON with include_read includes read notifications" do notifications = users(:kevin).notifications expected_ids = notifications.unread.ordered.limit(100).pluck(:id) + notifications.read.ordered.limit(100).pluck(:id) - get tray_notifications_path(format: :json, include_unread: true) + get tray_notifications_path(format: :json, include_read: true) assert_response :success assert_equal expected_ids, @response.parsed_body.map { |s| s["id"] } From b755522a7323d465e78e89c8be6fb9cd5b44beda Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Tue, 3 Feb 2026 11:52:02 +0300 Subject: [PATCH 018/237] Add notification.source_type.underscore --- app/views/notifications/_notification.json.jbuilder | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index 5673097a7..d4dce9fff 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -3,6 +3,7 @@ json.cache! notification do json.read notification.read? json.read_at notification.read_at&.utc json.created_at notification.created_at.utc + json.source_type notification.source_type.underscore json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification From abcbb74ac8253069e9ec9d31e8e7a03e6852c13f Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Thu, 12 Feb 2026 15:32:10 +0300 Subject: [PATCH 019/237] Make notification ordering deterministic. --- app/models/notification.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/notification.rb b/app/models/notification.rb index 3d7df5885..70b02de2b 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -9,7 +9,7 @@ class Notification < ApplicationRecord scope :unread, -> { where(read_at: nil) } scope :read, -> { where.not(read_at: nil) } - scope :ordered, -> { order(read_at: :desc, updated_at: :desc) } + scope :ordered, -> { order(read_at: :desc, updated_at: :desc, id: :desc) } scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } before_validation :set_card From c4c46cce442aad255cf4ad806290f5128d4cad8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jirka=20Hut=C3=A1rek?= Date: Fri, 13 Feb 2026 00:45:47 +0100 Subject: [PATCH 020/237] Add postponed and closed flags to Pinned and Notification cards --- app/views/cards/_card.json.jbuilder | 1 + app/views/notifications/_notification.json.jbuilder | 2 ++ test/controllers/cards_controller_test.rb | 1 + test/controllers/notifications_controller_test.rb | 4 ++++ 4 files changed, 8 insertions(+) diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index f8e727732..bccf51d6e 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -8,6 +8,7 @@ json.cache! card do json.tags card.tags.pluck(:title).sort json.closed card.closed? + json.postponed card.postponed? json.golden card.golden? json.last_active_at card.last_active_at.utc json.created_at card.created_at.utc diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index d4dce9fff..ae166f44a 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -12,6 +12,8 @@ json.cache! notification do json.card do json.(notification.card, :id, :number, :title, :status) json.board_name notification.card.board.name + json.closed notification.card.closed? + json.postponed notification.card.postponed? json.url card_url(notification.card) json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index 8007657d4..b22a8eea6 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -171,6 +171,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_equal card.title, @response.parsed_body["title"] assert_equal card.closed?, @response.parsed_body["closed"] + assert_equal card.postponed?, @response.parsed_body["postponed"] assert_equal 2, @response.parsed_body["steps"].size assert_equal card_comments_url(card), @response.parsed_body["comments_url"] assert_equal card_reactions_url(card), @response.parsed_body["reactions_url"] diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index aa9c283a5..29b454ebd 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -26,5 +26,9 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest assert_not_nil notification.dig("card", "number") assert_not_nil notification.dig("card", "board_name") assert_not_nil notification.dig("card", "column") + + card = notifications(:logo_assignment_kevin).card + assert_equal card.closed?, notification.dig("card", "closed") + assert_equal card.postponed?, notification.dig("card", "postponed") end end From bcc0f93bb930593b332b84b2596e7ea4d81b0941 Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Mon, 16 Feb 2026 17:58:33 +0300 Subject: [PATCH 021/237] Preload notification card associations used by JSON responses --- app/models/notification.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/notification.rb b/app/models/notification.rb index 70b02de2b..2d868674c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -10,7 +10,13 @@ class Notification < ApplicationRecord scope :unread, -> { where(read_at: nil) } scope :read, -> { where.not(read_at: nil) } scope :ordered, -> { order(read_at: :desc, updated_at: :desc, id: :desc) } - scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } + scope :preloaded, -> { + preload( + :creator, :account, + card: [ :board, :column, :closure, :not_now ], + source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ] + ) + } before_validation :set_card after_create :bundle From a48c88cb5d6e35bd2000744589dfe085c8dc5385 Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Mon, 16 Feb 2026 18:33:32 +0300 Subject: [PATCH 022/237] Stabilize notifications tray test ordering via fixture updated_at timestamps --- app/models/notification.rb | 2 +- test/fixtures/notifications.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/notification.rb b/app/models/notification.rb index 2d868674c..417a2f22d 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -9,7 +9,7 @@ class Notification < ApplicationRecord scope :unread, -> { where(read_at: nil) } scope :read, -> { where.not(read_at: nil) } - scope :ordered, -> { order(read_at: :desc, updated_at: :desc, id: :desc) } + scope :ordered, -> { order(read_at: :desc, updated_at: :desc) } scope :preloaded, -> { preload( :creator, :account, diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml index b7ae167cd..8fa4404d5 100644 --- a/test/fixtures/notifications.yml +++ b/test/fixtures/notifications.yml @@ -5,6 +5,7 @@ logo_assignment_kevin: card: logo_uuid unread_count: 2 created_at: <%= 1.week.ago %> + updated_at: <%= 1.week.ago + 1.second %> creator: david_uuid account: 37s_uuid @@ -15,6 +16,7 @@ layout_commented_kevin: card: layout_uuid unread_count: 1 created_at: <%= 1.week.ago %> + updated_at: <%= 1.week.ago + 2.seconds %> creator: david_uuid account: 37s_uuid @@ -25,5 +27,6 @@ logo_mentioned_david: card: logo_uuid unread_count: 2 created_at: <%= 1.week.ago %> + updated_at: <%= 1.week.ago + 3.seconds %> creator: david_uuid account: 37s_uuid From 13d7cd43036e5090ff051d89652a9fe8ec317dd7 Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Tue, 17 Feb 2026 10:37:23 +0300 Subject: [PATCH 023/237] Paginate boards JSON --- app/controllers/boards_controller.rb | 4 ++-- app/views/boards/index.json.jbuilder | 2 +- test/controllers/boards_controller_test.rb | 28 ++++++++++++++++------ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 1175c889b..a8284b309 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -6,8 +6,8 @@ class BoardsController < ApplicationController def index if request.format.json? - @boards = Current.user.boards.ordered_by_recently_accessed - fresh_when @boards + 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 diff --git a/app/views/boards/index.json.jbuilder b/app/views/boards/index.json.jbuilder index 4c8325d73..047401cff 100644 --- a/app/views/boards/index.json.jbuilder +++ b/app/views/boards/index.json.jbuilder @@ -1 +1 @@ -json.array!(@boards, partial: "boards/board", as: :board) +json.array! @page.records, partial: "boards/board", as: :board diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 4b5744f83..8a426f00e 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -207,7 +207,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_equal users(:kevin).boards.count, @response.parsed_body.count end - test "index as JSON includes all boards ordered by recently accessed" do + test "index as JSON paginates and preserves recently-accessed order" do account = accounts("37s") kevin = users(:kevin) baseline_accessed_at = 3.days.ago.change(usec: 0) @@ -216,7 +216,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest access.update!(accessed_at: baseline_accessed_at + index.seconds) end - 35.times do |index| + 200.times do |index| board = Board.create!( name: "Recent board #{index}", creator: kevin, @@ -226,14 +226,22 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest board.access_for(kevin).update!(accessed_at: baseline_accessed_at + (index + 1).minutes) end - get boards_path, as: :json - - assert_response :success - expected_ids = kevin.boards.ordered_by_recently_accessed.pluck(:id) - actual_ids = @response.parsed_body.map { |board| board["id"] } + actual_ids = [] + next_page = boards_path(format: :json) + page_count = 0 + + while next_page + get next_page, as: :json + assert_response :success + + page_count += 1 + actual_ids.concat(@response.parsed_body.map { |board| board["id"] }) + next_page = next_page_from_link_header(@response.headers["Link"]) + end assert_equal expected_ids, actual_ids + assert_operator page_count, :>, 1 end test "show as JSON" do @@ -269,4 +277,10 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_response :no_content end + + private + def next_page_from_link_header(link_header) + url = link_header&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first + URI.parse(url).request_uri if url + end end From 6a5bab8771c187237958a673cefde8d151483cd8 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 17 Feb 2026 11:50:31 +0100 Subject: [PATCH 024/237] Wrap cancellation section in `.settings__section` --- .../account/settings/_cancellation.html.erb | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb index 5c493edd5..ec68c6f55 100644 --- a/app/views/account/settings/_cancellation.html.erb +++ b/app/views/account/settings/_cancellation.html.erb @@ -1,26 +1,28 @@ <% if Current.account.cancellable? && Current.user.owner? %> -
-

Cancel account

-

Delete your Fizzy account

-
+
+
+

Cancel account

+
Delete your Fizzy account.
+
-
- +
+ - -

Delete your account?

-
    -
  • All users, including you, will lose access
  • - <% if Current.account.try(:active_subscription) %> -
  • Your subscription will be canceled
  • - <% end %> -
  • After 30 days your data will be permanently deleted
  • -
+ +

Delete your account?

+
    +
  • All users, including you, will lose access
  • + <% if Current.account.try(:active_subscription) %> +
  • Your subscription will be canceled
  • + <% end %> +
  • After 30 days your data will be permanently deleted
  • +
-
- - <%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %> -
-
-
+
+ + <%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %> +
+ +
+
<% end %> From 18d4682b053bcec59c340d55ca4f4af828418dc2 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 17 Feb 2026 11:51:24 +0100 Subject: [PATCH 025/237] Set `panel-size` for confirmation dialog --- app/views/account/settings/_cancellation.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb index ec68c6f55..9165feeb7 100644 --- a/app/views/account/settings/_cancellation.html.erb +++ b/app/views/account/settings/_cancellation.html.erb @@ -8,7 +8,7 @@
- +

Delete your account?

  • All users, including you, will lose access
  • From 179fd18e4fee4a45f10b6a751d0898f87e4b79b5 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 17 Feb 2026 11:51:37 +0100 Subject: [PATCH 026/237] Add missing comma --- app/views/account/settings/_cancellation.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb index 9165feeb7..49f87512f 100644 --- a/app/views/account/settings/_cancellation.html.erb +++ b/app/views/account/settings/_cancellation.html.erb @@ -15,7 +15,7 @@ <% if Current.account.try(:active_subscription) %>
  • Your subscription will be canceled
  • <% end %> -
  • After 30 days your data will be permanently deleted
  • +
  • After 30 days, your data will be permanently deleted
From 4e9773f9f58547cf73580f2bde4fe98550cab0e8 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 17 Feb 2026 17:05:14 +0000 Subject: [PATCH 027/237] Correct card URL in comment JSON output When rendering details of a comment, we were using the card's `id` as the param for the URL. But card routes use the card's number, not ID. --- app/views/cards/comments/_comment.json.jbuilder | 6 +++--- test/controllers/cards/comments_controller_test.rb | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/cards/comments/_comment.json.jbuilder b/app/views/cards/comments/_comment.json.jbuilder index eaf1805d6..58e2f6290 100644 --- a/app/views/cards/comments/_comment.json.jbuilder +++ b/app/views/cards/comments/_comment.json.jbuilder @@ -13,9 +13,9 @@ json.cache! comment do json.card do json.id comment.card_id - json.url card_url(comment.card_id) + json.url card_url(comment.card) end - json.reactions_url card_comment_reactions_url(comment.card_id, comment.id) - json.url card_comment_url(comment.card_id, comment.id) + json.reactions_url card_comment_reactions_url(comment.card, comment) + json.url card_comment_url(comment.card, comment) end diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb index 26761d22e..a6e83cbef 100644 --- a/test/controllers/cards/comments_controller_test.rb +++ b/test/controllers/cards/comments_controller_test.rb @@ -78,9 +78,9 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_equal comment.id, @response.parsed_body["id"] assert_equal comment.card.id, @response.parsed_body.dig("card", "id") - assert_equal card_url(comment.card.id), @response.parsed_body.dig("card", "url") - assert_equal card_comment_reactions_url(comment.card_id, comment.id), @response.parsed_body["reactions_url"] - assert_equal card_comment_url(comment.card_id, comment.id), @response.parsed_body["url"] + assert_equal card_url(comment.card), @response.parsed_body.dig("card", "url") + assert_equal card_comment_reactions_url(comment.card, comment), @response.parsed_body["reactions_url"] + assert_equal card_comment_url(comment.card, comment), @response.parsed_body["url"] end test "update as JSON" do From 475fb63c6d66e3e3a84d5187da04dcbcb95179e8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 17 Feb 2026 20:34:33 -0800 Subject: [PATCH 028/237] Add actionpack-xml_parser for Queenbee sync request support (#2563) Queenbee sends account sync/cancel/etc requests as XML via ActiveResource, including the queenbee_signature in the XML body. Rails removed built-in XML parameter parsing in Rails 4.0, so without this gem the XML body is silently ignored and the signature check always fails with 403. --- saas/Gemfile | 1 + saas/Gemfile.lock | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/saas/Gemfile b/saas/Gemfile index bb3829719..ae814a2ef 100644 --- a/saas/Gemfile +++ b/saas/Gemfile @@ -5,5 +5,6 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "queenbee", bc: "queenbee-plugin", ref: "14312a940471e20617b38cdec7c092a01567d18b" gem "rails_structured_logging", bc: "rails-structured-logging" gem "activeresource", require: "active_resource" # needed by queenbee +gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing gem "rubocop-rails-omakase", require: false diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock index 16c647b27..1a9426209 100644 --- a/saas/Gemfile.lock +++ b/saas/Gemfile.lock @@ -51,6 +51,9 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) + actionpack-xml_parser (2.0.1) + actionpack (>= 5.0) + railties (>= 5.0) actiontext (8.1.1) action_text-trix (~> 2.1.15) actionpack (= 8.1.1) @@ -282,6 +285,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + actionpack-xml_parser activeresource queenbee! rails_structured_logging! From 8da0c47a5f9ae3e830f407489dbaf7fbbcdba387 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 17 Feb 2026 20:37:31 -0800 Subject: [PATCH 029/237] Add actionpack-xml_parser to Gemfile.saas (production bundle) The prior commit added it to saas/Gemfile but the production Docker build uses Gemfile.saas (via BUNDLE_GEMFILE). This ensures the XML parameter parser is present in the actual runtime environment. --- Gemfile.saas | 1 + Gemfile.saas.lock | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Gemfile.saas b/Gemfile.saas index b93efb1ee..c29eaeb79 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -4,6 +4,7 @@ eval_gemfile "Gemfile" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "activeresource", require: "active_resource" +gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" gem "fizzy-saas", path: "saas" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index b09872738..36d52edfd 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -188,6 +188,9 @@ GEM specs: action_text-trix (2.1.16) railties + actionpack-xml_parser (2.0.1) + actionpack (>= 5.0) + railties (>= 5.0) activemodel-serializers-xml (1.0.3) activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) @@ -643,6 +646,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + actionpack-xml_parser activeresource audits1984! autotuner From 5c0508d91cf1ecc85cfc38b152f676add907404d Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 18 Feb 2026 18:19:37 +0100 Subject: [PATCH 030/237] Don't show "no match" when not searching anything yet --- app/assets/stylesheets/search.css | 5 +++++ app/views/searches/show.html.erb | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index 5691ac5e5..32dec66b3 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -129,4 +129,9 @@ summary { padding-inline: 0; } } + .search-perma--empty { + .search { + display: none; + } + } } diff --git a/app/views/searches/show.html.erb b/app/views/searches/show.html.erb index 776ab9d38..197eebab2 100644 --- a/app/views/searches/show.html.erb +++ b/app/views/searches/show.html.erb @@ -1,4 +1,4 @@ -<% @page_title = params.has_key?(:q) ? "Search results for \"#{params[:q]}\"" : "Search" %> +<% @page_title = !params[:q].blank? ? "Search results for \"#{params[:q]}\"" : "Search" %> <% content_for :header do %>
@@ -8,7 +8,7 @@

<%= @page_title %>

<% end %> -
+<%= tag.div(class: token_list("search-perma", {"search-perma--empty": params[:q].blank?}, "margin-block-start")) do %> <%= render "form", query_terms: params[:q] %> <%= turbo_frame_tag "bar_content" do %> <% if @card %> @@ -17,4 +17,4 @@ <%= render "results", page: @page %> <% end %> <% end %> -
+<% end %> From f39253e5f93e6a399be3ed1a5636b10cbe773546 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 18 Feb 2026 09:27:28 -0800 Subject: [PATCH 031/237] Remove defunct saas/Gemfile and lockfile (#2566) These are leftovers from when fizzy-saas was a standalone repository. Since it was moved into fizzy as a path gem, the production bundle uses Gemfile.saas at the root. Nothing references saas/Gemfile. Also removes saas/bin/rails (references nonexistent test/dummy app) and saas/bin/rubocop (references nonexistent saas/.rubocop.yml), both equally defunct standalone-repo leftovers. --- saas/Gemfile | 10 -- saas/Gemfile.lock | 295 ---------------------------------------------- saas/bin/rails | 14 --- saas/bin/rubocop | 8 -- 4 files changed, 327 deletions(-) delete mode 100644 saas/Gemfile delete mode 100644 saas/Gemfile.lock delete mode 100755 saas/bin/rails delete mode 100755 saas/bin/rubocop diff --git a/saas/Gemfile b/saas/Gemfile deleted file mode 100644 index ae814a2ef..000000000 --- a/saas/Gemfile +++ /dev/null @@ -1,10 +0,0 @@ -source "https://rubygems.org" -git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } - -# 37id and Queenbee integration -gem "queenbee", bc: "queenbee-plugin", ref: "14312a940471e20617b38cdec7c092a01567d18b" -gem "rails_structured_logging", bc: "rails-structured-logging" -gem "activeresource", require: "active_resource" # needed by queenbee -gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing - -gem "rubocop-rails-omakase", require: false diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock deleted file mode 100644 index 1a9426209..000000000 --- a/saas/Gemfile.lock +++ /dev/null @@ -1,295 +0,0 @@ -GIT - remote: https://github.com/basecamp/queenbee-plugin - revision: 14312a940471e20617b38cdec7c092a01567d18b - ref: 14312a940471e20617b38cdec7c092a01567d18b - specs: - queenbee (3.2.0) - activeresource - builder - rexml - -GIT - remote: https://github.com/basecamp/rails-structured-logging - revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 - specs: - rails_structured_logging (0.2.1) - json - rails (>= 6.0.0) - -GEM - remote: https://rubygems.org/ - specs: - action_text-trix (2.1.15) - railties - actioncable (8.1.1) - actionpack (= 8.1.1) - activesupport (= 8.1.1) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - zeitwerk (~> 2.6) - actionmailbox (8.1.1) - actionpack (= 8.1.1) - activejob (= 8.1.1) - activerecord (= 8.1.1) - activestorage (= 8.1.1) - activesupport (= 8.1.1) - mail (>= 2.8.0) - actionmailer (8.1.1) - actionpack (= 8.1.1) - actionview (= 8.1.1) - activejob (= 8.1.1) - activesupport (= 8.1.1) - mail (>= 2.8.0) - rails-dom-testing (~> 2.2) - actionpack (8.1.1) - actionview (= 8.1.1) - activesupport (= 8.1.1) - nokogiri (>= 1.8.5) - rack (>= 2.2.4) - rack-session (>= 1.0.1) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - useragent (~> 0.16) - actionpack-xml_parser (2.0.1) - actionpack (>= 5.0) - railties (>= 5.0) - actiontext (8.1.1) - action_text-trix (~> 2.1.15) - actionpack (= 8.1.1) - activerecord (= 8.1.1) - activestorage (= 8.1.1) - activesupport (= 8.1.1) - globalid (>= 0.6.0) - nokogiri (>= 1.8.5) - actionview (8.1.1) - activesupport (= 8.1.1) - builder (~> 3.1) - erubi (~> 1.11) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - activejob (8.1.1) - activesupport (= 8.1.1) - globalid (>= 0.3.6) - activemodel (8.1.1) - activesupport (= 8.1.1) - activemodel-serializers-xml (1.0.3) - activemodel (>= 5.0.0.a) - activesupport (>= 5.0.0.a) - builder (~> 3.1) - activerecord (8.1.1) - activemodel (= 8.1.1) - activesupport (= 8.1.1) - timeout (>= 0.4.0) - activeresource (6.1.4) - activemodel (>= 6.0) - activemodel-serializers-xml (~> 1.0) - activesupport (>= 6.0) - activestorage (8.1.1) - actionpack (= 8.1.1) - activejob (= 8.1.1) - activerecord (= 8.1.1) - activesupport (= 8.1.1) - marcel (~> 1.0) - activesupport (8.1.1) - base64 - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - json - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - ast (2.4.3) - base64 (0.3.0) - bigdecimal (3.2.3) - builder (3.3.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) - crass (1.0.6) - date (3.5.0) - drb (2.2.3) - erb (6.0.0) - erubi (1.13.1) - globalid (1.3.0) - activesupport (>= 6.1) - i18n (1.14.7) - concurrent-ruby (~> 1.0) - io-console (0.8.1) - irb (1.15.3) - pp (>= 0.6.0) - rdoc (>= 4.0.0) - reline (>= 0.4.2) - json (2.16.0) - language_server-protocol (3.17.0.5) - lint_roller (1.1.0) - logger (1.7.0) - loofah (2.24.1) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) - mail (2.9.0) - logger - mini_mime (>= 0.1.1) - net-imap - net-pop - net-smtp - marcel (1.1.0) - mini_mime (1.1.5) - minitest (5.25.5) - net-imap (0.5.12) - date - net-protocol - net-pop (0.1.2) - net-protocol - net-protocol (0.2.2) - timeout - net-smtp (0.5.1) - net-protocol - nio4r (2.7.5) - nokogiri (1.18.10-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-aarch64-linux-musl) - racc (~> 1.4) - nokogiri (1.18.10-arm-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-arm-linux-musl) - racc (~> 1.4) - nokogiri (1.18.10-arm64-darwin) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-musl) - racc (~> 1.4) - parallel (1.27.0) - parser (3.3.10.0) - ast (~> 2.4.1) - racc - pp (0.6.3) - prettyprint - prettyprint (0.2.0) - prism (1.6.0) - psych (5.2.6) - date - stringio - racc (1.8.1) - rack (3.2.4) - rack-session (2.1.1) - base64 (>= 0.1.0) - rack (>= 3.0.0) - rack-test (2.2.0) - rack (>= 1.3) - rackup (2.2.1) - rack (>= 3) - rails (8.1.1) - actioncable (= 8.1.1) - actionmailbox (= 8.1.1) - actionmailer (= 8.1.1) - actionpack (= 8.1.1) - actiontext (= 8.1.1) - actionview (= 8.1.1) - activejob (= 8.1.1) - activemodel (= 8.1.1) - activerecord (= 8.1.1) - activestorage (= 8.1.1) - activesupport (= 8.1.1) - bundler (>= 1.15.0) - railties (= 8.1.1) - rails-dom-testing (2.3.0) - activesupport (>= 5.0.0) - minitest - nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) - nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.1.1) - actionpack (= 8.1.1) - activesupport (= 8.1.1) - irb (~> 1.13) - rackup (>= 1.0.0) - rake (>= 12.2) - thor (~> 1.0, >= 1.2.2) - tsort (>= 0.2) - zeitwerk (~> 2.6) - rainbow (3.1.1) - rake (13.3.1) - rdoc (6.15.1) - erb - psych (>= 4.0.0) - tsort - regexp_parser (2.11.3) - reline (0.6.3) - io-console (~> 0.5) - rexml (3.4.4) - rubocop (1.81.7) - json (~> 2.3) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.1.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.48.0) - parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-performance (1.26.1) - lint_roller (~> 1.1) - rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.2) - activesupport (>= 4.2.0) - lint_roller (~> 1.1) - rack (>= 1.1) - rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rails-omakase (1.1.0) - rubocop (>= 1.72) - rubocop-performance (>= 1.24) - rubocop-rails (>= 2.30) - ruby-progressbar (1.13.0) - securerandom (0.4.1) - stringio (3.1.8) - thor (1.4.0) - timeout (0.4.4) - tsort (0.2.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unicode-display_width (3.2.0) - unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) - uri (1.0.3) - useragent (0.16.11) - websocket-driver (0.8.0) - base64 - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) - zeitwerk (2.7.3) - -PLATFORMS - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl - arm64-darwin - x86_64-darwin - x86_64-linux - x86_64-linux-gnu - x86_64-linux-musl - -DEPENDENCIES - actionpack-xml_parser - activeresource - queenbee! - rails_structured_logging! - rubocop-rails-omakase - -BUNDLED WITH - 2.7.0 diff --git a/saas/bin/rails b/saas/bin/rails deleted file mode 100755 index 42a0e5bce..000000000 --- a/saas/bin/rails +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env ruby -# This command will automatically be run when you run "rails" with Rails gems -# installed from the root of your application. - -ENGINE_ROOT = File.expand_path("..", __dir__) -ENGINE_PATH = File.expand_path("../lib/fizzy/saas/engine", __dir__) -APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) - -# Set up gems listed in the Gemfile. -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) -require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) - -require "rails/all" -require "rails/engine/commands" diff --git a/saas/bin/rubocop b/saas/bin/rubocop deleted file mode 100755 index 40330c0ff..000000000 --- a/saas/bin/rubocop +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env ruby -require "rubygems" -require "bundler/setup" - -# explicit rubocop config increases performance slightly while avoiding config confusion. -ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) - -load Gem.bin_path("rubocop", "rubocop") From 1c3b3c55ef9063ac8ace69fd537abbfd0a408b78 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 18 Feb 2026 13:33:10 -0500 Subject: [PATCH 032/237] Update rails_structured_logging to v0.3.0 v0.3.0 removes usage of the INTERNAL_PARAMS constant which is being removed from ActionController in an upcoming Rails upgrade. --- Gemfile.saas.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 36d52edfd..25c70dab7 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -39,9 +39,9 @@ GIT GIT remote: https://github.com/basecamp/rails-structured-logging - revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 + revision: f6633fd8fee5906d8affa2329c61de7a3450eb7e specs: - rails_structured_logging (0.2.1) + rails_structured_logging (0.3.0) json rails (>= 6.0.0) From 41c2bd38af1b2ab415703bc72b44319c86b0d759 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 18 Feb 2026 13:34:57 -0500 Subject: [PATCH 033/237] Update Rails to 12e24ea (363 commits past 60d92e4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also updates transitive dependencies: - action_text-trix ~> 2.1.15 → ~> 2.1.16 - irb 1.16.0 → 1.17.0 - json 2.18.0 → 2.18.1 - net-imap 0.6.2 → 0.6.3 - nokogiri 1.19.0 → 1.19.1 - prism 1.8.0 → 1.9.0 - rack 3.2.4 → 3.2.5 - rdoc 7.0.3 → 7.2.0 --- Gemfile.lock | 33 +++++++++++++++++---------------- Gemfile.saas.lock | 31 ++++++++++++++++--------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 559b2990b..3208ea717 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8 + revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 branch: main specs: actioncable (8.2.0.alpha) @@ -47,7 +47,7 @@ GIT rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.2.0.alpha) - action_text-trix (~> 2.1.15) + action_text-trix (~> 2.1.16) actionpack (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activestorage (= 8.2.0.alpha) @@ -213,15 +213,16 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.0) + json (2.18.1) jwt (3.1.2) base64 kamal (2.10.1) @@ -276,7 +277,7 @@ GEM msgpack (1.8.0) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) - net-imap (0.6.2) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -291,21 +292,21 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-musl) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-musl) + nokogiri (1.19.1-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-darwin) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) ostruct (0.6.3) @@ -319,7 +320,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -332,7 +333,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-session (2.1.1) @@ -351,7 +352,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (7.0.3) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 25c70dab7..6e39b5ad5 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -62,7 +62,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8 + revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 branch: main specs: actioncable (8.2.0.alpha) @@ -96,7 +96,7 @@ GIT rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.2.0.alpha) - action_text-trix (~> 2.1.15) + action_text-trix (~> 2.1.16) actionpack (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activestorage (= 8.2.0.alpha) @@ -297,15 +297,16 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.0) + json (2.18.1) jwt (3.1.2) base64 kamal (2.10.1) @@ -360,7 +361,7 @@ GEM msgpack (1.8.0) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) - net-imap (0.6.2) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -375,19 +376,19 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-musl) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-musl) + nokogiri (1.19.1-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) ostruct (0.6.3) @@ -401,7 +402,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) prometheus-client-mmap (1.4.0) base64 bigdecimal @@ -444,7 +445,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-session (2.1.1) @@ -466,7 +467,7 @@ GEM rake-compiler-dock (1.9.1) rb_sys (0.9.117) rake-compiler-dock (= 1.9.1) - rdoc (7.0.3) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort From 41675224d07b7f04622e3ea8959d1087e4acf761 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 18 Feb 2026 14:06:46 -0500 Subject: [PATCH 034/237] Remove redundant insecure context CSRF check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rails now handles the insecure context case natively in verified_via_header_only? — when Sec-Fetch-Site is missing and the request is plain HTTP without force_ssl, the request is allowed. Remove the now-redundant allowed_insecure_context_request? method and update the test to set ActionDispatch::Http::URL.secure_protocol alongside Rails.configuration.force_ssl so the upstream check works correctly in the test environment. --- app/controllers/concerns/request_forgery_protection.rb | 6 +----- .../controllers/concerns/request_forgery_protection_test.rb | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb index fdff7ba8f..acfc4dbf1 100644 --- a/app/controllers/concerns/request_forgery_protection.rb +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -7,14 +7,10 @@ module RequestForgeryProtection private def verified_via_header_only? - super || allowed_api_request? || allowed_insecure_context_request? + super || allowed_api_request? end def allowed_api_request? sec_fetch_site_value.nil? && request.format.json? end - - def allowed_insecure_context_request? - sec_fetch_site_value.nil? && !request.ssl? && !Rails.configuration.force_ssl - end end diff --git a/test/controllers/concerns/request_forgery_protection_test.rb b/test/controllers/concerns/request_forgery_protection_test.rb index db1a84e63..9f50e353e 100644 --- a/test/controllers/concerns/request_forgery_protection_test.rb +++ b/test/controllers/concerns/request_forgery_protection_test.rb @@ -8,11 +8,13 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest ActionController::Base.allow_forgery_protection = true @original_force_ssl = Rails.configuration.force_ssl + @original_secure_protocol = ActionDispatch::Http::URL.secure_protocol end teardown do ActionController::Base.allow_forgery_protection = @original_allow_forgery_protection Rails.configuration.force_ssl = @original_force_ssl + ActionDispatch::Http::URL.secure_protocol = @original_secure_protocol end test "JSON request succeeds with missing Sec-Fetch-Site header" do @@ -38,6 +40,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest test "HTTP request fails with missing Sec-Fetch-Site header when force_ssl is enabled" do Rails.configuration.force_ssl = true + ActionDispatch::Http::URL.secure_protocol = true assert_no_difference -> { Board.count } do post boards_path, From 2d7950879dde281fc9cdeed6640d758fe28afcfa Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 18 Feb 2026 11:13:13 -0800 Subject: [PATCH 035/237] Normalize schema_sqlite.rb to reflect TableDefinitionColumnLimits initializer (#2569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table_definition_column_limits initializer (PR #1669) applies a default limit of 255 to all string columns during schema:load, but schema_sqlite.rb was never re-dumped after it landed. This caused drift on every subsequent migration PR that dumped the schema. Re-dump from schema:load → schema:dump to make the file idempotent. --- db/schema_sqlite.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 6ea0a137d..e06492288 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -42,7 +42,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false - t.string "failure_reason" + t.string "failure_reason", limit: 255 t.uuid "identity_id", null: false t.string "status", limit: 255, default: "pending", null: false t.datetime "updated_at", null: false @@ -299,7 +299,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do t.datetime "completed_at" t.datetime "created_at", null: false t.string "status", limit: 255, default: "pending", null: false - t.string "type" + t.string "type", limit: 255 t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_exports_on_account_id" @@ -495,7 +495,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do t.string "operation", limit: 255, null: false t.uuid "recordable_id" t.string "recordable_type", limit: 255 - t.string "request_id" + t.string "request_id", limit: 255 t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" From ed81ca760f5e068ba24dc2f33a39e78d7a65cc9e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 18 Feb 2026 11:21:25 -0800 Subject: [PATCH 036/237] Restore unique index on board_publications.key (#2568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restore unique index on board_publications.key The account_id rollout (fe6df7085) replaced the unique index on board_publications.key with a non-unique composite (account_id, key) index. This was unintentional — other tables in the same migration preserved uniqueness (account_join_codes, tags) but this one did not. Without global uniqueness on key, a crafted data import can insert a duplicate publication key via insert_all! (which bypasses model validations). Since find_by_published_key queries globally without account scoping, this enables public board URL hijacking after the legitimate owner unpublishes. Restore the original unique index on key and keep a plain account_id index for account-scoped queries. * Reorder index operations: add unique index before dropping composite On MySQL, DDL is non-transactional. If the unique index creation fails (e.g. duplicate keys exist), the previous ordering would leave the composite index already dropped. Adding the unique index first means a failure leaves the database unchanged. * Add schema dumps for both SQLite and MySQL Dump both schema files after running the migration against each adapter to keep the PR complete for CI. --- ...120000_restore_unique_index_on_board_publication_key.rb | 7 +++++++ db/schema.rb | 5 +++-- db/schema_sqlite.rb | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20260218120000_restore_unique_index_on_board_publication_key.rb diff --git a/db/migrate/20260218120000_restore_unique_index_on_board_publication_key.rb b/db/migrate/20260218120000_restore_unique_index_on_board_publication_key.rb new file mode 100644 index 000000000..dfcd5edaf --- /dev/null +++ b/db/migrate/20260218120000_restore_unique_index_on_board_publication_key.rb @@ -0,0 +1,7 @@ +class RestoreUniqueIndexOnBoardPublicationKey < ActiveRecord::Migration[8.2] + def change + add_index :board_publications, :key, unique: true + add_index :board_publications, :account_id + remove_index :board_publications, [:account_id, :key] + end +end diff --git a/db/schema.rb b/db/schema.rb index e74a97212..20cb0d161 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do +ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -147,8 +147,9 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do t.datetime "created_at", null: false t.string "key" t.datetime "updated_at", null: false - t.index ["account_id", "key"], name: "index_board_publications_on_account_id_and_key" + t.index ["account_id"], name: "index_board_publications_on_account_id" t.index ["board_id"], name: "index_board_publications_on_board_id" + t.index ["key"], name: "index_board_publications_on_key", unique: true end create_table "boards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index e06492288..c8ce19e07 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do +ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -147,8 +147,9 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do t.datetime "created_at", null: false t.string "key", limit: 255 t.datetime "updated_at", null: false - t.index ["account_id", "key"], name: "index_board_publications_on_account_id_and_key" + t.index ["account_id"], name: "index_board_publications_on_account_id" t.index ["board_id"], name: "index_board_publications_on_board_id" + t.index ["key"], name: "index_board_publications_on_key", unique: true end create_table "boards", id: :uuid, force: :cascade do |t| From 3b229e9aabc551a90b4d6f9643a5b12368a408b4 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 18 Feb 2026 15:10:11 -0500 Subject: [PATCH 037/237] Pin web-console to GitHub main for Rails compatibility web-console 4.2.1 overrides the now-renamed filter_proxies method in ActionDispatch::RemoteIp::GetIp. The fix (rails/web-console#344) is on main but not yet released. --- Gemfile | 2 +- Gemfile.lock | 16 ++++++++++------ Gemfile.saas.lock | 16 ++++++++++------ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Gemfile b/Gemfile index c524ee3c1..882ecb266 100644 --- a/Gemfile +++ b/Gemfile @@ -54,7 +54,7 @@ group :development, :test do end group :development do - gem "web-console" + gem "web-console", github: "rails/web-console" end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 3208ea717..a65b64861 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,6 +113,15 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +GIT + remote: https://github.com/rails/web-console.git + revision: bdbb39114348b037a515b2ce75a47457b0e647d1 + specs: + web-console (4.2.1) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + GEM remote: https://rubygems.org/ specs: @@ -460,11 +469,6 @@ GEM unicode-emoji (4.1.0) uri (1.1.1) vcr (6.4.0) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) - bindex (>= 0.4.0) - railties (>= 6.0.0) web-push (3.1.0) jwt (~> 3.0) openssl (>= 3.0) @@ -536,7 +540,7 @@ DEPENDENCIES turbo-rails useragent! vcr - web-console + web-console! web-push webmock zip_kit diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 6e39b5ad5..845856d2a 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -162,6 +162,15 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +GIT + remote: https://github.com/rails/web-console.git + revision: bdbb39114348b037a515b2ce75a47457b0e647d1 + specs: + web-console (4.2.1) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + PATH remote: saas specs: @@ -585,11 +594,6 @@ GEM unicode-emoji (4.1.0) uri (1.1.1) vcr (6.4.0) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) - bindex (>= 0.4.0) - railties (>= 6.0.0) web-push (3.1.0) jwt (~> 3.0) openssl (>= 3.0) @@ -700,7 +704,7 @@ DEPENDENCIES turbo-rails useragent! vcr - web-console + web-console! web-push webmock webrick From 953942694ff12cf22075e9a8ad2e267573fabea2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 18 Feb 2026 15:18:56 -0800 Subject: [PATCH 038/237] Handle MissingEOCD as invalid zip file, not job failure (#2572) ZipKit::FileReader raises MissingEOCD for truncated, corrupted, or non-zip files. This exception is a direct StandardError subclass, not a subclass of InvalidStructure or ReadError, so it escaped as an unhandled job failure instead of being caught and surfaced to the user. Broaden the rescue in ZipFile::Reader#initialize to catch ReadError (parent of InvalidStructure), MissingEOCD, and UnsupportedFeature. --- app/models/zip_file/reader.rb | 2 +- test/models/account/import_test.rb | 21 +++++++++++++++++++++ test/models/zip_file_test.rb | 11 +++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/models/zip_file/reader.rb b/app/models/zip_file/reader.rb index 2dc563f96..12ddd6fae 100644 --- a/app/models/zip_file/reader.rb +++ b/app/models/zip_file/reader.rb @@ -2,7 +2,7 @@ class ZipFile::Reader def initialize(io) @io = io @reader = ZipKit::FileReader.read_zip_structure(io: io) - rescue ZipKit::FileReader::InvalidStructure => e + rescue ZipKit::FileReader::ReadError, ZipKit::FileReader::MissingEOCD, ZipKit::FileReader::UnsupportedFeature => e raise ZipFile::InvalidFileError, e.message end diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index a377f69c2..a33cc32c2 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -96,6 +96,27 @@ class Account::ImportTest < ActiveSupport::TestCase tempfile&.unlink end + test "check sets failure_reason to invalid_export for non-ZIP file" do + target_account = Account.create!(name: "Import Test") + import = Account::Import.create!(identity: identities(:david), account: target_account) + + tempfile = Tempfile.new([ "not_a_zip", ".zip" ]) + tempfile.write("this is not a zip file at all") + tempfile.rewind + + Current.set(account: target_account) do + import.file.attach(io: tempfile, filename: "export.zip", content_type: "application/zip") + end + + assert_raises(ZipFile::InvalidFileError) { import.check } + + assert import.failed? + assert_equal "invalid_export", import.failure_reason + ensure + tempfile&.close + tempfile&.unlink + end + test "check sets failure_reason to conflict when records already exist" do source_account = accounts("37s") exporter = users(:david) diff --git a/test/models/zip_file_test.rb b/test/models/zip_file_test.rb index 42db30f9e..3484f9f92 100644 --- a/test/models/zip_file_test.rb +++ b/test/models/zip_file_test.rb @@ -119,6 +119,17 @@ class ZipFileTest < ActiveSupport::TestCase end end + test "reader raises InvalidFileError for non-zip file" do + tempfile = Tempfile.new([ "not_a_zip", ".zip" ]) + tempfile.write("this is not a zip file at all") + tempfile.rewind + + assert_raises(ZipFile::InvalidFileError) { ZipFile::Reader.new(tempfile) } + ensure + tempfile&.close + tempfile&.unlink + end + private def create_test_zip(files) tempfile = Tempfile.new([ "test", ".zip" ]) From 0e1feb0f3f449ba2b73bee1d1e1971fabf3ccf7e Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 19 Feb 2026 12:06:49 +0100 Subject: [PATCH 039/237] Create @query in controller instead of testing for blank? --- app/controllers/searches_controller.rb | 1 + app/views/searches/show.html.erb | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb index 5176cea02..e968edbc3 100644 --- a/app/controllers/searches_controller.rb +++ b/app/controllers/searches_controller.rb @@ -2,6 +2,7 @@ class SearchesController < ApplicationController include Turbo::DriveHelper def show + @query = params[:q].blank? ? nil : params[:q] if card = Current.user.accessible_cards.find_by_id(params[:q]) @card = card else diff --git a/app/views/searches/show.html.erb b/app/views/searches/show.html.erb index 197eebab2..235ab0915 100644 --- a/app/views/searches/show.html.erb +++ b/app/views/searches/show.html.erb @@ -1,4 +1,4 @@ -<% @page_title = !params[:q].blank? ? "Search results for \"#{params[:q]}\"" : "Search" %> +<% @page_title = @query ? "Search results for \"#{@query}\"" : "Search" %> <% content_for :header do %>
@@ -8,8 +8,8 @@

<%= @page_title %>

<% end %> -<%= tag.div(class: token_list("search-perma", {"search-perma--empty": params[:q].blank?}, "margin-block-start")) do %> - <%= render "form", query_terms: params[:q] %> +<%= tag.div(class: token_list("search-perma", {"search-perma--empty": !@query}, "margin-block-start")) do %> + <%= render "form", query_terms: @query %> <%= turbo_frame_tag "bar_content" do %> <% if @card %> <%= auto_submit_form_with url: card_path(@card), method: :get, data: { turbo_action: "advance", turbo_frame: "_top", search_redirect: true } %> From 542777409c2b5ad78d68536cb2686615ff0562d7 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 19 Feb 2026 12:07:08 +0100 Subject: [PATCH 040/237] Add test --- test/controllers/searches_controller_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 2493fe43f..11bac3bc9 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -15,6 +15,10 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest end test "search" do + # Search query is blank + get search_path(q: "", script_name: "/#{@account.external_account_id}") + assert @query.nil? + # Searching by card title get search_path(q: "broken", script_name: "/#{@account.external_account_id}") assert_select "li .search__title", text: /Layout is broken/ From 97f2f677d600e8661c560409a156f464469cba85 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 19 Feb 2026 14:04:14 +0100 Subject: [PATCH 041/237] Don't render empty state instead of hiding it --- app/assets/stylesheets/search.css | 5 ----- app/controllers/searches_controller.rb | 4 ++-- app/views/searches/_results.html.erb | 2 +- app/views/searches/show.html.erb | 6 +++--- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index 32dec66b3..5691ac5e5 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -129,9 +129,4 @@ summary { padding-inline: 0; } } - .search-perma--empty { - .search { - display: none; - } - } } diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb index e968edbc3..b849f04f1 100644 --- a/app/controllers/searches_controller.rb +++ b/app/controllers/searches_controller.rb @@ -3,10 +3,10 @@ class SearchesController < ApplicationController def show @query = params[:q].blank? ? nil : params[:q] - if card = Current.user.accessible_cards.find_by_id(params[:q]) + if card = Current.user.accessible_cards.find_by_id(@query) @card = card else - set_page_and_extract_portion_from Current.user.search(params[:q]) + set_page_and_extract_portion_from Current.user.search(@query) @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) end end diff --git a/app/views/searches/_results.html.erb b/app/views/searches/_results.html.erb index 0b622e95d..573d20397 100644 --- a/app/views/searches/_results.html.erb +++ b/app/views/searches/_results.html.erb @@ -1,6 +1,6 @@
-<%= bridged_share_button("card-url") %> +<%= bridged_share_button(bridge_share_card_description(@card)) %> From f835c211aff1913e4f99ed6e3d1ea14973a914bf Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 20 Feb 2026 11:05:26 -0600 Subject: [PATCH 057/237] Make generic scrollable list class for settings --- app/assets/stylesheets/print.css | 3 +- app/assets/stylesheets/settings.css | 49 +++++++------------ app/views/account/settings/_users.html.erb | 2 +- app/views/boards/edit/_users.html.erb | 2 +- .../notifications/settings/show.html.erb | 4 +- 5 files changed, 24 insertions(+), 36 deletions(-) diff --git a/app/assets/stylesheets/print.css b/app/assets/stylesheets/print.css index cd55b5196..7ced7b3a1 100644 --- a/app/assets/stylesheets/print.css +++ b/app/assets/stylesheets/print.css @@ -232,8 +232,7 @@ /* Settings /* ------------------------------------------------------------------------ */ - .settings__user-filter .input, - .settings__user-list-tips { + .settings__user-filter .input { display: none; } diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index 2a2d0c9cd..c731ee036 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -55,10 +55,7 @@ } } - /* Users - /* ------------------------------------------------------------------------ */ - - .settings__section--users { + .settings__section:has(.settings__scrollable-list) { @media (min-width: 640px) { display: flex; flex: 1; @@ -66,24 +63,8 @@ min-height: 0; } } - .settings__user-filter { - --btn-size: 3.5ch; - --avatar-size: var(--btn-size); - display: flex; - flex-direction: column; - gap: calc(var(--settings-spacer) / 2); - min-height: 0; - } - - .settings__user-filter--bg { - background-color: var(--color-ink-lightest); - border-radius: 0.5em; - margin-block: 0; - padding: var(--settings-spacer); - } - - .settings__user-list { + .settings__scrollable-list { flex: 1 1 auto; list-style: none; margin: calc(var(--settings-spacer) / -4) calc(-1 * var(--settings-item-padding-inline)); @@ -116,15 +97,23 @@ } } - .settings__user-list-tips { - border-top: 1px solid var(--color-ink-light); - color: var(--color-ink-dark); - font-size: var(--text-small); - padding-block-start: var(--settings-spacer); + /* Users + /* ------------------------------------------------------------------------ */ - .settings__user-filter--bg & { - margin-inline: calc(-1 * var(--settings-spacer)); - padding-inline: var(--settings-spacer); - } + .settings__user-filter { + --btn-size: 3.5ch; + --avatar-size: var(--btn-size); + + display: flex; + flex-direction: column; + gap: calc(var(--settings-spacer) / 2); + min-height: 0; + } + + .settings__user-filter--bg { + background-color: var(--color-ink-lightest); + border-radius: 0.5em; + margin-block: 0; + padding: var(--settings-spacer); } } diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index ceb17ff23..c28ab9d18 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -13,7 +13,7 @@
-
    +
      <%= render partial: "account/settings/user", collection: users %>
diff --git a/app/views/boards/edit/_users.html.erb b/app/views/boards/edit/_users.html.erb index d8c16484a..eeb7d94a1 100644 --- a/app/views/boards/edit/_users.html.erb +++ b/app/views/boards/edit/_users.html.erb @@ -33,7 +33,7 @@
-
    +
      <%= access_toggles_for selected_users, selected: true, disabled: %> <%= access_toggles_for unselected_users, selected: false, disabled: %>
    diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 916013ed7..25822e743 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -6,9 +6,9 @@
    -
    +

    Boards

    -
    +
    <%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %>
    From 17d869cf081b051b574784ae73b29ba4234a3968 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 20 Feb 2026 11:09:15 -0600 Subject: [PATCH 058/237] Cleanup unused classes --- app/views/account/settings/_users.html.erb | 2 +- app/views/notifications/settings/show.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index c28ab9d18..137532d75 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -1,4 +1,4 @@ -
    +

    People on this account

    diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 25822e743..3e6b8b84d 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -6,7 +6,7 @@
    -
    +

    Boards

    <%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %> From 3e5233239ba74d90c2e2576c9c22dac85518736b Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 18:22:41 +0100 Subject: [PATCH 059/237] Fix push notification not firing on notification creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rails only applies the last callback when `after_create_commit` and `after_update_commit` reference the same method name [1]: > However, if you use the `after_create_commit` and the `after_update_commit` callback with the same method name, it will only allow the last callback defined to take effect, as they both internally alias to `after_commit` which overrides previously defined callbacks with the same method name. - Push notifications were never sent when a notification was first created — only when the source was updated - Replaced the two callbacks with a single `after_save_commit`, which fires on both create and update, with the `source_id_previously_changed?` guard (true for both new records and source changes) Co-Authored-By: Claude Opus 4.5 [1] https://guides.rubyonrails.org/active_record_callbacks.html#aliases-for-after-commit --- app/models/concerns/push_notifiable.rb | 3 +-- test/models/concerns/push_notifiable_test.rb | 28 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 test/models/concerns/push_notifiable_test.rb diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb index e7bfdc56a..4bf9b575d 100644 --- a/app/models/concerns/push_notifiable.rb +++ b/app/models/concerns/push_notifiable.rb @@ -2,8 +2,7 @@ module PushNotifiable extend ActiveSupport::Concern included do - after_create_commit :push_notification_later - after_update_commit :push_notification_later, if: :source_id_previously_changed? + after_save_commit :push_notification_later, if: :source_id_previously_changed? end private diff --git a/test/models/concerns/push_notifiable_test.rb b/test/models/concerns/push_notifiable_test.rb new file mode 100644 index 000000000..f6d7ed26d --- /dev/null +++ b/test/models/concerns/push_notifiable_test.rb @@ -0,0 +1,28 @@ +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 From dc0452fd0ecb51739a14c1c0199f51a5a8818c87 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 20 Feb 2026 11:54:16 -0600 Subject: [PATCH 060/237] Use flex instead of grid to better support single sections --- app/assets/stylesheets/settings.css | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index c731ee036..a7d4db722 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -3,14 +3,12 @@ --settings-spacer: var(--block-space); --settings-item-padding-inline: 0.5ch; - display: grid; + align-items: start; + display: flex; gap: calc(var(--settings-spacer) * 2); + justify-content: center; margin: 0 auto; max-inline-size: min(100ch, 100%); - - @media (min-width: 960px) { - grid-template-columns: repeat(2, 1fr); - } } /* Sections & Panels @@ -29,6 +27,10 @@ @media (min-width: 640px) { --panel-padding: calc(var(--settings-spacer) * 2); } + + @media (min-width: 960px) { + max-inline-size: 50%; + } } .settings__panel--users { From cd80893957348dbe8d87b363cc66ae281ea924b2 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 20 Feb 2026 11:55:36 -0600 Subject: [PATCH 061/237] Handle two column layout on mobile --- app/assets/stylesheets/settings.css | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index a7d4db722..bd12916fc 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -6,9 +6,18 @@ align-items: start; display: flex; gap: calc(var(--settings-spacer) * 2); + flex-direction: column; justify-content: center; margin: 0 auto; max-inline-size: min(100ch, 100%); + + @media (min-width: 960px) { + flex-direction: row; + + .settings__panel { + max-inline-size: 50%; + } + } } /* Sections & Panels @@ -27,10 +36,6 @@ @media (min-width: 640px) { --panel-padding: calc(var(--settings-spacer) * 2); } - - @media (min-width: 960px) { - max-inline-size: 50%; - } } .settings__panel--users { From 8a591bd8ee8a4aac78903e75ef454e8686529ec0 Mon Sep 17 00:00:00 2001 From: Peter Berkenbosch Date: Sun, 22 Feb 2026 05:13:01 +0100 Subject: [PATCH 062/237] Fix typos in AGENTS.md (#2571) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d1c837079..72112cd7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,7 @@ Key recurring tasks (via `config/recurring.yml`): ### Imports and exports Allow people to move between OSS and SAAS Fizzy instances: -- Exports/Imports can be wirtten to/read from local or S3 storage depending on the config of the instance (both myst be supported) +- Exports/Imports can be written to/read from local or S3 storage depending on the config of the instance (both must be supported) - Must be able to handle very large ZIP files (500+GB) - Models in `app/models/account/data_transfer/`, `app/models/zip_file` From a02042b4c581940e73f7c4f9bbd01765206c7e10 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 22 Feb 2026 11:04:33 -0800 Subject: [PATCH 063/237] Add Deploy section to AGENTS.md (#2593) * AGENTS.md: add Deploy section (main, 7 destinations, saas:enable) Structured format for agent prelude discovery and cross-app drift checking via verify-app-registry. * Address review: document beta template env var requirement The beta deploy config requires BETA_NUMBER; typical targets are beta1-beta4. --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 72112cd7d..64c69151a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,14 @@ bin/jobs # Manage Solid Queue jobs bin/kamal deploy # Deploy (requires 1Password CLI for secrets) ``` +## Deploy + +Default branch: `main` +Pre-deploy: `bin/rails saas:enable` +Deploy: `bin/kamal deploy -d ` +Destinations: production, staging, beta, beta1, beta2, beta3, beta4 +Note: `beta` is a template requiring `BETA_NUMBER` env var; typical targets are `beta1`-`beta4`. + ## Architecture Overview ### Multi-Tenancy (URL-Based) From 5c7b3a8acb7be44849779ee92d168737217b88bc Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 23 Feb 2026 12:41:38 +0100 Subject: [PATCH 064/237] Keep reactions in static positioning on card perma ... and remove no longer needed styles --- app/assets/stylesheets/card-perma.css | 32 +++++++-------------------- app/assets/stylesheets/comments.css | 11 +++++++++ app/assets/stylesheets/reactions.css | 8 ------- 3 files changed, 19 insertions(+), 32 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 68d0cb2db..0d10762a4 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -38,20 +38,6 @@ &:has(.card__closed) { padding-block-start: 2rem; } - - &:has(.reactions .reaction) { - .card__meta-avatars--assignees { - margin-inline-end: unset; - } - } - - &:not(:has(.reactions .reaction)) { - &:has(.card__closed) { - .card__meta-text--assignees { - margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); - } - } - } } @media (max-width: 799px) { @@ -186,7 +172,7 @@ } .card__meta-avatars--assignees { - margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block)); + margin-inline: 0; margin-block-start: var(--meta-spacer-block); } @@ -251,8 +237,12 @@ } @media (max-width: 639px) { + display: grid; font-size: var(--text-x-small); - position: relative; + grid-template-columns: 1fr auto; + grid-template-areas: + "meta bg-zoom" + "meta reactions"; } } @@ -286,17 +276,11 @@ } &:not(:has(.reaction)) { + margin: 0; + .reactions__trigger { --btn-border-color: var(--color-ink-light); } - - @media (max-width: 639px) { - inset-inline-end: 0; - } - - @media (min-width: 640px) { - position: static; - } } } diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 01a9fbe91..2369d62b3 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -84,6 +84,17 @@ .reactions { margin-block-start: var(--block-space-half); margin-inline: calc(var(--column-gap) / -1); + + &:not(:has(.reaction)) { + inset-block-end: var(--comment-padding-block); + inset-inline-end: calc(var(--comment-padding-inline) / 2); + margin: 0; + position: absolute; + + @media (max-width: 640px) { + inset-inline-end: calc(var(--comment-padding-inline) / 3); + } + } } } diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index efd487f64..14e8b2086 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -17,14 +17,6 @@ &:not(:has(.reaction)) { inline-size: auto; - inset-block-end: var(--comment-padding-block); - inset-inline-end: calc(var(--comment-padding-inline) / 2); - margin: 0; - position: absolute; - - @media (max-width: 640px) { - inset-inline-end: calc(var(--comment-padding-inline) / 3); - } .reactions__list { display: none; From 73068bc2cf6b3e8dd39b9f33d3574c00c47f2756 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 23 Feb 2026 12:42:02 +0100 Subject: [PATCH 065/237] Restrict top positioning of closed stamp to perma --- app/assets/stylesheets/card-perma.css | 8 ++++++++ app/assets/stylesheets/cards.css | 6 ------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 0d10762a4..8fa21bdfb 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -218,6 +218,14 @@ } } + .card__closed { + @media (max-width: 639px) { + inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto; + translate: 0 40%; + scale: 75%; + } + } + .card__footer { --btn-size: 2.5rem; diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 27dbb2c46..011af9089 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -409,12 +409,6 @@ } } } - - @media (max-width: 639px) { - inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto; - translate: 0 40%; - scale: 75%; - } } .card:has(.card__closed), From 0b229c701b8d3ebf97ea7cf820ff40a58a220e7e Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 23 Feb 2026 16:10:54 +0100 Subject: [PATCH 066/237] Rename event --- app/helpers/bridge_helper.rb | 4 ++-- app/javascript/controllers/bridge/share_controller.js | 4 ++-- app/views/boards/show.html.erb | 2 +- app/views/cards/show.html.erb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/helpers/bridge_helper.rb b/app/helpers/bridge_helper.rb index ef96e89b3..ffebe6459 100644 --- a/app/helpers/bridge_helper.rb +++ b/app/helpers/bridge_helper.rb @@ -11,10 +11,10 @@ module BridgeHelper } end - def bridged_share_button(description = nil) + def bridged_share_url_button(description = nil) tag.button "Share", hidden: true, data: { controller: "bridge--share", - action: "bridge--share#share", + action: "bridge--share#shareUrl", bridge__overflow_menu_target: "item", bridge_title: "Share", bridge_share_description: description diff --git a/app/javascript/controllers/bridge/share_controller.js b/app/javascript/controllers/bridge/share_controller.js index 0080b5c34..46e91845d 100644 --- a/app/javascript/controllers/bridge/share_controller.js +++ b/app/javascript/controllers/bridge/share_controller.js @@ -3,9 +3,9 @@ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" export default class extends BridgeComponent { static component = "share" - share() { + shareUrl() { const description = this.bridgeElement.bridgeAttribute("share-description") - this.send("share", { + this.send("shareUrl", { title: document.title, url: window.location.href, description diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index 9f120728b..93c1447bb 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -31,4 +31,4 @@ <% end %> <% end %> -<%= bridged_share_button(bridge_share_board_description(@board)) %> +<%= bridged_share_url_button(bridge_share_board_description(@board)) %> diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index d996d4482..1ad92dd59 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -24,4 +24,4 @@ <% end %>
    -<%= bridged_share_button(bridge_share_card_description(@card)) %> +<%= bridged_share_url_button(bridge_share_card_description(@card)) %> From a4f1a6b106d9e68b4d8cec8b5a8e8dc2ffa0d33a Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 23 Feb 2026 16:11:52 +0100 Subject: [PATCH 067/237] Coding style --- app/javascript/controllers/bridge/share_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/bridge/share_controller.js b/app/javascript/controllers/bridge/share_controller.js index 46e91845d..75f0241cf 100644 --- a/app/javascript/controllers/bridge/share_controller.js +++ b/app/javascript/controllers/bridge/share_controller.js @@ -8,7 +8,7 @@ export default class extends BridgeComponent { this.send("shareUrl", { title: document.title, url: window.location.href, - description + description: description }) } } From 0ecc7527ca27c651b7d8f6ed15a7aa80b6bb4aa8 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Mon, 23 Feb 2026 11:02:10 -0500 Subject: [PATCH 068/237] Prevent pull-to-refresh gesture conflicting with side scrolling on the board perma on Android --- app/views/boards/show/_columns.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index 191d18cbc..6d700f47e 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -13,6 +13,7 @@ navigable_list_auto_select_value: false, navigable_list_auto_scroll_value: false, card_hotkeys_navigable_list_outlet: ".cards__transition-container", + native_prevent_pull_to_refresh: true, action: " keydown->navigable-list#navigate keydown->card-hotkeys#handleKeydown From d8908c4b92598bd6728a3fef08854e271b6b1417 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:41:17 -0500 Subject: [PATCH 069/237] Bump bootsnap from 1.22.0 to 1.23.0 (#2589) * Bump bootsnap from 1.22.0 to 1.23.0 Bumps [bootsnap](https://github.com/rails/bootsnap) from 1.22.0 to 1.23.0. - [Release notes](https://github.com/rails/bootsnap/releases) - [Changelog](https://github.com/rails/bootsnap/blob/main/CHANGELOG.md) - [Commits](https://github.com/rails/bootsnap/compare/v1.22.0...v1.23.0) --- updated-dependencies: - dependency-name: bootsnap dependency-version: 1.23.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Sync Gemfile.saas.lock --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a65b64861..b27610ab7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -156,7 +156,7 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.22.0) + bootsnap (1.23.0) msgpack (~> 1.2) brakeman (8.0.1) racc diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 845856d2a..636a2b373 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -239,7 +239,7 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.22.0) + bootsnap (1.23.0) msgpack (~> 1.2) brakeman (8.0.1) racc From 446640b8e9ebb256a1c0ac06b97aa25d5a5a4cfd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:41:44 -0500 Subject: [PATCH 070/237] Bump the development-dependencies group with 2 updates (#2588) * Bump the development-dependencies group with 2 updates Bumps the development-dependencies group with 2 updates: [brakeman](https://github.com/presidentbeef/brakeman) and [mocha](https://github.com/freerange/mocha). Updates `brakeman` from 8.0.1 to 8.0.2 - [Release notes](https://github.com/presidentbeef/brakeman/releases) - [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md) - [Commits](https://github.com/presidentbeef/brakeman/compare/v8.0.1...v8.0.2) Updates `mocha` from 3.0.1 to 3.0.2 - [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md) - [Commits](https://github.com/freerange/mocha/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: brakeman dependency-version: 8.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies - dependency-name: mocha dependency-version: 3.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies ... Signed-off-by: dependabot[bot] * Sync Gemfile.saas.lock --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- Gemfile.lock | 4 ++-- Gemfile.saas.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b27610ab7..2b1de5228 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -158,7 +158,7 @@ GEM bindex (0.8.1) bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.1) + brakeman (8.0.2) racc builder (3.3.0) bundler-audit (0.9.3) @@ -281,7 +281,7 @@ GEM stimulus-rails turbo-rails mittens (0.3.1) - mocha (3.0.1) + mocha (3.0.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) net-http-persistent (4.0.8) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 636a2b373..7043e5ca4 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -241,7 +241,7 @@ GEM bindex (0.8.1) bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.1) + brakeman (8.0.2) racc builder (3.3.0) bundler-audit (0.9.3) @@ -365,7 +365,7 @@ GEM stimulus-rails turbo-rails mittens (0.3.1) - mocha (3.0.1) + mocha (3.0.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) net-http-persistent (4.0.8) From 85bb2aa1fa8948e693b06cf53972987adb6c12c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:09:22 -0500 Subject: [PATCH 071/237] Bump turbo-rails from 2.0.21 to 2.0.23 (#2501) * Bump turbo-rails from 2.0.21 to 2.0.23 Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.21 to 2.0.23. - [Release notes](https://github.com/hotwired/turbo-rails/releases) - [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.21...v2.0.23) --- updated-dependencies: - dependency-name: turbo-rails dependency-version: 2.0.23 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Sync Gemfile.saas.lock --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2b1de5228..c47720e41 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -459,7 +459,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 7043e5ca4..dc5ab8327 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -584,7 +584,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) From 5fad13c59f35854a992afdf6d76b849561dd135e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:22:21 -0500 Subject: [PATCH 072/237] Bump rack from 3.2.4 to 3.2.5 (#2559) * Bump rack from 3.2.4 to 3.2.5 Bumps [rack](https://github.com/rack/rack) from 3.2.4 to 3.2.5. - [Release notes](https://github.com/rack/rack/releases) - [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md) - [Commits](https://github.com/rack/rack/compare/v3.2.4...v3.2.5) --- updated-dependencies: - dependency-name: rack dependency-version: 3.2.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] * Sync Gemfile.saas.lock --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] From 6f99e43f5ba5d68feab2a3501527fec12f41b2c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:25:30 -0500 Subject: [PATCH 073/237] Bump puma from 7.1.0 to 7.2.0 (#2462) * Bump puma from 7.1.0 to 7.2.0 Bumps [puma](https://github.com/puma/puma) from 7.1.0 to 7.2.0. - [Release notes](https://github.com/puma/puma/releases) - [Changelog](https://github.com/puma/puma/blob/main/History.md) - [Commits](https://github.com/puma/puma/compare/v7.1.0...v7.2.0) --- updated-dependencies: - dependency-name: puma dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Sync Gemfile.saas.lock --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c47720e41..2e438774e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -338,7 +338,7 @@ GEM date stringio public_suffix (6.0.2) - puma (7.1.0) + puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index dc5ab8327..9f17c480a 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -450,7 +450,7 @@ GEM date stringio public_suffix (6.0.2) - puma (7.1.0) + puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) From 361d4a7a52b94dd2caddf1418ea64282afe4c8c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:42:48 -0500 Subject: [PATCH 074/237] Bump solid_queue from 1.2.4 to 1.3.1 (#2425) Bumps [solid_queue](https://github.com/rails/solid_queue) from 1.2.4 to 1.3.1. - [Release notes](https://github.com/rails/solid_queue/releases) - [Commits](https://github.com/rails/solid_queue/compare/v1.2.4...v1.3.1) --- updated-dependencies: - dependency-name: solid_queue dependency-version: 1.3.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 6 +++--- Gemfile.saas.lock | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 882ecb266..209137c6d 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,7 @@ gem "kamal", require: false gem "puma", ">= 5.0" gem "solid_cable", ">= 3.0" gem "solid_cache", "~> 1.0" -gem "solid_queue", "~> 1.2" +gem "solid_queue", "~> 1.3" gem "sqlite3", ">= 2.0" gem "thruster", require: false gem "trilogy", "~> 2.9" diff --git a/Gemfile.lock b/Gemfile.lock index 2e438774e..304b1bdea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -424,7 +424,7 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.4) + solid_queue (1.3.1) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) @@ -493,7 +493,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin - x86_64-darwin-25 + x86_64-darwin x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -531,7 +531,7 @@ DEPENDENCIES selenium-webdriver solid_cable (>= 3.0) solid_cache (~> 1.0) - solid_queue (~> 1.2) + solid_queue (~> 1.3) sqlite3 (>= 2.0) stackprof stimulus-rails diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 9f17c480a..bf56e9b40 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -550,7 +550,7 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.4) + solid_queue (1.3.1) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) @@ -694,7 +694,7 @@ DEPENDENCIES sentry-ruby solid_cable (>= 3.0) solid_cache (~> 1.0) - solid_queue (~> 1.2) + solid_queue (~> 1.3) sqlite3 (>= 2.0) stackprof stimulus-rails From 3063a40f0fc7c129fd38ef3ba9a30bf3330e99d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:43:47 -0500 Subject: [PATCH 075/237] Bump trilogy from 2.9.0 to 2.10.0 (#2388) Bumps [trilogy](https://github.com/trilogy-libraries/trilogy) from 2.9.0 to 2.10.0. - [Release notes](https://github.com/trilogy-libraries/trilogy/releases) - [Changelog](https://github.com/trilogy-libraries/trilogy/blob/main/CHANGELOG.md) - [Commits](https://github.com/trilogy-libraries/trilogy/compare/v2.9.0...v2.10.0) --- updated-dependencies: - dependency-name: trilogy dependency-version: 2.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 5 +++-- Gemfile.saas.lock | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 209137c6d..17e7496c0 100644 --- a/Gemfile +++ b/Gemfile @@ -19,7 +19,7 @@ gem "solid_cache", "~> 1.0" gem "solid_queue", "~> 1.3" gem "sqlite3", ">= 2.0" gem "thruster", require: false -gem "trilogy", "~> 2.9" +gem "trilogy", "~> 2.10" # Features gem "bcrypt", "~> 3.1.7" diff --git a/Gemfile.lock b/Gemfile.lock index 304b1bdea..b1ebf20a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -457,7 +457,8 @@ GEM thruster (0.1.17-x86_64-darwin) thruster (0.1.17-x86_64-linux) timeout (0.6.0) - trilogy (2.9.0) + trilogy (2.10.0) + bigdecimal tsort (0.2.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) @@ -536,7 +537,7 @@ DEPENDENCIES stackprof stimulus-rails thruster - trilogy (~> 2.9) + trilogy (~> 2.10) turbo-rails useragent! vcr diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index bf56e9b40..d1d39882b 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -582,7 +582,8 @@ GEM thruster (0.1.17-arm64-darwin) thruster (0.1.17-x86_64-linux) timeout (0.6.0) - trilogy (2.9.0) + trilogy (2.10.0) + bigdecimal tsort (0.2.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) @@ -700,7 +701,7 @@ DEPENDENCIES stimulus-rails stripe (~> 18.0) thruster - trilogy (~> 2.9) + trilogy (~> 2.10) turbo-rails useragent! vcr From b0b14ead28c63a0fdee55ad7636a8585554725d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:48:52 -0500 Subject: [PATCH 076/237] Bump sqlite3 from 2.8.0 to 2.9.0 (#2386) Bumps [sqlite3](https://github.com/sparklemotion/sqlite3-ruby) from 2.8.0 to 2.9.0. - [Release notes](https://github.com/sparklemotion/sqlite3-ruby/releases) - [Changelog](https://github.com/sparklemotion/sqlite3-ruby/blob/main/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/sqlite3-ruby/compare/v2.8.0...v2.9.0) --- updated-dependencies: - dependency-name: sqlite3 dependency-version: 2.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 16 ++++++++-------- Gemfile.saas.lock | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b1ebf20a7..5e7db96c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -431,14 +431,14 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.0-aarch64-linux-gnu) - sqlite3 (2.8.0-aarch64-linux-musl) - sqlite3 (2.8.0-arm-linux-gnu) - sqlite3 (2.8.0-arm-linux-musl) - sqlite3 (2.8.0-arm64-darwin) - sqlite3 (2.8.0-x86_64-darwin) - sqlite3 (2.8.0-x86_64-linux-gnu) - sqlite3 (2.8.0-x86_64-linux-musl) + sqlite3 (2.9.0-aarch64-linux-gnu) + sqlite3 (2.9.0-aarch64-linux-musl) + sqlite3 (2.9.0-arm-linux-gnu) + sqlite3 (2.9.0-arm-linux-musl) + sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-darwin) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) sshkit (1.25.0) base64 logger diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index d1d39882b..bb23b04ca 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -557,13 +557,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.0-aarch64-linux-gnu) - sqlite3 (2.8.0-aarch64-linux-musl) - sqlite3 (2.8.0-arm-linux-gnu) - sqlite3 (2.8.0-arm-linux-musl) - sqlite3 (2.8.0-arm64-darwin) - sqlite3 (2.8.0-x86_64-linux-gnu) - sqlite3 (2.8.0-x86_64-linux-musl) + sqlite3 (2.9.0-aarch64-linux-gnu) + sqlite3 (2.9.0-aarch64-linux-musl) + sqlite3 (2.9.0-arm-linux-gnu) + sqlite3 (2.9.0-arm-linux-musl) + sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) sshkit (1.25.0) base64 logger From 395cc06f5c2a153c04de48cf1ccc39a1e8fcacf7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:53:45 -0500 Subject: [PATCH 077/237] Bump aws-sdk-s3 from 1.211.0 to 1.213.0 (#2502) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.211.0 to 1.213.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-version: 1.213.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 12 ++++++------ Gemfile.saas.lock | 23 +++++++++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5e7db96c7..f8d852cd8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -132,8 +132,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1203.0) - aws-sdk-core (3.241.3) + aws-partitions (1.1218.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -141,11 +141,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.120.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-kms (1.122.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index bb23b04ca..99bf98547 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -215,8 +215,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1203.0) - aws-sdk-core (3.241.3) + aws-partitions (1.1218.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -224,11 +224,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.120.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-kms (1.122.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -284,6 +284,7 @@ GEM ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) fugit (1.12.1) @@ -395,6 +396,8 @@ GEM racc (~> 1.4) nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.1-x86_64-darwin) + racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-musl) @@ -432,6 +435,11 @@ GEM bigdecimal logger rb_sys (~> 0.9.117) + prometheus-client-mmap (1.4.0-x86_64-darwin) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) prometheus-client-mmap (1.4.0-x86_64-linux-gnu) base64 bigdecimal @@ -562,6 +570,7 @@ GEM sqlite3 (2.9.0-arm-linux-gnu) sqlite3 (2.9.0-arm-linux-musl) sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-darwin) sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.9.0-x86_64-linux-musl) sshkit (1.25.0) @@ -580,6 +589,7 @@ GEM thruster (0.1.17) thruster (0.1.17-aarch64-linux) thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-darwin) thruster (0.1.17-x86_64-linux) timeout (0.6.0) trilogy (2.10.0) @@ -647,6 +657,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin + x86_64-darwin x86_64-linux x86_64-linux-gnu x86_64-linux-musl From d8ed2f44b88ed56ba41674d58029ba0ba76499fd Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 24 Feb 2026 07:36:20 +0100 Subject: [PATCH 078/237] Reconcile card count after import --- app/models/account/data_transfer/account_record_set.rb | 2 +- app/models/account/import.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/models/account/data_transfer/account_record_set.rb b/app/models/account/data_transfer/account_record_set.rb index b7869eb73..a5151f05f 100644 --- a/app/models/account/data_transfer/account_record_set.rb +++ b/app/models/account/data_transfer/account_record_set.rb @@ -31,7 +31,7 @@ class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet account_data = load(files.first) join_code_data = account_data.delete("join_code") - account.update!(name: account_data.fetch("name")) + account.update!(name: account_data.fetch("name"), cards_count: account_data.fetch("cards_count", 0)) account.join_code.update!(join_code_data.slice("usage_count", "usage_limit")) account.join_code.update(code: join_code_data.fetch("code")) end diff --git a/app/models/account/import.rb b/app/models/account/import.rb index 14ee376ea..50ec3769d 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -48,6 +48,7 @@ class Account::Import < ApplicationRecord end add_importer_to_all_access_boards + reconcile_cards_count reconcile_account_storage mark_completed @@ -78,6 +79,10 @@ class Account::Import < ApplicationRecord ImportMailer.failed(self).deliver_later end + def reconcile_cards_count + account.update_column :cards_count, [ account.cards_count, account.cards.maximum(:number).to_i ].max + end + def add_importer_to_all_access_boards importer = account.users.find_by!(identity: identity) From 55779f8ac47a9e44a173d1a6a29266cd3d3a0ac5 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 24 Feb 2026 07:36:51 +0100 Subject: [PATCH 079/237] Organize the record sets better --- .../rich_text_record_set.rb} | 10 +++++----- .../blob_record_set.rb} | 8 ++++---- .../file_record_set.rb} | 10 +++++----- app/models/account/data_transfer/manifest.rb | 6 +++--- .../rich_text_record_set_test.rb} | 10 +++++----- 5 files changed, 22 insertions(+), 22 deletions(-) rename app/models/account/data_transfer/{action_text_rich_text_record_set.rb => action_text/rich_text_record_set.rb} (86%) rename app/models/account/data_transfer/{active_storage_blob_record_set.rb => active_storage/blob_record_set.rb} (55%) rename app/models/account/data_transfer/{blob_file_record_set.rb => active_storage/file_record_set.rb} (72%) rename test/models/account/data_transfer/{action_text_rich_text_record_set_test.rb => action_text/rich_text_record_set_test.rb} (86%) diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text/rich_text_record_set.rb similarity index 86% rename from app/models/account/data_transfer/action_text_rich_text_record_set.rb rename to app/models/account/data_transfer/action_text/rich_text_record_set.rb index fa1a23cb9..d56a12dc1 100644 --- a/app/models/account/data_transfer/action_text_rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text/rich_text_record_set.rb @@ -1,4 +1,4 @@ -class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet +class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransfer::RecordSet ATTRIBUTES = %w[ account_id body @@ -11,12 +11,12 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer ].freeze def initialize(account) - super(account: account, model: ActionText::RichText) + super(account: account, model: ::ActionText::RichText) end private def records - ActionText::RichText.where(account: account) + ::ActionText::RichText.where(account: account) end def export_record(rich_text) @@ -35,7 +35,7 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer data.slice(*ATTRIBUTES).merge("account_id" => account.id) end - ActionText::RichText.insert_all!(batch_data) + ::ActionText::RichText.insert_all!(batch_data) end def check_record(file_path) @@ -58,7 +58,7 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer return nil if content.blank? content.send(:attachment_nodes).each do |node| - sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME) + sgid = SignedGlobalID.parse(node["sgid"], for: ::ActionText::Attachable::LOCATOR_NAME) record = begin sgid&.find diff --git a/app/models/account/data_transfer/active_storage_blob_record_set.rb b/app/models/account/data_transfer/active_storage/blob_record_set.rb similarity index 55% rename from app/models/account/data_transfer/active_storage_blob_record_set.rb rename to app/models/account/data_transfer/active_storage/blob_record_set.rb index 10ee326ad..1637768f3 100644 --- a/app/models/account/data_transfer/active_storage_blob_record_set.rb +++ b/app/models/account/data_transfer/active_storage/blob_record_set.rb @@ -1,9 +1,9 @@ -class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer::RecordSet +class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfer::RecordSet def initialize(account) super( account: account, - model: ActiveStorage::Blob, - attributes: ActiveStorage::Blob.column_names - %w[service_name] + model: ::ActiveStorage::Blob, + attributes: ::ActiveStorage::Blob.column_names - %w[service_name] ) end @@ -13,7 +13,7 @@ class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer: data = load(file) data.slice(*attributes).merge( "account_id" => account.id, - "service_name" => ActiveStorage::Blob.service.name + "service_name" => ::ActiveStorage::Blob.service.name ) end diff --git a/app/models/account/data_transfer/blob_file_record_set.rb b/app/models/account/data_transfer/active_storage/file_record_set.rb similarity index 72% rename from app/models/account/data_transfer/blob_file_record_set.rb rename to app/models/account/data_transfer/active_storage/file_record_set.rb index 4bbfcba10..15d359353 100644 --- a/app/models/account/data_transfer/blob_file_record_set.rb +++ b/app/models/account/data_transfer/active_storage/file_record_set.rb @@ -1,11 +1,11 @@ -class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet +class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfer::RecordSet def initialize(account) - super(account: account, model: ActiveStorage::Blob) + super(account: account, model: ::ActiveStorage::Blob) end private def records - ActiveStorage::Blob.where(account: account) + ::ActiveStorage::Blob.where(account: account) end def export_record(blob) @@ -23,7 +23,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe def import_batch(files) files.each do |file| key = File.basename(file) - blob = ActiveStorage::Blob.find_by(key: key, account: account) + blob = ::ActiveStorage::Blob.find_by(key: key, account: account) next unless blob zip.read(file) do |stream| @@ -35,7 +35,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe def check_record(file_path) key = File.basename(file_path) - unless zip.exists?("data/active_storage_blobs/#{key}.json") || ActiveStorage::Blob.exists?(key: key, account: account) + unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account) # File exists without corresponding blob record - could be orphaned or blob not yet imported # We allow this since blob metadata is imported before files end diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb index b5976e3d5..2159926d5 100644 --- a/app/models/account/data_transfer/manifest.rb +++ b/app/models/account/data_transfer/manifest.rb @@ -35,10 +35,10 @@ class Account::DataTransfer::Manifest ::Filter, ::Webhook::DelinquencyTracker, ::Event, ::Notification, ::Notification::Bundle, ::Webhook::Delivery ), - Account::DataTransfer::ActiveStorageBlobRecordSet.new(account), + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account), *build_record_sets(::ActiveStorage::Attachment), - Account::DataTransfer::ActionTextRichTextRecordSet.new(account), - Account::DataTransfer::BlobFileRecordSet.new(account) + Account::DataTransfer::ActionText::RichTextRecordSet.new(account), + Account::DataTransfer::ActiveStorage::FileRecordSet.new(account) ] end diff --git a/test/models/account/data_transfer/action_text_rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb similarity index 86% rename from test/models/account/data_transfer/action_text_rich_text_record_set_test.rb rename to test/models/account/data_transfer/action_text/rich_text_record_set_test.rb index 14a7aeb9c..08e0b5610 100644 --- a/test/models/account/data_transfer/action_text_rich_text_record_set_test.rb +++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Account::DataTransfer::ActionTextRichTextRecordSetTest < ActiveSupport::TestCase +class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport::TestCase test "check rejects ActionText record referencing existing card in another account" do importing_account = Account.create!(name: "Importing Account", external_account_id: 99999999) @@ -29,7 +29,7 @@ class Account::DataTransfer::ActionTextRichTextRecordSetTest < ActiveSupport::Te reader = ZipFile::Reader.new(tempfile) - record_set = Account::DataTransfer::ActionTextRichTextRecordSet.new(importing_account) + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(importing_account) error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do record_set.check(from: reader) @@ -50,7 +50,7 @@ class Account::DataTransfer::ActionTextRichTextRecordSetTest < ActiveSupport::Te cross_tenant_gid = victim_tag.to_global_id.to_s html = %() - record_set = Account::DataTransfer::ActionTextRichTextRecordSet.new(attacker_account) + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(attacker_account) result = record_set.send(:convert_gids_to_sgids, html) assert_no_match(/sgid=/, result, "Cross-tenant GID must not be converted to SGID") @@ -65,7 +65,7 @@ class Account::DataTransfer::ActionTextRichTextRecordSetTest < ActiveSupport::Te same_account_gid = own_tag.to_global_id.to_s html = %() - record_set = Account::DataTransfer::ActionTextRichTextRecordSet.new(own_account) + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(own_account) result = record_set.send(:convert_gids_to_sgids, html) assert_match(/sgid=/, result, "Same-account GID should be converted to SGID") @@ -76,7 +76,7 @@ class Account::DataTransfer::ActionTextRichTextRecordSetTest < ActiveSupport::Te nonexistent_gid = "gid://fizzy/Tag/00000000000000000000000000" html = %() - record_set = Account::DataTransfer::ActionTextRichTextRecordSet.new(accounts(:"37s")) + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) result = record_set.send(:convert_gids_to_sgids, html) assert_no_match(/sgid=/, result, "Non-existent record should not produce SGID") From 4eadce8ffe5a3167ae4625d954a10d9871f9c97f Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 24 Feb 2026 08:11:47 +0100 Subject: [PATCH 080/237] Automatically update account slugs in links --- .../action_text/rich_text_record_set.rb | 29 +++++++++-- .../action_text/rich_text_record_set_test.rb | 48 ++++++++++++++++--- test/models/account/import_test.rb | 30 ++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/app/models/account/data_transfer/action_text/rich_text_record_set.rb b/app/models/account/data_transfer/action_text/rich_text_record_set.rb index d56a12dc1..e29abd011 100644 --- a/app/models/account/data_transfer/action_text/rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text/rich_text_record_set.rb @@ -31,7 +31,7 @@ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransf def import_batch(files) batch_data = files.map do |file| data = load(file) - data["body"] = convert_gids_to_sgids(data["body"]) + data["body"] = transform_body_for_import(data["body"]) data.slice(*ATTRIBUTES).merge("account_id" => account.id) end @@ -75,11 +75,16 @@ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransf content.fragment.source.to_html end - def convert_gids_to_sgids(html) - return html if html.blank? + def transform_body_for_import(body) + return body if body.blank? - fragment = Nokogiri::HTML.fragment(html) + Nokogiri::HTML.fragment(body) + .then { convert_gids_to_sgids(it) } + .then { replace_account_slugs(it) } + .to_html + end + def convert_gids_to_sgids(fragment) fragment.css("action-text-attachment[gid]").each do |node| gid = GlobalID.parse(node["gid"]) @@ -97,6 +102,20 @@ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransf end end - fragment.to_html + fragment + end + + def replace_account_slugs(fragment) + fragment.css("a[href]").each do |link| + match = link["href"].match(AccountSlug::PATH_INFO_MATCH) + + if match + path = match.post_match.presence || "/" + valid_path = Rails.application.routes.recognize_path(path) rescue nil + link["href"] = "#{account.slug}#{remaining_path}" if valid_path + end + end + + fragment end end diff --git a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb index 08e0b5610..bee51869c 100644 --- a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb +++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb @@ -42,7 +42,7 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport:: importing_account&.destroy end - test "convert_gids_to_sgids skips GIDs belonging to another account" do + test "transform_body_for_import skips GIDs belonging to another account" do victim_tag = tags(:web) attacker_account = accounts(:initech) assert_not_equal attacker_account.id, victim_tag.account_id @@ -51,13 +51,13 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport:: html = %() record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(attacker_account) - result = record_set.send(:convert_gids_to_sgids, html) + result = record_set.send(:transform_body_for_import, html) assert_no_match(/sgid=/, result, "Cross-tenant GID must not be converted to SGID") assert_match(/gid=/, result, "Original GID should remain unconverted") end - test "convert_gids_to_sgids converts GIDs belonging to the same account" do + test "transform_body_for_import converts GIDs belonging to the same account" do own_tag = tags(:web) own_account = accounts(:"37s") assert_equal own_account.id, own_tag.account_id @@ -66,19 +66,55 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport:: html = %() record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(own_account) - result = record_set.send(:convert_gids_to_sgids, html) + result = record_set.send(:transform_body_for_import, html) assert_match(/sgid=/, result, "Same-account GID should be converted to SGID") assert_no_match(/ gid=/, result, "GID should be removed after SGID conversion") end - test "convert_gids_to_sgids handles non-existent record GIDs gracefully" do + test "transform_body_for_import handles non-existent record GIDs gracefully" do nonexistent_gid = "gid://fizzy/Tag/00000000000000000000000000" html = %() record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) - result = record_set.send(:convert_gids_to_sgids, html) + result = record_set.send(:transform_body_for_import, html) assert_no_match(/sgid=/, result, "Non-existent record should not produce SGID") end + + test "replace_account_slugs rewrites relative URLs with account slug" do + target_account = accounts(:"37s") + source_slug = "9999999" + target_slug = AccountSlug.encode(target_account.external_account_id) + + html = %(

    See card 42

    ) + + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(target_account) + result = record_set.send(:transform_body_for_import, html) + + assert_includes result, "/#{target_slug}/cards/42" + assert_not_includes result, source_slug + end + + test "replace_account_slugs leaves absolute URLs alone" do + target_account = accounts(:"37s") + + html = %(

    See board

    ) + + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(target_account) + result = record_set.send(:transform_body_for_import, html) + + assert_includes result, "https://fizzy.app/9999999/boards/7" + end + + test "replace_account_slugs leaves plain text alone" do + target_account = accounts(:"37s") + + html = "

    Nothing to rewrite here

    " + + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(target_account) + result = record_set.send(:transform_body_for_import, html) + + assert_equal html, result + end end diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index a33cc32c2..7bf407041 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -62,6 +62,36 @@ class Account::ImportTest < ActiveSupport::TestCase export_tempfile&.unlink end + test "import reconciles cards count so new cards get correct numbers" do + source_account = accounts("37s") + exporter = users(:david) + identity = exporter.identity + max_card_number = source_account.cards.maximum(:number) + + export = Account::Export.create!(account: source_account, user: exporter) + export.build + + export_tempfile = Tempfile.new([ "export", ".zip" ]) + export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) } + + source_account.destroy! + + target_account = Account.create_with_owner(account: { name: "Import Test" }, owner: { identity: identity, name: exporter.name }) + import = Account::Import.create!(identity: identity, account: target_account) + Current.set(account: target_account) do + import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip") + end + + import.check + import.process + + target_account.reload + assert_operator target_account.cards_count, :>=, max_card_number + ensure + export_tempfile&.close + export_tempfile&.unlink + end + test "check sets no failure_reason for unexpected errors" do import = Account::Import.create!(identity: identities(:david), account: Account.create!(name: "Import Test")) From 2f9d4533c582d4c575def9e2dbfe9eabe4b1f54c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 24 Feb 2026 08:22:01 +0100 Subject: [PATCH 081/237] Relativize absolute links to the instance on export --- .../action_text/rich_text_record_set.rb | 30 +++++++++-- .../action_text/rich_text_record_set_test.rb | 53 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/app/models/account/data_transfer/action_text/rich_text_record_set.rb b/app/models/account/data_transfer/action_text/rich_text_record_set.rb index e29abd011..01e046dbe 100644 --- a/app/models/account/data_transfer/action_text/rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text/rich_text_record_set.rb @@ -20,7 +20,7 @@ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransf end def export_record(rich_text) - data = rich_text.as_json.merge("body" => convert_sgids_to_gids(rich_text.body)) + data = rich_text.as_json.merge("body" => transform_body_for_export(rich_text.body)) zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json end @@ -54,9 +54,14 @@ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransf check_associations_dont_exist(data) end - def convert_sgids_to_gids(content) + def transform_body_for_export(content) return nil if content.blank? + html = convert_sgids_to_gids(content) + relativize_urls(html) + end + + def convert_sgids_to_gids(content) content.send(:attachment_nodes).each do |node| sgid = SignedGlobalID.parse(node["sgid"], for: ::ActionText::Attachable::LOCATOR_NAME) @@ -75,6 +80,25 @@ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransf content.fragment.source.to_html end + def relativize_urls(html) + host = Rails.application.routes.default_url_options[:host] + return html unless host + + fragment = Nokogiri::HTML.fragment(html) + + fragment.css("a[href]").each do |link| + uri = URI.parse(link["href"]) rescue nil + + if uri.respond_to?(:host) && uri.host == host + link["href"] = uri.path + link["href"] += "?#{uri.query}" if uri.query + link["href"] += "##{uri.fragment}" if uri.fragment + end + end + + fragment.to_html + end + def transform_body_for_import(body) return body if body.blank? @@ -112,7 +136,7 @@ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransf if match path = match.post_match.presence || "/" valid_path = Rails.application.routes.recognize_path(path) rescue nil - link["href"] = "#{account.slug}#{remaining_path}" if valid_path + link["href"] = "#{account.slug}#{path}" if valid_path end end diff --git a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb index bee51869c..3fed8126d 100644 --- a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb +++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb @@ -117,4 +117,57 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport:: assert_equal html, result end + + test "relativize_urls strips instance host from absolute URLs" do + with_default_url_host("fizzy.example.com") do + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) + + html = %(

    See card

    ) + result = record_set.send(:relativize_urls, html) + + assert_includes result, %(/123/cards/42) + assert_not_includes result, "fizzy.example.com" + end + end + + test "relativize_urls preserves query and fragment" do + with_default_url_host("fizzy.example.com") do + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) + + html = %(

    link

    ) + result = record_set.send(:relativize_urls, html) + + assert_includes result, "/123/cards/42?tab=comments#comment_1" + assert_not_includes result, "fizzy.example.com" + end + end + + test "relativize_urls leaves external URLs alone" do + with_default_url_host("fizzy.example.com") do + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) + + html = %(

    link

    ) + result = record_set.send(:relativize_urls, html) + + assert_includes result, "https://github.com/some/repo" + end + end + + test "relativize_urls is a no-op when host is not configured" do + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) + + html = %(

    link

    ) + result = record_set.send(:relativize_urls, html) + + assert_includes result, "https://fizzy.example.com/123/cards/42" + end + + private + def with_default_url_host(host) + original = Rails.application.routes.default_url_options[:host] + Rails.application.routes.default_url_options[:host] = host + yield + ensure + Rails.application.routes.default_url_options[:host] = original + end end From 66fdcae9603e5ede40401b1449dfeb3e41ba4149 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 24 Feb 2026 08:28:52 +0100 Subject: [PATCH 082/237] Fix CI failure with no host set --- .../action_text/rich_text_record_set_test.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb index 3fed8126d..05d4e0e6c 100644 --- a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb +++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb @@ -154,12 +154,14 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport:: end test "relativize_urls is a no-op when host is not configured" do - record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) + with_default_url_host(nil) do + record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(accounts(:"37s")) - html = %(

    link

    ) - result = record_set.send(:relativize_urls, html) + html = %(

    link

    ) + result = record_set.send(:relativize_urls, html) - assert_includes result, "https://fizzy.example.com/123/cards/42" + assert_includes result, "https://fizzy.example.com/123/cards/42" + end end private From 0648583289c77df784cbd4ef0f9a5526d0261d96 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 24 Feb 2026 08:35:11 +0100 Subject: [PATCH 083/237] Fix other test failures caused by default host manipulation --- .../action_text/rich_text_record_set_test.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb index 05d4e0e6c..a28935a3e 100644 --- a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb +++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb @@ -166,10 +166,16 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport:: private def with_default_url_host(host) - original = Rails.application.routes.default_url_options[:host] - Rails.application.routes.default_url_options[:host] = host + options = Rails.application.routes.default_url_options + had_key = options.key?(:host) + original = options[:host] + options[:host] = host yield ensure - Rails.application.routes.default_url_options[:host] = original + if had_key + options[:host] = original + else + options.delete(:host) + end end end From b76f608c6778564a80ec188b79687092e5dd3e4c Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Tue, 24 Feb 2026 11:22:20 +0100 Subject: [PATCH 084/237] Simplify Remove shadows and backgrounds --- app/assets/stylesheets/native.css | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index c48b5d621..521ca00ff 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -78,7 +78,18 @@ .card-perma--draft { .card-perma__bg { - box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container); + padding-inline: 0; + padding-block-start: 0; + background-color: unset; + } + + .card { + background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color)); + box-shadow: 0 101vh 0 100vh var(--card-bg-color); + } + + .card__board { + border-radius: 0 var(--border-radius) var(--border-radius) 0; } } From db9f67589ce6372abdbcc1c87d3b4d0905d98ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 24 Feb 2026 11:25:07 +0100 Subject: [PATCH 085/237] Implement `ClientConfigurationsController` for mobile client path config. --- .../client_configurations_controller.rb | 15 ++++++ .../client_configurations_controller_test.rb | 47 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 app/controllers/client_configurations_controller.rb create mode 100644 test/controllers/client_configurations_controller_test.rb diff --git a/app/controllers/client_configurations_controller.rb b/app/controllers/client_configurations_controller.rb new file mode 100644 index 000000000..0b0f2f670 --- /dev/null +++ b/app/controllers/client_configurations_controller.rb @@ -0,0 +1,15 @@ +class ClientConfigurationsController < ApplicationController + skip_before_action :require_account, :require_authentication + allow_unauthorized_access + + def show + expires_in 1.minute, public: true + + render action: client_configuration_name + end + + private + def client_configuration_name + "#{params.require(:platform)}_v#{params.require(:version)}" + end +end diff --git a/test/controllers/client_configurations_controller_test.rb b/test/controllers/client_configurations_controller_test.rb new file mode 100644 index 000000000..b2aa00e6d --- /dev/null +++ b/test/controllers/client_configurations_controller_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class ClientConfigurationsControllerTest < ActionDispatch::IntegrationTest + test "android" do + assert_ok "/client_configurations/android_v1.json" + end + + test "ios" do + assert_ok "/client_configurations/ios_v1.json" + end + + test "bad platform" do + assert_no_route "/client_configurations/blackberry_v1.json" + end + + test "bad version" do + assert_no_route "/client_configurations/android_va.json" + end + + test "nonexistent version" do + assert_missing "/client_configurations/android_v2000.json" + assert_missing "/client_configurations/ios_v2000.json" + end + + private + def assert_ok(url, cache_control: { public: true, max_age: "60" }) + get url + assert_response :ok + + assert_kind_of Hash, response.parsed_body["settings"] + assert_kind_of Array, response.parsed_body["rules"] + + assert_equal cache_control, response.cache_control + end + + def assert_no_route(url) + without_action_dispatch_exception_handling do + assert_raises(ActionController::RoutingError) { get url } + end + end + + def assert_missing(url) + without_action_dispatch_exception_handling do + assert_raises(ActionView::MissingTemplate) { get url } + end + end +end From de3c8c46b5e825020f458f0bd378848b217caeaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 24 Feb 2026 11:25:43 +0100 Subject: [PATCH 086/237] Add constrained route. --- config/routes.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/routes.rb b/config/routes.rb index 979d2a86b..0ca383dda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -245,6 +245,10 @@ Rails.application.routes.draw do get "manifest" => "rails/pwa#manifest", as: :pwa_manifest get "service-worker" => "pwa#service_worker" + # Mobile clients + get "client_configurations/(:platform)_v(:version)" => "client_configurations#show", + platform: /android|ios/, version: /\d+/ + namespace :admin do mount MissionControl::Jobs::Engine, at: "/jobs" end From e00a51b6c234fdbd7156261a903ed66a815a4fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 24 Feb 2026 11:26:00 +0100 Subject: [PATCH 087/237] Add android path config. --- .../client_configurations/android_v1.json | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/views/client_configurations/android_v1.json diff --git a/app/views/client_configurations/android_v1.json b/app/views/client_configurations/android_v1.json new file mode 100644 index 000000000..706d54b6f --- /dev/null +++ b/app/views/client_configurations/android_v1.json @@ -0,0 +1,90 @@ +{ + "settings": {}, + "rules": [ + { + "patterns": [ + ".*" + ], + "properties": { + "context": "default", + "presentation": "default", + "query_string_presentation": "replace", + "uri": "hotwire://fragment/web", + "fallback_uri": "hotwire://fragment/web", + "pull_to_refresh_enabled": true + } + }, + { + "patterns": [ + "/new$", + "/new\\?.+$", + "/edit$", + "/edit\\?.+$", + "/cards/[0-9]+/draft", + "/notifications/settings", + "/account/settings", + "/account/join_code" + ], + "properties": { + "context": "modal", + "pull_to_refresh_enabled": false + } + }, + { + "patterns": [ + "/native/login/blank" + ], + "properties": { + "uri": "hotwire://fragment/login/blank" + } + }, + { + "patterns": [ + "/native/login/email" + ], + "properties": { + "uri": "hotwire://fragment/login/email" + } + }, + { + "patterns": [ + "/native/login/magic_code" + ], + "properties": { + "uri": "hotwire://fragment/login/magic_code" + } + }, + { + "patterns": [ + "/native/login/signup_completion" + ], + "properties": { + "uri": "hotwire://fragment/login/signup_completion" + } + }, + { + "patterns": [ + "/native/settings" + ], + "properties": { + "uri": "hotwire://fragment/settings" + } + }, + { + "patterns": [ + "/my/pins" + ], + "properties": { + "uri": "hotwire://fragment/pins" + } + }, + { + "patterns": [ + "/notifications$" + ], + "properties": { + "uri": "hotwire://fragment/notifications" + } + } + ] +} \ No newline at end of file From c0c78152c9a27e031feda4ccdbfdb3fbc5487f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 24 Feb 2026 11:26:07 +0100 Subject: [PATCH 088/237] Add ios path config. --- app/views/client_configurations/ios_v1.json | 89 +++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 app/views/client_configurations/ios_v1.json diff --git a/app/views/client_configurations/ios_v1.json b/app/views/client_configurations/ios_v1.json new file mode 100644 index 000000000..2285e9bc7 --- /dev/null +++ b/app/views/client_configurations/ios_v1.json @@ -0,0 +1,89 @@ +{ + "settings": { + }, + "rules": [ + { + "patterns": [ + ".*" + ], + "properties": { + "context": "default", + "query_string_presentation": "replace", + "pull_to_refresh_enabled": true + } + }, + { + "patterns": [ + "/new$", + "/new\\?.+$", + "/edit$", + "/edit\\?.+$", + "/accounts$", + "/cards/[0-9]+/draft" + ], + "properties": { + "context": "modal", + "pull_to_refresh_enabled": false + } + }, + { + "patterns": [ + "/native/my_menu$" + ], + "properties": { + "context": "modal", + "view_controller": "main_menu" + } + }, + { + "patterns": [ + "/native/add_account$" + ], + "properties": { + "context": "modal", + "view_controller": "login" + } + }, + { + "patterns": [ + "/native/login$" + ], + "properties": { + "view_controller": "login" + } + }, + { + "patterns": [ + "/my/pins$" + ], + "properties": { + "view_controller": "pinned" + } + }, + { + "patterns": [ + "/notifications/tray$" + ], + "properties": { + "view_controller": "notifications" + } + }, + { + "patterns": [ + "internal/devtools/enable*" + ], + "properties": { + "context": "modal", + "view_controller": "dev_settings_launcher" + } + }, + { + "patterns": [ + "/native/settings$" + ], + "properties": { + "view_controller": "settings" + } + } + ] +} From bf98b1a6d749822789c94be76dbd3795e09f3bc7 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Tue, 24 Feb 2026 12:44:13 +0100 Subject: [PATCH 089/237] Adjust closed stamp position on perma --- app/assets/stylesheets/card-perma.css | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 8fa21bdfb..3dc16d5a2 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -36,7 +36,11 @@ @media (max-width: 639px) { &:has(.card__closed) { - padding-block-start: 2rem; + padding-block-start: 4.5rem; + + .card-perma__bg { + box-shadow: 0 -2rem 0 0 var(--color-container); + } } } @@ -96,6 +100,7 @@ @media (max-width: 639px) { flex-direction: column; padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5); + position: static; } } @@ -220,8 +225,8 @@ .card__closed { @media (max-width: 639px) { - inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto; - translate: 0 40%; + inset: auto 0 100% auto; + translate: 0 30%; scale: 75%; } } From bd4cfbfd10804dcb35b2cab645e8d585768e438f Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Tue, 24 Feb 2026 15:49:47 +0100 Subject: [PATCH 090/237] Change display on [open] dialog --- app/assets/stylesheets/card-columns.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index e2b576058..363adefcd 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -769,13 +769,16 @@ --panel-padding: 2ch; --panel-size: 100%; - display: flex; flex-wrap: wrap; gap: var(--gap); inset-block-start: 0; justify-content: center; position: absolute; z-index: 1; + + &[open] { + display: flex; + } } /* On Deck (Not Now) From 60b3c9c772595dbd3c8b2178cdd9e2113ee892ef Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 24 Feb 2026 11:58:22 +0100 Subject: [PATCH 091/237] Fix duplicate push notification for mention and comment event Event notifiers used the `mentionees` DB association to exclude mentioned users from comment/card notifications. Since mentions are created async via Mention::CreateJob, a race condition meant the mentionee list could be empty when the event notification job ran first, causing the user to receive both a comment and a mention push notification. Use `scan_mentionees` instead, which scans the rich text body directly for mentioned users without depending on Mention records existing yet. Co-Authored-By: Claude Opus 4.6 --- app/models/concerns/mentions.rb | 8 +++---- app/models/notifier/card_event_notifier.rb | 4 ++-- app/models/notifier/comment_event_notifier.rb | 2 +- test/models/notifier/event_notifier_test.rb | 21 +++++++++++++++++++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb index df8563ba3..5f7dc99ce 100644 --- a/app/models/concerns/mentions.rb +++ b/app/models/concerns/mentions.rb @@ -17,11 +17,11 @@ module Mentions rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ") end - private - def scan_mentionees - mentionees_from_attachments & mentionable_users - end + def scan_mentionees + mentionees_from_attachments & mentionable_users + end + private def mentionees_from_attachments rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact end diff --git a/app/models/notifier/card_event_notifier.rb b/app/models/notifier/card_event_notifier.rb index 6179a4404..11e6ee8a0 100644 --- a/app/models/notifier/card_event_notifier.rb +++ b/app/models/notifier/card_event_notifier.rb @@ -8,9 +8,9 @@ class Notifier::CardEventNotifier < Notifier when "card_assigned" source.assignees.excluding(creator) when "card_published" - board.watchers.without(creator, *card.mentionees).including(*card.assignees).uniq + board.watchers.without(creator, *card.scan_mentionees).including(*card.assignees).uniq when "comment_created" - card.watchers.without(creator, *source.eventable.mentionees) + card.watchers.without(creator, *source.eventable.scan_mentionees) else board.watchers.without(creator) end diff --git a/app/models/notifier/comment_event_notifier.rb b/app/models/notifier/comment_event_notifier.rb index ee342748b..7a108a701 100644 --- a/app/models/notifier/comment_event_notifier.rb +++ b/app/models/notifier/comment_event_notifier.rb @@ -3,7 +3,7 @@ class Notifier::CommentEventNotifier < Notifier private def recipients - card.watchers.without(creator, *source.eventable.mentionees) + card.watchers.without(creator, *source.eventable.scan_mentionees) end def card diff --git a/test/models/notifier/event_notifier_test.rb b/test/models/notifier/event_notifier_test.rb index 6393ddb99..2ded4539e 100644 --- a/test/models/notifier/event_notifier_test.rb +++ b/test/models/notifier/event_notifier_test.rb @@ -93,6 +93,22 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase end end + test "don't create notifications on comment for mentionees even before mention records exist" do + comment = cards(:layout).comments.create!( + body: "Hey #{mention_html_for(users(:kevin))}, what do you think?", + creator: users(:david) + ) + event = boards(:writebook).events.create!( + action: "comment_created", creator: users(:david), eventable: comment + ) + + assert_empty comment.mentionees, "Mention records should not exist yet" + + notifications = Notifier.for(event).notify + + assert_not_includes notifications.map(&:user), users(:kevin) + end + test "assignment events notify assignees regardless of involvement level" do boards(:writebook).access_for(users(:jz)).access_only! @@ -100,4 +116,9 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase assert_equal [ users(:jz) ], notifications.map(&:user) end + + private + def mention_html_for(user) + ActionText::Attachment.from_attachable(user).to_html + end end From 9ded80d448bf0d14ca080e01aab19e04c562c719 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 24 Feb 2026 12:26:12 +0100 Subject: [PATCH 092/237] Add test for notifying new mentionees when editing a comment Co-Authored-By: Claude Opus 4.6 --- test/models/concerns/mentions_test.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/models/concerns/mentions_test.rb b/test/models/concerns/mentions_test.rb index fca379bf1..c26d37632 100644 --- a/test/models/concerns/mentions_test.rb +++ b/test/models/concerns/mentions_test.rb @@ -81,6 +81,18 @@ class MentionsTest < ActiveSupport::TestCase end end + test "notify new mentionees when editing a comment to add a mention" do + card = boards(:writebook).cards.create title: "Fresh card", description: "Some content", status: :published + + perform_enqueued_jobs do + comment = card.comments.create!(body: "Initial thought") + + assert_difference -> { users(:kevin).notifications.count }, +1 do + comment.update!(body: "Actually, #{mention_html_for(users(:kevin))}, what do you think?") + end + end + end + test "mentionees are added as watchers of the card" do perform_enqueued_jobs only: Mention::CreateJob do card = boards(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup #{mention_html_for(users(:kevin))}?" From 6099884511d9fd1fbfb920b3c30e5c0224eb6210 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 24 Feb 2026 15:57:42 +0100 Subject: [PATCH 093/237] Remove jump menu input autofocus on touch screens --- app/helpers/my/menu_helper.rb | 1 + app/views/my/menus/_jump.html.erb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/helpers/my/menu_helper.rb b/app/helpers/my/menu_helper.rb index 753bad6a2..7cad2a25f 100644 --- a/app/helpers/my/menu_helper.rb +++ b/app/helpers/my/menu_helper.rb @@ -11,6 +11,7 @@ module My::MenuHelper aria: { activedescendant: "" }, data: { "1p-ignore": "true", + dialog_target: "focusMouse", filter_target: "input", nav_section_expander_target: "input", navigable_list_target: "input", diff --git a/app/views/my/menus/_jump.html.erb b/app/views/my/menus/_jump.html.erb index f22bedf23..cc1000770 100644 --- a/app/views/my/menus/_jump.html.erb +++ b/app/views/my/menus/_jump.html.erb @@ -1,6 +1,6 @@
    -
    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 106/237] 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 107/237] 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 108/237] 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 109/237] 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 110/237] 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 111/237] 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 112/237] 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 113/237] 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 114/237] 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 115/237] 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 116/237] 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 117/237] 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 118/237] 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 119/237] 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 120/237] 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 121/237] 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 122/237] 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 123/237] 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 124/237] 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 125/237] 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 126/237] 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 127/237] 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 128/237] 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 129/237] 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 130/237] 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 131/237] 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 132/237] 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 133/237] 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 134/237] 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 135/237] 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 136/237] 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 137/237] 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 138/237] 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 139/237] 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 140/237] 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 141/237] 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 142/237] 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 143/237] 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 144/237] 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 145/237] 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 146/237] 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 147/237] 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 148/237] 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 91aa806b722a5c154ffc04f4823c9c191177554d Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Feb 2026 14:40:31 -0500 Subject: [PATCH 149/237] Don't show the bridged button to the board when editing a card --- app/views/cards/container/_closure_buttons.html.erb | 2 ++ app/views/cards/show.html.erb | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 990a17982..9a73b287d 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -13,4 +13,6 @@ Mark as Done d <% end %> + + <%= bridged_button_to_board(@card.board) %>

diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index 1ad92dd59..fabd2ad02 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -9,7 +9,6 @@
<%= link_back_to_board(@card.board) %>
- <%= bridged_button_to_board(@card.board) %> <% end %> <%= turbo_stream_from @card %> From 6ceb5ef7c32ef1c42d22b89048b32425fa22a725 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Feb 2026 15:32:00 -0500 Subject: [PATCH 150/237] Notify the form/buttons components of a disconnect before a page reload. This gives the native components an opportunity to reset their state before the fresh page loads. --- .../controllers/bridge/buttons_controller.js | 16 ++++++++++++++++ .../controllers/bridge/form_controller.js | 16 ++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/bridge/buttons_controller.js b/app/javascript/controllers/bridge/buttons_controller.js index 47d09a158..7cbf73501 100644 --- a/app/javascript/controllers/bridge/buttons_controller.js +++ b/app/javascript/controllers/bridge/buttons_controller.js @@ -5,6 +5,14 @@ export default class extends BridgeComponent { static component = "buttons" static targets = [ "button" ] + connect() { + window.addEventListener("beforeunload", this.handleBeforeUnload.bind(this)) + } + + disconnect() { + window.removeEventListener("beforeunload", this.handleBeforeUnload.bind(this)) + } + buttonTargetConnected() { this.notifyBridgeOfConnect() } @@ -27,6 +35,14 @@ export default class extends BridgeComponent { }) } + notifyBridgeOfDisconnect() { + this.send("disconnect") + } + + handleBeforeUnload() { + this.notifyBridgeOfDisconnect() + } + #clickButton(message) { const selectedIndex = message.data.selectedIndex this.#enabledButtonTargets[selectedIndex].click() diff --git a/app/javascript/controllers/bridge/form_controller.js b/app/javascript/controllers/bridge/form_controller.js index a3c780e62..e2d3f929c 100644 --- a/app/javascript/controllers/bridge/form_controller.js +++ b/app/javascript/controllers/bridge/form_controller.js @@ -6,13 +6,21 @@ export default class extends BridgeComponent { static targets = [ "submit", "cancel" ] static values = { submitTitle: String } + connect() { + window.addEventListener("beforeunload", this.handleBeforeUnload.bind(this)) + } + + disconnect() { + window.removeEventListener("beforeunload", this.handleBeforeUnload.bind(this)) + } + submitTargetConnected() { this.notifyBridgeOfConnect() this.#observeSubmitTarget() } submitTargetDisconnected() { - this.notifyBridgeOfDisonnect() + this.notifyBridgeOfDisconnect() this.submitObserver?.disconnect() } @@ -37,7 +45,7 @@ export default class extends BridgeComponent { } } - notifyBridgeOfDisonnect() { + notifyBridgeOfDisconnect() { this.send("disconnect") } @@ -49,6 +57,10 @@ export default class extends BridgeComponent { this.send("submitEnd") } + handleBeforeUnload() { + this.notifyBridgeOfDisconnect() + } + #observeSubmitTarget() { this.submitObserver = new MutationObserver(() => { this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled") From 2095848d321ada730f036c4c848c1594314ad6c6 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 25 Feb 2026 15:40:37 -0500 Subject: [PATCH 151/237] Remove the ability to add/remove background images for now in the apps, since it's buggy --- app/views/cards/container/_image.html.erb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/cards/container/_image.html.erb b/app/views/cards/container/_image.html.erb index e044b9b0c..c8892f0d4 100644 --- a/app/views/cards/container/_image.html.erb +++ b/app/views/cards/container/_image.html.erb @@ -1,12 +1,12 @@ <% if card.image.attached? %> - <%= button_to card_image_path(card), method: :delete, class: "btn", - data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Remove background image" } do %> + <%= button_to card_image_path(card), method: :delete, class: "btn hide-on-native", + data: { controller: "tooltip" } do %> <%= icon_tag "picture-remove" %> Remove background image <% end %> <% elsif !card.closed? %> <%= form_with model: card, data: { controller: "form" } do |form| %> -