├── app ├── jobs │ ├── .keep │ └── noticed │ │ ├── application_job.rb │ │ └── event_job.rb └── models │ ├── .keep │ ├── concerns │ ├── .keep │ └── noticed │ │ ├── notification_methods.rb │ │ ├── readable.rb │ │ └── deliverable.rb │ └── noticed │ ├── application_record.rb │ ├── notification.rb │ ├── event.rb │ ├── ephemeral.rb │ └── deliverable │ └── deliver_by.rb ├── test ├── models │ ├── .keep │ └── noticed │ │ ├── deliverable │ │ └── deliver_by_test.rb │ │ ├── event_test.rb │ │ └── notification_test.rb ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── admin.rb │ │ │ ├── application_record.rb │ │ │ ├── account.rb │ │ │ └── user.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── notifiers │ │ │ ├── inherited_notifier.rb │ │ │ ├── test_notifier.rb │ │ │ ├── deprecated_notifier.rb │ │ │ ├── application_delivery_method.rb │ │ │ ├── priority_notifier.rb │ │ │ ├── record_notifier.rb │ │ │ ├── wait_notifier.rb │ │ │ ├── queue_notifier.rb │ │ │ ├── bulk_notifier.rb │ │ │ ├── wait_until_notifier.rb │ │ │ ├── application_notifier.rb │ │ │ ├── receipt_notifier.rb │ │ │ ├── simple_notifier.rb │ │ │ ├── ephemeral_notifier.rb │ │ │ └── comment_notifier.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── mailers │ │ │ ├── application_mailer.rb │ │ │ └── user_mailer.rb │ │ └── jobs │ │ │ └── application_job.rb │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── routes.rb │ │ ├── initializers │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── assets.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── database.yml │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── storage.yml │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── config.ru │ ├── db │ │ ├── migrate │ │ │ ├── 20231215202924_create_accounts.rb │ │ │ └── 20231215202921_create_users.rb │ │ └── schema.rb │ └── Rakefile ├── fixtures │ ├── files │ │ └── .keep │ ├── accounts.yml │ ├── users.yml │ └── noticed │ │ ├── notifications.yml │ │ └── events.yml ├── noticed_test.rb ├── ephemeral_notifier_test.rb ├── delivery_methods │ ├── microsoft_teams_test.rb │ ├── vonage_sms_test.rb │ ├── action_cable_test.rb │ ├── twilio_messaging_test.rb │ ├── ios_test.rb │ ├── webhook_test.rb │ ├── slack_test.rb │ ├── email_test.rb │ └── fcm_test.rb ├── test_helper.rb ├── has_notifications_test.rb ├── jobs │ └── event_job_test.rb ├── translation_test.rb ├── bulk_delivery_methods │ └── webhook_test.rb ├── delivery_method_test.rb └── notifier_test.rb ├── lib ├── noticed │ ├── version.rb │ ├── delivery_methods │ │ ├── test.rb │ │ ├── discord.rb │ │ ├── microsoft_teams.rb │ │ ├── action_cable.rb │ │ ├── webhook.rb │ │ ├── vonage_sms.rb │ │ ├── action_push_native.rb │ │ ├── email.rb │ │ ├── slack.rb │ │ ├── twilio_messaging.rb │ │ ├── fcm.rb │ │ └── ios.rb │ ├── bulk_delivery_methods │ │ ├── test.rb │ │ ├── discord.rb │ │ ├── webhook.rb │ │ ├── slack.rb │ │ └── bluesky.rb │ ├── engine.rb │ ├── notification_channel.rb │ ├── required_options.rb │ ├── coder.rb │ ├── translation.rb │ ├── api_client.rb │ ├── bulk_delivery_method.rb │ ├── has_notifications.rb │ └── delivery_method.rb ├── generators │ └── noticed │ │ ├── templates │ │ ├── application_notifier.rb.tt │ │ ├── application_delivery_method.rb.tt │ │ ├── application_bulk_delivery_method.rb.tt │ │ ├── README │ │ ├── delivery_method.rb.tt │ │ ├── bulk_delivery_method.rb.tt │ │ └── notifier.rb.tt │ │ ├── install_generator.rb │ │ ├── notifier_generator.rb │ │ └── delivery_method_generator.rb └── noticed.rb ├── .standard.yml ├── docs ├── images │ ├── fcm-credentials-json.png │ └── fcm-project-settings.png ├── delivery_methods │ ├── test.md │ ├── discord.md │ ├── slack.md │ ├── vonage.md │ ├── vonage_sms.md │ ├── microsoft_teams.md │ ├── action_push_native.md │ ├── action_cable.md │ ├── email.md │ ├── twilio_messaging.md │ ├── ios.md │ └── fcm.md ├── bulk_delivery_methods │ ├── webhook.md │ ├── bluesky.md │ ├── discord.md │ └── slack.md └── extending-noticed.md ├── bin ├── test └── rails ├── db └── migrate │ ├── 20240129184740_add_notifications_count_to_noticed_event.rb │ └── 20231215190233_create_noticed_tables.rb ├── .gitignore ├── gemfiles ├── rails_7_1.gemfile ├── rails_7_2.gemfile ├── rails_8_0.gemfile ├── rails_8_1.gemfile ├── rails_main.gemfile ├── rails_7_0.gemfile └── rails_7_0.gemfile.lock ├── .github ├── FUNDING.yml ├── pull_request_template.md ├── workflows │ ├── publish_gem.yml │ └── ci.yml └── ISSUE_TEMPLATE │ ├── config.yml │ └── issue_template.md ├── noticed.gemspec ├── Rakefile ├── Gemfile ├── Appraisals └── MIT-LICENSE /app/jobs/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/admin.rb: -------------------------------------------------------------------------------- 1 | class Admin < User 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /lib/noticed/version.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | VERSION = "2.9.3" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.0 2 | ignore: 3 | - '**/*': 4 | - Style/HashSyntax 5 | -------------------------------------------------------------------------------- /test/fixtures/accounts.yml: -------------------------------------------------------------------------------- 1 | one: 2 | name: Account One 3 | 4 | two: 5 | name: Account Two 6 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/inherited_notifier.rb: -------------------------------------------------------------------------------- 1 | class InheritedNotifier < SimpleNotifier 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/application_notifier.rb.tt: -------------------------------------------------------------------------------- 1 | class ApplicationNotifier < Noticed::Event 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/test_notifier.rb: -------------------------------------------------------------------------------- 1 | class TestNotifier < ApplicationNotifier 2 | deliver_by :test 3 | end 4 | -------------------------------------------------------------------------------- /docs/images/fcm-credentials-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/HEAD/docs/images/fcm-credentials-json.png -------------------------------------------------------------------------------- /docs/images/fcm-project-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/HEAD/docs/images/fcm-project-settings.png -------------------------------------------------------------------------------- /test/dummy/app/notifiers/deprecated_notifier.rb: -------------------------------------------------------------------------------- 1 | class DeprecatedNotifier < Noticed::Base 2 | param :message 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/application_delivery_method.rb: -------------------------------------------------------------------------------- 1 | class ApplicationDeliveryMethod < Noticed::DeliveryMethod 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/application_delivery_method.rb.tt: -------------------------------------------------------------------------------- 1 | class ApplicationDeliveryMethod < Noticed::DeliveryMethod 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/priority_notifier.rb: -------------------------------------------------------------------------------- 1 | class PriorityNotifier < ApplicationNotifier 2 | deliver_by :test, priority: 2 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/record_notifier.rb: -------------------------------------------------------------------------------- 1 | class RecordNotifier < ApplicationNotifier 2 | validates :record, presence: true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/wait_notifier.rb: -------------------------------------------------------------------------------- 1 | class WaitNotifier < ApplicationNotifier 2 | deliver_by :test, wait: 5.minutes 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/queue_notifier.rb: -------------------------------------------------------------------------------- 1 | class QueueNotifier < ApplicationNotifier 2 | deliver_by :test, queue: :example_queue 3 | end 4 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/application_bulk_delivery_method.rb.tt: -------------------------------------------------------------------------------- 1 | class ApplicationBulkDeliveryMethod < Noticed::BulkDeliveryMethod 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/bulk_notifier.rb: -------------------------------------------------------------------------------- 1 | class BulkNotifier < ApplicationNotifier 2 | bulk_deliver_by :webhook, url: "https://example.org/bulk" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/wait_until_notifier.rb: -------------------------------------------------------------------------------- 1 | class WaitUntilNotifier < ApplicationNotifier 2 | deliver_by :test, wait_until: -> { 1.hour.from_now } 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | one: 2 | email: one@example.org 3 | 4 | two: 5 | email: two@example.org 6 | 7 | admin: 8 | type: Admin 9 | email: admin@example.org 10 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com", to: "to@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/application_notifier.rb: -------------------------------------------------------------------------------- 1 | class ApplicationNotifier < Noticed::Event 2 | notification_methods do 3 | def inherited_method 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/noticed_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NoticedTest < ActiveSupport::TestCase 4 | test "it has a version number" do 5 | assert Noticed::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/models/noticed/application_record.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | self.table_name_prefix = "noticed_" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | if respond_to?(:primary_abstract_class) 3 | primary_abstract_class 4 | else 5 | self.abstract_class = true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240129184740_add_notifications_count_to_noticed_event.rb: -------------------------------------------------------------------------------- 1 | class AddNotificationsCountToNoticedEvent < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :noticed_events, :notifications_count, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/db/*.sqlite3-* 7 | test/dummy/log/*.log 8 | test/dummy/storage/ 9 | test/dummy/tmp/ 10 | .byebug_history 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20231215202924_create_accounts.rb: -------------------------------------------------------------------------------- 1 | class CreateAccounts < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :accounts do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account < ApplicationRecord 2 | has_many :notifications, as: :record, dependent: :destroy, class_name: "Noticed::Notification" 3 | has_many :notifiers, as: :record, dependent: :destroy, class_name: "Noticed::Event" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20231215202921_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :users do |t| 4 | t.string :type 5 | t.string :email 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/receipt_notifier.rb: -------------------------------------------------------------------------------- 1 | class ReceiptNotifier < ApplicationNotifier 2 | deliver_by :test 3 | 4 | deliver_by :email do |config| 5 | config.mailer = "UserMailer" 6 | config.method = :receipt 7 | config.params = -> { params } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /docs/delivery_methods/test.md: -------------------------------------------------------------------------------- 1 | # Test Delivery Method 2 | 3 | Saves deliveries for testing. 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class CommentNotification 9 | deliver_by :test 10 | end 11 | ``` 12 | 13 | ```ruby 14 | Noticed::DeliveryMethods::Test.delivered #=> [] 15 | ``` 16 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/user_mailer.rb: -------------------------------------------------------------------------------- 1 | class UserMailer < ApplicationMailer 2 | def new_comment(*args) 3 | mail(body: "new comment") 4 | end 5 | 6 | def greeting(message = "message", body:, subject: "Hello") 7 | mail(body: body, subject: subject) 8 | end 9 | 10 | def receipt 11 | mail(body: "receipt") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification" 3 | 4 | # Used for querying Noticed::Event where params[:user] is a User instance 5 | has_noticed_notifications 6 | has_noticed_notifications param_name: :owner, destroy: false 7 | end 8 | -------------------------------------------------------------------------------- /app/jobs/noticed/application_job.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class ApplicationJob < ActiveJob::Base 3 | # Automatically retry jobs that encountered a deadlock 4 | # retry_on ActiveRecord::Deadlocked 5 | 6 | # Most jobs are safe to ignore if the underlying records are no longer available 7 | discard_on ActiveJob::DeserializationError 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/simple_notifier.rb: -------------------------------------------------------------------------------- 1 | class SimpleNotifier < ApplicationNotifier 2 | deliver_by :test 3 | required_params :message 4 | 5 | def url 6 | root_url 7 | end 8 | 9 | notification_methods do 10 | def message 11 | "hello #{recipient.email}" 12 | end 13 | 14 | def url 15 | root_url 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 1.7" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "minitest", "< 6.0" 13 | gem "rails", "~> 7.1.0" 14 | gem "trilogy" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/rails_7_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 1.7" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "minitest", "< 6.0" 13 | gem "rails", "~> 7.2.0" 14 | gem "trilogy" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/rails_8_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 2.0" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "minitest", "< 6.0" 13 | gem "rails", "~> 8.0.0" 14 | gem "trilogy" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/rails_8_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 2.0" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "minitest", "< 6.0" 13 | gem "rails", "~> 8.1.0.beta1" 14 | gem "trilogy" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/test.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class Test < DeliveryMethod 4 | class_attribute :delivered, default: [] 5 | 6 | def deliver 7 | delivered << notification 8 | end 9 | end 10 | end 11 | end 12 | 13 | ActiveSupport.run_load_hooks :noticed_delivery_methods_test, Noticed::DeliveryMethods::Test 14 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |Hello world
" 33 | notifiers: 34 | noticed: 35 | i18n_example: 36 | message: "This is a notification" 37 | noticed: 38 | scoped_i18n_example: 39 | message: "This is a custom scoped translation" 40 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/slack.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class Slack < DeliveryMethod 4 | DEFAULT_URL = "https://slack.com/api/chat.postMessage" 5 | 6 | required_options :json 7 | 8 | def deliver 9 | headers = evaluate_option(:headers) 10 | json = evaluate_option(:json) 11 | response = post_request url, headers: headers, json: json 12 | 13 | if raise_if_not_ok? && !success?(response) 14 | raise ResponseUnsuccessful.new(response, url, {headers: headers, json: json}) 15 | end 16 | 17 | response 18 | end 19 | 20 | def url 21 | evaluate_option(:url) || DEFAULT_URL 22 | end 23 | 24 | def raise_if_not_ok? 25 | value = evaluate_option(:raise_if_not_ok) 26 | value.nil? || value 27 | end 28 | 29 | def success?(response) 30 | if response.content_type == "application/json" 31 | JSON.parse(response.body).dig("ok") 32 | else 33 | # https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks 34 | response.is_a?(Net::HTTPSuccess) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | ActiveSupport.run_load_hooks :noticed_delivery_methods_slack, Noticed::DeliveryMethods::Slack 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Get Help 4 | url: https://github.com/excid3/noticed/discussions/new?category=help 5 | about: If you can't get something to work the way you expect, open a question in our discussion forums. 6 | - name: Feature Request 7 | url: https://github.com/excid3/noticed/discussions/new?category=ideas 8 | about: 'Suggest any ideas you have using our discussion forums.' 9 | - name: Bug Report 10 | url: https://github.com/excid3/noticed/issues/new?body=%3C%21--%20Please%20provide%20all%20of%20the%20information%20requested%20below.%20We%27re%20a%20small%20team%20and%20without%20all%20of%20this%20information%20it%27s%20not%20possible%20for%20us%20to%20help%20and%20your%20bug%20report%20will%20be%20closed.%20--%3E%0A%0A%2A%2AWhat%20version%20of%20Noticed%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v2.0.4%0A%0A%2A%2AWhat%20version%20of%20Rails%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v7.1.1%0A%0A%2A%2ADescribe%20your%20issue%2A%2A%0A%0ADescribe%20the%20problem%20you%27re%20seeing%2C%20any%20important%20steps%20to%20reproduce%20and%20what%20behavior%20you%20expect%20instead. 11 | about: If you've already asked for help with a problem and confirmed something is broken with Noticed itself, create a bug report. -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_methods/slack.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module BulkDeliveryMethods 3 | class Slack < BulkDeliveryMethod 4 | DEFAULT_URL = "https://slack.com/api/chat.postMessage" 5 | 6 | required_options :json 7 | 8 | def deliver 9 | headers = evaluate_option(:headers) 10 | json = evaluate_option(:json) 11 | response = post_request url, headers: headers, json: json 12 | 13 | if raise_if_not_ok? && !success?(response) 14 | raise ResponseUnsuccessful.new(response, url, {headers: headers, json: json}) 15 | end 16 | 17 | response 18 | end 19 | 20 | def url 21 | evaluate_option(:url) || DEFAULT_URL 22 | end 23 | 24 | def raise_if_not_ok? 25 | value = evaluate_option(:raise_if_not_ok) 26 | value.nil? || value 27 | end 28 | 29 | def success?(response) 30 | if response.content_type == "application/json" 31 | JSON.parse(response.body).dig("ok") 32 | else 33 | # https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks 34 | response.is_a?(Net::HTTPSuccess) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | ActiveSupport.run_load_hooks :noticed_bulk_delivery_methods_slack, Noticed::BulkDeliveryMethods::Slack 42 | -------------------------------------------------------------------------------- /test/models/noticed/deliverable/deliver_by_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Noticed::Deliverable::DeliverByTest < ActiveSupport::TestCase 6 | class TestDelivery < Noticed::Deliverable::DeliverBy; end 7 | 8 | test "#perform? returns true when before_enqueue is missing" do 9 | config = ActiveSupport::OrderedOptions.new.merge({}) 10 | assert_equal true, TestDelivery.new(:test, config).perform?({}) 11 | end 12 | 13 | test "#perform? returns false when before_enqueue throws" do 14 | config = ActiveSupport::OrderedOptions.new.merge({}) 15 | config.before_enqueue = -> { throw :abort } 16 | assert_equal false, TestDelivery.new(:test, config).perform?({}) 17 | end 18 | 19 | test "#perform? returns true when before_enqueue does not throw" do 20 | config = ActiveSupport::OrderedOptions.new.merge({}) 21 | config.before_enqueue = -> { false } 22 | assert_equal true, TestDelivery.new(:test, config).perform?({}) 23 | end 24 | 25 | test "#perform? takes context into account" do 26 | config = ActiveSupport::OrderedOptions.new.merge({}) 27 | config.before_enqueue = -> { throw :abort if key?(:test_value) } 28 | assert_equal false, TestDelivery.new(:test, config).perform?({test_value: true}) 29 | assert_equal true, TestDelivery.new(:test, config).perform?({other_value: true}) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/models/noticed/event_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Noticed::EventTest < ActiveSupport::TestCase 4 | class ExampleNotifier < Noticed::Event 5 | deliver_by :test 6 | required_params :message 7 | end 8 | 9 | test "validates required params" do 10 | assert_raises Noticed::ValidationError do 11 | ExampleNotifier.deliver 12 | end 13 | end 14 | 15 | test "deliver saves event" do 16 | assert_difference "Noticed::Event.count" do 17 | ExampleNotifier.with(message: "test").deliver 18 | end 19 | end 20 | 21 | test "deliver saves notifications" do 22 | assert_no_difference "Noticed::Notification.count" do 23 | ExampleNotifier.with(message: "test").deliver 24 | end 25 | 26 | assert_difference "Noticed::Notification.count" do 27 | ExampleNotifier.with(message: "test").deliver(users(:one)) 28 | end 29 | 30 | assert_difference "Noticed::Notification.count", User.count do 31 | ExampleNotifier.with(message: "test").deliver(User.all) 32 | end 33 | end 34 | 35 | test "deliver extracts record from params" do 36 | account = accounts(:one) 37 | event = ExampleNotifier.with(message: "test", record: account).deliver 38 | assert_equal account, event.record 39 | end 40 | 41 | test "deserialize_error?" do 42 | assert noticed_events(:missing_account).deserialize_error? 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/noticed.rb: -------------------------------------------------------------------------------- 1 | require "noticed/version" 2 | require "noticed/engine" 3 | 4 | require "zeitwerk" 5 | 6 | loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false) 7 | loader.ignore("#{__dir__}/generators") 8 | loader.do_not_eager_load("#{__dir__}/noticed/bulk_delivery_methods") 9 | loader.do_not_eager_load("#{__dir__}/noticed/delivery_methods") 10 | loader.do_not_eager_load("#{__dir__}/noticed/notification_channel.rb") 11 | loader.setup 12 | 13 | module Noticed 14 | include ActiveSupport::Deprecation::DeprecatedConstantAccessor 15 | 16 | def self.deprecator # :nodoc: 17 | @deprecator ||= ActiveSupport::Deprecation.new("3.0", "Noticed") 18 | end 19 | 20 | deprecate_constant :Base, "Noticed::Event", deprecator: deprecator 21 | 22 | module DeliveryMethods 23 | include ActiveSupport::Deprecation::DeprecatedConstantAccessor 24 | 25 | deprecate_constant :Base, "Noticed::DeliveryMethod", deprecator: Noticed.deprecator 26 | end 27 | 28 | mattr_accessor :parent_class 29 | @@parent_class = "Noticed::ApplicationJob" 30 | 31 | class ValidationError < StandardError; end 32 | 33 | class ResponseUnsuccessful < StandardError 34 | attr_reader :response 35 | 36 | def initialize(response, url, args) 37 | @response = response 38 | @url = url 39 | @args = args 40 | 41 | super("POST request to #{url} returned #{response.code} response:\n#{response.body.inspect}") 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/has_notifications_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class HasNotificationsTest < ActiveSupport::TestCase 4 | test "has_noticed_notifications" do 5 | assert User.respond_to?(:has_noticed_notifications) 6 | end 7 | 8 | test "noticed notifications association" do 9 | assert user.respond_to?(:notifications_as_user) 10 | end 11 | 12 | test "noticed notifications with custom name" do 13 | assert user.respond_to?(:notifications_as_owner) 14 | end 15 | 16 | test "association returns notifications" do 17 | assert_difference "user.notifications_as_user.count" do 18 | SimpleNotifier.with(user: user, message: "test").deliver(user) 19 | end 20 | end 21 | 22 | test "association with custom name returns notifications" do 23 | assert_difference "user.notifications_as_owner.count" do 24 | SimpleNotifier.with(owner: user, message: "test").deliver(user) 25 | end 26 | end 27 | 28 | test "deletes notifications with matching param" do 29 | SimpleNotifier.with(user: user, message: "test").deliver(users(:two)) 30 | 31 | assert_difference "Noticed::Event.count", -1 do 32 | user.destroy 33 | end 34 | end 35 | 36 | test "doesn't delete notifications when disabled" do 37 | SimpleNotifier.with(owner: user, message: "test").deliver(users(:two)) 38 | 39 | assert_no_difference "Noticed::Event.count" do 40 | user.destroy 41 | end 42 | end 43 | 44 | def user 45 | @user ||= users(:one) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/noticed/api_client.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | 3 | module Noticed 4 | module ApiClient 5 | extend ActiveSupport::Concern 6 | 7 | # Helper method for making POST requests from delivery methods 8 | # 9 | # Usage: 10 | # post_request("http://example.com", basic_auth: {user:, pass:}, headers: {}, json: {}, form: {}) 11 | # 12 | def post_request(url, args = {}) 13 | args.compact! 14 | 15 | uri = URI(url) 16 | http = Net::HTTP.new(uri.host, uri.port) 17 | http.use_ssl = true if uri.instance_of? URI::HTTPS 18 | 19 | headers = args.delete(:headers) || {} 20 | headers["Content-Type"] = "application/json" if args.has_key?(:json) 21 | 22 | request = Net::HTTP::Post.new(uri.request_uri, headers) 23 | 24 | if (basic_auth = args.delete(:basic_auth)) 25 | request.basic_auth basic_auth.fetch(:user), basic_auth.fetch(:pass) 26 | end 27 | 28 | if (json = args.delete(:json)) 29 | request.body = json.to_json 30 | elsif (form = args.delete(:form)) 31 | request.form_data = form 32 | elsif (body = args.delete(:body)) 33 | request.body = body 34 | end 35 | 36 | logger.debug("POST #{url}") 37 | logger.debug(request.body) 38 | response = http.request(request) 39 | logger.debug("Response: #{response.code}: #{response.body.inspect}") 40 | 41 | raise ResponseUnsuccessful.new(response, url, args) unless response.code.start_with?("20") 42 | 43 | response 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /docs/delivery_methods/action_push_native.md: -------------------------------------------------------------------------------- 1 | # Action Push Native Notification Delivery Method 2 | 3 | Send Apple and Android push notifications with [Action Push Native](https://github.com/rails/action_push_native) 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class CommentNotifier < ApplicationNotifier 9 | deliver_by :action_push_native do |config| 10 | config.devices = -> { ApplicationPushDevice.where(owner: recipient) } 11 | config.format = -> { 12 | { 13 | title: "Hello world, #{recipient.first_name}!", 14 | body: "Welcome to Noticed with Action Push Native.", 15 | badge: 1, 16 | } 17 | } 18 | config.with_apple = -> { 19 | { category: "observable" } 20 | } 21 | config.with_google = -> { 22 | { } 23 | } 24 | config.with_data = -> { 25 | { } 26 | } 27 | end 28 | end 29 | ``` 30 | 31 | ## Options 32 | 33 | * `devices` 34 | 35 | Should return a list of `ApplicationPushDevice` records 36 | 37 | * `format` 38 | 39 | Should return a `Hash` of [Notification attributes](https://github.com/rails/action_push_native/tree/main?tab=readme-ov-file#actionpushnativenotification-attributes) 40 | 41 | * `with_data` 42 | 43 | Should return a `Hash` of custom data to be sent with the notification to all platforms. 44 | 45 | * `with_apple` 46 | 47 | Should return a `Hash` of APNs specific data 48 | 49 | * `with_google` 50 | 51 | Should return a `Hash` of FCM specific data 52 | 53 | * `silent` 54 | 55 | Should return a `Boolean` if notification should be silent 56 | -------------------------------------------------------------------------------- /docs/delivery_methods/action_cable.md: -------------------------------------------------------------------------------- 1 | # ActionCable Delivery Method 2 | 3 | Sends a notification to the browser via websockets (ActionCable channel by default). 4 | 5 | ```ruby 6 | deliver_by :action_cable do |config| 7 | config.channel = "Noticed::NotificationChannel" 8 | config.stream = ->{ recipient } 9 | config.message = ->{ params.merge( user_id: recipient.id) } 10 | end 11 | ``` 12 | 13 | ## Options 14 | 15 | * `message` 16 | 17 | Should return a Hash to be sent as the ActionCable message 18 | 19 | * `channel` 20 | 21 | Override the ActionCable channel used to send notifications. Defaults to `Noticed::NotificationChannel` 22 | 23 | * `stream` 24 | 25 | Should return the stream the message is broadcasted to. Defaults to `recipient` 26 | 27 | ## Authentication 28 | 29 | To send notifications to individual users, you'll want to use `stream_for current_user`. This requires `identified_by :current_user` in your ApplicationCable::Connection. For example, using Devise for authentication: 30 | 31 | ```ruby 32 | module ApplicationCable 33 | class Connection < ActionCable::Connection::Base 34 | identified_by :current_user 35 | 36 | def connect 37 | self.current_user = find_verified_user 38 | logger.add_tags "ActionCable", "User #{current_user.id}" 39 | end 40 | 41 | protected 42 | 43 | def find_verified_user 44 | if current_user = env['warden'].user 45 | current_user 46 | else 47 | reject_unauthorized_connection 48 | end 49 | end 50 | end 51 | end 52 | ``` 53 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/twilio_messaging.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class TwilioMessaging < DeliveryMethod 4 | def deliver 5 | post_request url, basic_auth: {user: account_sid, pass: auth_token}, form: json.stringify_keys 6 | rescue Noticed::ResponseUnsuccessful => exception 7 | if exception.response.code.start_with?("4") && config[:error_handler] 8 | notification.instance_exec(exception.response, &config[:error_handler]) 9 | else 10 | raise 11 | end 12 | end 13 | 14 | def json 15 | evaluate_option(:json) || { 16 | From: phone_number, 17 | To: recipient.phone_number, 18 | Body: params.fetch(:message) 19 | } 20 | end 21 | 22 | def url 23 | evaluate_option(:url) || "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}/Messages.json" 24 | end 25 | 26 | def account_sid 27 | evaluate_option(:account_sid) || credentials.fetch(:account_sid) 28 | end 29 | 30 | def auth_token 31 | evaluate_option(:auth_token) || credentials.fetch(:auth_token) 32 | end 33 | 34 | def phone_number 35 | evaluate_option(:phone_number) || credentials.fetch(:phone_number) 36 | end 37 | 38 | def credentials 39 | evaluate_option(:credentials) || Rails.application.credentials.twilio 40 | end 41 | end 42 | end 43 | end 44 | 45 | ActiveSupport.run_load_hooks :noticed_delivery_methods_twilio_messaging, Noticed::DeliveryMethods::TwilioMessaging 46 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_methods/bluesky.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module BulkDeliveryMethods 3 | class Bluesky < BulkDeliveryMethod 4 | required_options :identifier, :password, :json 5 | 6 | # bulk_deliver_by :bluesky do |config| 7 | # config.identifier = ENV["BLUESKY_ID"] 8 | # config.password = ENV["BLUESKY_PASSWORD"] 9 | # config.json = {text: "...", createdAt: "..."} 10 | # end 11 | 12 | def deliver 13 | Rails.logger.debug(evaluate_option(:json)) 14 | post_request( 15 | "https://#{host}/xrpc/com.atproto.repo.createRecord", 16 | headers: {"Authorization" => "Bearer #{token}"}, 17 | json: { 18 | repo: identifier, 19 | collection: "app.bsky.feed.post", 20 | record: evaluate_option(:json) 21 | } 22 | ) 23 | end 24 | 25 | def token 26 | start_session.dig("accessJwt") 27 | end 28 | 29 | def start_session 30 | response = post_request( 31 | "https://#{host}/xrpc/com.atproto.server.createSession", 32 | json: { 33 | identifier: identifier, 34 | password: evaluate_option(:password) 35 | } 36 | ) 37 | JSON.parse(response.body) 38 | end 39 | 40 | def host 41 | @host ||= evaluate_option(:host) || "bsky.social" 42 | end 43 | 44 | def identifier 45 | @identifier ||= evaluate_option(:identifier) 46 | end 47 | end 48 | end 49 | end 50 | 51 | ActiveSupport.run_load_hooks :noticed_bulk_delivery_methods_bluesky, Noticed::BulkDeliveryMethods::Bluesky 52 | -------------------------------------------------------------------------------- /test/jobs/event_job_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EventJobTest < ActiveJob::TestCase 4 | module ::Noticed 5 | class DeliveryMethods::Test1 < DeliveryMethod; end 6 | 7 | class DeliveryMethods::Test2 < DeliveryMethod; end 8 | 9 | class BulkDeliveryMethods::Test1 < BulkDeliveryMethod; end 10 | 11 | class BulkDeliveryMethods::Test2 < BulkDeliveryMethod; end 12 | end 13 | 14 | test "enqueues jobs for each notification and delivery method" do 15 | Noticed::EventJob.perform_now(noticed_notifications(:one).event) 16 | assert_enqueued_jobs 3 17 | end 18 | 19 | test "skips enqueueing jobs if before_enqueue raises an error" do 20 | notification = noticed_notifications(:one) 21 | event = notification.event 22 | event.class.deliver_by :test1 do |config| 23 | config.before_enqueue = -> { false } 24 | end 25 | event.class.deliver_by :test2 do |config| 26 | config.before_enqueue = -> { throw :abort } 27 | end 28 | 29 | Noticed::EventJob.perform_now(event) 30 | assert_enqueued_jobs 4 31 | 32 | event.class.delivery_methods.delete(:test1) 33 | event.class.delivery_methods.delete(:test2) 34 | end 35 | 36 | test "skips enqueueing bulk delivery job if before_enqueue raises an error" do 37 | notification = noticed_notifications(:one) 38 | event = notification.event 39 | event.class.bulk_deliver_by :test1 do |config| 40 | config.before_enqueue = -> { false } 41 | end 42 | event.class.bulk_deliver_by :test2 do |config| 43 | config.before_enqueue = -> { throw :abort } 44 | end 45 | 46 | Noticed::EventJob.perform_now(event) 47 | assert_enqueued_jobs 4 48 | 49 | event.class.bulk_delivery_methods.delete(:test1) 50 | event.class.bulk_delivery_methods.delete(:test2) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_method.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class BulkDeliveryMethod < Noticed.parent_class.constantize 3 | include ApiClient 4 | include RequiredOptions 5 | 6 | extend ActiveModel::Callbacks 7 | 8 | define_model_callbacks :deliver 9 | 10 | class_attribute :logger, default: Rails.logger 11 | 12 | attr_reader :config, :event 13 | 14 | def perform(delivery_method_name, event, recipient: nil, params: {}, overrides: {}) 15 | # Ephemeral notifications 16 | if event.is_a? String 17 | @event = event.constantize.new_with_params(recipient, params) 18 | @config = overrides 19 | else 20 | @event = event 21 | @config = event.bulk_delivery_methods.fetch(delivery_method_name).config.merge(overrides) 22 | end 23 | 24 | return false if config.has_key?(:if) && !evaluate_option(:if) 25 | return false if config.has_key?(:unless) && evaluate_option(:unless) 26 | 27 | run_callbacks :deliver do 28 | deliver 29 | end 30 | end 31 | 32 | def deliver 33 | raise NotImplementedError, "Bulk delivery methods must implement the `deliver` method" 34 | end 35 | 36 | def fetch_constant(name) 37 | option = config[name] 38 | option.is_a?(String) ? option.constantize : evaluate_option(option) 39 | end 40 | 41 | def evaluate_option(name) 42 | option = config[name] 43 | 44 | # Evaluate Proc within the context of the notifier 45 | if option&.respond_to?(:call) 46 | event.instance_exec(&option) 47 | 48 | # Call method if symbol and matching method 49 | elsif option.is_a?(Symbol) && event.respond_to?(option, true) 50 | event.send(option) 51 | 52 | # Return the value 53 | else 54 | option 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | 5 | # Puma can serve each request in a thread from an internal thread pool. 6 | # The `threads` method setting takes two numbers: a minimum and maximum. 7 | # Any libraries that use thread pools should be configured to match 8 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 9 | # and maximum; this matches the default thread size of Active Record. 10 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 11 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 12 | threads min_threads_count, max_threads_count 13 | 14 | # Specifies that the worker count should equal the number of processors in production. 15 | if ENV["RAILS_ENV"] == "production" 16 | require "concurrent-ruby" 17 | worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) 18 | workers worker_count if worker_count > 1 19 | end 20 | 21 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 22 | # terminating a worker in development environments. 23 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 24 | 25 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 26 | port ENV.fetch("PORT") { 3000 } 27 | 28 | # Specifies the `environment` that Puma will run in. 29 | environment ENV.fetch("RAILS_ENV") { "development" } 30 | 31 | # Specifies the `pidfile` that Puma will use. 32 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 33 | 34 | # Allow puma to be restarted by `bin/rails restart` command. 35 | plugin :tmp_restart 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug 3 | about: File a bug/issue 4 | title: '[BUG]If you are the application owner check the logs for more information.
64 |Maybe you tried to change something you didn't have access to.
63 |If you are the application owner check the logs for more information.
65 |You may have mistyped the address or the page may have moved.
63 |If you are the application owner check the logs for more information.
65 |Hello world
", message 53 | assert message.html_safe? 54 | end 55 | end 56 | 57 | test "delivery method blocks can use translations" do 58 | block = I18nExample.delivery_methods[:test].config[:message] 59 | assert_equal "Hello world", noticed_notifications(:one).instance_exec(&block) 60 | end 61 | 62 | test "ephemeral translations" do 63 | assert_equal "Hello world", EphemeralNotifier.new.t("hello") 64 | end 65 | 66 | test "ephemeral notification translations" do 67 | assert_equal "Hello world", EphemeralNotifier::Notification.new.t("hello") 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /app/models/concerns/noticed/readable.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module Readable 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | scope :read, -> { where.not(read_at: nil) } 7 | scope :unread, -> { where(read_at: nil) } 8 | scope :seen, -> { where.not(seen_at: nil) } 9 | scope :unseen, -> { where(seen_at: nil) } 10 | end 11 | 12 | class_methods do 13 | def mark_as_read_and_seen(**kwargs) 14 | update_all(**kwargs.with_defaults(read_at: Time.current, seen_at: Time.current, updated_at: Time.current)) 15 | end 16 | 17 | def mark_as_unread_and_unseen(**kwargs) 18 | update_all(**kwargs.with_defaults(read_at: nil, seen_at: nil, updated_at: Time.current)) 19 | end 20 | 21 | def mark_as_read(**kwargs) 22 | update_all(**kwargs.with_defaults(read_at: Time.current, updated_at: Time.current)) 23 | end 24 | 25 | def mark_as_unread(**kwargs) 26 | update_all(**kwargs.with_defaults(read_at: nil, updated_at: Time.current)) 27 | end 28 | 29 | def mark_as_seen(**kwargs) 30 | update_all(**kwargs.with_defaults(seen_at: Time.current, updated_at: Time.current)) 31 | end 32 | 33 | def mark_as_unseen(**kwargs) 34 | update_all(**kwargs.with_defaults(seen_at: nil, updated_at: Time.current)) 35 | end 36 | end 37 | 38 | def mark_as_read 39 | update(read_at: Time.current) 40 | end 41 | 42 | def mark_as_read! 43 | update!(read_at: Time.current) 44 | end 45 | 46 | def mark_as_unread 47 | update(read_at: nil) 48 | end 49 | 50 | def mark_as_unread! 51 | update!(read_at: nil) 52 | end 53 | 54 | def mark_as_seen 55 | update(seen_at: Time.current) 56 | end 57 | 58 | def mark_as_seen! 59 | update!(seen_at: Time.current) 60 | end 61 | 62 | def mark_as_unseen 63 | update(seen_at: nil) 64 | end 65 | 66 | def mark_as_unseen! 67 | update!(seen_at: nil) 68 | end 69 | 70 | def read? 71 | read_at? 72 | end 73 | 74 | def unread? 75 | !read_at? 76 | end 77 | 78 | def seen? 79 | seen_at? 80 | end 81 | 82 | def unseen? 83 | !seen_at? 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/delivery_methods/twilio_messaging_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TwilioMessagingTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::TwilioMessaging.new 6 | @config = { 7 | account_sid: "acct_1234", 8 | auth_token: "token", 9 | json: -> { 10 | { 11 | From: "+1234567890", 12 | To: "+1234567890", 13 | Body: "Hello world" 14 | } 15 | } 16 | } 17 | set_config(@config) 18 | end 19 | 20 | test "sends sms" do 21 | stub_request(:post, "https://api.twilio.com/2010-04-01/Accounts/acct_1234/Messages.json").with( 22 | headers: { 23 | "Authorization" => "Basic YWNjdF8xMjM0OnRva2Vu", 24 | "Content-Type" => "application/x-www-form-urlencoded" 25 | }, 26 | body: { 27 | From: "+1234567890", 28 | To: "+1234567890", 29 | Body: "Hello world" 30 | } 31 | ).to_return(status: 200) 32 | 33 | assert_nothing_raised do 34 | @delivery_method.deliver 35 | end 36 | end 37 | 38 | test "raises error on failure" do 39 | stub_request(:post, "https://api.twilio.com/2010-04-01/Accounts/acct_1234/Messages.json").to_return(status: 422) 40 | assert_raises Noticed::ResponseUnsuccessful do 41 | @delivery_method.deliver 42 | end 43 | end 44 | 45 | test "passes error to notification instance if error_handler is configured" do 46 | @delivery_method = Noticed::DeliveryMethods::TwilioMessaging.new( 47 | "delivery_method_name", 48 | noticed_notifications(:one) 49 | ) 50 | 51 | error_handler_called = false 52 | @config[:error_handler] = lambda do |twilio_error_message| 53 | error_handler_called = true 54 | end 55 | set_config(@config) 56 | 57 | stub_request(:post, "https://api.twilio.com/2010-04-01/Accounts/acct_1234/Messages.json").to_return(status: 422) 58 | assert_nothing_raised do 59 | @delivery_method.deliver 60 | end 61 | 62 | assert(error_handler_called, "Handler is called if status is 4xx") 63 | end 64 | 65 | private 66 | 67 | def set_config(config) 68 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2024_01_29_184740) do 14 | create_table "accounts", force: :cascade do |t| 15 | t.string "name" 16 | t.datetime "created_at", null: false 17 | t.datetime "updated_at", null: false 18 | end 19 | 20 | create_table "noticed_events", force: :cascade do |t| 21 | t.string "type" 22 | t.string "record_type" 23 | t.integer "record_id" 24 | if t.respond_to?(:jsonb) 25 | t.jsonb "params" 26 | else 27 | t.json "params" 28 | end 29 | t.integer "notifications_count" 30 | t.datetime "created_at", null: false 31 | t.datetime "updated_at", null: false 32 | t.index ["record_type", "record_id"], name: "index_noticed_events_on_record" 33 | end 34 | 35 | create_table "noticed_notifications", force: :cascade do |t| 36 | t.string "type" 37 | t.integer "event_id", null: false 38 | t.string "recipient_type", null: false 39 | t.integer "recipient_id", null: false 40 | t.datetime "read_at" 41 | t.datetime "seen_at" 42 | t.datetime "created_at", null: false 43 | t.datetime "updated_at", null: false 44 | t.index ["event_id"], name: "index_noticed_notifications_on_event_id" 45 | t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient" 46 | end 47 | 48 | create_table "users", force: :cascade do |t| 49 | t.string "type" 50 | t.string "email" 51 | t.datetime "created_at", null: false 52 | t.datetime "updated_at", null: false 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/noticed/deliverable/deliver_by.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module Deliverable 3 | class DeliverBy 4 | attr_reader :name, :config, :bulk 5 | 6 | def initialize(name, config, bulk: false) 7 | @name, @config, @bulk, = name, config, bulk 8 | end 9 | 10 | def constant 11 | namespace = bulk ? "Noticed::BulkDeliveryMethods" : "Noticed::DeliveryMethods" 12 | config.fetch(:class, [namespace, name.to_s.camelize].join("::")).constantize 13 | end 14 | 15 | def validate! 16 | constant.required_option_names.each do |option| 17 | raise ValidationError, "option `#{option}` must be set for `deliver_by :#{name}`" unless config[option].present? 18 | end 19 | end 20 | 21 | def perform_later(event_or_notification, options = {}) 22 | constant.set(computed_options(options, event_or_notification)).perform_later(name, event_or_notification) 23 | end 24 | 25 | def ephemeral_perform_later(notifier, recipient, params, options = {}) 26 | constant.set(computed_options(options, recipient)) 27 | .perform_later(name, "#{notifier}::Notification", recipient: recipient, params: params) 28 | end 29 | 30 | def evaluate_option(name, context) 31 | option = config[name] 32 | 33 | if option.respond_to?(:call) 34 | context.instance_exec(&option) 35 | elsif option.is_a?(Symbol) && context.respond_to?(option, true) 36 | context.send(option) 37 | else 38 | option 39 | end 40 | end 41 | 42 | def perform?(notification) 43 | return true unless config.key?(:before_enqueue) 44 | 45 | perform = false 46 | catch(:abort) { 47 | evaluate_option(:before_enqueue, notification) 48 | perform = true 49 | } 50 | perform 51 | end 52 | 53 | private 54 | 55 | def computed_options(options, recipient) 56 | options[:wait] ||= evaluate_option(:wait, recipient) if config.has_key?(:wait) 57 | options[:wait_until] ||= evaluate_option(:wait_until, recipient) if config.has_key?(:wait_until) 58 | options[:queue] ||= evaluate_option(:queue, recipient) if config.has_key?(:queue) 59 | options[:priority] ||= evaluate_option(:priority, recipient) if config.has_key?(:priority) 60 | options 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/delivery_methods/ios_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class IosTest < ActiveSupport::TestCase 4 | class FakeConnectionPool 5 | class_attribute :invalid_tokens, default: [] 6 | attr_reader :deliveries 7 | 8 | def initialize(response) 9 | @response = response 10 | @deliveries = [] 11 | end 12 | 13 | def with 14 | yield self 15 | end 16 | 17 | def push(apn) 18 | @deliveries.push(apn) 19 | @response 20 | end 21 | 22 | def close 23 | end 24 | end 25 | 26 | class FakeResponse 27 | attr_reader :status 28 | 29 | def initialize(status, body = {}) 30 | @status = status 31 | end 32 | 33 | def ok? 34 | status.start_with?("20") 35 | end 36 | end 37 | 38 | setup do 39 | FakeConnectionPool.invalid_tokens = [] 40 | 41 | @delivery_method = Noticed::DeliveryMethods::Ios.new 42 | @delivery_method.instance_variable_set :@notification, noticed_notifications(:one) 43 | set_config( 44 | bundle_identifier: "bundle_id", 45 | key_id: "key_id", 46 | team_id: "team_id", 47 | apns_key: "apns_key", 48 | device_tokens: [:a, :b], 49 | format: ->(apn) { 50 | apn.alert = "Hello world" 51 | apn.custom_payload = {url: root_url(host: "example.org")} 52 | }, 53 | invalid_token: ->(device_token) { 54 | FakeConnectionPool.invalid_tokens << device_token 55 | } 56 | ) 57 | end 58 | 59 | test "notifies each device token" do 60 | connection_pool = FakeConnectionPool.new(FakeResponse.new("200")) 61 | @delivery_method.stub(:production_pool, connection_pool) do 62 | @delivery_method.deliver 63 | end 64 | 65 | assert_equal 2, connection_pool.deliveries.count 66 | assert_equal 0, FakeConnectionPool.invalid_tokens.count 67 | end 68 | 69 | test "notifies of invalid tokens for cleanup" do 70 | connection_pool = FakeConnectionPool.new(FakeResponse.new("410")) 71 | @delivery_method.stub(:production_pool, connection_pool) do 72 | @delivery_method.deliver 73 | end 74 | 75 | # Our fake connection pool doesn't understand these wouldn't be delivered in the real world 76 | assert_equal 2, connection_pool.deliveries.count 77 | assert_equal 2, FakeConnectionPool.invalid_tokens.count 78 | end 79 | 80 | private 81 | 82 | def set_config(config) 83 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/delivery_methods/webhook_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class WebhookDeliveryMethodTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::Webhook.new 6 | end 7 | 8 | test "webhook with json payload" do 9 | set_config( 10 | url: "https://example.org/webhook", 11 | json: {foo: :bar} 12 | ) 13 | stub_request(:post, "https://example.org/webhook").with(body: "{\"foo\":\"bar\"}") 14 | 15 | assert_nothing_raised do 16 | @delivery_method.deliver 17 | end 18 | end 19 | 20 | test "webhook with form payload" do 21 | set_config( 22 | url: "https://example.org/webhook", 23 | form: {foo: :bar} 24 | ) 25 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => /application\/x-www-form-urlencoded/}) 26 | 27 | assert_nothing_raised do 28 | @delivery_method.deliver 29 | end 30 | end 31 | 32 | test "webhook with body payload" do 33 | set_config( 34 | url: "https://example.org/webhook", 35 | body: "ANYTHING" 36 | ) 37 | stub_request(:post, "https://example.org/webhook").with(body: "ANYTHING") 38 | assert_nothing_raised do 39 | @delivery_method.deliver 40 | end 41 | end 42 | 43 | test "webhook with basic auth" do 44 | set_config( 45 | url: "https://example.org/webhook", 46 | basic_auth: {user: "username", pass: "password"} 47 | ) 48 | stub_request(:post, "https://example.org/webhook").with(headers: {"Authorization" => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="}) 49 | 50 | assert_nothing_raised do 51 | @delivery_method.deliver 52 | end 53 | end 54 | 55 | test "webhook with headers" do 56 | set_config( 57 | url: "https://example.org/webhook", 58 | headers: {"Content-Type" => "application/json"} 59 | ) 60 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => "application/json"}) 61 | 62 | assert_nothing_raised do 63 | @delivery_method.deliver 64 | end 65 | end 66 | 67 | test "webhook raises error with unsuccessful status codes" do 68 | set_config(url: "https://example.org/webhook") 69 | stub_request(:post, "https://example.org/webhook").to_return(status: 422) 70 | assert_raises Noticed::ResponseUnsuccessful do 71 | @delivery_method.deliver 72 | end 73 | end 74 | 75 | private 76 | 77 | def set_config(config) 78 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/fcm.rb: -------------------------------------------------------------------------------- 1 | require "googleauth" 2 | 3 | module Noticed 4 | module DeliveryMethods 5 | class Fcm < DeliveryMethod 6 | required_option :credentials, :device_tokens, :json 7 | 8 | def deliver 9 | evaluate_option(:device_tokens).each do |device_token| 10 | send_notification device_token 11 | end 12 | end 13 | 14 | def send_notification(device_token) 15 | post_request("https://fcm.googleapis.com/v1/projects/#{credentials[:project_id]}/messages:send", 16 | headers: {authorization: "Bearer #{access_token}"}, 17 | json: format_notification(device_token)) 18 | rescue Noticed::ResponseUnsuccessful => exception 19 | if bad_token?(exception.response) && config[:invalid_token] 20 | notification.instance_exec(device_token, &config[:invalid_token]) 21 | elsif config[:error_handler] 22 | notification.instance_exec(exception.response, &config[:error_handler]) 23 | else 24 | raise 25 | end 26 | end 27 | 28 | def format_notification(device_token) 29 | method = config[:json] 30 | if method.is_a?(Symbol) && event.respond_to?(method, true) 31 | event.send(method, device_token) 32 | else 33 | notification.instance_exec(device_token, &method) 34 | end 35 | end 36 | 37 | def bad_token?(response) 38 | response.code == "404" || response.code == "400" 39 | end 40 | 41 | def credentials 42 | @credentials ||= begin 43 | value = evaluate_option(:credentials) 44 | case value 45 | when Hash 46 | value 47 | when Pathname 48 | load_json(value) 49 | when String 50 | load_json(Rails.root.join(value)) 51 | else 52 | raise ArgumentError, "FCM credentials must be a Hash, String, Pathname, or Symbol" 53 | end 54 | end 55 | end 56 | 57 | def load_json(path) 58 | JSON.parse(File.read(path), symbolize_names: true) 59 | end 60 | 61 | def access_token 62 | @authorizer ||= (evaluate_option(:authorizer) || Google::Auth::ServiceAccountCredentials).make_creds( 63 | json_key_io: StringIO.new(credentials.to_json), 64 | scope: "https://www.googleapis.com/auth/firebase.messaging" 65 | ) 66 | @authorizer.fetch_access_token!["access_token"] 67 | end 68 | end 69 | end 70 | end 71 | 72 | ActiveSupport.run_load_hooks :noticed_delivery_methods_fcm, Noticed::DeliveryMethods::Fcm 73 | -------------------------------------------------------------------------------- /docs/delivery_methods/twilio_messaging.md: -------------------------------------------------------------------------------- 1 | # Twilio Messaging Delivery Method 2 | 3 | Sends an SMS or Whatsapp message via Twilio Messaging. 4 | 5 | ```ruby 6 | deliver_by :twilio_messaging do |config| 7 | config.json = ->{ 8 | { 9 | From: phone_number, 10 | To: recipient.phone_number, 11 | Body: params.fetch(:message) 12 | } 13 | } 14 | 15 | config.credentials = { 16 | phone_number: Rails.application.credentials.dig(:twilio, :phone_number), 17 | account_sid: Rails.application.credentials.dig(:twilio, :account_sid), 18 | auth_token: Rails.application.credentials.dig(:twilio, :auth_token) 19 | } 20 | # config.credentials = Rails.application.credentials.twilio 21 | # config.phone = "+1234567890" 22 | # config.url = "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}/Messages.json" 23 | end 24 | ``` 25 | 26 | ## Content Templates 27 | 28 | ```ruby 29 | deliver_by :twilio_messaging do |config| 30 | config.json = -> { 31 | { 32 | From: "+1234567890", 33 | To: recipient.phone_number, 34 | ContentSid: "value", # Template SID 35 | ContentVariables: {1: recipient.first_name} 36 | } 37 | } 38 | end 39 | ``` 40 | 41 | ## Error Handling 42 | 43 | Twilio provides a full list of error codes that can be handled as needed. See https://www.twilio.com/docs/api/errors 44 | 45 | ```ruby 46 | deliver_by :twilio_messaging do |config| 47 | config.error_handler = lambda do |twilio_error_response| 48 | error_hash = JSON.parse(twilio_error_response.body) 49 | case error_hash["code"] 50 | when 21211 51 | # The 'To' number is not a valid phone number. 52 | # Write your error handling code 53 | else 54 | raise "Unhandled Twilio error: #{error_hash}" 55 | end 56 | end 57 | end 58 | ``` 59 | 60 | ## Options 61 | 62 | * `json` - *Optional* 63 | 64 | Use a custom method to define the payload sent to Twilio. Method should return a Hash. 65 | 66 | Defaults to: 67 | 68 | ```ruby 69 | { 70 | Body: params[:message], # From notification.params 71 | From: Rails.application.credentials.twilio[:phone_number], 72 | To: recipient.phone_number 73 | } 74 | ``` 75 | 76 | * `credentials` - *Optional* 77 | 78 | Retrieve the credentials for Twilio. Should return a Hash with `:account_sid`, `:auth_token` and `:phone_number` keys. 79 | 80 | Defaults to `Rails.application.credentials.twilio[:account_sid]` and `Rails.application.credentials.twilio[:auth_token]` 81 | 82 | * `url` - *Optional* 83 | 84 | Retrieve the Twilio URL. Should return the Twilio API url as a string. 85 | 86 | Defaults to `"https://api.twilio.com/2010-04-01/Accounts/#{twilio_credentials(recipient)[:account_sid]}/Messages.json"` 87 | -------------------------------------------------------------------------------- /test/bulk_delivery_methods/webhook_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class WebhookBulkDeliveryMethodTest < ActiveSupport::TestCase 4 | include ActiveJob::TestHelper 5 | 6 | setup do 7 | @delivery_method = Noticed::BulkDeliveryMethods::Webhook.new 8 | end 9 | 10 | test "end to end" do 11 | stub_request(:post, "https://example.org/bulk") 12 | perform_enqueued_jobs do 13 | BulkNotifier.deliver 14 | end 15 | end 16 | 17 | test "webhook with json payload" do 18 | set_config( 19 | url: "https://example.org/webhook", 20 | json: {foo: :bar} 21 | ) 22 | stub_request(:post, "https://example.org/webhook").with(body: "{\"foo\":\"bar\"}") 23 | 24 | assert_nothing_raised do 25 | @delivery_method.deliver 26 | end 27 | end 28 | 29 | test "webhook with form payload" do 30 | set_config( 31 | url: "https://example.org/webhook", 32 | form: {foo: :bar} 33 | ) 34 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => /application\/x-www-form-urlencoded/}) 35 | assert_nothing_raised do 36 | @delivery_method.deliver 37 | end 38 | end 39 | 40 | test "webhook with body payload" do 41 | set_config( 42 | url: "https://example.org/webhook", 43 | body: "ANYTHING" 44 | ) 45 | stub_request(:post, "https://example.org/webhook").with(body: "ANYTHING") 46 | assert_nothing_raised do 47 | @delivery_method.deliver 48 | end 49 | end 50 | 51 | test "webhook with basic auth" do 52 | set_config( 53 | url: "https://example.org/webhook", 54 | basic_auth: {user: "username", pass: "password"} 55 | ) 56 | stub_request(:post, "https://example.org/webhook").with(headers: {"Authorization" => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="}) 57 | assert_nothing_raised do 58 | @delivery_method.deliver 59 | end 60 | end 61 | 62 | test "webhook with headers" do 63 | set_config( 64 | url: "https://example.org/webhook", 65 | headers: {"Content-Type" => "application/json"} 66 | ) 67 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => "application/json"}) 68 | 69 | assert_nothing_raised do 70 | @delivery_method.deliver 71 | end 72 | end 73 | 74 | test "webhook raises error with unsuccessful status codes" do 75 | set_config(url: "https://example.org/webhook") 76 | stub_request(:post, "https://example.org/webhook").to_return(status: 422) 77 | assert_raises Noticed::ResponseUnsuccessful do 78 | @delivery_method.deliver 79 | end 80 | end 81 | 82 | private 83 | 84 | def set_config(config) 85 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # While tests run files are not watched, reloading is not necessary. 12 | config.enable_reloading = false 13 | 14 | # Eager loading loads your entire application. When running a single test locally, 15 | # this is usually not necessary, and can slow down your test suite. However, it's 16 | # recommended that you enable it in continuous integration systems to ensure eager 17 | # loading is working properly before deploying your code. 18 | config.eager_load = ENV["CI"].present? 19 | 20 | # Configure public file server for tests with Cache-Control for performance. 21 | config.public_file_server.enabled = true 22 | config.public_file_server.headers = { 23 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 24 | } 25 | 26 | # Show full error reports and disable caching. 27 | config.consider_all_requests_local = true 28 | config.action_controller.perform_caching = false 29 | config.cache_store = :null_store 30 | 31 | # Render exception templates for rescuable exceptions and raise for other exceptions. 32 | config.action_dispatch.show_exceptions = :rescuable 33 | 34 | # Disable request forgery protection in test environment. 35 | config.action_controller.allow_forgery_protection = false 36 | 37 | # Store uploaded files on the local file system in a temporary directory. 38 | config.active_storage.service = :test 39 | 40 | config.action_mailer.perform_caching = false 41 | 42 | # Tell Action Mailer not to deliver emails to the real world. 43 | # The :test delivery method accumulates sent emails in the 44 | # ActionMailer::Base.deliveries array. 45 | config.action_mailer.delivery_method = :test 46 | 47 | # Print deprecation notices to the stderr. 48 | config.active_support.deprecation = :stderr 49 | 50 | # Raise exceptions for disallowed deprecations. 51 | config.active_support.disallowed_deprecation = :raise 52 | 53 | # Tell Active Support which deprecation messages to disallow. 54 | config.active_support.disallowed_deprecation_warnings = [] 55 | 56 | # Raises error for missing translations. 57 | # config.i18n.raise_on_missing_translations = true 58 | 59 | # Annotate rendered view with file names. 60 | # config.action_view.annotate_rendered_view_with_filenames = true 61 | 62 | # Raise error when a before_action's only/except options reference missing actions 63 | # config.action_controller.raise_on_missing_callback_actions = true 64 | 65 | routes.default_url_options[:host] = "localhost:3000" 66 | end 67 | -------------------------------------------------------------------------------- /test/delivery_methods/slack_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SlackTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::Slack.new 6 | set_config(json: {foo: :bar}) 7 | end 8 | 9 | test "sends a slack message with application/json content type" do 10 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL) 11 | .with( 12 | body: "{\"foo\":\"bar\"}", 13 | headers: {"Content-Type" => "application/json"} 14 | ) 15 | .to_return( 16 | status: 200, 17 | body: {ok: true}.to_json, 18 | headers: {"Content-Type" => "application/json"} 19 | ) 20 | 21 | assert_nothing_raised do 22 | @delivery_method.deliver 23 | end 24 | end 25 | 26 | test "sends a slack message with text/html content type" do 27 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL) 28 | .with( 29 | body: "{\"foo\":\"bar\"}", 30 | headers: {"Content-Type" => "application/json"} 31 | ) 32 | .to_return( 33 | status: 200, 34 | body: "ok", 35 | headers: {"Content-Type" => "text/html"} 36 | ) 37 | 38 | assert_nothing_raised do 39 | @delivery_method.deliver 40 | end 41 | end 42 | 43 | test "raises error on failure" do 44 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).to_return(status: 422) 45 | assert_raises Noticed::ResponseUnsuccessful do 46 | @delivery_method.deliver 47 | end 48 | end 49 | 50 | test "doesnt raise error on failed 200 status code request with raise_if_not_ok false" do 51 | @delivery_method.config[:raise_if_not_ok] = false 52 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).with(body: "{\"foo\":\"bar\"}").to_return(status: 200, body: {ok: false}.to_json, headers: {"Content-Type" => "application/json"}) 53 | assert_nothing_raised do 54 | @delivery_method.deliver 55 | end 56 | end 57 | 58 | test "raises error on 200 status code request with raise_if_not_ok true" do 59 | @delivery_method.config[:raise_if_not_ok] = true 60 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).with(body: "{\"foo\":\"bar\"}").to_return(status: 200, body: {ok: false}.to_json, headers: {"Content-Type" => "application/json"}) 61 | assert_raises Noticed::ResponseUnsuccessful do 62 | @delivery_method.deliver 63 | end 64 | end 65 | 66 | test "raises error on 400 status code request with raise_if_not_ok true" do 67 | @delivery_method.config[:raise_if_not_ok] = true 68 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).with(body: "{\"foo\":\"bar\"}").to_return(status: 403, headers: {"Content-Type" => "text/html"}) 69 | assert_raises Noticed::ResponseUnsuccessful do 70 | @delivery_method.deliver 71 | end 72 | end 73 | 74 | private 75 | 76 | def set_config(config) 77 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.enable_reloading = true 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Highlight code that enqueued background job in logs. 60 | config.active_job.verbose_enqueue_logs = true 61 | 62 | # Suppress logger output for asset requests. 63 | # config.assets.quiet = true 64 | 65 | # Raises error for missing translations. 66 | # config.i18n.raise_on_missing_translations = true 67 | 68 | # Annotate rendered view with file names. 69 | # config.action_view.annotate_rendered_view_with_filenames = true 70 | 71 | # Uncomment if you wish to allow Action Cable access from any origin. 72 | # config.action_cable.disable_request_forgery_protection = true 73 | 74 | # Raise error when a before_action's only/except options reference missing actions 75 | config.action_controller.raise_on_missing_callback_actions = true 76 | 77 | routes.default_url_options[:host] = "localhost:3000" 78 | end 79 | -------------------------------------------------------------------------------- /test/models/noticed/notification_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Noticed::NotificationTest < ActiveSupport::TestCase 4 | test "delegates params to event" do 5 | notification = noticed_notifications(:one) 6 | assert_equal notification.event.params, notification.params 7 | end 8 | 9 | test "delegates record to event" do 10 | notification = noticed_notifications(:one) 11 | assert_equal notification.event.record, notification.record 12 | end 13 | 14 | test "notification associations" do 15 | assert_equal 1, users(:one).notifications.count 16 | end 17 | 18 | test "read scope" do 19 | assert_equal 4, Noticed::Notification.read.count 20 | end 21 | 22 | test "unread scope" do 23 | assert_equal 0, Noticed::Notification.unread.count 24 | end 25 | 26 | test "seen scope" do 27 | assert_equal 4, Noticed::Notification.seen.count 28 | end 29 | 30 | test "unseen scope" do 31 | assert_equal 0, Noticed::Notification.unseen.count 32 | end 33 | 34 | test "mark_as_read" do 35 | Noticed::Notification.update_all(read_at: nil) 36 | assert_equal 0, Noticed::Notification.read.count 37 | Noticed::Notification.mark_as_read 38 | assert_equal 4, Noticed::Notification.read.count 39 | end 40 | 41 | test "mark_as_unread" do 42 | Noticed::Notification.update_all(read_at: Time.current) 43 | assert_equal 4, Noticed::Notification.read.count 44 | Noticed::Notification.mark_as_unread 45 | assert_equal 0, Noticed::Notification.read.count 46 | end 47 | 48 | test "mark_as_seen" do 49 | Noticed::Notification.update_all(seen_at: nil) 50 | assert_equal 0, Noticed::Notification.seen.count 51 | Noticed::Notification.mark_as_seen 52 | assert_equal 4, Noticed::Notification.seen.count 53 | end 54 | 55 | test "mark_as_unseen" do 56 | Noticed::Notification.update_all(seen_at: Time.current) 57 | assert_equal 4, Noticed::Notification.seen.count 58 | Noticed::Notification.mark_as_unseen 59 | assert_equal 0, Noticed::Notification.seen.count 60 | end 61 | 62 | test "mark_as_read_and_seen" do 63 | Noticed::Notification.update_all(read_at: nil, seen_at: nil) 64 | assert_equal 0, Noticed::Notification.read.count 65 | assert_equal 0, Noticed::Notification.seen.count 66 | Noticed::Notification.mark_as_read_and_seen 67 | assert_equal 4, Noticed::Notification.read.count 68 | assert_equal 4, Noticed::Notification.seen.count 69 | end 70 | 71 | test "mark_as_unread_and_unseen" do 72 | Noticed::Notification.update_all(read_at: Time.current, seen_at: Time.current) 73 | assert_equal 4, Noticed::Notification.read.count 74 | assert_equal 4, Noticed::Notification.seen.count 75 | Noticed::Notification.mark_as_unread_and_unseen 76 | assert_equal 0, Noticed::Notification.read.count 77 | assert_equal 0, Noticed::Notification.seen.count 78 | end 79 | 80 | test "read?" do 81 | assert noticed_notifications(:one).read? 82 | end 83 | 84 | test "unread?" do 85 | assert_not noticed_notifications(:one).unread? 86 | end 87 | 88 | test "seen?" do 89 | assert noticed_notifications(:one).seen? 90 | end 91 | 92 | test "unseen?" do 93 | assert_not noticed_notifications(:one).unseen? 94 | end 95 | 96 | test "notification url helpers" do 97 | assert_equal "http://localhost:3000/", CommentNotifier::Notification.new.root_url 98 | end 99 | 100 | test "ephemeral notification url helpers" do 101 | assert_equal "http://localhost:3000/", EphemeralNotifier::Notification.new.root_url 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/ios.rb: -------------------------------------------------------------------------------- 1 | require "apnotic" 2 | 3 | module Noticed 4 | module DeliveryMethods 5 | class Ios < DeliveryMethod 6 | cattr_accessor :development_connection_pool, :production_connection_pool 7 | 8 | required_options :bundle_identifier, :key_id, :team_id, :apns_key, :device_tokens 9 | 10 | def deliver 11 | evaluate_option(:device_tokens).each do |device_token| 12 | apn = Apnotic::Notification.new(device_token) 13 | format_notification(apn) 14 | 15 | connection_pool = (!!evaluate_option(:development)) ? development_pool : production_pool 16 | connection_pool.with do |connection| 17 | response = connection.push(apn) 18 | raise "Timeout sending iOS push notification" unless response 19 | connection.close 20 | 21 | if bad_token?(response) && config[:invalid_token] 22 | # Allow notification to cleanup invalid iOS device tokens 23 | notification.instance_exec(device_token, &config[:invalid_token]) 24 | elsif !response.ok? 25 | raise "Request failed #{response.body}" 26 | end 27 | end 28 | end 29 | end 30 | 31 | private 32 | 33 | def format_notification(apn) 34 | apn.topic = evaluate_option(:bundle_identifier) 35 | 36 | method = config[:format] 37 | # Call method on Notifier if defined 38 | if method&.is_a?(Symbol) && event.respond_to?(method, true) 39 | event.send(method, notification, apn) 40 | # If Proc, evaluate it on the Notification 41 | elsif method&.respond_to?(:call) 42 | notification.instance_exec(apn, &method) 43 | elsif notification.params.try(:has_key?, :message) 44 | apn.alert = notification.params[:message] 45 | else 46 | raise ArgumentError, "No message for iOS delivery. Either include message in params or add the 'format' option in 'deliver_by :ios'." 47 | end 48 | end 49 | 50 | def bad_token?(response) 51 | response.status == "410" || (response.status == "400" && response.body["reason"] == "BadDeviceToken") 52 | end 53 | 54 | def development_pool 55 | self.class.development_connection_pool ||= new_connection_pool(development: true) 56 | end 57 | 58 | def production_pool 59 | self.class.production_connection_pool ||= new_connection_pool(development: false) 60 | end 61 | 62 | def new_connection_pool(development:) 63 | handler = proc do |connection| 64 | connection.on(:error) do |exception| 65 | Rails.logger.info "Apnotic exception raised: #{exception}" 66 | if config[:error_handler].respond_to?(:call) 67 | notification.instance_exec(exception, &config[:error_handler]) 68 | end 69 | end 70 | end 71 | 72 | if development 73 | Apnotic::ConnectionPool.development(connection_pool_options, pool_options, &handler) 74 | else 75 | Apnotic::ConnectionPool.new(connection_pool_options, pool_options, &handler) 76 | end 77 | end 78 | 79 | def connection_pool_options 80 | { 81 | auth_method: :token, 82 | cert_path: StringIO.new(evaluate_option(:apns_key)), 83 | key_id: evaluate_option(:key_id), 84 | team_id: evaluate_option(:team_id) 85 | } 86 | end 87 | 88 | def pool_options 89 | {size: evaluate_option(:pool_size) || 5} 90 | end 91 | end 92 | end 93 | end 94 | 95 | ActiveSupport.run_load_hooks :noticed_delivery_methods_ios, Noticed::DeliveryMethods::Ios 96 | -------------------------------------------------------------------------------- /test/delivery_methods/email_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EmailTest < ActiveSupport::TestCase 4 | include ActionMailer::TestHelper 5 | 6 | setup do 7 | @delivery_method = Noticed::DeliveryMethods::Email.new 8 | @notification = noticed_notifications(:one) 9 | end 10 | 11 | test "sends email (with args)" do 12 | set_config( 13 | mailer: "UserMailer", 14 | method: "new_comment", 15 | params: -> { {foo: :bar} }, 16 | args: -> { ["hey"] } 17 | ) 18 | 19 | assert_emails(1) do 20 | @delivery_method.deliver 21 | end 22 | end 23 | 24 | test "sends email (with kwargs)" do 25 | set_config( 26 | mailer: "UserMailer", 27 | method: "greeting", 28 | params: -> { {foo: :bar} }, 29 | kwargs: -> { {body: "Custom"} } 30 | ) 31 | 32 | assert_emails(1) do 33 | @delivery_method.deliver 34 | end 35 | end 36 | 37 | test "sends email (with kwargs, replacing default argument)" do 38 | set_config( 39 | mailer: "UserMailer", 40 | method: "greeting", 41 | params: -> { {foo: :bar} }, 42 | kwargs: -> { {body: "Custom", subject: "Testing"} } 43 | ) 44 | 45 | assert_emails(1) do 46 | @delivery_method.deliver 47 | end 48 | end 49 | 50 | test "raises the underlying ArgumentError if kwargs are missing" do 51 | set_config( 52 | mailer: "UserMailer", 53 | method: "greeting", 54 | params: -> { {foo: :bar} }, 55 | kwargs: -> { {baz: 123} } 56 | ) 57 | 58 | error = assert_raises ArgumentError do 59 | @delivery_method.deliver 60 | end 61 | 62 | assert_equal "missing keyword: :body", error.message 63 | end 64 | 65 | test "raises the underlying ArgumentError if unknown kwargs are given" do 66 | set_config( 67 | mailer: "UserMailer", 68 | method: "greeting", 69 | params: -> { {foo: :bar} }, 70 | kwargs: -> { {body: "Test", baz: 123} } 71 | ) 72 | 73 | error = assert_raises ArgumentError do 74 | @delivery_method.deliver 75 | end 76 | 77 | assert_equal "unknown keyword: :baz", error.message 78 | end 79 | 80 | test "accepts both args and kwargs" do 81 | set_config( 82 | mailer: "UserMailer", 83 | method: "greeting", 84 | params: -> { {foo: :bar} }, 85 | args: -> { ["hey"] }, 86 | kwargs: -> { {body: "Test"} } 87 | ) 88 | 89 | assert_emails(1) do 90 | @delivery_method.deliver 91 | end 92 | end 93 | 94 | test "enqueues email" do 95 | set_config( 96 | mailer: "UserMailer", 97 | method: "receipt", 98 | enqueue: true 99 | ) 100 | 101 | assert_enqueued_emails(1) do 102 | @delivery_method.deliver 103 | end 104 | end 105 | 106 | test "includes notification in params" do 107 | set_config(mailer: "UserMailer", method: "new_comment") 108 | assert_equal @notification, @delivery_method.params.fetch(:notification) 109 | end 110 | 111 | test "includes record in params" do 112 | set_config(mailer: "UserMailer", method: "new_comment") 113 | assert_equal @notification.record, @delivery_method.params.fetch(:record) 114 | end 115 | 116 | test "includes recipient in params" do 117 | set_config(mailer: "UserMailer", method: "new_comment") 118 | assert_equal @notification.recipient, @delivery_method.params.fetch(:recipient) 119 | end 120 | 121 | private 122 | 123 | def set_config(config) 124 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 125 | @delivery_method.instance_variable_set :@notification, @notification 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/delivery_method_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class DeliveryMethodTest < ActiveSupport::TestCase 4 | class InheritedDeliveryMethod < Noticed::DeliveryMethods::ActionCable 5 | end 6 | 7 | test "fetch_constant looks up constants from String" do 8 | @delivery_method = Noticed::DeliveryMethod.new 9 | set_config(mailer: "UserMailer") 10 | assert_equal UserMailer, @delivery_method.fetch_constant(:mailer) 11 | end 12 | 13 | test "fetch_constant looks up constants from proc that returns String" do 14 | @delivery_method = Noticed::DeliveryMethod.new 15 | set_config(mailer: -> { "UserMailer" }) 16 | assert_equal UserMailer, @delivery_method.fetch_constant(:mailer) 17 | end 18 | 19 | test "delivery methods inherit required options" do 20 | assert_equal [:message], InheritedDeliveryMethod.required_option_names 21 | end 22 | 23 | test "if config" do 24 | event = TestNotifier.deliver(User.first) 25 | notification = event.notifications.first 26 | delivery_method = Noticed::DeliveryMethods::Test.new 27 | 28 | assert delivery_method.perform(:test, notification, overrides: {if: true}) 29 | assert delivery_method.perform(:test, notification, overrides: {if: -> { unread? }}) 30 | refute delivery_method.perform(:test, notification, overrides: {if: false}) 31 | end 32 | 33 | test "unless overrides" do 34 | event = TestNotifier.deliver(User.first) 35 | notification = event.notifications.first 36 | delivery_method = Noticed::DeliveryMethods::Test.new 37 | 38 | refute delivery_method.perform(:test, notification, overrides: {unless: true}) 39 | assert delivery_method.perform(:test, notification, overrides: {unless: false}) 40 | assert delivery_method.perform(:test, notification, overrides: {unless: -> { read? }}) 41 | end 42 | 43 | test "passes notification when calling methods on Event" do 44 | notification = noticed_notifications(:one) 45 | event = notification.event 46 | 47 | def event.example_method(notification) 48 | @example = notification 49 | end 50 | 51 | delivery_method = Noticed::DeliveryMethods::Test.new 52 | delivery_method.instance_variable_set :@notification, notification 53 | delivery_method.instance_variable_set :@event, event 54 | delivery_method.instance_variable_set :@config, {if: :example_method} 55 | delivery_method.evaluate_option(:if) 56 | 57 | assert_equal notification, event.instance_variable_get(:@example) 58 | end 59 | 60 | class CallbackDeliveryMethod < Noticed::DeliveryMethod 61 | before_deliver :set_message 62 | attr_reader :message 63 | 64 | def set_message 65 | @message = "new message" 66 | end 67 | 68 | def deliver 69 | end 70 | end 71 | 72 | class CallbackBulkDeliveryMethod < Noticed::BulkDeliveryMethod 73 | before_deliver :set_message 74 | attr_reader :message 75 | 76 | def set_message 77 | @message = "new message" 78 | end 79 | 80 | def deliver 81 | end 82 | end 83 | 84 | class CallbackNotifier < Noticed::Event 85 | deliver_by :test 86 | end 87 | 88 | class CallbackBulkNotifier < Noticed::Event 89 | bulk_deliver_by :test 90 | end 91 | 92 | test "calls callbacks" do 93 | event = CallbackNotifier.with(message: "test") 94 | notification = Noticed::Notification.create(recipient: User.first, event: event) 95 | delivery_method = CallbackDeliveryMethod.new 96 | delivery_method.perform(:test, notification) 97 | assert_equal delivery_method.message, "new message" 98 | end 99 | 100 | test "calls callbacks for bulk delivery" do 101 | event = CallbackBulkNotifier.with(message: "test") 102 | delivery_method = CallbackBulkDeliveryMethod.new 103 | delivery_method.perform(:test, event) 104 | assert_equal delivery_method.message, "new message" 105 | end 106 | 107 | private 108 | 109 | def set_config(config) 110 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment 20 | # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. 24 | # config.public_file_server.enabled = false 25 | 26 | # Compress CSS using a preprocessor. 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 33 | # config.asset_host = "http://assets.example.com" 34 | 35 | # Specifies the header that your server uses for sending files. 36 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 37 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 38 | 39 | # Store uploaded files on the local file system (see config/storage.yml for options). 40 | config.active_storage.service = :local 41 | 42 | # Mount Action Cable outside main process or domain. 43 | # config.action_cable.mount_path = nil 44 | # config.action_cable.url = "wss://example.com/cable" 45 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 46 | 47 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 48 | # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. 49 | # config.assume_ssl = true 50 | 51 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 52 | config.force_ssl = true 53 | 54 | # Log to STDOUT by default 55 | config.logger = ActiveSupport::Logger.new($stdout) 56 | .tap { |logger| logger.formatter = ::Logger::Formatter.new } 57 | .then { |logger| ActiveSupport::TaggedLogging.new(logger) } 58 | 59 | # Prepend all log lines with the following tags. 60 | config.log_tags = [:request_id] 61 | 62 | # Info include generic and useful information about system operation, but avoids logging too much 63 | # information to avoid inadvertent exposure of personally identifiable information (PII). If you 64 | # want to log everything, set the level to "debug". 65 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 66 | 67 | # Use a different cache store in production. 68 | # config.cache_store = :mem_cache_store 69 | 70 | # Use a real queuing backend for Active Job (and separate queues per environment). 71 | # config.active_job.queue_adapter = :resque 72 | # config.active_job.queue_name_prefix = "dummy_production" 73 | 74 | config.action_mailer.perform_caching = false 75 | 76 | # Ignore bad email addresses and do not raise email delivery errors. 77 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 78 | # config.action_mailer.raise_delivery_errors = false 79 | 80 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 81 | # the I18n.default_locale when a translation cannot be found). 82 | config.i18n.fallbacks = true 83 | 84 | # Don't log any deprecations. 85 | config.active_support.report_deprecations = false 86 | 87 | # Do not dump schema after migrations. 88 | config.active_record.dump_schema_after_migration = false 89 | 90 | # Enable DNS rebinding protection and other `Host` header attacks. 91 | # config.hosts = [ 92 | # "example.com", # Allow requests from example.com 93 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 94 | # ] 95 | # Skip DNS rebinding protection for the default health check endpoint. 96 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 97 | end 98 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - main 10 | workflow_call: 11 | 12 | jobs: 13 | sqlite: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | ruby: ['3.2', '3.3', '3.4'] 18 | gemfile: 19 | - rails_7_0 20 | - rails_7_1 21 | - rails_7_2 22 | - rails_8_0 23 | - rails_8_1 24 | - rails_main 25 | exclude: 26 | # sqlite3 ~> 1.7 is not compatible with Ruby 3.4+ 27 | - gemfile: rails_7_0 28 | ruby: '3.4' 29 | - gemfile: rails_7_1 30 | ruby: '3.4' 31 | - gemfile: rails_7_2 32 | ruby: '3.4' 33 | 34 | env: 35 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 36 | BUNDLE_PATH_RELATIVE_TO_CWD: true 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: Set up Ruby 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby }} 45 | bundler: default 46 | bundler-cache: true 47 | rubygems: latest 48 | 49 | - name: StandardRb check 50 | run: bundle exec standardrb 51 | 52 | - name: Run tests 53 | env: 54 | DATABASE_URL: "sqlite3:noticed_test" 55 | RAILS_ENV: test 56 | run: | 57 | bundle exec rails db:test:prepare 58 | bundle exec rails test 59 | 60 | mysql: 61 | runs-on: ubuntu-latest 62 | strategy: 63 | matrix: 64 | ruby: ['3.2', '3.3', '3.4'] 65 | gemfile: 66 | - rails_7_0 67 | - rails_7_1 68 | - rails_7_2 69 | - rails_8_0 70 | - rails_8_1 71 | - rails_main 72 | exclude: 73 | # sqlite3 ~> 1.7 is not compatible with Ruby 3.4+ 74 | ruby: '3.4' 75 | - gemfile: rails_7_0 76 | ruby: '3.4' 77 | - gemfile: rails_7_1 78 | ruby: '3.4' 79 | - gemfile: rails_7_2 80 | ruby: '3.4' 81 | 82 | env: 83 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 84 | BUNDLE_PATH_RELATIVE_TO_CWD: true 85 | 86 | services: 87 | mysql: 88 | image: mysql:8 89 | env: 90 | MYSQL_ALLOW_EMPTY_PASSWORD: true 91 | MYSQL_DATABASE: test 92 | ports: ['3306:3306'] 93 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 94 | 95 | steps: 96 | - uses: actions/checkout@v4 97 | 98 | - name: Set up Ruby 99 | uses: ruby/setup-ruby@v1 100 | with: 101 | ruby-version: ${{ matrix.ruby }} 102 | bundler: default 103 | bundler-cache: true 104 | rubygems: latest 105 | 106 | - name: StandardRb check 107 | run: bundle exec standardrb 108 | 109 | - name: Run tests 110 | env: 111 | DATABASE_URL: trilogy://root:@127.0.0.1:3306/test 112 | RAILS_ENV: test 113 | run: | 114 | bundle exec rails db:test:prepare 115 | bundle exec rails test 116 | 117 | postgres: 118 | runs-on: ubuntu-latest 119 | strategy: 120 | matrix: 121 | ruby: ['3.2', '3.3', '3.4'] 122 | gemfile: 123 | - rails_7_0 124 | - rails_7_1 125 | - rails_7_2 126 | - rails_8_0 127 | - rails_8_1 128 | - rails_main 129 | exclude: 130 | # sqlite3 ~> 1.7 is not compatible with Ruby 3.4+ 131 | ruby: '3.4' 132 | - gemfile: rails_7_0 133 | ruby: '3.4' 134 | - gemfile: rails_7_1 135 | ruby: '3.4' 136 | - gemfile: rails_7_2 137 | ruby: '3.4' 138 | 139 | env: 140 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 141 | BUNDLE_PATH_RELATIVE_TO_CWD: true 142 | 143 | services: 144 | postgres: 145 | image: postgres:16 146 | env: 147 | POSTGRES_USER: postgres 148 | POSTGRES_PASSWORD: password 149 | POSTGRES_DB: test 150 | ports: ['5432:5432'] 151 | 152 | steps: 153 | - uses: actions/checkout@v4 154 | 155 | - name: Set up Ruby 156 | uses: ruby/setup-ruby@v1 157 | with: 158 | ruby-version: ${{ matrix.ruby }} 159 | bundler: default 160 | bundler-cache: true 161 | rubygems: latest 162 | 163 | - name: StandardRb check 164 | run: bundle exec standardrb 165 | 166 | - name: Run tests 167 | env: 168 | DATABASE_URL: postgres://postgres:password@localhost:5432/test 169 | RAILS_ENV: test 170 | CI: true 171 | run: | 172 | bundle exec rails db:test:prepare 173 | bundle exec rails test 174 | -------------------------------------------------------------------------------- /test/delivery_methods/fcm_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FcmTest < ActiveSupport::TestCase 4 | class FakeAuthorizer 5 | def self.make_creds(options = {}) 6 | new 7 | end 8 | 9 | def fetch_access_token! 10 | {"access_token" => "access-token-12341234"} 11 | end 12 | end 13 | 14 | setup do 15 | @delivery_method = Noticed::DeliveryMethods::Fcm.new 16 | end 17 | 18 | test "notifies each device token" do 19 | set_config( 20 | authorizer: FakeAuthorizer, 21 | credentials: { 22 | "type" => "service_account", 23 | "project_id" => "p_1234", 24 | "private_key_id" => "private_key" 25 | }, 26 | device_tokens: [:a, :b], 27 | json: ->(device_token) { 28 | { 29 | message: { 30 | token: device_token, 31 | notification: {title: "Title", body: "Body"} 32 | } 33 | } 34 | } 35 | ) 36 | 37 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").with(body: "{\"message\":{\"token\":\"a\",\"notification\":{\"title\":\"Title\",\"body\":\"Body\"}}}") 38 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").with(body: "{\"message\":{\"token\":\"b\",\"notification\":{\"title\":\"Title\",\"body\":\"Body\"}}}") 39 | 40 | assert_nothing_raised do 41 | @delivery_method.deliver 42 | end 43 | end 44 | 45 | test "notifies of invalid tokens for clean up" do 46 | cleanups = 0 47 | 48 | set_config( 49 | authorizer: FakeAuthorizer, 50 | credentials: { 51 | "type" => "service_account", 52 | "project_id" => "p_1234", 53 | "private_key_id" => "private_key" 54 | }, 55 | device_tokens: [:a, :b], 56 | json: ->(device_token) { 57 | { 58 | message: { 59 | token: device_token, 60 | notification: {title: "Title", body: "Body"} 61 | } 62 | } 63 | }, 64 | invalid_token: ->(device_token) { cleanups += 1 } 65 | ) 66 | 67 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").to_return(status: 404, body: "", headers: {}) 68 | 69 | @delivery_method.deliver 70 | assert_equal 2, cleanups 71 | end 72 | 73 | test "notifies of unregistered tokens for clean up" do 74 | cleanups = 0 75 | 76 | set_config( 77 | authorizer: FakeAuthorizer, 78 | credentials: { 79 | "type" => "service_account", 80 | "project_id" => "p_1234", 81 | "private_key_id" => "private_key" 82 | }, 83 | device_tokens: [:a, :b], 84 | json: ->(device_token) { 85 | { 86 | message: { 87 | token: device_token, 88 | notification: {title: "Title", body: "Body"} 89 | } 90 | } 91 | }, 92 | invalid_token: ->(device_token) { cleanups += 1 } 93 | ) 94 | 95 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").to_return(status: 400, body: "", headers: {}) 96 | 97 | @delivery_method.deliver 98 | assert_equal 2, cleanups 99 | end 100 | 101 | test "notifies error handler if exists for other errors" do 102 | error_notifications = 0 103 | 104 | set_config( 105 | authorizer: FakeAuthorizer, 106 | credentials: { 107 | "type" => "service_account", 108 | "project_id" => "p_1234", 109 | "private_key_id" => "private_key" 110 | }, 111 | device_tokens: [:a, :b], 112 | json: ->(device_token) { 113 | { 114 | message: { 115 | token: device_token, 116 | notification: {title: "Title", body: "Body"} 117 | } 118 | } 119 | }, 120 | error_handler: ->(response) { error_notifications += 1 } 121 | ) 122 | 123 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").to_return(status: 403, body: "", headers: {}) 124 | 125 | @delivery_method.deliver 126 | assert_equal 2, error_notifications 127 | end 128 | 129 | test "re-raises errors if there is no error handler" do 130 | set_config( 131 | authorizer: FakeAuthorizer, 132 | credentials: { 133 | "type" => "service_account", 134 | "project_id" => "p_1234", 135 | "private_key_id" => "private_key" 136 | }, 137 | device_tokens: [:a, :b], 138 | json: ->(device_token) { 139 | { 140 | message: { 141 | token: device_token, 142 | notification: {title: "Title", body: "Body"} 143 | } 144 | } 145 | } 146 | ) 147 | 148 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").to_return(status: 403, body: "", headers: {}) 149 | 150 | assert_raises(Noticed::ResponseUnsuccessful) do 151 | @delivery_method.deliver 152 | end 153 | end 154 | 155 | private 156 | 157 | def set_config(config) 158 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /docs/delivery_methods/ios.md: -------------------------------------------------------------------------------- 1 | # iOS Notification Delivery Method 2 | 3 | > [!WARNING] 4 | > Deprecated. Please use Action Push Native instead. 5 | 6 | Send Apple Push Notifications with HTTP2 using the `apnotic` gem. The benefit of HTTP2 is that we can receive feedback for invalid device tokens without running a separate feedback service like RPush does. 7 | 8 | ```bash 9 | bundle add "apnotic" 10 | ``` 11 | 12 | ## Apple Push Notification Service (APNS) Authentication 13 | 14 | Token-based authentication is used for APNS. 15 | * A single key can be used for every app in your developer account. 16 | * Token authentication never expires, unlike certificate authentication which must be renewed annually. 17 | 18 | Follow these docs for setting up Token-based authentication. 19 | https://github.com/ostinelli/apnotic#token-based-authentication 20 | https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns 21 | 22 | ## Usage 23 | 24 | ```ruby 25 | class CommentNotifier < ApplicationNotifier 26 | deliver_by :ios do |config| 27 | config.device_tokens = -> { recipient.notification_tokens.where(platform: :iOS).pluck(:token) } 28 | config.format = ->(apn) { 29 | apn.alert = "Hello world" 30 | apn.custom_payload = {url: root_url(host: "example.org")} 31 | } 32 | config.bundle_identifier = Rails.application.credentials.dig(:ios, :bundle_id) 33 | config.key_id = Rails.application.credentials.dig(:ios, :key_id) 34 | config.team_id = Rails.application.credentials.dig(:ios, :team_id) 35 | config.apns_key = Rails.application.credentials.dig(:ios, :apns_key) 36 | config.error_handler = ->(exception) { ... } 37 | end 38 | end 39 | ``` 40 | 41 | ## Options 42 | 43 | * `format` 44 | 45 | Customize the Apnotic notification object 46 | 47 | See https://github.com/ostinelli/apnotic#apnoticnotification 48 | 49 | * `bundle_identifier` 50 | 51 | The APN bundle identifier 52 | 53 | * `apns_key` 54 | 55 | The contents of your p8 apns key file. 56 | 57 | * `key_id` 58 | 59 | Your APN Key ID 60 | 61 | * `team_id` 62 | 63 | Your APN Team ID 64 | 65 | * `pool_size: 5` - *Optional* 66 | 67 | The connection pool size for Apnotic 68 | 69 | * `development` - *Optional* 70 | 71 | Set this to `true` to use the APNS sandbox environment for sending notifications. This is required when running the app to your device via Xcode. Running the app via TestFlight or the App Store should not use development. 72 | 73 | * `error_handler` - *Optional* 74 | A lambda to allow your app to handle Apnotic errors. 75 | 76 | ## Gathering Notification Tokens 77 | 78 | A recipient can have multiple tokens (i.e. multiple iOS devices), so make sure to return them all. 79 | 80 | Here, the recipient `has_many :notification_tokens` with columns `platform` and `token`. 81 | 82 | ```ruby 83 | deliver_by :ios do |config| 84 | config.device_tokens = -> { recipient.notification_tokens.where(platform: :iOS).pluck(:token) } 85 | end 86 | ``` 87 | 88 | ## Handling Failures 89 | 90 | Apple Push Notifications may fail delivery if the user has removed the app from their device. Noticed allows you 91 | 92 | ```ruby 93 | class CommentNotifier < ApplicationNotifier 94 | deliver_by :ios do |config| 95 | config.invalid_token = ->(token) { NotificationToken.where(token: token).destroy_all } 96 | end 97 | end 98 | ``` 99 | 100 | ## Updating the iOS app badge 101 | 102 | If you're managing the iOS app badge, you can pass it along in the format 103 | 104 | ```ruby 105 | class CommentNotifier < ApplicationNotifier 106 | deliver_by :ios do |config| 107 | config.format = ->(apn) { 108 | apn.alert = "Hello world" 109 | apn.custom_payload = {url: root_url(host: "example.org")} 110 | apn.badge = recipient.notifications.unread.count 111 | } 112 | end 113 | end 114 | ``` 115 | 116 | Another common action is to update the badge after a user reads a notification. 117 | 118 | This is a great use of the Noticed::Ephemeral class. Since it's all in-memory, it will perform the job and not touch the database. 119 | 120 | ```ruby 121 | class NativeBadgeNotifier < Noticed::Ephemeral 122 | deliver_by :ios do |config| 123 | config.format = ->(apn) { 124 | # Setting the alert text to nil will deliver the notification in 125 | # the background. This is used to update the app badge on the iOS home screen 126 | apn.alert = nil 127 | apn.custom_payload = {} 128 | apn.badge = recipient.notifications.unread.count 129 | } 130 | end 131 | end 132 | ``` 133 | 134 | Then you can simply deliver this notifier to update the badge when you mark the notification as read 135 | 136 | ```ruby 137 | notification.mark_as_read! 138 | NativeBadgeNotifier.with(record: notification).deliver(notification.recipient) 139 | ``` 140 | 141 | ## Delivering to Sandboxes and real devices 142 | 143 | If you wish to send notifications to both sandboxed and real devices from the same application, you can configure two iOS delivery methods 144 | A user has_many tokens that can be generated from both development (sandboxed devices), or production (not sandboxed devices) and is unrelated to the rails environment or endpoint being used. I 145 | 146 | ```ruby 147 | deliver_by :ios do |config| 148 | config.device_tokens = -> { recipient.notification_tokens.where(environment: :production, platform: :iOS).pluck(:token) } 149 | end 150 | 151 | deliver_by :ios_development, class: "Noticed::DeliveryMethods::Ios" do |config| 152 | config.development = true 153 | config.device_tokens = ->{ recipient.notification_tokens.where(environment: :development, platform: :iOS).pluck(:token) } 154 | end 155 | ``` 156 | -------------------------------------------------------------------------------- /app/models/concerns/noticed/deliverable.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module Deliverable 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | class_attribute :bulk_delivery_methods, instance_writer: false, default: {} 7 | class_attribute :delivery_methods, instance_writer: false, default: {} 8 | class_attribute :required_param_names, instance_writer: false, default: [] 9 | class_attribute :_recipients, instance_writer: false 10 | end 11 | 12 | class_methods do 13 | def inherited(base) 14 | base.bulk_delivery_methods = bulk_delivery_methods.dup 15 | base.delivery_methods = delivery_methods.dup 16 | base.required_param_names = required_param_names.dup 17 | super 18 | end 19 | 20 | def bulk_deliver_by(name, options = {}) 21 | raise NameError, "#{name} has already been used for this Notifier." if bulk_delivery_methods.has_key?(name) 22 | 23 | config = ActiveSupport::OrderedOptions.new.merge(options) 24 | yield config if block_given? 25 | bulk_delivery_methods[name] = DeliverBy.new(name, config, bulk: true) 26 | end 27 | 28 | def deliver_by(name, options = {}) 29 | raise NameError, "#{name} has already been used for this Notifier." if delivery_methods.has_key?(name) 30 | 31 | if name == :database 32 | Noticed.deprecator.warn <<-WARNING.squish 33 | The :database delivery method has been deprecated and does nothing. Notifiers automatically save to the database now. 34 | WARNING 35 | return 36 | end 37 | 38 | config = ActiveSupport::OrderedOptions.new.merge(options) 39 | yield config if block_given? 40 | delivery_methods[name] = DeliverBy.new(name, config) 41 | end 42 | 43 | def recipients(option = nil, &block) 44 | self._recipients = block || option 45 | end 46 | 47 | def required_params(*names) 48 | required_param_names.concat names 49 | end 50 | alias_method :required_param, :required_params 51 | 52 | def params(*names) 53 | Noticed.deprecator.warn <<-WARNING.squish 54 | `params` is deprecated and has been renamed to `required_params` 55 | WARNING 56 | required_params(*names) 57 | end 58 | 59 | def param(*names) 60 | Noticed.deprecator.warn <<-WARNING.squish 61 | `param :name` is deprecated and has been renamed to `required_param :name` 62 | WARNING 63 | required_params(*names) 64 | end 65 | 66 | def with(params) 67 | if self < Ephemeral 68 | new(params: params) 69 | else 70 | record = params.delete(:record) 71 | new(params: params, record: record) 72 | end 73 | end 74 | 75 | def deliver(recipients = nil, **options) 76 | new.deliver(recipients, **options) 77 | end 78 | alias_method :deliver_later, :deliver 79 | end 80 | 81 | # CommentNotifier.deliver(User.all) 82 | # CommentNotifier.deliver(User.all, priority: 10) 83 | # CommentNotifier.deliver(User.all, queue: :low_priority) 84 | # CommentNotifier.deliver(User.all, wait: 5.minutes) 85 | # CommentNotifier.deliver(User.all, wait_until: 1.hour.from_now) 86 | def deliver(recipients = nil, enqueue_job: true, **options) 87 | recipients ||= evaluate_recipients 88 | 89 | validate! 90 | 91 | transaction do 92 | recipients_attributes = Array.wrap(recipients).map do |recipient| 93 | recipient_attributes_for(recipient) 94 | end 95 | 96 | self.notifications_count = recipients_attributes.size 97 | save! 98 | 99 | if Rails.gem_version >= Gem::Version.new("7.0.0.alpha1") 100 | notifications.insert_all!(recipients_attributes, record_timestamps: true) if recipients_attributes.any? 101 | else 102 | time = Time.current 103 | recipients_attributes.each do |attributes| 104 | attributes[:created_at] = time 105 | attributes[:updated_at] = time 106 | end 107 | notifications.insert_all!(recipients_attributes) if recipients_attributes.any? 108 | end 109 | end 110 | 111 | # Enqueue delivery job 112 | EventJob.set(options).perform_later(self) if enqueue_job 113 | 114 | self 115 | end 116 | alias_method :deliver_later, :deliver 117 | 118 | def evaluate_recipients 119 | return unless _recipients 120 | 121 | if _recipients.respond_to?(:call, true) 122 | instance_exec(&_recipients) 123 | elsif _recipients.is_a?(Symbol) && respond_to?(_recipients, true) 124 | send(_recipients) 125 | end 126 | end 127 | 128 | def recipient_attributes_for(recipient) 129 | { 130 | type: "#{self.class.name}::Notification", 131 | recipient_type: recipient.class.base_class.name, 132 | recipient_id: recipient.id 133 | } 134 | end 135 | 136 | def validate! 137 | validate_params! 138 | validate_delivery_methods! 139 | end 140 | 141 | def validate_params! 142 | required_param_names.each do |param_name| 143 | raise ValidationError, "Param `#{param_name}` is required for #{self.class.name}." unless params.has_key?(param_name) 144 | end 145 | end 146 | 147 | def validate_delivery_methods! 148 | bulk_delivery_methods.values.each(&:validate!) 149 | delivery_methods.values.each(&:validate!) 150 | end 151 | 152 | # If a GlobalID record in params is no longer found, the params will default with a noticed_error key 153 | def deserialize_error? 154 | !!params[:noticed_error] 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /docs/delivery_methods/fcm.md: -------------------------------------------------------------------------------- 1 | # Firebase Cloud Messaging Delivery Method 2 | 3 | > [!WARNING] 4 | > Deprecated. Please use Action Push Native instead. 5 | 6 | Send Device Notifications using the Google Firebase Cloud Messaging service and the `googleauth` gem. FCM supports Android, iOS, and web clients. 7 | 8 | ```bash 9 | bundle add "googleauth" 10 | ``` 11 | 12 | ## Google Firebase Cloud Messaging Notification Service 13 | 14 | To generate your Firebase Cloud Messaging credentials, you'll need to create your project if you have not already. See https://console.firebase.google.com/u/1/ 15 | Once you have created your project, visit the project dashboard and click the settings cog in the top of the left sidebar menu, then click project settings. 16 | 17 |  18 | 19 | In the project settings screen click on the Service accounts tab in the top navigation menu, then click the Generate new private key button. 20 | 21 |  22 | 23 | This json file will contain the necessary credentials in order to send notifications via Google Firebase Cloud Messaging. 24 | See the below instructions on where to store this information within your application. 25 | 26 | ## Usage 27 | 28 | ```ruby 29 | class CommentNotification 30 | deliver_by :fcm do |config| 31 | config.credentials = Rails.root.join("config/certs/fcm.json") 32 | config.device_tokens = -> { recipient.notification_tokens.where(platform: "fcm").pluck(:token) } 33 | config.json = ->(device_token) { 34 | { 35 | message: { 36 | token: device_token, 37 | notification: { 38 | title: "Test Title", 39 | body: "Test body" 40 | } 41 | } 42 | } 43 | } 44 | config.if = -> { recipient.android_notifications? } 45 | end 46 | end 47 | ``` 48 | 49 | ## Options 50 | 51 | ### `json` 52 | Customize the Firebase Cloud Messaging notification object. This can be a Lambda or Symbol of a method name on the notifier. 53 | 54 | The callable object will be given the device token as an argument. 55 | 56 | There are lots of options of how to structure a FCM notification message. See https://firebase.google.com/docs/cloud-messaging/concept-options for more details. 57 | 58 | ### `invalid_token` - *Optional* 59 | A lambda to allow your app to clean up invalid tokens. 60 | 61 | ### `error_handler` - *Optional* 62 | A lambda to allow your app to handle other errors with FCM, such as incorrect configuration. 63 | Can be useful to squash intermittent errors you don't want polluting your error collection service 64 | or perform other clean up actions. 65 | 66 | ### `credentials` 67 | The location of your Firebase Cloud Messaging credentials. 68 | 69 | #### When a String Object 70 | 71 | Internally, this string is passed to `Rails.root.join()` as an argument so there is no need to do this beforehand. 72 | 73 | ```ruby 74 | deliver_by :fcm do |config| 75 | config.credentials = "config/credentials/fcm.json" 76 | end 77 | ``` 78 | 79 | #### When a Pathname object 80 | 81 | The Pathname object can point to any location where you are storing your credentials. 82 | 83 | ```ruby 84 | deliver_by :fcm do |config| 85 | config.credentials = Rails.root.join("config/credentials/fcm.json") 86 | end 87 | ``` 88 | 89 | #### When a Hash object 90 | 91 | A Hash which contains your credentials 92 | 93 | ```ruby 94 | deliver_by :fcm do |config| 95 | config.credentials = credentials_hash 96 | end 97 | 98 | credentials_hash = { 99 | "type": "service_account", 100 | "project_id": "test-project-1234", 101 | "private_key_id": ..., 102 | etc..... 103 | } 104 | ``` 105 | 106 | #### When a Symbol 107 | 108 | Points to a method which can return a Hash of your credentials, Pathname, or String to your credentials like the examples above. 109 | 110 | We pass the notification object as an argument to the method. If you don't need to use it you can use the splat operator `(*)` to ignore it. 111 | 112 | ```ruby 113 | deliver_by :fcm do |config| 114 | config.credentials = :fcm_credentials 115 | config.json = :format_notification 116 | end 117 | 118 | def fcm_credentials(*) 119 | Rails.root.join("config/certs/fcm.json") 120 | end 121 | ``` 122 | 123 | #### Otherwise 124 | 125 | If the credentials option is left out, it will look for your credentials in: `Rails.application.credentials.fcm` 126 | 127 | ## Gathering Notification Tokens 128 | 129 | A recipient can have multiple tokens (i.e. multiple Android devices), so make sure to return them all. 130 | 131 | Here, the recipient `has_many :notification_tokens` with columns `platform` and `token`. 132 | 133 | ```ruby 134 | def fcm_device_tokens(recipient) 135 | recipient.notification_tokens.where(platform: "fcm").pluck(:token) 136 | end 137 | ``` 138 | 139 | ## Handling Failures 140 | 141 | Firebase Cloud Messaging Notifications may fail delivery if the user has removed the app from their device. 142 | In this case, FCM will return a [400 or 404 HTTP Status Code](https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode) 143 | and the delivery method will call the `invalid_token` handler, if you configure one. 144 | 145 | ```ruby 146 | class CommentNotification 147 | deliver_by :fcm do |config| 148 | config.invalid_token = ->(device_token) { NotificationToken.find_by(token: device_token).destroy } 149 | end 150 | end 151 | ``` 152 | 153 | In case of other errors, the delivery method call the `error_handler` handler, if you configure one, 154 | or otherwise simply raise `Noticed::ResponseUnsuccessful` and the job can be retried. 155 | 156 | ```ruby 157 | class CommentNotification 158 | deliver_by :fcm do |config| 159 | config.error_handler = ->(response) { Rails.logger.error(response.inspect) } 160 | end 161 | end 162 | ``` 163 | -------------------------------------------------------------------------------- /test/notifier_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NotifierTest < ActiveSupport::TestCase 4 | include ActiveJob::TestHelper 5 | 6 | class RecipientsBlock < Noticed::Event 7 | recipients do 8 | params.fetch(:recipients) 9 | end 10 | deliver_by :test 11 | end 12 | 13 | class RecipientsLambda < Noticed::Event 14 | recipients -> { params.fetch(:recipients) } 15 | deliver_by :test 16 | end 17 | 18 | class RecipientsMethod < Noticed::Event 19 | deliver_by :test 20 | recipients :recipients 21 | 22 | private 23 | 24 | def recipients 25 | params.fetch(:recipients) 26 | end 27 | end 28 | 29 | class RecipientsLambdaEphemeral < Noticed::Ephemeral 30 | recipients -> { params.fetch(:recipients) } 31 | deliver_by :test 32 | end 33 | 34 | test "includes Rails urls" do 35 | assert_equal "http://localhost:3000/", SimpleNotifier.new.url 36 | end 37 | 38 | test "notifiers inherit required params" do 39 | assert_equal [:message], InheritedNotifier.required_params 40 | end 41 | 42 | test "notification_methods adds methods to Noticed::Notifications" do 43 | user = users(:one) 44 | event = SimpleNotifier.with(message: "test").deliver(user) 45 | assert_equal "hello #{user.email}", event.notifications.last.message 46 | end 47 | 48 | test "notification_methods url helpers" do 49 | assert_equal "http://localhost:3000/", SimpleNotifier::Notification.new.url 50 | end 51 | 52 | test "serializes globalid objects with text column" do 53 | user = users(:one) 54 | notification = Noticed::Event.create!(type: "SimpleNotifier", params: {user: user}) 55 | assert_equal({user: user}, notification.params) 56 | end 57 | 58 | test "assigns record association from params" do 59 | user = users(:one) 60 | notifier = RecordNotifier.with(record: user) 61 | assert_equal user, notifier.record 62 | assert_empty notifier.params 63 | end 64 | 65 | test "can add validations for record association" do 66 | notifier = RecordNotifier.with({}) 67 | refute notifier.valid? 68 | assert_equal ["can't be blank"], notifier.errors[:record] 69 | end 70 | 71 | test "recipients block" do 72 | event = RecipientsBlock.with(recipients: [User.create!(email: "foo"), User.create!(email: "bar")]).deliver 73 | assert_equal 2, event.notifications.count 74 | assert_equal User.find_by(email: "foo"), event.notifications.first.recipient 75 | end 76 | 77 | test "recipients lambda" do 78 | event = RecipientsLambda.with(recipients: [User.create!(email: "foo"), User.create!(email: "bar")]).deliver 79 | assert_equal 2, event.notifications.count 80 | assert_equal User.find_by(email: "foo"), event.notifications.first.recipient 81 | end 82 | 83 | test "recipients" do 84 | event = RecipientsMethod.with(recipients: [User.create!(email: "foo"), User.create!(email: "bar")]).deliver 85 | assert_equal 2, event.notifications.count 86 | assert_equal User.find_by(email: "foo"), event.notifications.first.recipient 87 | 88 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last]) do 89 | perform_enqueued_jobs 90 | end 91 | end 92 | 93 | test "recipients ephemeral" do 94 | users = [User.create!(email: "foo"), User.create!(email: "bar")] 95 | 96 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, "NotifierTest::RecipientsLambdaEphemeral::Notification", {recipient: User.find_by(email: "foo"), params: {recipients: users}}]) do 97 | RecipientsLambdaEphemeral.with(recipients: users).deliver 98 | end 99 | end 100 | 101 | test "deliver without recipients" do 102 | assert_nothing_raised do 103 | ReceiptNotifier.deliver 104 | end 105 | end 106 | 107 | test "deliver creates an event" do 108 | assert_difference "Noticed::Event.count" do 109 | ReceiptNotifier.deliver(User.first) 110 | end 111 | end 112 | 113 | test "deliver creates notifications for each recipient" do 114 | assert_no_difference "Noticed::Notification.count" do 115 | event = ReceiptNotifier.deliver 116 | assert_equal 0, event.notifications_count 117 | end 118 | 119 | assert_difference "Noticed::Notification.count" do 120 | event = ReceiptNotifier.deliver(User.first) 121 | assert_equal 1, event.notifications_count 122 | end 123 | 124 | assert_difference "Noticed::Notification.count", User.count do 125 | event = ReceiptNotifier.deliver(User.all) 126 | assert_equal User.count, event.notifications_count 127 | end 128 | 129 | assert_difference "Noticed::Notification.count", -1 do 130 | event = noticed_events(:one) 131 | event.notifications.destroy_all 132 | assert_equal 0, event.notifications_count 133 | end 134 | end 135 | 136 | test "deliver to STI recipient writes base class" do 137 | admin = Admin.first 138 | assert_difference "Noticed::Notification.count" do 139 | ReceiptNotifier.deliver(admin) 140 | end 141 | notification = Noticed::Notification.last 142 | assert_equal "User", notification.recipient_type 143 | assert_equal admin, notification.recipient 144 | end 145 | 146 | test "creates jobs for deliveries" do 147 | # Delivering a notification creates records 148 | assert_enqueued_jobs 1, only: Noticed::EventJob do 149 | ReceiptNotifier.deliver(User.first) 150 | end 151 | 152 | # Run the Event Job 153 | assert_enqueued_jobs 1, only: Noticed::DeliveryMethods::Test do 154 | perform_enqueued_jobs 155 | end 156 | 157 | # Run the individual deliveries 158 | perform_enqueued_jobs 159 | 160 | assert_equal Noticed::Notification.last, Noticed::DeliveryMethods::Test.delivered.last 161 | end 162 | 163 | test "creates jobs for bulk deliveries" do 164 | assert_enqueued_jobs 1, only: Noticed::EventJob do 165 | BulkNotifier.deliver 166 | end 167 | 168 | assert_enqueued_jobs 1, only: Noticed::BulkDeliveryMethods::Webhook do 169 | perform_enqueued_jobs 170 | end 171 | end 172 | 173 | test "creates jobs for bulk ephemeral deliveries" do 174 | assert_enqueued_jobs 1, only: Noticed::BulkDeliveryMethods::Test do 175 | EphemeralNotifier.deliver 176 | end 177 | 178 | assert_difference("Noticed::BulkDeliveryMethods::Test.delivered.length" => 1) do 179 | perform_enqueued_jobs 180 | end 181 | end 182 | 183 | test "deliver wait" do 184 | freeze_time 185 | assert_enqueued_with job: Noticed::EventJob, at: 5.minutes.from_now do 186 | ReceiptNotifier.deliver(User.first, wait: 5.minutes) 187 | end 188 | end 189 | 190 | test "deliver queue" do 191 | freeze_time 192 | assert_enqueued_with job: Noticed::EventJob, queue: "low_priority" do 193 | ReceiptNotifier.deliver(User.first, queue: :low_priority) 194 | end 195 | end 196 | 197 | test "wait delivery method option" do 198 | freeze_time 199 | event = WaitNotifier.deliver(User.first) 200 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], at: 5.minutes.from_now) do 201 | perform_enqueued_jobs 202 | end 203 | end 204 | 205 | test "wait_until delivery method option" do 206 | freeze_time 207 | event = WaitUntilNotifier.deliver(User.first) 208 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], at: 1.hour.from_now) do 209 | perform_enqueued_jobs 210 | end 211 | end 212 | 213 | test "queue delivery method option" do 214 | event = QueueNotifier.deliver(User.first) 215 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], queue: "example_queue") do 216 | perform_enqueued_jobs 217 | end 218 | end 219 | 220 | # assert_enqeued_with doesn't support priority before Rails 7 221 | if Rails.gem_version >= Gem::Version.new("7.0.0.alpha1") 222 | test "priority delivery method option" do 223 | event = PriorityNotifier.deliver(User.first) 224 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], priority: 2) do 225 | perform_enqueued_jobs 226 | end 227 | end 228 | end 229 | 230 | test "deprecations don't cause problems" do 231 | assert_nothing_raised do 232 | Noticed.deprecator.silence do 233 | DeprecatedNotifier.with(message: "test").deliver_later 234 | end 235 | end 236 | end 237 | 238 | test "inherits notification_methods from application notifier" do 239 | assert SimpleNotifier::Notification.new.respond_to?(:inherited_method) 240 | end 241 | end 242 | -------------------------------------------------------------------------------- /gemfiles/rails_7_0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | noticed (2.9.3) 5 | rails (>= 6.1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (7.0.10) 11 | actionpack (= 7.0.10) 12 | activesupport (= 7.0.10) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailbox (7.0.10) 16 | actionpack (= 7.0.10) 17 | activejob (= 7.0.10) 18 | activerecord (= 7.0.10) 19 | activestorage (= 7.0.10) 20 | activesupport (= 7.0.10) 21 | mail (>= 2.7.1) 22 | net-imap 23 | net-pop 24 | net-smtp 25 | actionmailer (7.0.10) 26 | actionpack (= 7.0.10) 27 | actionview (= 7.0.10) 28 | activejob (= 7.0.10) 29 | activesupport (= 7.0.10) 30 | mail (~> 2.5, >= 2.5.4) 31 | net-imap 32 | net-pop 33 | net-smtp 34 | rails-dom-testing (~> 2.0) 35 | actionpack (7.0.10) 36 | actionview (= 7.0.10) 37 | activesupport (= 7.0.10) 38 | racc 39 | rack (~> 2.0, >= 2.2.4) 40 | rack-test (>= 0.6.3) 41 | rails-dom-testing (~> 2.0) 42 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 43 | actiontext (7.0.10) 44 | actionpack (= 7.0.10) 45 | activerecord (= 7.0.10) 46 | activestorage (= 7.0.10) 47 | activesupport (= 7.0.10) 48 | globalid (>= 0.6.0) 49 | nokogiri (>= 1.8.5) 50 | actionview (7.0.10) 51 | activesupport (= 7.0.10) 52 | builder (~> 3.1) 53 | erubi (~> 1.4) 54 | rails-dom-testing (~> 2.0) 55 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 56 | activejob (7.0.10) 57 | activesupport (= 7.0.10) 58 | globalid (>= 0.3.6) 59 | activemodel (7.0.10) 60 | activesupport (= 7.0.10) 61 | activerecord (7.0.10) 62 | activemodel (= 7.0.10) 63 | activesupport (= 7.0.10) 64 | activerecord-trilogy-adapter (3.2.0) 65 | activerecord (>= 6.0.a, < 7.1.a) 66 | trilogy (>= 2.4.0) 67 | activestorage (7.0.10) 68 | actionpack (= 7.0.10) 69 | activejob (= 7.0.10) 70 | activerecord (= 7.0.10) 71 | activesupport (= 7.0.10) 72 | marcel (~> 1.0) 73 | mini_mime (>= 1.1.0) 74 | activesupport (7.0.10) 75 | base64 76 | benchmark (>= 0.3) 77 | bigdecimal 78 | concurrent-ruby (~> 1.0, >= 1.0.2) 79 | drb 80 | i18n (>= 1.6, < 2) 81 | logger (>= 1.4.2) 82 | minitest (>= 5.1) 83 | mutex_m 84 | securerandom (>= 0.3) 85 | tzinfo (~> 2.0) 86 | addressable (2.8.8) 87 | public_suffix (>= 2.0.2, < 8.0) 88 | apnotic (1.8.0) 89 | base64 90 | connection_pool (>= 2, < 4) 91 | net-http2 (>= 0.18.3, < 2) 92 | appraisal (2.5.0) 93 | bundler 94 | rake 95 | thor (>= 0.14.0) 96 | ast (2.4.3) 97 | base64 (0.3.0) 98 | benchmark (0.5.0) 99 | bigdecimal (4.0.1) 100 | builder (3.3.0) 101 | concurrent-ruby (1.3.4) 102 | connection_pool (3.0.2) 103 | crack (1.0.1) 104 | bigdecimal 105 | rexml 106 | crass (1.0.6) 107 | date (3.5.1) 108 | drb (2.2.3) 109 | erubi (1.13.1) 110 | faraday (2.14.0) 111 | faraday-net_http (>= 2.0, < 3.5) 112 | json 113 | logger 114 | faraday-net_http (3.4.2) 115 | net-http (~> 0.5) 116 | globalid (1.3.0) 117 | activesupport (>= 6.1) 118 | google-cloud-env (2.3.1) 119 | base64 (~> 0.2) 120 | faraday (>= 1.0, < 3.a) 121 | google-logging-utils (0.2.0) 122 | googleauth (1.16.0) 123 | faraday (>= 1.0, < 3.a) 124 | google-cloud-env (~> 2.2) 125 | google-logging-utils (~> 0.1) 126 | jwt (>= 1.4, < 4.0) 127 | multi_json (~> 1.11) 128 | os (>= 0.9, < 2.0) 129 | signet (>= 0.16, < 2.a) 130 | hashdiff (1.2.1) 131 | http-2 (1.1.1) 132 | i18n (1.14.7) 133 | concurrent-ruby (~> 1.0) 134 | json (2.18.0) 135 | jwt (3.1.2) 136 | base64 137 | language_server-protocol (3.17.0.5) 138 | lint_roller (1.1.0) 139 | logger (1.7.0) 140 | loofah (2.25.0) 141 | crass (~> 1.0.2) 142 | nokogiri (>= 1.12.0) 143 | mail (2.9.0) 144 | logger 145 | mini_mime (>= 0.1.1) 146 | net-imap 147 | net-pop 148 | net-smtp 149 | marcel (1.1.0) 150 | method_source (1.1.0) 151 | mini_mime (1.1.5) 152 | mini_portile2 (2.8.9) 153 | minitest (5.27.0) 154 | multi_json (1.18.0) 155 | mutex_m (0.3.0) 156 | net-http (0.9.1) 157 | uri (>= 0.11.1) 158 | net-http2 (0.19.0) 159 | http-2 (>= 1.0) 160 | net-imap (0.6.2) 161 | date 162 | net-protocol 163 | net-pop (0.1.2) 164 | net-protocol 165 | net-protocol (0.2.2) 166 | timeout 167 | net-smtp (0.5.1) 168 | net-protocol 169 | nio4r (2.7.5) 170 | nokogiri (1.18.10) 171 | mini_portile2 (~> 2.8.2) 172 | racc (~> 1.4) 173 | nokogiri (1.18.10-aarch64-linux-gnu) 174 | racc (~> 1.4) 175 | nokogiri (1.18.10-aarch64-linux-musl) 176 | racc (~> 1.4) 177 | nokogiri (1.18.10-arm-linux-gnu) 178 | racc (~> 1.4) 179 | nokogiri (1.18.10-arm-linux-musl) 180 | racc (~> 1.4) 181 | nokogiri (1.18.10-arm64-darwin) 182 | racc (~> 1.4) 183 | nokogiri (1.18.10-x86_64-darwin) 184 | racc (~> 1.4) 185 | nokogiri (1.18.10-x86_64-linux-gnu) 186 | racc (~> 1.4) 187 | nokogiri (1.18.10-x86_64-linux-musl) 188 | racc (~> 1.4) 189 | os (1.1.4) 190 | parallel (1.27.0) 191 | parser (3.3.10.0) 192 | ast (~> 2.4.1) 193 | racc 194 | pg (1.6.2) 195 | pg (1.6.2-aarch64-linux) 196 | pg (1.6.2-aarch64-linux-musl) 197 | pg (1.6.2-arm64-darwin) 198 | pg (1.6.2-x86_64-darwin) 199 | pg (1.6.2-x86_64-linux) 200 | pg (1.6.2-x86_64-linux-musl) 201 | prism (1.7.0) 202 | public_suffix (7.0.0) 203 | racc (1.8.1) 204 | rack (2.2.21) 205 | rack-test (2.2.0) 206 | rack (>= 1.3) 207 | rails (7.0.10) 208 | actioncable (= 7.0.10) 209 | actionmailbox (= 7.0.10) 210 | actionmailer (= 7.0.10) 211 | actionpack (= 7.0.10) 212 | actiontext (= 7.0.10) 213 | actionview (= 7.0.10) 214 | activejob (= 7.0.10) 215 | activemodel (= 7.0.10) 216 | activerecord (= 7.0.10) 217 | activestorage (= 7.0.10) 218 | activesupport (= 7.0.10) 219 | bundler (>= 1.15.0) 220 | railties (= 7.0.10) 221 | rails-dom-testing (2.3.0) 222 | activesupport (>= 5.0.0) 223 | minitest 224 | nokogiri (>= 1.6) 225 | rails-html-sanitizer (1.6.2) 226 | loofah (~> 2.21) 227 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 228 | railties (7.0.10) 229 | actionpack (= 7.0.10) 230 | activesupport (= 7.0.10) 231 | method_source 232 | rake (>= 12.2) 233 | thor (~> 1.0) 234 | zeitwerk (~> 2.5) 235 | rainbow (3.1.1) 236 | rake (13.3.1) 237 | regexp_parser (2.11.3) 238 | rexml (3.4.4) 239 | rubocop (1.81.7) 240 | json (~> 2.3) 241 | language_server-protocol (~> 3.17.0.2) 242 | lint_roller (~> 1.1.0) 243 | parallel (~> 1.10) 244 | parser (>= 3.3.0.2) 245 | rainbow (>= 2.2.2, < 4.0) 246 | regexp_parser (>= 2.9.3, < 3.0) 247 | rubocop-ast (>= 1.47.1, < 2.0) 248 | ruby-progressbar (~> 1.7) 249 | unicode-display_width (>= 2.4.0, < 4.0) 250 | rubocop-ast (1.48.0) 251 | parser (>= 3.3.7.2) 252 | prism (~> 1.4) 253 | rubocop-performance (1.26.1) 254 | lint_roller (~> 1.1) 255 | rubocop (>= 1.75.0, < 2.0) 256 | rubocop-ast (>= 1.47.1, < 2.0) 257 | ruby-progressbar (1.13.0) 258 | securerandom (0.4.1) 259 | signet (0.21.0) 260 | addressable (~> 2.8) 261 | faraday (>= 0.17.5, < 3.a) 262 | jwt (>= 1.5, < 4.0) 263 | multi_json (~> 1.10) 264 | sqlite3 (1.7.3) 265 | mini_portile2 (~> 2.8.0) 266 | sqlite3 (1.7.3-aarch64-linux) 267 | sqlite3 (1.7.3-arm-linux) 268 | sqlite3 (1.7.3-arm64-darwin) 269 | sqlite3 (1.7.3-x86-linux) 270 | sqlite3 (1.7.3-x86_64-darwin) 271 | sqlite3 (1.7.3-x86_64-linux) 272 | standard (1.52.0) 273 | language_server-protocol (~> 3.17.0.2) 274 | lint_roller (~> 1.0) 275 | rubocop (~> 1.81.7) 276 | standard-custom (~> 1.0.0) 277 | standard-performance (~> 1.8) 278 | standard-custom (1.0.2) 279 | lint_roller (~> 1.0) 280 | rubocop (~> 1.50) 281 | standard-performance (1.9.0) 282 | lint_roller (~> 1.1) 283 | rubocop-performance (~> 1.26.0) 284 | thor (1.4.0) 285 | timeout (0.6.0) 286 | trilogy (2.9.0) 287 | tzinfo (2.0.6) 288 | concurrent-ruby (~> 1.0) 289 | unicode-display_width (3.2.0) 290 | unicode-emoji (~> 4.1) 291 | unicode-emoji (4.2.0) 292 | uri (1.1.1) 293 | webmock (3.26.1) 294 | addressable (>= 2.8.0) 295 | crack (>= 0.3.2) 296 | hashdiff (>= 0.4.0, < 2.0.0) 297 | websocket-driver (0.8.0) 298 | base64 299 | websocket-extensions (>= 0.1.0) 300 | websocket-extensions (0.1.5) 301 | zeitwerk (2.7.4) 302 | 303 | PLATFORMS 304 | aarch64-linux 305 | aarch64-linux-gnu 306 | aarch64-linux-musl 307 | arm-linux 308 | arm-linux-gnu 309 | arm-linux-musl 310 | arm64-darwin 311 | ruby 312 | x86-linux 313 | x86_64-darwin 314 | x86_64-linux 315 | x86_64-linux-gnu 316 | x86_64-linux-musl 317 | 318 | DEPENDENCIES 319 | activerecord-trilogy-adapter 320 | apnotic (~> 1.7) 321 | appraisal 322 | bigdecimal 323 | concurrent-ruby (< 1.3.5) 324 | drb 325 | googleauth (~> 1.1) 326 | minitest (< 6.0) 327 | mutex_m 328 | noticed! 329 | pg 330 | rails (~> 7.0.0) 331 | sqlite3 (~> 1.7) 332 | standard 333 | webmock 334 | 335 | BUNDLED WITH 336 | 4.0.2 337 | --------------------------------------------------------------------------------