noticed 1.3.2 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,11 +6,11 @@ module Noticed
6
6
 
7
7
  class_attribute :option_names, instance_writer: false, default: []
8
8
 
9
- attr_reader :notification, :options, :params, :recipient, :record
9
+ attr_reader :notification, :options, :params, :recipient, :record, :logger
10
10
 
11
11
  class << self
12
12
  # Copy option names from parent
13
- def inherited(base) #:nodoc:
13
+ def inherited(base) # :nodoc:
14
14
  base.option_names = option_names.dup
15
15
  super
16
16
  end
@@ -29,16 +29,24 @@ module Noticed
29
29
  end
30
30
  end
31
31
 
32
- def perform(args)
33
- @notification = args[:notification_class].constantize.new(args[:params])
34
- @options = args[:options]
32
+ def assign_args(args)
33
+ @notification = args.fetch(:notification_class).constantize.new(args[:params])
34
+ @options = args[:options] || {}
35
35
  @params = args[:params]
36
36
  @recipient = args[:recipient]
37
37
  @record = args[:record]
38
38
 
39
+ # Set the default logger
40
+ @logger = @options.fetch(:logger, Rails.logger)
41
+
39
42
  # Make notification aware of database record and recipient during delivery
40
43
  @notification.record = args[:record]
41
44
  @notification.recipient = args[:recipient]
45
+ self
46
+ end
47
+
48
+ def perform(args)
49
+ assign_args(args)
42
50
 
43
51
  return if (condition = @options[:if]) && !@notification.send(condition)
44
52
  return if (condition = @options[:unless]) && @notification.send(condition)
@@ -57,25 +65,26 @@ module Noticed
57
65
  # Helper method for making POST requests from delivery methods
58
66
  #
59
67
  # Usage:
60
- # post("http://example.com", basic_auth: {user:, pass:}, json: {}, form: {})
68
+ # post("http://example.com", basic_auth: {user:, pass:}, headers: {}, json: {}, form: {})
61
69
  #
62
70
  def post(url, args = {})
63
71
  basic_auth = args.delete(:basic_auth)
72
+ headers = args.delete(:headers)
64
73
 
65
- request = if basic_auth
66
- HTTP.basic_auth(user: basic_auth[:user], pass: basic_auth[:pass])
67
- else
68
- HTTP
69
- end
74
+ request = HTTP
75
+ request = request.basic_auth(user: basic_auth[:user], pass: basic_auth[:pass]) if basic_auth
76
+ request = request.headers(headers) if headers
70
77
 
71
78
  response = request.post(url, args)
72
79
 
73
80
  if options[:debug]
74
- Rails.logger.debug("POST #{url}")
75
- Rails.logger.debug("Response: #{response.code}: #{response}")
81
+ logger.debug("POST #{url}")
82
+ logger.debug("Response: #{response.code}: #{response}")
76
83
  end
77
84
 
78
85
  if !options[:ignore_failure] && !response.status.success?
86
+ puts response.status
87
+ puts response.body
79
88
  raise ResponseUnsuccessful.new(response)
80
89
  end
81
90
 
@@ -4,17 +4,42 @@ module Noticed
4
4
  option :mailer
5
5
 
6
6
  def deliver
7
- mailer.with(format).send(method.to_sym).deliver_now
7
+ if options[:enqueue]
8
+ mailer.with(format).send(mailer_method.to_sym).deliver_later
9
+ else
10
+ mailer.with(format).send(mailer_method.to_sym).deliver_now
11
+ end
8
12
  end
9
13
 
10
14
  private
11
15
 
16
+ # mailer: "UserMailer"
17
+ # mailer: UserMailer
18
+ # mailer: :my_method - `my_method` should return Class
12
19
  def mailer
13
- options.fetch(:mailer).constantize
20
+ option = options.fetch(:mailer)
21
+ case option
22
+ when String
23
+ option.constantize
24
+ when Symbol
25
+ notification.send(option)
26
+ else
27
+ option
28
+ end
14
29
  end
15
30
 
16
- def method
17
- options[:method] || notification.class.name.underscore
31
+ # Method should be a symbol
32
+ #
33
+ # If notification responds to symbol, call that method and use return value
34
+ # If notification does not respond to symbol, use the symbol for the mailer method
35
+ # Otherwise, use the underscored notification class name as the mailer method
36
+ def mailer_method
37
+ method_name = options[:method]&.to_sym
38
+ if method_name.present?
39
+ notification.respond_to?(method_name) ? notification.send(method_name) : method_name
40
+ else
41
+ notification.class.name.underscore
42
+ end
18
43
  end
