noticent 0.0.1.pre.pre → 0.0.1
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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/Gemfile.lock +58 -0
- data/LICENSE +201 -0
- data/README.md +77 -12
- data/config.ru +9 -0
- data/lib/noticent.rb +2 -1
- data/lib/noticent/active_record_opt_in_provider.rb +10 -1
- data/lib/noticent/channel.rb +24 -31
- data/lib/noticent/config.rb +76 -17
- data/lib/noticent/definitions/alert.rb +80 -6
- data/lib/noticent/definitions/channel.rb +7 -2
- data/lib/noticent/definitions/scope.rb +10 -6
- data/lib/noticent/dispatcher.rb +16 -10
- data/lib/noticent/version.rb +1 -1
- data/lib/noticent/view.rb +16 -14
- data/noticent.gemspec +25 -21
- data/testing/channels/email.rb +1 -0
- data/testing/channels/exclusive.rb +9 -0
- data/testing/channels/simple.rb +9 -0
- data/testing/payloads/comment_payload.rb +10 -0
- data/testing/payloads/post_payload.rb +44 -0
- data/testing/views/email/some_event.html.erb +3 -0
- data/testing/views/email/some_event.txt.erb +2 -0
- data/testing/views/layouts/layout.html.erb +1 -1
- data/testing/views/simple/default.html.erb +1 -0
- metadata +66 -6
@@ -9,11 +9,17 @@ module Noticent
|
|
9
9
|
attr_reader :options
|
10
10
|
|
11
11
|
def initialize(config, name, group: :default, klass: nil)
|
12
|
+
raise BadConfiguration, 'name should be a symbol' unless name.is_a? Symbol
|
13
|
+
raise BadConfiguration, '\'_any_\' is a reserved channel name' if name == :_any_
|
14
|
+
raise BadConfiguration, '\'_none_\' is a reserved channel name' if name == :_none_
|
15
|
+
|
12
16
|
@name = name
|
13
17
|
@group = group
|
14
18
|
@config = config
|
15
19
|
|
16
|
-
|
20
|
+
sub_module = @config.use_sub_modules ? '::Channels::' : '::'
|
21
|
+
suggested_class_name = @config.base_module_name + sub_module + name.to_s.camelize
|
22
|
+
|
17
23
|
@klass = klass.nil? ? suggested_class_name.camelize.constantize : klass
|
18
24
|
rescue NameError
|
19
25
|
raise Noticent::BadConfiguration, "no class found for #{suggested_class_name}"
|
@@ -37,7 +43,6 @@ module Noticent
|
|
37
43
|
rescue ArgumentError
|
38
44
|
raise Noticent::BadConfiguration, "channel #{@klass} initializer arguments are mismatching."
|
39
45
|
end
|
40
|
-
|
41
46
|
end
|
42
47
|
end
|
43
48
|
end
|
@@ -5,22 +5,26 @@ module Noticent
|
|
5
5
|
class Scope
|
6
6
|
attr_reader :name
|
7
7
|
attr_reader :payload_class
|
8
|
+
attr_reader :check_constructor
|
8
9
|
|
9
|
-
def initialize(config, name, payload_class: nil)
|
10
|
+
def initialize(config, name, payload_class: nil, check_constructor: true)
|
10
11
|
@config = config
|
11
12
|
@name = name
|
12
|
-
|
13
|
-
|
13
|
+
@check_constructor = check_constructor
|
14
|
+
|
15
|
+
sub_module = @config.use_sub_modules ? '::Payloads::' : '::'
|
16
|
+
suggested_name = config.base_module_name + sub_module + "#{name.capitalize}Payload"
|
17
|
+
@payload_class = payload_class.nil? ? suggested_name.constantize : payload_class
|
14
18
|
rescue NameError
|
15
|
-
raise BadConfiguration, "scope #{
|
19
|
+
raise BadConfiguration, "scope #{suggested_name} class not found"
|
16
20
|
end
|
17
21
|
|
18
|
-
def alert(name, &block)
|
22
|
+
def alert(name, constructor_name: nil, &block)
|
19
23
|
alerts = @config.instance_variable_get(:@alerts) || {}
|
20
24
|
|
21
25
|
raise BadConfiguration, "alert '#{name}' already defined" if alerts.include? name
|
22
26
|
|
23
|
-
alert = Noticent::Definitions::Alert.new(@config, name: name, scope: self)
|
27
|
+
alert = Noticent::Definitions::Alert.new(@config, name: name, scope: self, constructor_name: constructor_name.nil? ? name : constructor_name)
|
24
28
|
@config.hooks&.run(:pre_alert_registration, alert)
|
25
29
|
alert.instance_eval(&block) if block_given?
|
26
30
|
@config.hooks&.run(:post_alert_registration, alert)
|
data/lib/noticent/dispatcher.rb
CHANGED
@@ -2,11 +2,11 @@
|
|
2
2
|
|
3
3
|
module Noticent
|
4
4
|
class Dispatcher
|
5
|
-
def initialize(config, alert_name, payload,
|
5
|
+
def initialize(config, alert_name, payload, configuration = {})
|
6
6
|
@config = config
|
7
7
|
@alert_name = alert_name
|
8
8
|
@payload = payload
|
9
|
-
@
|
9
|
+
@configuration = configuration
|
10
10
|
|
11
11
|
validate!
|
12
12
|
|
@@ -34,8 +34,8 @@ module Noticent
|
|
34
34
|
|
35
35
|
# only returns recipients that have opted-in for this channel
|
36
36
|
def filter_recipients(recipients, channel)
|
37
|
-
raise ArgumentError,
|
38
|
-
raise ArgumentError,
|
37
|
+
raise ArgumentError, "channel should be a string or symbol" unless channel.is_a?(String) || channel.is_a?(Symbol)
|
38
|
+
raise ArgumentError, "recipients is nil" if recipients.nil?
|
39
39
|
|
40
40
|
recipients.select { |recipient| @config.opt_in_provider.opted_in?(recipient_id: recipient.id, scope: scope.name, entity_id: @entity_id, alert_name: alert.name, channel_name: channel) }
|
41
41
|
end
|
@@ -43,9 +43,15 @@ module Noticent
|
|
43
43
|
def dispatch
|
44
44
|
notifiers.values.each do |notifier|
|
45
45
|
recs = recipients(notifier.recipient)
|
46
|
-
|
46
|
+
notifier.applicable_channels.each do |channel|
|
47
47
|
to_send = filter_recipients(recs, channel.name)
|
48
|
-
|
48
|
+
|
49
|
+
if to_send.count == 0 && @config.skip_alert_with_no_subscribers
|
50
|
+
Noticent.configuration.logger.info "Skipping alert #{alert.name} as they are no subscribers"
|
51
|
+
return
|
52
|
+
end
|
53
|
+
|
54
|
+
channel_instance = channel.instance(@config, to_send, @payload, @configuration)
|
49
55
|
begin
|
50
56
|
raise Noticent::BadConfiguration, "channel #{channel.name} (#{channel.klass}) doesn't have a method called #{alert.name}" unless channel_instance.respond_to? alert.name
|
51
57
|
|
@@ -54,7 +60,7 @@ module Noticent
|
|
54
60
|
# log and move on
|
55
61
|
raise if @config.halt_on_error
|
56
62
|
|
57
|
-
Noticent.logger.error e
|
63
|
+
Noticent.configuration.logger.error e
|
58
64
|
end
|
59
65
|
end
|
60
66
|
end
|
@@ -63,12 +69,12 @@ module Noticent
|
|
63
69
|
private
|
64
70
|
|
65
71
|
def validate!
|
66
|
-
raise Noticent::BadConfiguration,
|
72
|
+
raise Noticent::BadConfiguration, "no base_dir defined" if @config.base_dir.nil?
|
67
73
|
raise Noticent::MissingConfiguration if @config.nil?
|
68
74
|
raise Noticent::BadConfiguration if @config.alerts.nil?
|
69
75
|
raise Noticent::InvalidAlert, "no alert #{@alert_name} found" if @config.alerts[@alert_name].nil?
|
70
|
-
raise ::ArgumentError,
|
71
|
-
raise ::ArgumentError,
|
76
|
+
raise ::ArgumentError, "payload is nil" if @payload.nil?
|
77
|
+
raise ::ArgumentError, "alert is not a symbol" unless @alert_name.is_a?(Symbol)
|
72
78
|
raise Noticent::BadConfiguration, "payload (#{@payload.class}) doesn't belong to this scope (#{scope.name}) as it requires #{scope.payload_class}" unless !scope.payload_class.nil? && @payload.is_a?(scope.payload_class)
|
73
79
|
raise Noticent::BadConfiguration, "payload doesn't have a #{scope.name}_id method" unless @payload.respond_to?("#{scope.name}_id")
|
74
80
|
end
|
data/lib/noticent/version.rb
CHANGED
data/lib/noticent/view.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
3
|
+
require "yaml"
|
4
4
|
|
5
5
|
module Noticent
|
6
6
|
class View
|
@@ -16,38 +16,40 @@ module Noticent
|
|
16
16
|
attr_reader :raw_data # frontmatter in their raw (pre render) format
|
17
17
|
attr_reader :rendered_data # frontmatter rendered in string format
|
18
18
|
|
19
|
-
def initialize(filename, template_filename:
|
19
|
+
def initialize(filename, template_filename: "", channel:)
|
20
20
|
raise ViewNotFound, "view #{filename} not found" unless File.exist?(filename)
|
21
|
-
raise ViewNotFound, "template #{template_filename} not found" if template_filename !=
|
22
|
-
raise ArgumentError,
|
21
|
+
raise ViewNotFound, "template #{template_filename} not found" if template_filename != "" && !File.exist?(template_filename)
|
22
|
+
raise ArgumentError, "channel is nil" if channel.nil?
|
23
23
|
|
24
24
|
@filename = filename
|
25
25
|
@view_content = File.read(filename)
|
26
|
-
@template_content = template_filename !=
|
27
|
-
@template_filename = template_filename !=
|
26
|
+
@template_content = template_filename != "" ? File.read(template_filename) : "<%= @content %>"
|
27
|
+
@template_filename = template_filename != "" ? template_filename : ""
|
28
28
|
@channel = channel
|
29
29
|
end
|
30
30
|
|
31
|
-
def process
|
31
|
+
def process(context)
|
32
32
|
parse
|
33
|
-
|
34
|
-
render_data
|
33
|
+
render_data(context)
|
35
34
|
read_data
|
35
|
+
# TODO this is nasty. we need to refactor to have an independent render context which somehow merges the binding with the channel.
|
36
|
+
@channel.data = @data
|
37
|
+
render_content(context)
|
36
38
|
end
|
37
39
|
|
38
40
|
private
|
39
41
|
|
40
|
-
def render_content
|
41
|
-
@content = @channel.render_within_context(@template_content, @
|
42
|
+
def render_content(context)
|
43
|
+
@content = @channel.render_within_context(template: @template_content, content: @raw_content, context: context)
|
42
44
|
end
|
43
45
|
|
44
|
-
def render_data
|
46
|
+
def render_data(context)
|
45
47
|
if @raw_data.nil?
|
46
48
|
@rendered_data = nil
|
47
49
|
return
|
48
50
|
end
|
49
51
|
|
50
|
-
@rendered_data = @channel.render_within_context(nil, @raw_data)
|
52
|
+
@rendered_data = @channel.render_within_context(template: nil, content: @raw_data, context: context)
|
51
53
|
end
|
52
54
|
|
53
55
|
def parse
|
@@ -70,7 +72,7 @@ module Noticent
|
|
70
72
|
if @raw_data.nil?
|
71
73
|
@data = nil
|
72
74
|
else
|
73
|
-
raise ArgumentError,
|
75
|
+
raise ArgumentError, "read_data was called before rendering" if @rendered_data.nil?
|
74
76
|
|
75
77
|
data = ::YAML.safe_load(@rendered_data)
|
76
78
|
@data = data.deep_symbolize_keys
|
data/noticent.gemspec
CHANGED
@@ -1,36 +1,40 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
lib = File.expand_path(
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
4
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
-
require
|
5
|
+
require "noticent/version"
|
6
6
|
|
7
7
|
Gem::Specification.new do |spec|
|
8
|
-
spec.name
|
9
|
-
spec.version
|
10
|
-
spec.authors
|
11
|
-
spec.email
|
8
|
+
spec.name = "noticent"
|
9
|
+
spec.version = Noticent::VERSION
|
10
|
+
spec.authors = ["Khash Sajadi"]
|
11
|
+
spec.email = ["khash@cloud66.com"]
|
12
12
|
|
13
|
-
spec.summary
|
13
|
+
spec.summary = "Act as Notified is a flexible framework to add notifications to a Rails application"
|
14
14
|
|
15
15
|
# Specify which files should be added to the gem when it is released.
|
16
16
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
17
17
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
18
18
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
19
19
|
end
|
20
|
-
spec.bindir
|
21
|
-
spec.executables
|
22
|
-
spec.require_paths = [
|
20
|
+
spec.bindir = "exe"
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ["lib"]
|
23
23
|
|
24
|
-
spec.add_dependency
|
25
|
-
spec.add_dependency
|
24
|
+
spec.add_dependency "activerecord", "~> 5.2"
|
25
|
+
spec.add_dependency "activesupport", "~> 5.2"
|
26
|
+
spec.add_dependency "actionpack", "~> 5.2"
|
26
27
|
|
27
|
-
spec.add_development_dependency
|
28
|
-
spec.add_development_dependency
|
29
|
-
spec.add_development_dependency
|
30
|
-
spec.add_development_dependency
|
31
|
-
spec.add_development_dependency
|
32
|
-
spec.add_development_dependency
|
33
|
-
spec.add_development_dependency
|
34
|
-
spec.add_development_dependency
|
35
|
-
spec.add_development_dependency
|
28
|
+
spec.add_development_dependency "combustion", "~> 1.1"
|
29
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
30
|
+
spec.add_development_dependency "factory_bot", "~> 5.0"
|
31
|
+
spec.add_development_dependency "generator_spec", "~> 0.9"
|
32
|
+
spec.add_development_dependency "rails", "~> 5.2"
|
33
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
34
|
+
spec.add_development_dependency "rspec", "~> 3.8"
|
35
|
+
spec.add_development_dependency "rubocop", "~> 0.69"
|
36
|
+
spec.add_development_dependency "rubocop-performance", "~> 1.3"
|
37
|
+
spec.add_development_dependency "rufo", "~> 0.7"
|
38
|
+
spec.add_development_dependency "sqlite3", "~> 1.4"
|
39
|
+
spec.add_development_dependency "byebug", "~> 11.0"
|
36
40
|
end
|
data/testing/channels/email.rb
CHANGED
@@ -3,6 +3,16 @@ module Noticent
|
|
3
3
|
# this is a example payload for comment as scope
|
4
4
|
class CommentPayload < Noticent::Testing::Payload
|
5
5
|
attr_accessor :comment_id
|
6
|
+
attr_reader :users
|
7
|
+
|
8
|
+
def self.three
|
9
|
+
# nop
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.new_signup
|
13
|
+
# nop
|
14
|
+
end
|
15
|
+
|
6
16
|
end
|
7
17
|
end
|
8
18
|
end
|
@@ -16,6 +16,50 @@ module Noticent
|
|
16
16
|
[]
|
17
17
|
end
|
18
18
|
|
19
|
+
def self.some_event(some_value)
|
20
|
+
# nop
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.foo(options)
|
24
|
+
# nop
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.boo
|
28
|
+
# nop
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.one
|
32
|
+
# nop
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.only_here
|
36
|
+
# nop
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.two
|
40
|
+
# nop
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.tfa_enabled
|
44
|
+
# nop
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.sign_up
|
48
|
+
# nop
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.new_signup
|
52
|
+
# nop
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.fuzz
|
56
|
+
# nop
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.buzz
|
60
|
+
# nop
|
61
|
+
end
|
62
|
+
|
19
63
|
end
|
20
64
|
end
|
21
65
|
end
|
@@ -4,3 +4,6 @@ fuzz: <%= @payload.some_attribute %>
|
|
4
4
|
This is normal test
|
5
5
|
Some non-context stuff <%= 1 + 1 %>
|
6
6
|
This comes from <%= @payload.some_attribute %>
|
7
|
+
This comes from an instance variable <%= @some_value %>
|
8
|
+
This is from Rails <%= @routes.hello_path %>
|
9
|
+
This is foo <%= @data[:foo] %> and this is bar <%= @data[:fuzz] %>
|
@@ -0,0 +1 @@
|
|
1
|
+
this is from the default view
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: noticent
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.1
|
4
|
+
version: 0.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Khash Sajadi
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-08-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -38,6 +38,34 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '5.2'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: actionpack
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '5.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '5.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: combustion
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.1'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.1'
|
41
69
|
- !ruby/object:Gem::Dependency
|
42
70
|
name: bundler
|
43
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,6 +108,20 @@ dependencies:
|
|
80
108
|
- - "~>"
|
81
109
|
- !ruby/object:Gem::Version
|
82
110
|
version: '0.9'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rails
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '5.2'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '5.2'
|
83
125
|
- !ruby/object:Gem::Dependency
|
84
126
|
name: rake
|
85
127
|
requirement: !ruby/object:Gem::Requirement
|
@@ -164,6 +206,20 @@ dependencies:
|
|
164
206
|
- - "~>"
|
165
207
|
- !ruby/object:Gem::Version
|
166
208
|
version: '1.4'
|
209
|
+
- !ruby/object:Gem::Dependency
|
210
|
+
name: byebug
|
211
|
+
requirement: !ruby/object:Gem::Requirement
|
212
|
+
requirements:
|
213
|
+
- - "~>"
|
214
|
+
- !ruby/object:Gem::Version
|
215
|
+
version: '11.0'
|
216
|
+
type: :development
|
217
|
+
prerelease: false
|
218
|
+
version_requirements: !ruby/object:Gem::Requirement
|
219
|
+
requirements:
|
220
|
+
- - "~>"
|
221
|
+
- !ruby/object:Gem::Version
|
222
|
+
version: '11.0'
|
167
223
|
description:
|
168
224
|
email:
|
169
225
|
- khash@cloud66.com
|
@@ -178,10 +234,12 @@ files:
|
|
178
234
|
- ".vscode/settings.json"
|
179
235
|
- Gemfile
|
180
236
|
- Gemfile.lock
|
237
|
+
- LICENSE
|
181
238
|
- README.md
|
182
239
|
- Rakefile
|
183
240
|
- bin/console
|
184
241
|
- bin/setup
|
242
|
+
- config.ru
|
185
243
|
- lib/generators/noticent/noticent.rb
|
186
244
|
- lib/generators/noticent/templates/create_opt_ins.rb
|
187
245
|
- lib/generators/noticent/templates/noticent_initializer.rb
|
@@ -205,7 +263,9 @@ files:
|
|
205
263
|
- noticent.gemspec
|
206
264
|
- testing/channels/boo.rb
|
207
265
|
- testing/channels/email.rb
|
266
|
+
- testing/channels/exclusive.rb
|
208
267
|
- testing/channels/foo.rb
|
268
|
+
- testing/channels/simple.rb
|
209
269
|
- testing/channels/slack.rb
|
210
270
|
- testing/channels/webhook.rb
|
211
271
|
- testing/models/receipient.rb
|
@@ -215,6 +275,7 @@ files:
|
|
215
275
|
- testing/views/email/some_event.html.erb
|
216
276
|
- testing/views/email/some_event.txt.erb
|
217
277
|
- testing/views/layouts/layout.html.erb
|
278
|
+
- testing/views/simple/default.html.erb
|
218
279
|
homepage:
|
219
280
|
licenses: []
|
220
281
|
metadata: {}
|
@@ -229,12 +290,11 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
229
290
|
version: '0'
|
230
291
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
231
292
|
requirements:
|
232
|
-
- - "
|
293
|
+
- - ">="
|
233
294
|
- !ruby/object:Gem::Version
|
234
|
-
version:
|
295
|
+
version: '0'
|
235
296
|
requirements: []
|
236
|
-
|
237
|
-
rubygems_version: 2.7.6.2
|
297
|
+
rubygems_version: 3.0.4
|
238
298
|
signing_key:
|
239
299
|
specification_version: 4
|
240
300
|
summary: Act as Notified is a flexible framework to add notifications to a Rails application
|