Merge branch 'main' into card-perma-editing

* main: (56 commits)
  Update app/views/cards/container/_closure_buttons.html.erb
  Update app/views/columns/show/_add_card_button.html.erb
  Update app/views/events/index/_add_card_button.html.erb
  Just return the object
  Rename is-main-action to display-as-primary-action
  Remove debug line
  Rename show-label to display-title
  Include path in web push payload for backwards compatibility
  Add base_url to native push notification payload
  Use internal account ID as `account_id` and add `account_slug`
  Make APNS and FCM env vars available
  Fix devices_path route helper in native devices partial
  Fix tests for renamed fixtures and new stacked notifications
  Add creator_familiar_name to push notification payload
  Add creator initials and avatar color to push notification payload
  Add avatar_url so we always get an avatar even when the user hasn't set one
  Document --push flag for testing native push notifications
  Don't read APNS_TEAM_ID from 1Password
  Simplify push.yml to only use B64 encryption keys
  Rename apns-dev to push-dev and unify 1Password credentials
  ...
This commit is contained in:
Jay Ohms
2026-02-26 07:32:18 -05:00
61 changed files with 1521 additions and 233 deletions
+3 -3
View File
@@ -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)
+3
View File
@@ -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
View File
@@ -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!
+2 -6
View File
@@ -5,12 +5,8 @@ class BoardsController < ApplicationController
before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]
def index
if request.format.json?
set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed
fresh_when etag: @page.records
else
set_page_and_extract_portion_from Current.user.boards
end
set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity)
fresh_when etag: @page.records
end
def show
+1 -1
View File
@@ -3,7 +3,7 @@ class UsersController < ApplicationController
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from Current.account.users.active.alphabetically
set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity)
end
def show
+1 -8
View File
@@ -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)
@@ -3,7 +3,9 @@ import { BridgeElement } from "@hotwired/hotwire-native-bridge"
BridgeElement.prototype.getButton = function() {
return {
title: this.title,
icon: this.getIcon()
icon: this.getIcon(),
displayTitle: this.getDisplayTitle(),
displayAsPrimaryAction: this.getDisplayAsPrimaryAction()
}
}
@@ -16,3 +18,11 @@ BridgeElement.prototype.getIcon = function() {
return null
}
BridgeElement.prototype.getDisplayTitle = function() {
return !!this.bridgeAttribute(`display-title`)
}
BridgeElement.prototype.getDisplayAsPrimaryAction = function() {
return !!this.bridgeAttribute(`display-as-primary-action`)
}
+5
View File
@@ -0,0 +1,5 @@
class Notification::PushJob < ApplicationJob
def perform(notification)
notification.push
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(notification)
NotificationPusher.new(notification).push
notification.push
end
end
-12
View File
@@ -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
+2 -1
View File
@@ -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
+67
View File
@@ -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
+17
View File
@@ -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
+52
View File
@@ -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
-124
View File
@@ -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
+10
View File
@@ -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
@@ -7,7 +7,7 @@
<% end %>
<%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless",
data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark as Done", bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
form: { data: { controller: "form" } } do %>
<%= icon_tag "check", class: "icon--mobile-only" %>
<span class="overflow-ellipsis">Mark as Done</span>
@@ -1,7 +1,7 @@
<div class="board-tools card">
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link",
form: { data: { turbo_frame: "_top" } },
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_icon_url: bridge_icon("add") } do %>
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %>
<%= icon_tag "add", class: "show-on-touch" %>
<span>Add a card</span>
<kbd class="hide-on-touch">C</kbd>
@@ -1,7 +1,7 @@
<div class="header__actions header__actions--end">
<div>
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile",
data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a board", bridge_icon_url: bridge_icon("board") } do %>
data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add board", bridge_display_title: true, bridge_icon_url: bridge_icon("board") } do %>
<%= icon_tag "board" %>
<span class="overflow-ellipsis">Add a board</span>
<kbd class="hide-on-touch">B</kbd>
@@ -1,7 +1,7 @@
<div class="header__actions header__actions--start">
<% if board = user_filtering.single_board_or_first %>
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile",
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a card", bridge_icon_url: bridge_icon("add") } do %>
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %>
<%= icon_tag "add" %>
<span class="overflow-ellipsis">Add a card</span>
<kbd class="hide-on-touch">C</kbd>
@@ -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>
+1 -1
View File
@@ -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))
})
+18
View File
@@ -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
+3 -3
View File
@@ -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, path: @url, badge: @badge } }
end
def icon_path
+4 -1
View File
@@ -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)
+4 -1
View File
@@ -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)
+10
View File
@@ -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
+7
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
module Session::Devices
extend ActiveSupport::Concern
included do
has_many :devices, class_name: "ApplicationPushDevice", dependent: :destroy
end
end
+16
View File
@@ -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>
+3
View File
@@ -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
+3
View File
@@ -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
+10
View File
@@ -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
+2
View File
@@ -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
+13
View File
@@ -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
+42
View File
@@ -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"
+1 -1
View File
@@ -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"
+15 -4
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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
@@ -278,6 +278,20 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_response :no_content
end
test "index avoids N+1 queries on creator and identity" do
assert_queries_match(/FROM [`"]users[`"].* IN \(/, count: 1) do
assert_queries_match(/FROM [`"]identities[`"].* IN \(/, count: 1) do
get boards_path, as: :json
assert_response :success
end
end
json = @response.parsed_body
first_board = json.first
assert first_board["creator"].present?
assert first_board["creator"]["email_address"].present?
end
private
def next_page_from_link_header(link_header)
url = link_header&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first
+12
View File
@@ -144,4 +144,16 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_response :no_content
end
test "index avoids N+1 queries on identity" do
sign_in_as :kevin
assert_queries_match(/FROM [`"]identities[`"].* IN \(/, count: 1) do
get users_path, as: :json
assert_response :success
end
json = @response.parsed_body
assert json.first["email_address"].present?
end
end
+172
View File
@@ -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
+1 -1
View File
@@ -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
+97
View File
@@ -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
-29
View File
@@ -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