19
44
 
20
45
  def format
@@ -0,0 +1,96 @@
1
+ require "googleauth"
2
+
3
+ # class CommentNotifier
4
+ # deliver_by :fcm, credentials: Rails.root.join("config/certs/fcm.json"), format: :format_notification
5
+ #
6
+ # deliver_by :fcm, credentials: :fcm_credentials
7
+ # def fcm_credentials
8
+ # { project_id: "api-12345" }
9
+ # end
10
+ # end
11
+
12
+ module Noticed
13
+ module DeliveryMethods
14
+ class Fcm < Base
15
+ BASE_URI = "https://fcm.googleapis.com/v1/projects/"
16
+
17
+ option :format
18
+
19
+ def deliver
20
+ device_tokens.each do |device_token|
21
+ post("#{BASE_URI}#{project_id}/messages:send", headers: {authorization: "Bearer #{access_token}"}, json: {message: format(device_token)})
22
+ rescue ResponseUnsuccessful => exception
23
+ if exception.response.code == 404
24
+ cleanup_invalid_token(device_token)
25
+ else
26
+ raise
27
+ end
28
+ end
29
+ end
30
+
31
+ def cleanup_invalid_token(device_token)
32
+ return unless notification.respond_to?(:cleanup_device_token)
33
+ notification.send(:cleanup_device_token, token: device_token, platform: "fcm")
34
+ end
35
+
36
+ def credentials
37
+ @credentials ||= begin
38
+ option = options[:credentials]
39
+ credentials_hash = case option
40
+ when Hash
41
+ option
42
+ when Pathname
43
+ load_json(option)
44
+ when String
45
+ load_json(Rails.root.join(option))
46
+ when Symbol
47
+ notification.send(option)
48
+ else
49
+ Rails.application.credentials.fcm
50
+ end
51
+
52
+ credentials_hash.symbolize_keys
53
+ end
54
+ end
55
+
56
+ def load_json(path)
57
+ JSON.parse(File.read(path))
58
+ end
59
+
60
+ def project_id
61
+ credentials[:project_id]
62
+ end
63
+
64
+ def access_token
65
+ token = authorizer.fetch_access_token!
66
+ token["access_token"]
67
+ end
68
+
69
+ def authorizer
70
+ @authorizer ||= options.fetch(:authorizer, Google::Auth::ServiceAccountCredentials).make_creds(
71
+ json_key_io: StringIO.new(credentials.to_json),
72
+ scope: "https://www.googleapis.com/auth/firebase.messaging"
73
+ )
74
+ end
75
+
76
+ def format(device_token)
77
+ notification.send(options[:format], device_token)
78
+ end
79
+
80
+ def device_tokens
81
+ if notification.respond_to?(:fcm_device_tokens)
82
+ Array.wrap(notification.fcm_device_tokens(recipient))
83
+ else
84
+ raise NoMethodError, <<~MESSAGE
85
+ You must implement `fcm_device_tokens` to send Firebase Cloud Messaging notifications
86
+
87
+ # This must return an Array of FCM device tokens
88
+ def fcm_device_tokens(recipient)
89
+ recipient.fcm_device_tokens.pluck(:token)
90
+ end
91
+ MESSAGE
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,171 @@
1
+ require "apnotic"
2
+
3
+ module Noticed
4
+ module DeliveryMethods
5
+ class Ios < Base
6
+ cattr_accessor :connection_pool
7
+
8
+ def deliver
9
+ raise ArgumentError, "bundle_identifier is missing" if bundle_identifier.blank?
10
+ raise ArgumentError, "key_id is missing" if key_id.blank?
11
+ raise ArgumentError, "team_id is missing" if team_id.blank?
12
+ raise ArgumentError, "Could not find APN cert at '#{cert_path}'" unless valid_cert_path?
13
+
14
+ device_tokens.each do |device_token|
15
+ connection_pool.with do |connection|
16
+ apn = Apnotic::Notification.new(device_token)
17
+ format_notification(apn)
18
+
19
+ response = connection.push(apn)
20
+ raise "Timeout sending iOS push notification" unless response
21
+
22
+ if bad_token?(response)
23
+ # Allow notification to cleanup invalid iOS device tokens
24
+ cleanup_invalid_token(device_token)
25
+ elsif !response.ok?
26
+ raise "Request failed #{response.body}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def format_notification(apn)
35
+ apn.topic = bundle_identifier
36
+
37
+ if (method = options[:format])
38
+ notification.send(method, apn)
39
+ elsif params[:message].present?
40
+ apn.alert = params[:message]
41
+ else
42
+ raise ArgumentError, "No message for iOS delivery. Either include message in params or add the 'format' option in 'deliver_by :ios'."
43
+ end
44
+ end
45
+
46
+ def device_tokens
47
+ if notification.respond_to?(:ios_device_tokens)
48
+ Array.wrap(notification.ios_device_tokens(recipient))
49
+ else
50
+ raise NoMethodError, <<~MESSAGE
51
+ You must implement `ios_device_tokens` to send iOS notifications
52
+
53
+ # This must return an Array of iOS device tokens
54
+ def ios_device_tokens(recipient)
55
+ recipient.ios_device_tokens.pluck(:token)
56
+ end
57
+ MESSAGE
58
+ end
59
+ end
60
+
61
+ def bad_token?(response)
62
+ response.status == "410" || (response.status == "400" && response.body["reason"] == "BadDeviceToken")
63
+ end
64
+
65
+ def cleanup_invalid_token(token)
66
+ return unless notification.respond_to?(:cleanup_device_token)
67
+ notification.send(:cleanup_device_token, token: token, platform: "iOS")
68
+ end
69
+
70
+ def connection_pool
71
+ self.class.connection_pool ||= new_connection_pool
72
+ end
73
+
74
+ def new_connection_pool
75
+ handler = proc do |connection|
76
+ connection.on(:error) do |exception|
77
+ Rails.logger.info "Apnotic exception raised: #{exception}"
78
+ end
79
+ end
80
+
81
+ if development?
82
+ Apnotic::ConnectionPool.development(connection_pool_options, pool_options, &handler)
83
+ else
84
+ Apnotic::ConnectionPool.new(connection_pool_options, pool_options, &handler)
85
+ end
86
+ end
87
+
88
+ def connection_pool_options
89
+ {
90
+ auth_method: :token,
91
+ cert_path: cert_path,
92
+ key_id: key_id,
93
+ team_id: team_id
94
+ }
95
+ end
96
+
97
+ def bundle_identifier
98
+ option = options[:bundle_identifier]
99
+ case option
100
+ when String
101
+ option
102
+ when Symbol
103
+ notification.send(option)
104
+ else
105
+ Rails.application.credentials.dig(:ios, :bundle_identifier)
106
+ end
107
+ end
108
+
109
+ def cert_path
110
+ option = options[:cert_path]
111
+ case option
112
+ when String
113
+ option
114
+ when Symbol
115
+ notification.send(option)
116
+ else
117
+ Rails.root.join("config/certs/ios/apns.p8")
118
+ end
119
+ end
120
+
121
+ def key_id
122
+ option = options[:key_id]
123
+ case option
124
+ when String
125
+ option
126
+ when Symbol
127
+ notification.send(option)
128
+ else
129
+ Rails.application.credentials.dig(:ios, :key_id)
130
+ end
131
+ end
132
+
133
+ def team_id
134
+ option = options[:team_id]
135
+ case option
136
+ when String
137
+ option
138
+ when Symbol
139
+ notification.send(option)
140
+ else
141
+ Rails.application.credentials.dig(:ios, :team_id)
142
+ end
143
+ end
144
+
145
+ def development?
146
+ option = options[:development]
147
+ case option
148
+ when Symbol
149
+ !!notification.send(option)
150
+ else
151
+ !!option
152
+ end
153
+ end
154
+
155
+ def valid_cert_path?
156
+ case cert_path
157
+ when File, StringIO
158
+ cert_path.size > 0
159
+ else
160
+ File.exist?(cert_path)
161
+ end
162
+ end
163
+
164
+ def pool_options
165
+ {
166
+ size: options.fetch(:pool_size, 5)
167
+ }
168
+ end
169
+ end
170
+ end
171
+ end
@@ -5,5 +5,9 @@ module Noticed
5
5
  include Noticed::HasNotifications
