noticent 0.0.1.pre.pre → 0.0.5

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.
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <img src="http://cdn2-cloud66-com.s3.amazonaws.com/images/oss-sponsorship.png" width=150/>
2
+
1
3
  # Noticent
2
4
 
3
5
  Noticent is a Ruby gem for user notification management. It is written to deliver a developer friendly way to managing application notifications in a typical web application. Many applications have user notification: sending emails when a task is done or support for webhooks or Slack upon certain events. Noticent makes it easy to write maintainable code for notification subscription and delivery in a typical web application.
@@ -13,6 +15,9 @@ The primary design goal for Noticent is developer friendliness. Using Noticent,
13
15
 
14
16
  ## Installation
15
17
 
18
+ ### Notice on Rails version
19
+ Noticent 0.0.5 has been upgraded to work with Rails 6.0. If you would like to use it with an older version of Rails, please use Noticent 0.0.4
20
+
16
21
  Add this line to your application's Gemfile:
17
22
 
18
23
  ```ruby
@@ -93,12 +98,8 @@ Noticent.configure do
93
98
  channel :email
94
99
 
95
100
  scope :account do
96
- alert :new_signup do
97
- notify :owner
98
- end
99
- alert :new_team_member do
100
- notify :users
101
- end
101
+ alert(:new_signup) { notify :owner}
102
+ alert(:new_team_member) { notify :users }
102
103
  end
103
104
  end
104
105
  ```
@@ -117,7 +118,7 @@ class Email < ::Noticent::Channel
117
118
  end
118
119
  ```
119
120
 
120
- Now that we have our channel, we can define a Payload. We can do this in `app/modesl/noticent/account_payload.rb`:
121
+ Now that we have our channel, we can define a Payload. We can do this in `app/models/noticent/account_payload.rb`:
121
122
 
122
123
  ```ruby
123
124
  class AccountPayload
@@ -167,7 +168,6 @@ In the channel, you can use this:
167
168
 
168
169
  ```ruby
169
170
  class EmailChannel < ::Noticent::Channel
170
-
171
171
  def new_member
172
172
  data, content = render
173
173
  send_email(subject: data[:subject], content: content) # this is an example code
@@ -195,20 +195,25 @@ Noticent.configure do
195
195
  product :product_buzz
196
196
  product :product_bar
197
197
 
198
- scope :account do
198
+ scope :account, check_constructor: false do
199
199
  alert :new_user do
200
200
  applies.to :product_foo
201
201
  notify :users
202
202
  notify(:staff).on(:internal)
203
203
  notify :owners
204
+
205
+ default true
204
206
  end
205
207
  end
206
208
 
207
209
  scope :comment do
208
- alert :new_comment do
210
+ alert :new_comment, constructor_name: :some_constructor do
209
211
  applies.not_to :product_buzz
210
212
  notify :commenter
211
- notify :auther
213
+ notify :author
214
+
215
+ default true
216
+ default(false) { on(:email) }
212
217
  end
213
218
  alert :comment_updated do
214
219
  notify :commenter
@@ -232,6 +237,11 @@ account_payload = AccountPayload.new(1, user.first)
232
237
  Noticent.notify(:new_user, account_payload)
233
238
  ```
234
239
 
240
+ While it is possible to define and use alert names as symbols, Noticent also creates a constant with the name of the alert under the `Noticent` namespace to help with the use of alert names.
241
+ By using the constants you can make sure alert names are free of typos.
242
+
243
+ For example, if you have an alert called `some_event` then after configuration there will be a constant called `Noticent::ALERT_SOME_EVENT` available to use with the value `:some_event`.
244
+
235
245
  ### Using Each Noticent Component
236
246
 
237
247
  #### Payload
@@ -250,6 +260,10 @@ end
250
260
 
251
261
  If specified, the type of the payload is checked against this class at runtime (when `Notify` is called).
252
262
 
263
+ To enforce development type consistency payload should have class method constructors that are named after the alert names. This can be turned off by setting `check_constructor` on scopes to `false`.
264
+ To share the same class method constructor for different alerts, you can use the `constructor_name` on alert to tell Noticent to look for a constructor that is not named after the alert itself.
265
+ This is a validation step only and doesn't affect the performance of Noticent.
266
+
253
267
  #### Channel
254
268
 
255
269
  Channels should be derived from `::Noticent::Channel` class and called the same as with the name of the channel with a `Channel` suffix: `email` would be `EmailChannel` and `slack` will be `SlackChannel`. Also, channels should have a method for each type of alert they are supposed to handle. Channel class can be changed using the `klass` argument during definition.
