Merge pull request #2511 from basecamp/native-push-notifications
Support native push notifications
This commit is contained in:
+3
-3
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+41
-3
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class Notification::PushJob < ApplicationJob
|
||||
def perform(notification)
|
||||
notification.push
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(notification)
|
||||
NotificationPusher.new(notification).push
|
||||
notification.push
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,8 +1,14 @@
|
||||
require "zlib"
|
||||
|
||||
module User::Avatar
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
|
||||
MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
|
||||
AVATAR_COLORS = %w[
|
||||
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
|
||||
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
|
||||
].freeze
|
||||
|
||||
included do
|
||||
has_one_attached :avatar do |attachable|
|
||||
@@ -22,6 +28,10 @@ module User::Avatar
|
||||
avatar.variable? ? avatar.variant(:thumb) : avatar
|
||||
end
|
||||
|
||||
def avatar_background_color
|
||||
AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size]
|
||||
end
|
||||
|
||||
# Avatars are always publicly accessible
|
||||
def publicly_accessible?
|
||||
true
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<div class="settings__panel panel shadow">
|
||||
<%= render "notifications/settings/push_notifications" %>
|
||||
<%= render "notifications/settings/native_devices" if Fizzy.saas? %>
|
||||
<%= render "notifications/settings/email", settings: @settings %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Rails.application.config.to_prepare do
|
||||
Notification.register_push_target(:web)
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class WebPush::Notification
|
||||
def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:)
|
||||
@title, @body, @path, @badge = title, body, path, badge
|
||||
def initialize(title:, body:, url:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:)
|
||||
@title, @body, @url, @badge = title, body, url, badge
|
||||
@endpoint, @endpoint_ip, @p256dh_key, @auth_key = endpoint, endpoint_ip, p256dh_key, auth_key
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@ class WebPush::Notification
|
||||
end
|
||||
|
||||
def encoded_message
|
||||
JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } }
|
||||
JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { url: @url, badge: @badge } }
|
||||
end
|
||||
|
||||
def icon_path
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
class ApplicationPushNotificationJob < ActionPushNative::NotificationJob
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
class ApplicationPushNotification < ActionPushNative::Notification
|
||||
queue_as :default
|
||||
self.enabled = Fizzy.saas? && (!Rails.env.local? || ENV["ENABLE_NATIVE_PUSH"] == "true")
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Identity::Devices
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :devices, class_name: "ApplicationPushDevice", as: :owner, dependent: :destroy
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
module Session::Devices
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :devices, class_name: "ApplicationPushDevice", dependent: :destroy
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
<h1>Registered Devices</h1>
|
||||
|
||||
<% if @devices.any? %>
|
||||
<ul>
|
||||
<% @devices.each do |device| %>
|
||||
<li>
|
||||
<strong><%= device.name || "Unnamed device" %></strong>
|
||||
(<%= device.platform == "apple" ? "iOS" : "Android" %>)
|
||||
<span>Added <%= time_ago_in_words(device.created_at) %> ago</span>
|
||||
<%= button_to "Remove", saas.device_path(device), method: :delete, data: { confirm: "Remove this device?" } %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p>No devices registered. Install the mobile app to receive push notifications.</p>
|
||||
<% end %>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="native-devices margin-block-start">
|
||||
<h3 class="txt-small txt-uppercase divider">Mobile Devices</h3>
|
||||
|
||||
<% if Current.identity.devices.any? %>
|
||||
<p class="txt-small">
|
||||
You have <%= pluralize(Current.identity.devices.count, "mobile device") %> registered for push notifications.
|
||||
</p>
|
||||
<%= link_to "Manage devices", saas.devices_path, class: "btn txt-small" %>
|
||||
<% else %>
|
||||
<p class="txt-small color-secondary">
|
||||
No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +43,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_26_230838) do
|
||||
t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true
|
||||
end
|
||||
|
||||
create_table "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
|
||||
|
||||
Executable
+42
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -10,6 +10,11 @@ module Fizzy
|
||||
# moved from config/initializers/queenbee.rb
|
||||
Queenbee.host_app = Fizzy
|
||||
|
||||
# Configure ActionPushNative to use the saas database
|
||||
ActiveSupport.on_load(:action_push_native_record) do
|
||||
connects_to database: { writing: :saas, reading: :saas }
|
||||
end
|
||||
|
||||
initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app|
|
||||
app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com"
|
||||
end
|
||||
@@ -23,6 +28,10 @@ module Fizzy
|
||||
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 @@ module Fizzy
|
||||
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 @@ module Fizzy
|
||||
::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name)
|
||||
::Object.const_set const_name, Subscription.const_get(short_name, false)
|
||||
end
|
||||
|
||||
::ApplicationController.include Fizzy::Saas::Authorization::Controller
|
||||
::Identity.include Fizzy::Saas::Authorization::Identity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
+17
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user