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? %> + +<% 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")