@@ -273,7 +287,22 @@ end
273
287
  ```
274
288
 
275
289
  In the example above, we are creating 2 flavors of the slack channel, one called `team_slack` but using the same class and configured differently. When `using` is used in a channel, any attribute passed into `using` will be called on the channel after creation with the given values.
276
- For example, in this example, the `Slack` class is instantiated and attribute `fuzz` is set to `:buzz` on it before the alert method is called.
290
+ For example, in this example, the `Slack` class is instantiated and attribute `fuzz` is set to `:buzz` on it before the alert method is called.
291
+
292
+ You can use `on` with a channel name instead of a channel group name instead:
293
+
294
+ ```ruby
295
+ Noticent.configure do
296
+ channel :email
297
+ channel :private_emails, group: :internal
298
+ channel :slack
299
+
300
+ alert :some_event do
301
+ notify(:users).on(:internal) # this is a group name
302
+ notify(:staff).on(:slack) # this is a channel name
303
+ end
304
+ end
305
+ ```
277
306
 
278
307
  You can use `render` in the channel code to render and return the view file and its front matter (if available). By default, channel will look for `html` and `erb` as the file content and format. You can change these both when calling `render` or at the top of the controller:
279
308
 
@@ -305,7 +334,7 @@ Views are like Rails views. Noticent supports rendering ERB files. You can also
305
334
  ```html
306
335
  This is at the top
307
336
 
308
- <%= yield %>
337
+ <%= @content %>
309
338
 
310
339
  This is at the bottom
311
340
  ```
@@ -327,6 +356,28 @@ Noticent uses a combination of channel, alert and scope to determine if a recipi
327
356
 
328
357
  Use `Noticent.configuration.opt_in_provider`'s `opt_in`, `opt_out` and `opted_in?` methods to change the opt-in state of each recipient.
329
358
 
359
+ ## Default Values
360
+
361
+ You can specify a default opt-in value for each alert. By default alerts have a default value of `false` (no opt-in) unless this is globally changed (see Customization section).
362
+
363
+ The default value for an alert can be set while this can also be changed per channel. For example:
364
+
365
+ ```ruby
366
+ Noticent.configure do
367
+ channel :email
368
+ channel :slack
369
+ channel :webhook
370
+
371
+ scope :post do
372
+ alert :foo do
373
+ notify :users
374
+ default(true) # sets the default value for all channels for this alert to true
375
+ default(false) { on(:slack) } # sets the default value for this alert to false for the slack channel only
376
+ end
377
+ end
378
+ end
379
+ ```
380
+
330
381
  ## Migration
331
382
 
332
383
  Noticent provides a method to add new alerts or remove deprecated alerts from the existing recipients. To add a new alert type, you can use `ActiveRecordOptInProvider.add_alert` method:
@@ -343,7 +394,17 @@ To remove any deprecated alert, use the `ActiveRecordOptInProvider.remove_alert`
343
394
  Noticent.opt_in_provider.remove_alert(scope: :foo, alert_name: :some_old_alert)
344
395
  ```
345
396
 
346
- This removes all instances of the old alert from the opt-ins.
397
+ This removes all instances of the old alert from the opt-ins.
398
+
399
+ ## New Recipient Sign up
400
+
401
+ When a new recipient signs up, you might want to make sure they have all the default alerts setup for them. You can achieve this by calling `Noticent.setup_recipient`:
402
+
403
+ ```ruby
404
+ Noticent.setup_recipient(recipient_id: 1, scope: :post, entity_ids: [2])
405
+ ```
406
+
407
+ This will adds the default opt-ins for recipient 1 on all channels that are applicable to it on scope `post` for entity 2.
347
408
 
348
409
  ## Validation
349
410
 
@@ -379,6 +440,12 @@ The following items can be customized:
379
440
 
380
441
  `halt_on_error`: Should notification fail after the first incident of an error during rendering. Default is `false`
381
442
 
443
+ `default_value`: Default value for all alerts unless explicitly specified. Default is `false`
444
+
445
+ `use_sub_modules`: If set to true, Noticent will look for Channel and Scope classes in sub modules under the `base_module_name`.
446
+ With `use_sub_modules` set to false, a channel named `:email` should be called `Noticent::Email` (if `base_module_name` is `Noticent`), while with `use_sub_modules` set to true, the same class should be `Noticent::Channels::Email`.
447
+ For Payloads, the sub module name will be `Payloads`.
448
+
382
449
 