6
6
  end
7
7
  end
8
+
9
+ initializer "noticed.rails_5_2_support" do
10
+ require "rails_6_polyfills/base" if Rails::VERSION::MAJOR < 6
11
+ end
8
12
  end
9
13
  end
@@ -1,6 +1,6 @@
1
1
  module Noticed
2
2
  module HasNotifications
3
- # Defines a method for the association and a before_destory callback to remove notifications
3
+ # Defines a method for the association and a before_destroy callback to remove notifications
4
4
  # where this record is a param
5
5
  #
6
6
  # class User < ApplicationRecord
@@ -15,10 +15,19 @@ module Noticed
15
15
 
16
16
  class_methods do
17
17
  def has_noticed_notifications(param_name: model_name.singular, **options)
18
- model = options.fetch(:model_name, "Notification").constantize
19
-
20
18
  define_method "notifications_as_#{param_name}" do
21
- model.where(params: {param_name.to_sym => self})
19
+ model = options.fetch(:model_name, "Notification").constantize
20
+ case current_adapter
21
+ when "postgresql", "postgis"
22
+ model.where("params @> ?", Noticed::Coder.dump(param_name.to_sym => self).to_json)
23
+ when "mysql2"
24
+ model.where("JSON_CONTAINS(params, ?)", Noticed::Coder.dump(param_name.to_sym => self).to_json)
25
+ when "sqlite3"
26
+ model.where("json_extract(params, ?) = ?", "$.#{param_name}", Noticed::Coder.dump(self).to_json)
27
+ else
28
+ # This will perform an exact match which isn't ideal
29
+ model.where(params: {param_name.to_sym => self})
30
+ end
22
31
  end
