diff --git a/Gemfile.lock b/Gemfile.lock
index ea92feb395..d4b2d491ed 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -118,8 +118,8 @@ GEM
specs:
action_text-trix (2.1.16)
railties
- addressable (2.8.7)
- public_suffix (>= 2.0.2, < 7.0)
+ addressable (2.8.8)
+ public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
@@ -327,7 +327,7 @@ GEM
psych (5.3.1)
date
stringio
- public_suffix (6.0.2)
+ public_suffix (7.0.2)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
@@ -351,7 +351,7 @@ GEM
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rainbow (3.1.1)
rake (13.3.1)
- rdoc (7.0.3)
+ rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
diff --git a/Gemfile.saas b/Gemfile.saas
index 05e6bbabdf..88eb2b4248 100644
--- a/Gemfile.saas
+++ b/Gemfile.saas
@@ -10,6 +10,9 @@ gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984"
+# Native push notifications (iOS/Android)
+gem "action_push_native"
+
# Telemetry
gem "rails_structured_logging", bc: "rails-structured-logging"
gem "sentry-ruby"
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 6dcb7b9ee3..272349d33a 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -184,6 +184,13 @@ PATH
GEM
remote: https://rubygems.org/
specs:
+ action_push_native (0.3.1)
+ activejob (>= 8.0)
+ activerecord (>= 8.0)
+ googleauth (~> 1.14)
+ httpx (~> 1.6)
+ jwt (>= 2)
+ railties (>= 8.0)
action_text-trix (2.1.16)
railties
activemodel-serializers-xml (1.0.3)
@@ -194,8 +201,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)
@@ -265,6 +272,12 @@ GEM
tzinfo
faker (3.5.3)
i18n (>= 1.8.11, < 2)
+ faraday (2.14.0)
+ faraday-net_http (>= 2.0, < 3.5)
+ json
+ logger
+ faraday-net_http (3.4.2)
+ net-http (~> 0.5)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
@@ -280,7 +293,22 @@ GEM
addressable (>= 2.5.0)
globalid (1.3.0)
activesupport (>= 6.1)
+ google-cloud-env (2.3.1)
+ base64 (~> 0.2)
+ faraday (>= 1.0, < 3.a)
+ google-logging-utils (0.2.0)
+ googleauth (1.16.1)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-env (~> 2.2)
+ google-logging-utils (~> 0.1)
+ jwt (>= 1.4, < 4.0)
+ multi_json (~> 1.11)
+ os (>= 0.9, < 2.0)
+ signet (>= 0.16, < 2.a)
hashdiff (1.2.1)
+ http-2 (1.1.1)
+ httpx (1.7.1)
+ http-2 (>= 1.0.0)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
@@ -352,6 +380,9 @@ GEM
mocha (3.0.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
+ multi_json (1.19.1)
+ net-http (0.9.1)
+ uri (>= 0.11.1)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
@@ -384,6 +415,7 @@ GEM
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
openssl (4.0.0)
+ os (1.1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -433,7 +465,7 @@ GEM
psych (5.3.1)
date
stringio
- public_suffix (6.0.2)
+ public_suffix (7.0.2)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
@@ -460,7 +492,7 @@ GEM
rake-compiler-dock (1.9.1)
rb_sys (0.9.117)
rake-compiler-dock (= 1.9.1)
- rdoc (7.0.3)
+ rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
@@ -523,6 +555,11 @@ GEM
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
+ signet (0.21.0)
+ addressable (~> 2.8)
+ faraday (>= 0.17.5, < 3.a)
+ jwt (>= 1.5, < 4.0)
+ multi_json (~> 1.10)
sniffer (0.5.0)
anyway_config (>= 1.0)
dry-initializer (~> 3)
@@ -639,6 +676,7 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
+ action_push_native
activeresource
audits1984!
autotuner
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 8ae4346ac3..d806853495 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -1,13 +1,6 @@
-require "zlib"
-
module AvatarsHelper
- AVATAR_COLORS = %w[
- #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
- #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
- ]
-
def avatar_background_color(user)
- AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]
+ user.avatar_background_color
end
def avatar_tag(user, hidden_for_screen_reader: false, **options)
diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js
index 02fce86d7e..cbd117b345 100644
--- a/app/javascript/controllers/notifications_controller.js
+++ b/app/javascript/controllers/notifications_controller.js
@@ -20,6 +20,9 @@ export default class extends Controller {
if (registration && subscription) {
this.element.classList.add(this.enabledClass)
+ } else {
+ this.subscribeButtonTarget.hidden = false
+ this.explainerTarget.hidden = true
}
break
}
@@ -54,8 +57,9 @@ export default class extends Controller {
return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" })
}
- #registerServiceWorker() {
- return navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
+ async #registerServiceWorker() {
+ await navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
+ return navigator.serviceWorker.ready
}
async #subscribe(registration) {
diff --git a/app/jobs/notification/push_job.rb b/app/jobs/notification/push_job.rb
new file mode 100644
index 0000000000..233762b372
--- /dev/null
+++ b/app/jobs/notification/push_job.rb
@@ -0,0 +1,5 @@
+class Notification::PushJob < ApplicationJob
+ def perform(notification)
+ notification.push
+ end
+end
diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb
deleted file mode 100644
index c912e141d8..0000000000
--- a/app/jobs/push_notification_job.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class PushNotificationJob < ApplicationJob
- discard_on ActiveJob::DeserializationError
-
- def perform(notification)
- NotificationPusher.new(notification).push
- end
-end
diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb
deleted file mode 100644
index e497bc3fe7..0000000000
--- a/app/models/concerns/push_notifiable.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-module PushNotifiable
- extend ActiveSupport::Concern
-
- included do
- after_create_commit :push_notification_later
- end
-
- private
- def push_notification_later
- PushNotificationJob.perform_later(self)
- end
-end
diff --git a/app/models/event.rb b/app/models/event.rb
index bbbf2a60c6..6ba11a28d3 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -37,6 +37,10 @@ def description_for(user)
Event::Description.new(self, user)
end
+ def high_priority_push?
+ action.card_assigned?
+ end
+
private
def dispatch_webhooks
Event::WebhookDispatchJob.perform_later(self)
diff --git a/app/models/mention.rb b/app/models/mention.rb
index 5491bfd9fe..66a02428a3 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -18,6 +18,10 @@ def notifiable_target
source
end
+ def high_priority_push?
+ true
+ end
+
private
def watch_source_by_mentionee
source.watch_by(mentionee)
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 545f6ab75f..48a66b3e03 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -1,5 +1,5 @@
class Notification < ApplicationRecord
- include PushNotifiable
+ include Notification::Pushable
belongs_to :account, default: -> { user.account }
belongs_to :user
@@ -18,6 +18,7 @@ class Notification < ApplicationRecord
delegate :notifiable_target, to: :source
delegate :card, to: :source
+ delegate :identity, to: :user
def self.read_all
all.each { |notification| notification.read }
diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb
new file mode 100644
index 0000000000..65ca068e26
--- /dev/null
+++ b/app/models/notification/default_payload.rb
@@ -0,0 +1,53 @@
+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
+
+ def title
+ "New notification"
+ end
+
+ def body
+ "You have a new notification"
+ end
+
+ def url
+ notifications_url
+ end
+
+ def category
+ "default"
+ end
+
+ def high_priority?
+ false
+ end
+
+ def avatar_url
+ Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options)
+ end
+
+ private
+ def card_url(card)
+ Rails.application.routes.url_helpers.card_url(card, **url_options)
+ end
+
+ def notifications_url
+ Rails.application.routes.url_helpers.notifications_url(**url_options)
+ end
+
+ def url_options
+ base_options = Rails.application.routes.default_url_options.presence ||
+ Rails.application.config.action_mailer.default_url_options ||
+ {}
+ base_options.merge(script_name: notification.account.slug)
+ end
+end
diff --git a/app/models/notification/event_payload.rb b/app/models/notification/event_payload.rb
new file mode 100644
index 0000000000..fba3a6cb26
--- /dev/null
+++ b/app/models/notification/event_payload.rb
@@ -0,0 +1,67 @@
+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
+
+ 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
+ end
+
+ def card_title
+ card.title.presence || "Card #{card.number}"
+ end
+
+ def card_url_with_comment_anchor(comment)
+ Rails.application.routes.url_helpers.card_url(
+ comment.card,
+ anchor: ActionView::RecordIdentifier.dom_id(comment),
+ **url_options
+ )
+ end
+end
diff --git a/app/models/notification/mention_payload.rb b/app/models/notification/mention_payload.rb
new file mode 100644
index 0000000000..480771c153
--- /dev/null
+++ b/app/models/notification/mention_payload.rb
@@ -0,0 +1,28 @@
+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
+
+ def category
+ "mention"
+ end
+
+ def high_priority?
+ true
+ end
+
+ private
+ def mention
+ notification.source
+ end
+end
diff --git a/app/models/notification/push_target.rb b/app/models/notification/push_target.rb
new file mode 100644
index 0000000000..1fc2502592
--- /dev/null
+++ b/app/models/notification/push_target.rb
@@ -0,0 +1,17 @@
+class Notification::PushTarget
+ attr_reader :notification
+
+ delegate :card, to: :notification
+
+ def self.process(notification)
+ new(notification).process
+ end
+
+ def initialize(notification)
+ @notification = notification
+ end
+
+ def process
+ raise NotImplementedError
+ end
+end
diff --git a/app/models/notification/push_target/web.rb b/app/models/notification/push_target/web.rb
new file mode 100644
index 0000000000..9bf8399063
--- /dev/null
+++ b/app/models/notification/push_target/web.rb
@@ -0,0 +1,12 @@
+class Notification::PushTarget::Web < Notification::PushTarget
+ 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
+end
diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb
new file mode 100644
index 0000000000..ecc0138470
--- /dev/null
+++ b/app/models/notification/pushable.rb
@@ -0,0 +1,51 @@
+module Notification::Pushable
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :push_targets, default: []
+ after_create_commit :push_later
+ end
+
+ class_methods do
+ def register_push_target(target)
+ target = resolve_push_target(target)
+ push_targets << target unless push_targets.include?(target)
+ end
+
+ private
+ def resolve_push_target(target)
+ if target.is_a?(Symbol)
+ "Notification::PushTarget::#{target.to_s.classify}".constantize
+ else
+ target
+ end
+ end
+ end
+
+ def push_later
+ Notification::PushJob.perform_later(self)
+ end
+
+ def push
+ return unless pushable?
+
+ self.class.push_targets.each { |target| push_to(target) }
+ end
+
+ def payload
+ "Notification::#{payload_type}Payload".constantize.new(self)
+ end
+
+ private
+ def pushable?
+ !creator.system? && user.active? && account.active?
+ end
+
+ def push_to(target)
+ target.process(self)
+ end
+
+ def payload_type
+ source_type.presence_in(%w[ Event Mention ]) || "Default"
+ end
+end
diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb
deleted file mode 100644
index d6425e561a..0000000000
--- a/app/models/notification_pusher.rb
+++ /dev/null
@@ -1,124 +0,0 @@
-class NotificationPusher
- include Rails.application.routes.url_helpers
- include ExcerptHelper
-
- attr_reader :notification
-
- def initialize(notification)
- @notification = notification
- end
-
- def push
- return unless should_push?
-
- build_payload.tap do |payload|
- push_to_user(payload)
- end
- end
-
- private
- def should_push?
- notification.user.push_subscriptions.any? &&
- !notification.creator.system? &&
- notification.user.active? &&
- notification.account.active?
- end
-
- def build_payload
- case notification.source_type
- when "Event"
- build_event_payload
- when "Mention"
- build_mention_payload
- else
- build_default_payload
- end
- end
-
- def build_event_payload
- event = notification.source
- card = event.card
-
- base_payload = {
- title: card_notification_title(card),
- path: card_path(card)
- }
-
- case event.action
- when "comment_created"
- base_payload.merge(
- title: "RE: #{base_payload[:title]}",
- body: comment_notification_body(event),
- path: card_path_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
- card = mention.card
-
- {
- title: "#{mention.mentioner.first_name} mentioned you",
- body: format_excerpt(mention.source.mentionable_content, length: 200),
- path: card_path(card)
- }
- end
-
- def build_default_payload
- {
- title: "New notification",
- body: "You have a new notification",
- path: notifications_path(script_name: notification.account.slug)
- }
- end
-
- def push_to_user(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
-
- def comment_notification_body(event)
- format_excerpt(event.eventable.body, length: 200)
- end
-
- def card_path(card)
- Rails.application.routes.url_helpers.card_path(card, script_name: notification.account.slug)
- end
-
- def card_path_with_comment_anchor(comment)
- Rails.application.routes.url_helpers.card_path(
- comment.card,
- anchor: ActionView::RecordIdentifier.dom_id(comment),
- script_name: notification.account.slug
- )
- end
-end
diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb
index c62d87e09f..970104d6f4 100644
--- a/app/models/user/avatar.rb
+++ b/app/models/user/avatar.rb
@@ -1,8 +1,14 @@
+require "zlib"
+
module User::Avatar
extend ActiveSupport::Concern
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
+ AVATAR_COLORS = %w[
+ #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
+ #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
+ ].freeze
included do
has_one_attached :avatar do |attachable|
@@ -22,6 +28,10 @@ def avatar_thumbnail
avatar.variable? ? avatar.variant(:thumb) : avatar
end
+ def avatar_background_color
+ AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size]
+ end
+
# Avatars are always publicly accessible
def publicly_accessible?
true
diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb
index 2a37b3092d..ab23e505cc 100644
--- a/app/views/notifications/settings/show.html.erb
+++ b/app/views/notifications/settings/show.html.erb
@@ -14,6 +14,7 @@
<%= render "notifications/settings/push_notifications" %>
+ <%= render "notifications/settings/native_devices" if Fizzy.saas? %>
<%= render "notifications/settings/email", settings: @settings %>
diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js
index df284d5780..292d089918 100644
--- a/app/views/pwa/service_worker.js
+++ b/app/views/pwa/service_worker.js
@@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) {
self.addEventListener("notificationclick", (event) => {
event.notification.close()
- const url = new URL(event.notification.data.path, self.location.origin).href
+ const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
diff --git a/bin/dev b/bin/dev
index 05032e7c36..cd8101659f 100755
--- a/bin/dev
+++ b/bin/dev
@@ -2,13 +2,31 @@
PORT=3006
USE_TAILSCALE=0
+USE_APNS=0
for arg in "$@"; do
case $arg in
--tailscale) USE_TAILSCALE=1 ;;
+ --apns) USE_APNS=1 ;;
esac
done
+if [ "$USE_APNS" = "1" ]; then
+ if [ ! -f tmp/saas.txt ]; then
+ echo "Enabling SaaS mode for APNs..."
+ ./bin/rails saas:enable
+ fi
+ echo "Loading APNs credentials from 1Password..."
+ if ! eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec apns-dev)"; then
+ echo "Error: failed to load APNs credentials. Are you signed into 1Password?" >&2
+ exit 1
+ fi
+ if [ -z "$APNS_ENCRYPTION_KEY" ] || [ -z "$APNS_KEY_ID" ]; then
+ echo "Error: APNs credentials not set. Missing APNS_ENCRYPTION_KEY or APNS_KEY_ID." >&2
+ exit 1
+ fi
+fi
+
if [ ! -f tmp/solid-queue.txt ]; then
export SOLID_QUEUE_IN_PUMA=false
fi
diff --git a/config/initializers/push_notifications.rb b/config/initializers/push_notifications.rb
new file mode 100644
index 0000000000..fa82a46a71
--- /dev/null
+++ b/config/initializers/push_notifications.rb
@@ -0,0 +1,3 @@
+Rails.application.config.to_prepare do
+ Notification.register_push_target(:web)
+end
diff --git a/db/queue_schema.rb b/db/queue_schema.rb
index 84bc6b8a6f..c4713f8d13 100644
--- a/db/queue_schema.rb
+++ b/db/queue_schema.rb
@@ -132,7 +132,6 @@
t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
end
- # If these FKs are removed, make sure to periodically run `RecurringExecution.clear_in_batches`
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb
index 4c873fb48f..c01caa9535 100644
--- a/lib/web_push/notification.rb
+++ b/lib/web_push/notification.rb
@@ -1,6 +1,6 @@
class WebPush::Notification
- def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:)
- @title, @body, @path, @badge = title, body, path, badge
+ def initialize(title:, body:, url:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:)
+ @title, @body, @url, @badge = title, body, url, badge
@endpoint, @endpoint_ip, @p256dh_key, @auth_key = endpoint, endpoint_ip, p256dh_key, auth_key
end
@@ -20,7 +20,7 @@ def vapid_identification
end
def encoded_message
- JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } }
+ JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { url: @url, badge: @badge } }
end
def icon_path
diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta
index 423ef11fb5..b180f2ff62 100644
--- a/saas/.kamal/secrets.beta
+++ b/saas/.kamal/secrets.beta
@@ -1,4 +1,4 @@
-SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
+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,3 +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)
+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 46d7abfbcb..27b99b9f1c 100644
--- a/saas/.kamal/secrets.production
+++ b/saas/.kamal/secrets.production
@@ -1,4 +1,4 @@
-SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
+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,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_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS)
+APNS_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS)
+APNS_TEAM_ID=$(kamal secrets extract APNS_TEAM_ID $SECRETS)
+FCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS)
diff --git a/saas/app/controllers/devices_controller.rb b/saas/app/controllers/devices_controller.rb
new file mode 100644
index 0000000000..e6b6b1d197
--- /dev/null
+++ b/saas/app/controllers/devices_controller.rb
@@ -0,0 +1,31 @@
+class DevicesController < ApplicationController
+ disallow_account_scope
+ before_action :set_device, only: :destroy
+
+ def index
+ @devices = Current.identity.devices.order(created_at: :desc)
+ end
+
+ def create
+ ApplicationPushDevice.register(session: Current.session, **device_params)
+ head :created
+ end
+
+ def destroy
+ @device.destroy
+ respond_to do |format|
+ format.html { redirect_to saas.devices_path, notice: "Device removed" }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ def set_device
+ @device = Current.identity.devices.find_by(token: params[:id]) || Current.identity.devices.find(params[:id])
+ end
+
+ def device_params
+ params.require([ :token, :platform ])
+ params.permit(:token, :platform, :name).to_h.symbolize_keys
+ end
+end
diff --git a/saas/app/jobs/application_push_notification_job.rb b/saas/app/jobs/application_push_notification_job.rb
new file mode 100644
index 0000000000..5db7811c20
--- /dev/null
+++ b/saas/app/jobs/application_push_notification_job.rb
@@ -0,0 +1,2 @@
+class ApplicationPushNotificationJob < ActionPushNative::NotificationJob
+end
diff --git a/saas/app/models/application_push_device.rb b/saas/app/models/application_push_device.rb
new file mode 100644
index 0000000000..7d9aad4e7f
--- /dev/null
+++ b/saas/app/models/application_push_device.rb
@@ -0,0 +1,9 @@
+class ApplicationPushDevice < ActionPushNative::Device
+ 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/application_push_notification.rb b/saas/app/models/application_push_notification.rb
new file mode 100644
index 0000000000..a0b5e3ee59
--- /dev/null
+++ b/saas/app/models/application_push_notification.rb
@@ -0,0 +1,4 @@
+class ApplicationPushNotification < ActionPushNative::Notification
+ queue_as :default
+ self.enabled = Fizzy.saas? && (!Rails.env.local? || ENV["ENABLE_NATIVE_PUSH"] == "true")
+end
diff --git a/saas/app/models/identity/devices.rb b/saas/app/models/identity/devices.rb
new file mode 100644
index 0000000000..ce7eec457e
--- /dev/null
+++ b/saas/app/models/identity/devices.rb
@@ -0,0 +1,7 @@
+module Identity::Devices
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :devices, class_name: "ApplicationPushDevice", as: :owner, dependent: :destroy
+ end
+end
diff --git a/saas/app/models/notification/push_target/native.rb b/saas/app/models/notification/push_target/native.rb
new file mode 100644
index 0000000000..86212176f8
--- /dev/null
+++ b/saas/app/models/notification/push_target/native.rb
@@ -0,0 +1,57 @@
+class Notification::PushTarget::Native < Notification::PushTarget
+ def process
+ if devices.any?
+ native_notification.deliver_later_to(devices)
+ end
+ end
+
+ private
+ def devices
+ @devices ||= notification.identity.devices
+ end
+
+ def payload
+ @payload ||= notification.payload
+ end
+
+ def native_notification
+ ApplicationPushNotification
+ .with_apple(
+ aps: {
+ category: payload.category,
+ "mutable-content": 1,
+ "interruption-level": interruption_level
+ }
+ )
+ .with_google(
+ android: { notification: nil }
+ )
+ .with_data(
+ title: payload.title,
+ body: payload.body,
+ url: payload.url,
+ account_id: notification.account.external_account_id,
+ avatar_url: payload.avatar_url,
+ card_id: card&.id,
+ 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
+ )
+ .new(
+ title: payload.title,
+ body: payload.body,
+ badge: notification.user.notifications.unread.count,
+ sound: "default",
+ thread_id: card&.id,
+ high_priority: payload.high_priority?
+ )
+ end
+
+ def interruption_level
+ payload.high_priority? ? "time-sensitive" : "active"
+ end
+end
diff --git a/saas/app/models/session/devices.rb b/saas/app/models/session/devices.rb
new file mode 100644
index 0000000000..c159ada723
--- /dev/null
+++ b/saas/app/models/session/devices.rb
@@ -0,0 +1,7 @@
+module Session::Devices
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :devices, class_name: "ApplicationPushDevice", dependent: :destroy
+ end
+end
diff --git a/saas/app/views/devices/index.html.erb b/saas/app/views/devices/index.html.erb
new file mode 100644
index 0000000000..9e467731a1
--- /dev/null
+++ b/saas/app/views/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", saas.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/app/views/notifications/settings/_native_devices.html.erb b/saas/app/views/notifications/settings/_native_devices.html.erb
new file mode 100644
index 0000000000..ea9e9987d1
--- /dev/null
+++ b/saas/app/views/notifications/settings/_native_devices.html.erb
@@ -0,0 +1,14 @@
+
+
Mobile Devices
+
+ <% if Current.identity.devices.any? %>
+
+ You have <%= pluralize(Current.identity.devices.count, "mobile device") %> registered for push notifications.
+
+ <%= 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.
+
+ <% end %>
+
diff --git a/saas/config/push.yml b/saas/config/push.yml
new file mode 100644
index 0000000000..4d4e9e236a
--- /dev/null
+++ b/saas/config/push.yml
@@ -0,0 +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 %>
+ 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 %>
+ project_id: fizzy-a148c
diff --git a/saas/config/routes.rb b/saas/config/routes.rb
index ece9fef17c..3c5a7fb983 100644
--- a/saas/config/routes.rb
+++ b/saas/config/routes.rb
@@ -1,6 +1,8 @@
Fizzy::Saas::Engine.routes.draw do
Queenbee.routes(self)
+ resources :devices, only: [ :index, :create, :destroy ]
+
namespace :admin do
mount Audits1984::Engine, at: "/console"
get "stats", to: "stats#show"
diff --git a/saas/db/migrate/20260114203313_create_action_push_native_devices.rb b/saas/db/migrate/20260114203313_create_action_push_native_devices.rb
new file mode 100644
index 0000000000..c696a35bc1
--- /dev/null
+++ b/saas/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 :name
+ 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
+
+ t.timestamps
+ end
+
+ add_index :action_push_native_devices, [ :owner_type, :owner_id, :token ], unique: true
+ end
+end
diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb
index 7e9b80b92d..2a4232d8bb 100644
--- a/saas/db/saas_schema.rb
+++ b/saas/db/saas_schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.2].define(version: 2025_12_16_000000) do
+ActiveRecord::Schema[8.2].define(version: 2026_01_14_203313) do
create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
@@ -43,6 +43,19 @@
t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true
end
+ create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "name"
+ t.uuid "owner_id"
+ t.string "owner_type"
+ t.string "platform", null: false
+ t.uuid "session_id"
+ t.string "token", null: false
+ t.datetime "updated_at", null: false
+ t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true
+ t.index ["session_id"], name: "index_action_push_native_devices_on_session_id"
+ end
+
create_table "audits1984_audits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "auditor_id", null: false
t.datetime "created_at", null: false
diff --git a/saas/exe/apns-dev b/saas/exe/apns-dev
new file mode 100755
index 0000000000..e2ad0147f5
--- /dev/null
+++ b/saas/exe/apns-dev
@@ -0,0 +1,48 @@
+#!/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/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec
index 1368ef63c3..de690f5d60 100644
--- a/saas/fizzy-saas.gemspec
+++ b/saas/fizzy-saas.gemspec
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
end
spec.bindir = "exe"
- spec.executables = [ "stripe-dev" ]
+ spec.executables = [ "apns-dev", "stripe-dev" ]
spec.add_dependency "rails", ">= 8.1.0.beta1"
spec.add_dependency "queenbee"
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb
index 757de6bebc..67a6969166 100644
--- a/saas/lib/fizzy/saas/engine.rb
+++ b/saas/lib/fizzy/saas/engine.rb
@@ -9,6 +9,11 @@ class Engine < ::Rails::Engine
# moved from config/initializers/queenbee.rb
Queenbee.host_app = Fizzy
+ # Configure ActionPushNative to use the saas database
+ ActiveSupport.on_load(:action_push_native_record) do
+ connects_to database: { writing: :saas, reading: :saas }
+ end
+
initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app|
app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com"
end
@@ -17,6 +22,10 @@ class Engine < ::Rails::Engine
app.config.assets.paths << root.join("app/assets/stylesheets")
end
+ 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|
# Routes that rely on the implicit account tenant should go here instead of in +routes.rb+.
app.routes.prepend do
@@ -133,10 +142,16 @@ class Engine < ::Rails::Engine
config.to_prepare do
::Account.include Account::Billing, Account::Limited
- ::Signup.prepend Fizzy::Saas::Signup
+ ::Identity.include Authorization::Identity, Identity::Devices
+ ::Session.include Session::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
@@ -147,9 +162,6 @@ class Engine < ::Rails::Engine
::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name)
::Object.const_set const_name, Subscription.const_get(short_name, false)
end
-
- ::ApplicationController.include Fizzy::Saas::Authorization::Controller
- ::Identity.include Fizzy::Saas::Authorization::Identity
end
end
end
diff --git a/saas/test/controllers/devices_controller_test.rb b/saas/test/controllers/devices_controller_test.rb
new file mode 100644
index 0000000000..b05ca5ed29
--- /dev/null
+++ b/saas/test/controllers/devices_controller_test.rb
@@ -0,0 +1,249 @@
+require "test_helper"
+
+class DevicesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @identity = identities(:david)
+ sign_in_as :david
+ end
+
+ test "index shows identity's devices" do
+ @identity.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro")
+
+ untenanted { get saas.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
+ @identity.devices.delete_all
+
+ untenanted { get saas.devices_path }
+
+ assert_response :success
+ assert_select "p", /No devices registered/
+ end
+
+ test "index requires authentication" do
+ sign_out
+
+ untenanted { get saas.devices_path }
+
+ assert_response :redirect
+ end
+
+ test "creates a new device via api" do
+ token = SecureRandom.hex(32)
+
+ assert_difference -> { ApplicationPushDevice.count }, 1 do
+ untenanted do
+ post saas.devices_path, params: {
+ token: token,
+ platform: "apple",
+ name: "iPhone 15 Pro"
+ }, as: :json
+ end
+ end
+
+ assert_response :created
+
+ device = ApplicationPushDevice.last
+ assert_equal token, device.token
+ assert_equal "apple", device.platform
+ assert_equal "iPhone 15 Pro", device.name
+ assert_equal @identity, device.owner
+ end
+
+ test "creates android device" do
+ untenanted do
+ post saas.devices_path, params: {
+ token: SecureRandom.hex(32),
+ platform: "google",
+ name: "Pixel 8"
+ }, as: :json
+ end
+
+ assert_response :created
+
+ device = ApplicationPushDevice.last
+ assert_equal "google", device.platform
+ end
+
+ test "same token can be registered by multiple identities" do
+ shared_token = "shared_push_token_123"
+ other_identity = identities(:kevin)
+
+ # Other identity registers the token first
+ other_device = other_identity.devices.create!(
+ token: shared_token,
+ platform: "apple",
+ name: "Kevin's iPhone"
+ )
+
+ # Current identity registers the same token with their own device
+ assert_difference -> { ApplicationPushDevice.count }, 1 do
+ untenanted do
+ post saas.devices_path, params: {
+ token: shared_token,
+ platform: "apple",
+ name: "David's iPhone"
+ }, as: :json
+ end
+ end
+
+ assert_response :created
+
+ # Both identities have their own device records
+ assert_equal shared_token, other_device.reload.token
+ assert_equal other_identity, other_device.owner
+
+ davids_device = @identity.devices.last
+ assert_equal shared_token, davids_device.token
+ assert_equal @identity, davids_device.owner
+ end
+
+ test "rejects invalid platform" do
+ 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
+ untenanted do
+ post saas.devices_path, params: {
+ platform: "apple",
+ name: "iPhone"
+ }, as: :json
+ end
+
+ assert_response :bad_request
+ end
+
+ test "create requires authentication" do
+ sign_out
+
+ untenanted do
+ post saas.devices_path, params: {
+ token: SecureRandom.hex(32),
+ platform: "apple"
+ }, as: :json
+ end
+
+ assert_response :redirect
+ end
+
+ test "destroys device by id" do
+ device = @identity.devices.create!(
+ token: "token_to_delete",
+ platform: "apple",
+ name: "iPhone"
+ )
+
+ assert_difference -> { ApplicationPushDevice.count }, -1 do
+ untenanted { delete saas.device_path(device) }
+ end
+
+ 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
+ untenanted { delete saas.device_path(id: "nonexistent") }
+ end
+
+ assert_response :not_found
+ end
+
+ 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 "ApplicationPushDevice.count" do
+ untenanted { delete saas.device_path(device) }
+ end
+
+ assert_response :not_found
+ assert ApplicationPushDevice.exists?(device.id)
+ end
+
+ test "destroy by id requires authentication" do
+ device = @identity.devices.create!(
+ token: "my_token",
+ platform: "apple",
+ name: "iPhone"
+ )
+
+ sign_out
+
+ untenanted { delete saas.device_path(device) }
+
+ assert_response :redirect
+ assert ApplicationPushDevice.exists?(device.id)
+ end
+
+ test "destroys device by token" do
+ device = @identity.devices.create!(
+ token: "token_to_unregister",
+ platform: "apple",
+ name: "iPhone"
+ )
+
+ assert_difference -> { ApplicationPushDevice.count }, -1 do
+ untenanted { delete saas.device_path("token_to_unregister"), as: :json }
+ end
+
+ assert_response :no_content
+ assert_not ApplicationPushDevice.exists?(device.id)
+ end
+
+ test "returns not found when device not found by token" do
+ assert_no_difference "ApplicationPushDevice.count" do
+ untenanted { delete saas.device_path("nonexistent_token"), as: :json }
+ end
+
+ assert_response :not_found
+ end
+
+ 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 "ApplicationPushDevice.count" do
+ untenanted { delete saas.device_path("other_identity_token"), as: :json }
+ end
+
+ assert_response :not_found
+ assert ApplicationPushDevice.exists?(device.id)
+ end
+
+ test "destroy by token requires authentication" do
+ device = @identity.devices.create!(
+ token: "my_token",
+ platform: "apple",
+ name: "iPhone"
+ )
+
+ sign_out
+
+ untenanted { delete saas.device_path("my_token"), as: :json }
+
+ assert_response :redirect
+ assert ApplicationPushDevice.exists?(device.id)
+ end
+end
diff --git a/saas/test/fixtures/application_push_devices.yml b/saas/test/fixtures/application_push_devices.yml
new file mode 100644
index 0000000000..7601d52849
--- /dev/null
+++ b/saas/test/fixtures/application_push_devices.yml
@@ -0,0 +1,17 @@
+davids_iphone:
+ name: iPhone 15 Pro
+ token: abc123def456abc123def456abc123def456abc123def456abc123def456abcd
+ platform: apple
+ owner: david (User)
+
+davids_pixel:
+ name: Pixel 8
+ token: def456abc123def456abc123def456abc123def456abc123def456abc123defg
+ platform: google
+ owner: david (User)
+
+kevins_iphone:
+ name: iPhone 14
+ token: 789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7890
+ platform: apple
+ owner: kevin (User)
diff --git a/saas/test/models/notification/push_target/native_test.rb b/saas/test/models/notification/push_target/native_test.rb
new file mode 100644
index 0000000000..5e038fa878
--- /dev/null
+++ b/saas/test/models/notification/push_target/native_test.rb
@@ -0,0 +1,192 @@
+require "test_helper"
+
+class Notification::PushTarget::NativeTest < ActiveSupport::TestCase
+ setup do
+ @user = users(:kevin)
+ @identity = @user.identity
+ @notification = notifications(:logo_published_kevin)
+
+ # Ensure user has no web push subscriptions (we want to test native push independently)
+ @user.push_subscriptions.delete_all
+ end
+
+ test "payload category returns assignment for card_assigned" do
+ notification = notifications(:logo_assignment_kevin)
+
+ assert_equal "assignment", notification.payload.category
+ end
+
+ test "payload category returns comment for comment_created" do
+ notification = notifications(:layout_commented_kevin)
+
+ assert_equal "comment", notification.payload.category
+ end
+
+ test "payload category returns mention for mentions" do
+ notification = notifications(:logo_card_david_mention_by_jz)
+
+ assert_equal "mention", notification.payload.category
+ end
+
+ test "payload category returns card for other card events" do
+ assert_equal "card", @notification.payload.category
+ end
+
+
+ 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
+ Notification::PushTarget::Native.new(@notification).process
+ end
+ end
+
+ test "does not push when user has no devices" do
+ @identity.devices.delete_all
+
+ 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
+ @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
+ Notification::PushTarget::Native.new(@notification).process
+ end
+ end
+
+ test "native notification includes required fields" do
+ @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone")
+
+ push = Notification::PushTarget::Native.new(@notification)
+ native = push.send(:native_notification)
+
+ 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
+ @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone")
+
+ push = Notification::PushTarget::Native.new(@notification)
+ native = push.send(:native_notification)
+
+ 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.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 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)
+ native = push.send(:native_notification)
+
+ assert_not native.high_priority
+ end
+
+ test "native notification includes apple-specific fields" do
+ @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone")
+
+ push = Notification::PushTarget::Native.new(@notification)
+ native = push.send(:native_notification)
+
+ assert_equal 1, native.apple_data.dig(:aps, :"mutable-content")
+ 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")
+
+ push = Notification::PushTarget::Native.new(@notification)
+ native = push.send(:native_notification)
+
+ 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")
+
+ push = Notification::PushTarget::Native.new(@notification)
+ 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.creator.name, native.data[:creator_name]
+ end
+
+end
diff --git a/saas/test/models/push_config_test.rb b/saas/test/models/push_config_test.rb
new file mode 100644
index 0000000000..554315818b
--- /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 = 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/models/session/devices_test.rb b/saas/test/models/session/devices_test.rb
new file mode 100644
index 0000000000..00874895de
--- /dev/null
+++ b/saas/test/models/session/devices_test.rb
@@ -0,0 +1,40 @@
+require "test_helper"
+
+class Session::DevicesTest < ActiveSupport::TestCase
+ setup do
+ @session = sessions(:david)
+ @identity = @session.identity
+ end
+
+ test "destroying session destroys associated devices" do
+ device = ApplicationPushDevice.register(
+ session: @session,
+ token: "test_token",
+ platform: "apple",
+ name: "Test iPhone"
+ )
+
+ assert_difference -> { ApplicationPushDevice.count }, -1 do
+ @session.destroy
+ end
+
+ assert_nil ApplicationPushDevice.find_by(id: device.id)
+ end
+
+ test "destroying session does not destroy devices from other sessions" do
+ other_session = sessions(:kevin)
+
+ device = ApplicationPushDevice.register(
+ session: other_session,
+ token: "other_token",
+ platform: "apple",
+ name: "Other iPhone"
+ )
+
+ assert_no_difference -> { ApplicationPushDevice.count } do
+ @session.destroy
+ end
+
+ assert ApplicationPushDevice.exists?(device.id)
+ end
+end
diff --git a/saas/test/test_helpers/push_notification_test_helper.rb b/saas/test/test_helpers/push_notification_test_helper.rb
new file mode 100644
index 0000000000..24355edf7e
--- /dev/null
+++ b/saas/test/test_helpers/push_notification_test_helper.rb
@@ -0,0 +1,33 @@
+module PushNotificationTestHelper
+ # Assert native push notification is queued for delivery
+ def assert_native_push_delivery(count: 1, &block)
+ assert_enqueued_jobs count, only: ApplicationPushNotificationJob, &block
+ end
+
+ # Assert no native push notifications are queued
+ def assert_no_native_push_delivery(&block)
+ assert_native_push_delivery(count: 0, &block)
+ end
+
+ # Expect push notification to be delivered (using mocha)
+ def expect_native_push_delivery(count: 1)
+ ApplicationPushNotification.any_instance.expects(:deliver_later_to).times(count)
+ yield if block_given?
+ end
+
+ # Expect no push notification delivery
+ def expect_no_native_push_delivery(&block)
+ expect_native_push_delivery(count: 0, &block)
+ end
+
+ # Stub the push service to avoid actual API calls
+ def stub_push_services
+ ActionPushNative.stubs(:service_for).returns(stub(push: true))
+ end
+
+ # Stub push service to simulate token error (device should be deleted)
+ def stub_push_token_error
+ push_stub = stub.tap { |s| s.stubs(:push).raises(ActionPushNative::TokenError) }
+ ActionPushNative.stubs(:service_for).returns(push_stub)
+ end
+end
diff --git a/test/models/notification/push_target/web_test.rb b/test/models/notification/push_target/web_test.rb
new file mode 100644
index 0000000000..b492b48dd5
--- /dev/null
+++ b/test/models/notification/push_target/web_test.rb
@@ -0,0 +1,81 @@
+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)
+ )
+
+ @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::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).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
+ end
+
+ Notification::PushTarget::Web.new(@notification).process
+ 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::PushTarget::Web.new(notification).process
+ 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::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
+ end
+
+end
diff --git a/test/models/notification/pushable_test.rb b/test/models/notification/pushable_test.rb
new file mode 100644
index 0000000000..7c54b0036e
--- /dev/null
+++ b/test/models/notification/pushable_test.rb
@@ -0,0 +1,88 @@
+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 enqueues Notification::PushJob" do
+ assert_enqueued_with(job: Notification::PushJob, args: [ @notification ]) do
+ @notification.push_later
+ end
+ end
+
+ test "push calls process on all registered targets" 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 "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::PushTarget::Web
+ ensure
+ Notification.push_targets = original_targets
+ end
+
+ 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 "push skips targets when creator is system user" do
+ @notification.update!(creator: users(:system))
+
+ 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 "push skips targets for cancelled accounts" do
+ @user.account.cancel(initiated_by: @user)
+
+ 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
diff --git a/test/models/notification_pusher_test.rb b/test/models/notification_pusher_test.rb
deleted file mode 100644
index 21b9e202b4..0000000000
--- a/test/models/notification_pusher_test.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require "test_helper"
-
-class NotificationPusherTest < ActiveSupport::TestCase
- setup do
- @user = users(:david)
- @notification = @user.notifications.create!(
- source: events(:logo_published),
- creator: users(:jason)
- )
- @pusher = NotificationPusher.new(@notification)
-
- @user.push_subscriptions.create!(
- endpoint: "https://fcm.googleapis.com/fcm/send/test123",
- p256dh_key: "test_key",
- auth_key: "test_auth"
- )
- end
-
- test "push does not send notifications for cancelled accounts" do
- @user.account.cancel(initiated_by: @user)
-
- result = @pusher.push
-
- assert_nil result, "Should not push notifications for cancelled accounts"
- end
-
- test "push sends notifications for active accounts with subscriptions" do
- result = @pusher.push
-
- assert_not_nil result, "Should push notifications for active accounts with subscriptions"
- end
-end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index b1f8137610..b8e96bf972 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -37,6 +37,10 @@
}
end
+if Fizzy.saas?
+ require_relative "../saas/test/test_helpers/push_notification_test_helper"
+end
+
module ActiveSupport
class TestCase
parallelize workers: :number_of_processors, work_stealing: ENV["WORK_STEALING"] != "false"
@@ -47,6 +51,7 @@ class TestCase
include ActiveJob::TestHelper
include ActionTextTestHelper, CachingTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
include Turbo::Broadcastable::TestHelper
+ include PushNotificationTestHelper if Fizzy.saas?
setup do
Current.account = accounts("37s")