383
450
  ## Development
384
451
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+ require "bundler"
5
+
6
+ Bundler.require :default, :development
7
+
8
+ Combustion.initialize! :all
9
+ run Combustion::Application
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/all'
3
+ require "active_support/all"
4
+ require "rails/all"
4
5
 
5
6
  Dir["#{File.dirname(__FILE__)}/noticent/**/*.rb"].each { |f| load(f) }
6
7
  load "#{File.dirname(__FILE__)}/generators/noticent/noticent.rb"
@@ -8,7 +8,7 @@ module Noticent
8
8
  end
9
9
 
10
10
  def opt_out(recipient_id:, scope:, entity_id:, alert_name:, channel_name:)
11
- Noticent::OptIn.where(recipient_id: recipient_id, scope: scope, entity_id: entity_id, alert_name: alert_name, channel_name: channel_name).delete
11
+ Noticent::OptIn.where(recipient_id: recipient_id, scope: scope, entity_id: entity_id, alert_name: alert_name, channel_name: channel_name).destroy_all
12
12
  end
13
13
 
14
14
  def opted_in?(recipient_id:, scope:, entity_id:, alert_name:, channel_name:)
@@ -33,5 +33,14 @@ module Noticent
33
33
  def remove_alert(scope:, alert_name:)
34
34
  Noticent::OptIn.where('scope = ? AND alert_name = ?', scope, alert_name).destroy_all
35
35
  end
36
+
37
+ def remove_entity(scope:, entity_id:)
38
+ Noticent::OptIn.where('scope = ? AND entity_id = ?', scope, entity_id).destroy_all
39
+ end
40
+
41
+ def remove_recipient(recipient_id:)
42
+ Noticent::OptIn.where(recipient_id: recipient_id).destroy_all
43
+ end
44
+
36
45
  end
37
46
  end
@@ -2,41 +2,30 @@
2
2
 
3
3
  module Noticent
4
4
  class Channel
5
- @@default_ext = :erb
6
- @@default_format = :html
5
+ class_attribute :default_ext, default: :erb
6
+ class_attribute :default_format, default: :html
7
7
 
8
- def initialize(config, recipients, payload, context)
8
+ attr_accessor :data
9
+
10
+ def initialize(config, recipients, payload, configuration)
9
11
  @config = config
10
12
  @recipients = recipients
11
13
  @payload = payload
12
- @context = context
14
+ @configuration = configuration
13
15
  @current_user = payload.current_user if payload.respond_to? :current_user
16
+ @routes = Rails.application.routes.url_helpers
14
17
  end
15
18
 
16
- def render_within_context(template, content)
17
- rendered_content = ERB.new(content).result(get_binding)
18
- template.nil? ? rendered_content : ERB.new(template).result(get_binding { rendered_content })
19
+ def render_within_context(template:, content:, context:)
20
+ @content = ERB.new(content).result(context)
21
+ template.nil? ? @content : ERB.new(template).result(binding)
19
22
  end
20
23
 
21
24
  protected
22
25
 
23
26
  attr_reader :payload
24
27
  attr_reader :recipients
25
- attr_reader :context
26
-
27
- class << self
28
- def default_format(format)
29
- @@default_format = format
30
- end
31
-
32
- def default_ext(ext)
33
- @@default_ext = ext
34
- end
35
- end
36
-
37
- def get_binding
38
- binding
39
- end
28
+ attr_reader :configuration
40
29
 
41
30
  def current_user
42
31
  raise Noticent::NoCurrentUser if @current_user.nil?
@@ -44,15 +33,13 @@ module Noticent
44
33
  @current_user
45
34
  end
46
35
 