23
32
 
24
33
  if options.fetch(:destroy, true)
@@ -28,5 +37,13 @@ module Noticed
28
37
  end
29
38
  end
30
39
  end
40
+
41
+ def current_adapter
42
+ if ActiveRecord::Base.respond_to?(:connection_db_config)
43
+ ActiveRecord::Base.connection_db_config.adapter
44
+ else
45
+ ActiveRecord::Base.connection_config[:adapter]
46
+ end
47
+ end
31
48
  end
32
49
  end
data/lib/noticed/model.rb CHANGED
@@ -1,11 +1,23 @@
1
1
  module Noticed
2
2
  module Model
3
+ DATABASE_ERROR_CLASS_NAMES = lambda {
4
+ classes = [ActiveRecord::NoDatabaseError]
5
+ classes << ActiveRecord::ConnectionNotEstablished
6
+ classes << Mysql2::Error if defined?(::Mysql2)
7
+ classes << PG::ConnectionBad if defined?(::PG)
8
+ classes
9
+ }.call.freeze
10
+
3
11
  extend ActiveSupport::Concern
4
12
 
5
13
  included do
6
14
  self.inheritance_column = nil
7
15
 
8
- serialize :params, noticed_coder
16
+ if Rails.gem_version >= Gem::Version.new("7.1.0.alpha")
17
+ serialize :params, coder: noticed_coder
18
+ else
19
+ serialize :params, noticed_coder
20
+ end
9
21
 
10
22
  belongs_to :recipient, polymorphic: true
11
23
 
@@ -32,7 +44,9 @@ module Noticed
32
44
  else
33
45
  Noticed::TextCoder
34
46
  end
35
- rescue ActiveRecord::NoDatabaseError
47
+ rescue *DATABASE_ERROR_CLASS_NAMES => _error
48
+ warn("Noticed was unable to bootstrap correctly as the database is unavailable.")
49
+
36
50
  Noticed::TextCoder
37
51
  end
38
52
  end
@@ -42,6 +56,7 @@ module Noticed
42
56
  @_notification ||= begin
43
57
  instance = type.constantize.with(params)
44
58
  instance.record = self
59
+ instance.recipient = recipient
45
60
  instance
46
61
  end
47
62
  end
@@ -61,5 +76,10 @@ module Noticed
61
76
  def read?
62
77
  read_at?
63
78
  end
79
+
80
+ # If a GlobalID record in params is no longer found, the params will default with a noticed_error key
81
+ def deserialize_error?
82
+ !!params[:noticed_error]
83
+ end
64
84
  end
65
85
  end
@@ -7,6 +7,10 @@ module Noticed
7
7
  :notifications
8
8
  end
9
9
 
10
+ def class_scope
11
+ self.class.name.underscore.tr("/", ".")
12
+ end
13
+
10
14
  def translate(key, **options)
