diff --git a/Gemfile.lock b/Gemfile.lock index 24449909ba..6efc361eb2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -127,8 +127,8 @@ GEM specs: action_text-trix (2.1.16) railties - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -337,7 +337,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) diff --git a/Gemfile.saas b/Gemfile.saas index c29eaeb794..39aa49a71c 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -11,6 +11,9 @@ gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" +# Native push notifications (iOS/Android) +gem "action_push_native" + # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" gem "sentry-ruby" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index b18d2fa8b5..fc88e7aec5 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -195,6 +195,13 @@ PATH GEM remote: https://rubygems.org/ specs: + action_push_native (0.3.1) + activejob (>= 8.0) + activerecord (>= 8.0) + googleauth (~> 1.14) + httpx (~> 1.6) + jwt (>= 2) + railties (>= 8.0) action_text-trix (2.1.16) railties actionpack-xml_parser (2.0.1) @@ -208,8 +215,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) @@ -279,6 +286,12 @@ GEM tzinfo faker (3.6.0) i18n (>= 1.8.11, < 2) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -295,8 +308,23 @@ 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) gvltools (0.4.0) 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) @@ -369,6 +397,9 @@ GEM mocha (3.0.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_json (1.19.1) + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) net-imap (0.6.3) @@ -403,6 +434,7 @@ GEM nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) + os (1.1.4) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -457,7 +489,7 @@ GEM psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) @@ -546,6 +578,11 @@ GEM sentry-ruby (6.2.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) sniffer (0.5.0) anyway_config (>= 1.0) dry-initializer (~> 3) @@ -663,6 +700,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + action_push_native actionpack-xml_parser activeresource audits1984! 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/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 index c912e141d8..8625649272 100644 --- a/app/jobs/push_notification_job.rb +++ b/app/jobs/push_notification_job.rb @@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(notification) - NotificationPusher.new(notification).push + notification.push end end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb deleted file mode 100644 index 4bf9b575dd..0000000000 --- a/app/models/concerns/push_notifiable.rb +++ /dev/null @@ -1,12 +0,0 @@ -module PushNotifiable - extend ActiveSupport::Concern - - included do - after_save_commit :push_notification_later, if: :source_id_previously_changed? - end - - private - def push_notification_later - PushNotificationJob.perform_later(self) - end -end diff --git a/app/models/notification.rb b/app/models/notification.rb index 417a2f22d2..a5eab3ccde 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,5 +1,5 @@ class Notification < ApplicationRecord - include PushNotifiable + include Notification::Pushable belongs_to :account, default: -> { user.account } belongs_to :user @@ -27,6 +27,7 @@ class Notification < ApplicationRecord after_destroy_commit -> { broadcast_remove_to user, :notifications } delegate :notifiable_target, to: :source + delegate :identity, to: :user class << self def read_all diff --git a/app/models/notification/default_payload.rb b/app/models/notification/default_payload.rb new file mode 100644 index 0000000000..98f104856e --- /dev/null +++ b/app/models/notification/default_payload.rb @@ -0,0 +1,57 @@ +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 base_url + Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp("/") + end + + def avatar_url + Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options) + end + + 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..e02f3a8de3 --- /dev/null +++ b/app/models/notification/pushable.rb @@ -0,0 +1,52 @@ +module Notification::Pushable + extend ActiveSupport::Concern + + included do + class_attribute :push_targets, default: [] + + after_save_commit :push_later, if: :source_id_previously_changed? + end + + class_methods do + def register_push_target(target) + target = resolve_push_target(target) + push_targets << target unless push_targets.include?(target) + end + + private + def resolve_push_target(target) + if target.is_a?(Symbol) + "Notification::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 3e6b8b84d5..dc43bafd8c 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -16,6 +16,7 @@
<%= render "notifications/settings/push_notifications" %> + <%= render "notifications/settings/native_devices" if Fizzy.saas? %> <%= render "notifications/settings/email", settings: @settings %>
diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js index 1216c550d9..91903e7359 100644 --- a/app/views/pwa/service_worker.js +++ b/app/views/pwa/service_worker.js @@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) { self.addEventListener("notificationclick", (event) => { event.notification.close() - const url = new URL(event.notification.data.path, self.location.origin).href + const url = new URL(event.notification.data.url, self.location.origin).href event.waitUntil(openURL(url)) }) diff --git a/bin/dev b/bin/dev index 05032e7c36..24923069d8 100755 --- a/bin/dev +++ b/bin/dev @@ -2,13 +2,31 @@ PORT=3006 USE_TAILSCALE=0 +USE_PUSH=0 for arg in "$@"; do case $arg in --tailscale) USE_TAILSCALE=1 ;; + --push) USE_PUSH=1 ;; esac done +if [ "$USE_PUSH" = "1" ]; then + if [ ! -f tmp/saas.txt ]; then + echo "Enabling SaaS mode for push notifications..." + ./bin/rails saas:enable + fi + echo "Loading push credentials from 1Password..." + if ! eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec push-dev)"; then + echo "Error: failed to load push credentials. Are you signed into 1Password?" >&2 + exit 1 + fi + if [ -z "$APNS_ENCRYPTION_KEY_B64" ] || [ -z "$APNS_KEY_ID" ]; then + echo "Error: Push credentials not set. Missing APNS_ENCRYPTION_KEY_B64 or APNS_KEY_ID." >&2 + exit 1 + fi +fi + 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/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..2e955054b5 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET Beta/APNS_KEY_ID Beta/APNS_ENCRYPTION_KEY_B64 Beta/FCM_ENCRYPTION_KEY_B64) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,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_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) +APNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS) +FCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 46d7abfbcb..1c6577dc89 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET Production/APNS_KEY_ID Production/APNS_ENCRYPTION_KEY_B64 Production/FCM_ENCRYPTION_KEY_B64) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -25,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_KEY_ID=$(kamal secrets extract APNS_KEY_ID $SECRETS) +APNS_ENCRYPTION_KEY_B64=$(kamal secrets extract APNS_ENCRYPTION_KEY_B64 $SECRETS) +FCM_ENCRYPTION_KEY_B64=$(kamal secrets extract FCM_ENCRYPTION_KEY_B64 $SECRETS) diff --git a/saas/README.md b/saas/README.md index 69697ded44..b71afff08d 100644 --- a/saas/README.md +++ b/saas/README.md @@ -46,6 +46,16 @@ This will ask for your 1password authorization to read and set the environment v * [Staging](https://dashboard.stripe.com/acct_1SdTbuRvb8txnPBR/test/dashboard) * [Production](https://dashboard.stripe.com/acct_1SNy97RwChFE4it8/dashboard) +## Working with Push Notifications + +To test native push notifications (APNs and FCM) locally, start the dev server with the `--push` flag: + +```sh +bin/dev --push +``` + +This will ask for your 1Password authorization to fetch the push credentials. Note that this loads the **production** APNs and FCM credentials into your environment. + ## Environments Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. 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..5366ed0322 --- /dev/null +++ b/saas/app/models/notification/push_target/native.rb @@ -0,0 +1,59 @@ +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, + base_url: payload.base_url, + account_id: notification.account.id, + account_slug: notification.account.slug, + avatar_url: payload.avatar_url, + card_id: card&.id, + card_title: card&.title, + 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..a0ddbed130 --- /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", saas.devices_path, class: "btn txt-small" %> + <% else %> +