47
- def render(format: @@default_format, ext: @@default_ext, layout: '')
36
+ def render(format: default_format, ext: default_ext, layout: "")
48
37
  alert_name = caller[0][/`.*'/][1..-2]
49
- channel_name = self.class.name.split('::').last.underscore
38
+ channel_name = self.class.name.split("::").last.underscore
50
39
  view_filename, layout_filename = filenames(channel: channel_name, alert: alert_name, format: format, ext: ext, layout: layout)
51
40
 
52
- raise Noticent::ViewNotFound, "view #{view_filename} not found" unless File.exist?(view_filename)
53
-
54
41
  view = View.new(view_filename, template_filename: layout_filename, channel: self)
55
- view.process
42
+ view.process(binding)
56
43
 
57
44
  [view.data, view.content]
58
45
  end
@@ -60,16 +47,22 @@ module Noticent
60
47
  private
61
48
 
62
49
  def view_file(channel:, alert:, format:, ext:)
63
- File.join(@config.view_dir, channel, "#{alert}.#{format}.#{ext}")
50
+ view_filename = File.join(@config.view_dir, channel, "#{alert}.#{format}.#{ext}")
51
+ if !File.exist?(view_filename)
52
+ # no specific file found, use a convention
53
+ view_filename = File.join(@config.view_dir, channel, "default.#{format}.#{ext}")
54
+ raise Noticent::ViewNotFound, "view #{view_filename} not found" unless File.exist?(view_filename)
55
+ end
56
+
57
+ return view_filename
64
58
  end
65
59
 
66
60
  def filenames(channel:, alert:, format:, ext:, layout:)
67
61
  view_filename = view_file(channel: channel, alert: alert, format: format, ext: ext)
68
- layout_filename = ''
69
- layout_filename = File.join(@config.view_dir, 'layouts', "#{layout}.#{format}.#{ext}") unless layout == ''
62
+ layout_filename = ""
63
+ layout_filename = File.join(@config.view_dir, "layouts", "#{layout}.#{format}.#{ext}") unless layout == ""
70
64
 
71
65
  return view_filename, layout_filename
72
66
  end
73
-
74
67
  end
75
68
  end
@@ -2,17 +2,20 @@
2
2
 
3
3
  module Noticent
4
4
  def self.configure(options = {}, &block)
5
- if ENV['NOTICENT_RSPEC'] == '1'
5
+ if ENV["NOTICENT_RSPEC"] == "1"
6
6
  options = options.merge(
7
- base_module_name: 'Noticent::Testing',
7
+ base_module_name: "Noticent::Testing",
8
8
  base_dir: File.expand_path("#{File.dirname(__FILE__)}/../../testing"),
9
- halt_on_error: true
9
+ halt_on_error: true,
10
10
  )
11
11
  end
12
12
 
13
13
  @config = Noticent::Config::Builder.new(options, &block).build
14
14
  @config.validate!
15
15
 
16
+ # construct dynamics
17
+ @config.create_dynamics
18
+
16
19
  @config
17
20
  end
18
21
 
@@ -28,12 +31,38 @@ module Noticent
28
31
  engine.dispatch
29
32
  end
30
33
 
34
+ # recipient is the recipient object id
35
+ # entities is an array of all entity ids this recipient needs to opt in based on the alert defaults
36
+ # scope is the name of the scope these entities belong to
37
+ def self.setup_recipient(recipient_id:, scope:, entity_ids:)
38
+ raise ArgumentError, "no scope named '#{scope}' found" if @config.scopes[scope].nil?
39
+
40
+ alerts = @config.alerts_by_scope(scope)
41
+
42
+ alerts.each do |alert|
43
+ channels = @config.alert_channels(alert.name)
44
+
45
+ channels.each do |channel|
46
+ next unless alert.default_for(channel.name)
47
+
48
+ entity_ids.each do |entity_id|
49
+ @config.opt_in_provider.opt_in(recipient_id: recipient_id,
50
+ scope: scope,
51
+ entity_id: entity_id,
52
+ alert_name: alert.name,
53
+ channel_name: channel.name)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
31
59
  class Config
32
60
  attr_reader :hooks
33
61
  attr_reader :channels
34
62
  attr_reader :scopes
35
63
  attr_reader :alerts
36
64
  attr_reader :products
65
+ attr_reader :channel_groups
37
66
 
38
67
  def initialize(options = {})
39
68
  @options = options
@@ -46,12 +75,6 @@ module Noticent
46
75
  @channels.values.select { |x| x.group == group }
47
76
  end
48
77
 
49
- def channel_groups
50
- return [] if @channels.nil?
51
-
52
- @channels.values.collect(&:group).uniq
53
- end
54
-
55
78
  def alert_channels(alert_name)
56
79
  alert = @alerts[alert_name]
57
80
  raise ArgumentError, "no alert #{alert_name} found" if alert.nil?
@@ -62,7 +85,7 @@ module Noticent
62
85
 
63
86
  def products_by_alert(alert_name)
64
87
  alert = @alerts[alert_name]
65
- raise ArgumentError "no alert #{alert_name} found" if alert.nil?
88
+ raise ArgumentError, "no alert #{alert_name} found" if alert.nil?
66
89
 
67
90
  alert.products
68
91
  end
@@ -90,23 +113,46 @@ module Noticent
90
113
  end
91
114
 
92
115
  def halt_on_error
93
- @options[:halt_on_error].nil? || false
116
+ @options[:halt_on_error].nil? ? false : @options[:halt_on_error]
117
+ end
118
+
119
+ def skip_alert_with_no_subscribers
120
+ @options[:skip_alert_with_no_subscribers].nil? ? false : @options[:skip_alert_with_no_subscribers]
121
+ end
122
+
123
+ def default_value
124
+ @options[:default_value].nil? ? false : @options[:default_value]
125
+ end
126
+
127
+ def use_sub_modules
128
+ @options[:use_sub_modules].nil? ? false : @options[:use_sub_modules]
94
129
  end
95
130
 
96
131
  def payload_dir
97
- File.join(base_dir, 'payloads')
132
+ File.join(base_dir, "payloads")
98
133
  end
99
134
 
100
135
  def scope_dir
101
- File.join(base_dir, 'scopes')
136
+ File.join(base_dir, "scopes")
102
137
  end
103
138
 
104
139
  def channel_dir
105
- File.join(base_dir, 'channels')
140
+ File.join(base_dir, "channels")
106
141
  end
107
142
 
108
143
  def view_dir
109
- File.join(base_dir, 'views')
144
+ File.join(base_dir, "views")
145
+ end
146
+
147
+ def create_dynamics
148
+ return if alerts.nil?
149
+
150
+ alerts.keys.each do |alert|
151
+ const_name = "ALERT_#{alert.to_s.upcase}"
152
+ next if Noticent.const_defined?(const_name)
153
+
154
+ Noticent.const_set(const_name, alert)
155
+ end
110
156
  end
111
157
 
112
158
  def validate!
@@ -119,7 +165,7 @@ module Noticent
119
165
  def initialize(options = {}, &block)
120
166
  @options = options
121
167
  @config = Noticent::Config.new(options)
122
- raise BadConfiguration, 'no OptInProvider configured' if @config.opt_in_provider.nil?
168
+ raise BadConfiguration, "no OptInProvider configured" if @config.opt_in_provider.nil?
123
169
 
124
170
  instance_eval(&block) if block_given?
125
171
 
@@ -150,6 +196,14 @@ module Noticent
150
196
  @options[:halt_on_error] = value
151
197
  end
152
198
 
199
+ def skip_alert_with_no_subscribers=(value)
200
+ @options[:skip_alert_with_no_subscribers] = value
201
+ end
202
+
203
+ def use_sub_modules=(value)
204
+ @options[:use_sub_modules] = value
205
+ end
206
+
153
207
  def hooks
154
208
  if @config.hooks.nil?
155
209
  @config.instance_variable_set(:@hooks, Noticent::Definitions::Hooks.new)
@@ -177,8 +231,12 @@ module Noticent
177
231
 
178
232
  def channel(name, group: :default, klass: nil, &block)
179
233
  channels = @config.instance_variable_get(:@channels) || {}
234
+ channel_groups = @config.instance_variable_get(:@channel_groups) || []
180
235
 
181
236
  raise BadConfiguration, "channel '#{name}' already defined" if channels.include? name
237
+ raise BadConfiguration, "a channel group named '#{group}' already exists. channels and channel groups cannot have duplicates" if channel_groups.include? name
238
+
239
+ channel_groups << group
182
240
 
183
241
  channel = Noticent::Definitions::Channel.new(@config, name, group: group, klass: klass)
184
242
  hooks.run(:pre_channel_registration, channel)
@@ -188,15 +246,16 @@ module Noticent
188
246
  channels[name] = channel
189
247
 
190
248
  @config.instance_variable_set(:@channels, channels)
249
+ @config.instance_variable_set(:@channel_groups, channel_groups.uniq)
191
250
  channel
192
251
  end
193
252
 
194
- def scope(name, payload_class: nil, &block)
253
+ def scope(name, payload_class: nil, check_constructor: true, &block)
195
254
  scopes = @config.instance_variable_get(:@scopes) || {}
196
255
 
197
256
  raise BadConfiguration, "scope '#{name}' already defined" if scopes.include? name
198
257
 
199
- scope = Noticent::Definitions::Scope.new(@config, name, payload_class: payload_class)
258
+ scope = Noticent::Definitions::Scope.new(@config, name, payload_class: payload_class, check_constructor: check_constructor)
200
259
  scope.instance_eval(&block)
201
260
 
202
261
  scopes[name] = scope