noticent 0.0.1.pre.pre → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
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