+ No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone. +

+ <% end %> +
diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index d5fce9d320..ddb67e16cb 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -106,6 +106,9 @@ env: - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET + - APNS_KEY_ID + - APNS_ENCRYPTION_KEY_B64 + - FCM_ENCRYPTION_KEY_B64 tags: df_iad: PRIMARY_DATACENTER: true diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index eadb66e7f4..7865b95a62 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -55,6 +55,9 @@ env: - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET + - APNS_KEY_ID + - APNS_ENCRYPTION_KEY_B64 + - FCM_ENCRYPTION_KEY_B64 tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/push.yml b/saas/config/push.yml new file mode 100644 index 0000000000..4db9ae2c9e --- /dev/null +++ b/saas/config/push.yml @@ -0,0 +1,10 @@ +shared: + apple: + key_id: <%= ENV["APNS_KEY_ID"] %> + encryption_key: <%= Base64.decode64(ENV["APNS_ENCRYPTION_KEY_B64"] || "").dump %> + team_id: <%= ENV["APNS_TEAM_ID"] || "2WNYUYRS7G" %> + topic: <%= ENV["APNS_TOPIC"] || "do.fizzy.app.ios" %> + connect_to_development_server: <%= Rails.env.local? %> + google: + encryption_key: <%= Base64.decode64(ENV["FCM_ENCRYPTION_KEY_B64"] || "").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 980e952ac5..298cc033af 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -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_auditor_tokens", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "auditor_id", null: false t.datetime "created_at", null: false diff --git a/saas/exe/push-dev b/saas/exe/push-dev new file mode 100755 index 0000000000..cac878fae3 --- /dev/null +++ b/saas/exe/push-dev @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby +# +# Fetches push notification credentials from 1Password for development. +# Uses the same 1Password items as production (Deploy/Fizzy Production). +# +# Usage: eval "$(bundle exec push-dev)" + +OP_ACCOUNT = "23QPQDKZC5BKBIIG7UGT5GR5RM" +OP_VAULT = "Deploy" +OP_ITEM = "Fizzy" + +def op_read(field) + `op read "op://#{OP_VAULT}/#{OP_ITEM}/Production/#{field}" --account #{OP_ACCOUNT} 2>/dev/null`.strip +end + +apns_key_id = op_read("APNS_KEY_ID") +apns_encryption_key_b64 = op_read("APNS_ENCRYPTION_KEY_B64") +fcm_encryption_key_b64 = op_read("FCM_ENCRYPTION_KEY_B64") + +if apns_key_id.empty? || apns_encryption_key_b64.empty? + warn "Error: Could not fetch APNs credentials from 1Password" + warn "Make sure you're signed in: op signin --account #{OP_ACCOUNT}" + exit 1 +end + +if fcm_encryption_key_b64.empty? + warn "Warning: Could not fetch FCM credentials from 1Password" + warn "Android push notifications will not work" +end + +puts %Q(export APNS_KEY_ID="#{apns_key_id}") +puts %Q(export APNS_TEAM_ID="#{apns_team_id}") +puts %Q(export APNS_ENCRYPTION_KEY_B64="#{apns_encryption_key_b64}") +puts %Q(export FCM_ENCRYPTION_KEY_B64="#{fcm_encryption_key_b64}") +puts %Q(export ENABLE_NATIVE_PUSH="true") + +warn "" +warn "Push notification credentials loaded for development" +warn " APNs Key ID: #{apns_key_id}" +warn " APNs: #{apns_encryption_key_b64.empty? ? "not configured" : "configured"}" +warn " FCM: #{fcm_encryption_key_b64.empty? ? "not configured" : "configured"}" +warn " Native push: enabled" diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 1368ef63c3..7cc1f90a84 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |spec| end spec.bindir = "exe" - spec.executables = [ "stripe-dev" ] + spec.executables = [ "push-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 3d20f9df85..b901135440 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -10,6 +10,11 @@ class Engine < ::Rails::Engine # moved from config/initializers/queenbee.rb Queenbee.host_app = Fizzy + # Configure ActionPushNative to use the saas database + ActiveSupport.on_load(:action_push_native_record) do + connects_to database: { writing: :saas, reading: :saas } + end + initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app| app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com" end @@ -23,6 +28,10 @@ class Engine < ::Rails::Engine headers: app.config.public_file_server.headers 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 @@ -148,10 +157,15 @@ class Engine < ::Rails::Engine config.to_prepare do ::Account.include Account::Billing, Account::Limited ::User.include User::NotifiesAccountOfEmailChange - ::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 @@ -162,9 +176,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..8b37a0164a --- /dev/null +++ b/saas/test/controllers/devices_controller_test.rb @@ -0,0 +1,257 @@ +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 "show notification settings with registered devices" do + @identity.devices.create!(token: "test_token", platform: "apple", name: "iPhone 15 Pro") + + get notifications_settings_path + + assert_response :success + end + + test "index requires authentication" do + sign_out + + 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..c6e1c9d4f2 --- /dev/null +++ b/saas/test/models/notification/push_target/native_test.rb @@ -0,0 +1,222 @@ +require "test_helper" + +class Notification::PushTarget::NativeTest < ActiveSupport::TestCase + setup do + @user = users(:kevin) + @identity = @user.identity + @notification = notifications(:logo_assignment_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_mentioned_david) + + assert_equal "mention", notification.payload.category + end + + test "payload category returns card for other card events" do + @notification.update!(source: events(:logo_published)) + + assert_equal "card", @notification.payload.category + end + + + 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_mentioned_david) + 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 + @notification.update!(source: events(:logo_published)) + @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_mentioned_david) + 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 + @notification.update!(source: events(:logo_published)) + @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.id, native.data[:account_id] + assert_equal @notification.account.slug, native.data[:account_slug] + assert_equal @notification.creator.name, native.data[:creator_name] + end + + test "native notification includes base_url without account slug" do + @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") + + push = Notification::PushTarget::Native.new(@notification) + native = push.send(:native_notification) + + assert_equal "http://example.com", native.data[:base_url] + end + + private + def assert_native_push_delivery(count: 1, &block) + assert_enqueued_jobs count, only: ApplicationPushNotificationJob do + perform_enqueued_jobs only: Notification::PushJob, &block + end + end + + def assert_no_native_push_delivery(&block) + assert_enqueued_jobs 0, only: ApplicationPushNotificationJob do + perform_enqueued_jobs only: Notification::PushJob, &block + end + end + + def stub_push_services + ActionPushNative.stubs(:service_for).returns(stub(push: true)) + end +end diff --git a/saas/test/models/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/test/integration/notifications_test.rb b/test/integration/notifications_test.rb new file mode 100644 index 0000000000..f581fb5e94 --- /dev/null +++ b/test/integration/notifications_test.rb @@ -0,0 +1,172 @@ +require "test_helper" + +class NotificationDeliveryTest < ActiveSupport::TestCase + setup do + @assigner = users(:david) + @assignee = users(:kevin) + @card = cards(:logo) + + @card.assignments.destroy_all + @assignee.notifications.destroy_all + + stub_web_push_pool + + @original_targets = Notification.push_targets.dup + Notification.push_targets = [] + Notification.register_push_target(:web) + Notification.register_push_target(push_target_with_tracking) + + # Give assignee a web push subscription + @assignee.push_subscriptions.create!( + endpoint: "https://fcm.googleapis.com/fcm/send/test123", + p256dh_key: "test_key", + auth_key: "test_auth" + ) + + Current.user = @assigner + end + + teardown do + Notification.push_targets = @original_targets + @assignee.push_subscriptions.delete_all + end + + test "card assignment creates notification and triggers push" do + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.toggle_assignment(@assignee) + end + end + + notification = Notification.last + assert_equal @assignee, notification.user + assert_equal @assigner, notification.creator + assert_equal "card_assigned", notification.source.action + + assert_push_delivered_for notification + assert_web_push_delivered + end + + test "card assignment notification is bundled for email delivery when bundling enabled" do + @assignee.settings.update!(bundle_email_frequency: :every_few_hours) + + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: NotifyRecipientsJob do + @card.toggle_assignment(@assignee) + end + end + + notification = @assignee.notifications.reload.last + assert_not_nil notification, "Notification should be created for assignee" + + bundle = @assignee.notification_bundles.pending.last + assert_not_nil bundle, "Bundle should be created when bundling is enabled" + assert_includes bundle.notifications, notification + end + + test "comment creates notification for card watchers and triggers push" do + @card.watch_by(@assignee) + + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.comments.create!(body: "Great work on this!", creator: @assigner) + end + end + + notification = Notification.last + assert_equal @assignee, notification.user + assert_equal "comment_created", notification.source.action + + assert_push_delivered + assert_web_push_delivered + end + + test "mention creates notification and triggers push" do + mention_html = ActionText::Attachment.from_attachable(@assignee).to_html + + perform_enqueued_jobs only: [ Mention::CreateJob, NotifyRecipientsJob, Notification::PushJob ] do + @card.comments.create!( + body: "#{mention_html} check this out", + creator: @assigner + ) + end + + mention_notification = @assignee.notifications.find_by(source_type: "Mention") + assert_not_nil mention_notification + + assert_push_delivered_for mention_notification + assert_web_push_delivered + end + + test "system user actions do not create notifications" do + Current.user = users(:system) + + assert_no_difference -> { Notification.count } do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.toggle_assignment(@assignee) + end + end + + assert_no_push_delivered + assert_no_web_push_delivered + end + + test "notifications for inactive users are created but do not trigger push" do + @assignee.deactivate + + assert_difference -> { Notification.count }, 1 do + perform_enqueued_jobs only: [ NotifyRecipientsJob, Notification::PushJob ] do + @card.toggle_assignment(@assignee) + end + end + + assert_no_push_delivered + assert_no_web_push_delivered + end + + private + def stub_web_push_pool + @web_push_calls = [] + web_push_pool = stub("web_push_pool") + web_push_pool.stubs(:queue).with do |payload, subs| + @web_push_calls << { payload: payload, subscriptions: subs } + end + + Rails.configuration.x.stubs(:web_push_pool).returns(web_push_pool) + end + + def push_target_with_tracking + @push_target_calls = [] + fake_push_target = Class.new(Notification::PushTarget) do + class << self + attr_accessor :calls + end + + def self.process(notification) + calls << notification + end + end + + fake_push_target.tap { it.calls = @push_target_calls } + end + + def assert_push_delivered + assert_not_empty @push_target_calls, "Expected push to be delivered" + end + + def assert_push_delivered_for(notification) + assert_includes @push_target_calls, notification, "Expected push to be delivered for notification" + end + + def assert_no_push_delivered + assert_empty @push_target_calls, "Expected no push to be delivered" + end + + def assert_web_push_delivered + assert_not_empty @web_push_calls, "Expected web push to be delivered" + end + + def assert_no_web_push_delivered + assert_empty @web_push_calls, "Expected no web push to be delivered" + end +end diff --git a/test/lib/web_push/persistent_request_test.rb b/test/lib/web_push/persistent_request_test.rb index 2cf8a6c7c7..649e27b759 100644 --- a/test/lib/web_push/persistent_request_test.rb +++ b/test/lib/web_push/persistent_request_test.rb @@ -12,7 +12,7 @@ class WebPush::PersistentRequestTest < ActiveSupport::TestCase notification = WebPush::Notification.new( title: "Test", body: "Test notification", - path: "/test", + url: "/test", badge: 0, endpoint: ENDPOINT, endpoint_ip: PUBLIC_TEST_IP, diff --git a/test/models/concerns/push_notifiable_test.rb b/test/models/concerns/push_notifiable_test.rb deleted file mode 100644 index f6d7ed26d2..0000000000 --- a/test/models/concerns/push_notifiable_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -require "test_helper" - -class PushNotifiableTest < ActiveSupport::TestCase - test "enqueues push notification job when notification is created" do - assert_enqueued_with(job: PushNotificationJob) do - users(:david).notifications.create!( - source: events(:layout_published), - creator: users(:jason) - ) - end - end - - test "enqueues push notification job when notification source changes" do - notification = notifications(:logo_mentioned_david) - - assert_enqueued_with(job: PushNotificationJob) do - notification.update!(source: events(:logo_published)) - end - end - - test "does not enqueue push notification job for other updates" do - notification = notifications(:logo_mentioned_david) - - assert_no_enqueued_jobs only: PushNotificationJob do - notification.update!(unread_count: 5) - end - end -end diff --git a/test/models/notification/push_target/web_test.rb b/test/models/notification/push_target/web_test.rb new file mode 100644 index 0000000000..94628fb828 --- /dev/null +++ b/test/models/notification/push_target/web_test.rb @@ -0,0 +1,75 @@ +require "test_helper" + +class Notification::PushTarget::WebTest < ActiveSupport::TestCase + setup do + @user = users(:david) + @notification = notifications(:logo_mentioned_david) + + @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 + @notification.update!(source: events(:logo_published)) + + @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 + @notification.update!(source: events(:logo_assignment_david)) + + @web_push_pool.expects(:queue).once.with do |payload, _| + payload[:body].include?("Assigned to you") + end + + Notification::PushTarget::Web.new(@notification).process + end + + test "payload for mention includes mentioner name" do + @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..9036453bdf --- /dev/null +++ b/test/models/notification/pushable_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class Notification::PushableTest < ActiveSupport::TestCase + setup do + @user = users(:david) + @notification = notifications(:logo_mentioned_david) + 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 + assert_enqueued_with(job: Notification::PushJob) do + @user.notifications.create!( + source: events(:layout_published), + creator: users(:jason) + ) + end + end + + test "push_later is called when notification source changes" do + assert_enqueued_with(job: Notification::PushJob) do + @notification.update!(source: events(:logo_published)) + end + end + + test "push_later is not called for other updates" do + assert_no_enqueued_jobs only: Notification::PushJob do + @notification.update!(unread_count: 5) + end + end + + test "register_push_target accepts symbols" do + 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 53380effe6..0000000000 --- a/test/models/notification_pusher_test.rb +++ /dev/null @@ -1,29 +0,0 @@ -require "test_helper" - -class NotificationPusherTest < ActiveSupport::TestCase - setup do - @user = users(:david) - @notification = notifications(:logo_mentioned_david) - @pusher = NotificationPusher.new(@notification) - - @user.push_subscriptions.create!( - endpoint: "https://fcm.googleapis.com/fcm/send/test123", - p256dh_key: "test_key", - auth_key: "test_auth" - ) - end - - test "push does not send notifications for cancelled accounts" do - @user.account.cancel(initiated_by: @user) - - result = @pusher.push - - assert_nil result, "Should not push notifications for cancelled accounts" - end - - test "push sends notifications for active accounts with subscriptions" do - result = @pusher.push - - assert_not_nil result, "Should push notifications for active accounts with subscriptions" - end -end