11
15
  I18n.translate(scope_translation_key(key), **options)
12
16
  end
@@ -14,7 +18,7 @@ module Noticed
14
18
 
15
19
  def scope_translation_key(key)
16
20
  if key.to_s.start_with?(".")
17
- "#{i18n_scope}.#{self.class.name.underscore}#{key}"
21
+ "#{i18n_scope}.#{class_scope}#{key}"
18
22
  else
19
23
  key
20
24
  end
@@ -1,3 +1,3 @@
1
1
  module Noticed
2
- VERSION = "1.3.2"
2
+ VERSION = "1.6.3"
3
3
  end
data/lib/noticed.rb CHANGED
@@ -12,26 +12,19 @@ module Noticed
12
12
  autoload :NotificationChannel, "noticed/notification_channel"
13
13
 
14
14
  module DeliveryMethods
15
- autoload :Base, "noticed/delivery_methods/base"
16
15
  autoload :ActionCable, "noticed/delivery_methods/action_cable"
16
+ autoload :Base, "noticed/delivery_methods/base"
17
17
  autoload :Database, "noticed/delivery_methods/database"
18
18
  autoload :Email, "noticed/delivery_methods/email"
19
- autoload :Slack, "noticed/delivery_methods/slack"
19
+ autoload :Fcm, "noticed/delivery_methods/fcm"
20
+ autoload :Ios, "noticed/delivery_methods/ios"
20
21
  autoload :MicrosoftTeams, "noticed/delivery_methods/microsoft_teams"
22
+ autoload :Slack, "noticed/delivery_methods/slack"
21
23
  autoload :Test, "noticed/delivery_methods/test"
22
24
  autoload :Twilio, "noticed/delivery_methods/twilio"
23
25
  autoload :Vonage, "noticed/delivery_methods/vonage"
24
26
  end
25
27
 
26
- def self.notify(recipients:, notification:)
27
- recipients.each do |recipient|
28
- notification.notify(recipient)
29
- end
30
-
31
- # Clear the recipient after sending to the group
32
- notification.recipient = nil
33
- end
34
-
35
28
  mattr_accessor :parent_class
36
29
  @@parent_class = "ApplicationJob"
37
30
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/subscription_adapter/base"
4
+ require "action_cable/subscription_adapter/subscriber_map"
5
+ require "action_cable/subscription_adapter/async"
6
+
7
+ module ActionCable
8
+ module SubscriptionAdapter
9
+ # == Test adapter for Action Cable
10
+ #
11
+ # The test adapter should be used only in testing. Along with
12
+ # <tt>ActionCable::TestHelper</tt> it makes a great tool to test your Rails application.
13
+ #
14
+ # To use the test adapter set +adapter+ value to +test+ in your +config/cable.yml+ file.
15
+ #
16
+ # NOTE: Test adapter extends the <tt>ActionCable::SubscriptionsAdapter::Async</tt> adapter,
17
+ # so it could be used in system tests too.
18
+ class Test < Async
19
+ def broadcast(channel, payload)
20
+ broadcasts(channel) << payload
21
+ super
22
+ end
23
+
24
+ def broadcasts(channel)
25
+ channels_data[channel] ||= []
26
+ end
27
+
28
+ def clear_messages(channel)
29
+ channels_data[channel] = []
30
+ end
31
+
32
+ def clear
33
+ @channels_data = nil
34
+ end
35
+
36
+ private
37
+
38
+ def channels_data
39
+ @channels_data ||= {}
40
+ end
41
+ end
42
+ end
43
+
44
+ # Update how broadcast_for determines the channel name so it's consistent with the Rails 6 way
45
+ module Channel
46
+ module Broadcasting
47
+ delegate :broadcast_to, to: :class
48
+ module ClassMethods
49
+ def broadcast_to(model, message)
50
+ ActionCable.server.broadcast(broadcasting_for(model), message)
51
+ end
52
+
53
+ def broadcasting_for(model)
54
+ serialize_broadcasting([channel_name, model])
55
+ end
56
+
57
+ def serialize_broadcasting(object) # :nodoc:
58
+ case # standard:disable Style/EmptyCaseCondition
59
+ when object.is_a?(Array)
60
+ object.map { |m| serialize_broadcasting(m) }.join(":")
61
+ when object.respond_to?(:to_gid_param)
62
+ object.to_gid_param
63
+ else
64
+ object.to_param
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end