notey 0.1.0 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf9f791db654a77800eacc7033b1566c2083b2dd30750d71888f33725b83db33
4
- data.tar.gz: d942224462d599deb0525be5443b3b81a8f9a901a23edbaa730378cbed4ce9f8
3
+ metadata.gz: 2a4f1b1236c0e43b64f5624a251fdc64f4ce94cd3e12f000efb4619799d6c7c4
4
+ data.tar.gz: 8bc6db30919be535ab9b1a6064f8ba58be63791b0cb091fe63a6dca47e57df76
5
5
  SHA512:
6
- metadata.gz: 0e764a54cababf319b8d8259ed775fd6dfdca0346c98281d6f08971d8b6ee3169d9804dc82d6a178712b914afb736d368ccc3e26060acb811520c7a544b2ed49
7
- data.tar.gz: cc0023e05f0cb136f7a3cfb5c395afed39210135348470d4fec84105b9e108f47830bf29bc15d6eedab841ebf51d77a48c92a7589ab18b51774a936e1a18cb94
6
+ metadata.gz: 3e29900e2827d55ab8efd6bebe99a5a73fdd4d49b709dbeeafeefc73aeddbf31fba242105b0dd7e547f15852855e5a3e45dd3bf4df51fc6365dc156da936a094
7
+ data.tar.gz: cb4ae5408587d29aab57d7e87f94a5fbfcd3097eb93a2e5a0e9335816f704560c42acde42f6c1bd3b970ab043cb9163249a8d4672b59e04a26d4a9244ae414bf
data/README.md CHANGED
@@ -15,8 +15,8 @@ leaves to the host.
15
15
 
16
16
  ## What Notey adds
17
17
 
18
- - **A catalog.** One declaration naming every notification type, the channels it
19
- may be delivered on, and the channels a person gets by default.
18
+ - **Channels.** One registration per channel the application can send on, made
19
+ once for the whole application rather than on every notification.
20
20
  - **Preferences.** Which channels a person wants for each type, per account,
21
21
  with a page they set them on.
22
22
  - **Digest windows.** A type set to daily or weekly is held back and sent as one
@@ -24,8 +24,8 @@ leaves to the host.
24
24
  - **An inbox.** In-app notification pages scoped to the account the person is in.
25
25
  - **Destinations.** An address and an encrypted credential per account per
26
26
  channel, so an account points a channel at its own workspace or endpoint.
27
- - **Domain events.** A mapping from an event your app already publishes to the
28
- notifier that should deliver it.
27
+ - **Notification types.** A class describing what a notification carries and
28
+ nothing about how it is sent.
29
29
 
30
30
  ## Installation
31
31
 
@@ -54,20 +54,21 @@ That gives you `/notey/preferences`, `/notey/notifications` and
54
54
  Notey owns its own tables and never owns your person or account records. Six
55
55
  things connect it to yours.
56
56
 
57
- ### 1. Declare the catalog
57
+ ### 1. Register the channels your application has
58
58
 
59
- Nothing works until a type is declared. A preference for an undeclared type is
60
- refused, a channel the catalog does not offer for a type is refused, and a
61
- notifier naming an undeclared type raises when the app boots.
59
+ Every application has email and in-app without registering anything. Register a
60
+ channel for anything else you send on, once, for the whole application.
62
61
 
63
62
  ```ruby
64
63
  # config/initializers/notey.rb
65
- Notey.catalog do
66
- notification :comment, channels: %w[email sms], default: %w[email]
67
- notification :mention, channels: %w[email], default: []
68
- end
64
+ Notey.channel :sms, delivery_method: "Noticed::DeliveryMethods::TwilioMessaging", addressed: true
69
65
  ```
70
66
 
67
+ A registration names the channel, the delivery method that sends it, and whether
68
+ it needs an address before anything can go out on it. Nothing else names a
69
+ channel: a notification type does not, and a person's preferences offer exactly
70
+ what is registered.
71
+
71
72
  ### 2. Make your person model a recipient
72
73
 
73
74
  ```ruby
@@ -113,23 +114,41 @@ ActiveSupport.on_load :noticed_event do
113
114
  end
114
115
  ```
115
116
 
116
- ### 5. Point your notifiers at the catalog
117
+ ### 5. Define a notification type
117
118
 
118
- Each notifier names its type, and each delivery method asks whether the
119
- recipient wants that channel.
119
+ A notification type describes what the notification carries and says nothing
120
+ about channels. Notey decides which channels each recipient gets.
120
121
 
121
122
  ```ruby
122
- class CommentNotifier < Noticed::Event
123
- include Notey::Notifier
123
+ class CommentNotification < Notey::Notification
124
124
  notey_type :comment
125
125
 
126
- deliver_by :email do |config|
127
- config.mailer = "CommentMailer"
128
- config.if = Notey.wanted(:comment, on: :email)
126
+ required_params :comment_id
127
+
128
+ def title
129
+ "New comment"
130
+ end
131
+
132
+ def body
133
+ "Someone replied to you"
129
134
  end
130
135
  end
131
136
  ```
132
137
 
138
+ `notey_type` is the name a person's stored preferences are keyed by, so renaming
139
+ the class does not orphan what they chose. Send it by naming the recipients your
140
+ own code resolved:
141
+
142
+ ```ruby
143
+ CommentNotification.notify(recipients, comment_id: comment.id)
144
+ ```
145
+
146
+ Notey builds the delivery list from the registered channels each time, so a
147
+ channel registered later needs no change here. For each recipient and each
148
+ channel it answers three things before sending: the person wants that channel
149
+ for that type, their window is immediate, and they have an address if the
150
+ channel needs one.
151
+
133
152
  ### 6. Tell Notey where a notification lives
134
153
 
135
154
  Digest emails link to each notification through a lambda you supply, so the link
@@ -183,13 +202,10 @@ releases the window so it can be sent again.
183
202
 
184
203
  A channel like email reaches a person at an address the app already holds. A
185
204
  channel like SMS, a webhook, Slack or Discord does not, so somebody has to say
186
- where it goes. Declare which channels take an address:
205
+ where it goes. Say so when you register the channel:
187
206
 
188
207
  ```ruby
189
- Notey.catalog do
190
- notification :comment, channels: %w[email sms], default: %w[email]
191
- addressed :sms
192
- end
208
+ Notey.channel :sms, delivery_method: "Noticed::DeliveryMethods::TwilioMessaging", addressed: true
193
209
  ```
194
210
 
195
211
  **A person sets their own.** Their phone number, their endpoint. A notification
@@ -209,12 +225,14 @@ Bureau.section :notification_destinations, area: :account, title: "Notification
209
225
  renders: "notey/destinations", runs: "Notey::SaveDestination", capability: :configure_site
210
226
  ```
211
227
 
212
- A notifier reads the address through the options Noticed already evaluates:
228
+ Notey sends nothing on an addressed channel until the person has set an address.
229
+ The delivery method reads that address when it sends:
213
230
 
214
231
  ```ruby
215
- deliver_by :webhook do |config|
216
- config.url = Notey.destination_address(:webhook)
217
- config.if = Notey.addressed(:webhook)
232
+ class WebhookDeliveryMethod < Noticed::DeliveryMethod
233
+ def deliver
234
+ post_to Notey::Destinations.for(event.account_id, :webhook, member: recipient).address
235
+ end
218
236
  end
219
237
  ```
220
238
 
@@ -226,13 +244,9 @@ it as a settings section so the shell's capability check guards it.
226
244
 
227
245
  ## Domain events
228
246
 
229
- Notey does not depend on any event pipeline. It holds the mapping from an event
230
- name to a notifier, and your app owns the one class that knows both.
231
-
232
- ```ruby
233
- # config/initializers/notey.rb
234
- Notey.deliver_on :comment_posted, CommentNotifier
235
- ```
247
+ Notey does not depend on any event pipeline and holds no mapping from an event
248
+ to a notification. Your subscriber resolves who should hear about something and
249
+ calls the notification type.
236
250
 
237
251
  ```ruby
238
252
  # app/subscribers/notey_notifications.rb
@@ -240,14 +254,17 @@ class NoteyNotifications < EventEngine::Subscribers::Base
240
254
  subscribes_to :comment_posted
241
255
 
242
256
  def handle(event)
243
- Notey::EventDelivery.call(event)
257
+ payload = event.payload.to_h.symbolize_keys
258
+
259
+ Notey::Current.set(account_id: payload[:account_id]) do
260
+ CommentNotification.notify(User.where(id: payload[:user_ids]), comment_id: payload[:comment_id])
261
+ end
244
262
  end
245
263
  end
246
264
  ```
247
265
 
248
- `Notey::EventDelivery.call` takes anything answering `event_name` and `payload`,
249
- sets the account from the payload for the delivery, and restores the account it
250
- found. An event with no mapping does nothing.
266
+ Who receives a notification is decided outside notey; how each of them receives
267
+ it is decided inside.
251
268
 
252
269
  ## Development
253
270
 
@@ -3,5 +3,15 @@
3
3
  module Notey
4
4
  class Attempt < ApplicationRecord
5
5
  belongs_to :notification, class_name: "Noticed::Notification"
6
+
7
+ def send_again
8
+ raise UnsendableAttempt, "#{channel} was already sent for this notification" if state == "sent"
9
+
10
+ delivery = notification.event.class.delivery_methods[channel.to_sym]
11
+ raise UnsendableAttempt, "#{channel} is not a channel this application has" if delivery.nil?
12
+
13
+ destroy
14
+ delivery.perform_later(notification)
15
+ end
6
16
  end
7
17
  end
@@ -10,7 +10,7 @@ module Notey
10
10
  stored = stored_for(member, notification_type, account_id)
11
11
 
12
12
  Decision.new(
13
- channels: stored ? Array(stored.channels).map(&:to_s) : Notey.default_channels,
13
+ channels: channels_of(stored, notification_type) & Notey.channels,
14
14
  window: stored&.digest_window || "immediate"
15
15
  )
16
16
  end
data/lib/notey/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Notey
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/notey.rb CHANGED
@@ -1,9 +1,7 @@
1
1
  require "notey/version"
2
2
  require "notey/engine"
3
- require "notey/catalog"
4
3
  require "notey/channels"
5
4
  require "notey/destinations"
6
- require "notey/event_delivery"
7
5
  require "notey/inbox"
8
6
  require "notey/digest_run"
9
7
  require "notey/records_attempt"
@@ -18,9 +16,9 @@ module Notey
18
16
  end
19
17
  end
20
18
 
21
- class UndeclaredType < StandardError; end
22
- class UndeliverableChannel < StandardError; end
23
19
  class MissingSender < StandardError; end
20
+ class UnsendableChannel < StandardError; end
21
+ class UnsendableAttempt < StandardError; end
24
22
 
25
23
  class << self
26
24
  attr_writer :notification_url, :mailer_sender
@@ -85,65 +83,29 @@ module Notey
85
83
  end
86
84
 
87
85
  def self.check!
88
- return if catalog.notifications.empty?
89
-
90
- check_declared_types!
91
- check_deliverable_channels!
86
+ registered_channels.each { |channel| check_channel!(channel) }
92
87
  check_sender!
93
88
  end
94
89
 
95
- def self.check_sender!
96
- return if mailer_sender.present?
97
-
98
- raise MissingSender, "notey sends digests by email and no mailer_sender is set"
99
- end
100
-
101
- def self.check_deliverable_channels!
102
- delivered = notifiers.flat_map { |notifier| notifier.delivery_methods.keys.map(&:to_s) }.uniq
103
-
104
- (catalog.channels - delivered).each do |channel|
105
- raise UndeliverableChannel, "the catalog offers the channel #{channel}, which no notifier delivers on"
106
- end
107
- end
108
-
109
- def self.check_declared_types!
110
- notifiers.each do |notifier|
111
- notification_type = notifier.notey_notification_type
112
- next if notification_type.nil? || catalog.declared?(notification_type)
113
-
114
- raise UndeclaredType,
115
- "#{notifier} declares the notification type #{notification_type}, which the catalog does not hold"
116
- end
117
- end
118
-
119
- def self.destination_address(channel)
120
- -> { Destinations.for(event.account_id, channel, member: recipient)&.address }
121
- end
122
-
123
- def self.addressed(channel)
124
- -> { Destinations.for(event.account_id, channel, member: recipient).present? }
125
- end
126
-
127
- def self.deliver_on(event_name, notifier)
128
- event_notifiers[event_name.to_s] = notifier
129
- end
90
+ def self.check_channel!(channel)
91
+ required = delivery_method_for(channel).required_option_names
92
+ return if required.empty?
130
93
 
131
- def self.notifier_for(event_name)
132
- event_notifiers[event_name.to_s]
94
+ raise UnsendableChannel,
95
+ "the channel #{channel.name} needs the option #{required.first}, which notey does not supply"
133
96
  end
134
97
 
135
- def self.event_notifiers
136
- @event_notifiers ||= {}
98
+ def self.delivery_method_for(channel)
99
+ name = channel.delivery_method || "Noticed::DeliveryMethods::#{channel.name.camelize}"
100
+ name.to_s.constantize
101
+ rescue NameError
102
+ raise UnsendableChannel, "the channel #{channel.name} names the delivery method #{name}, which does not exist"
137
103
  end
138
104
 
139
- def self.catalog(&block)
140
- @catalog ||= Catalog.new
141
- @catalog.instance_eval(&block) if block
142
- @catalog
143
- end
105
+ def self.check_sender!
106
+ return if mailer_sender.present?
144
107
 
145
- def self.wanted(notification_type, on:)
146
- sends(notification_type, on: on)
108
+ raise MissingSender, "notey sends digests by email and no mailer_sender is set"
147
109
  end
148
110
 
149
111
  def self.sends(notification_type, on:, addressed: false)
@@ -158,8 +120,6 @@ module Notey
158
120
  end
159
121
 
160
122
  def self.reset!
161
- @catalog = nil
162
- @event_notifiers = nil
163
123
  @host_channels = nil
164
124
  forget_notifiers
165
125
  end
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: notey-develop
3
- description: Use PROACTIVELY for naming a notifier's notification type, gating a delivery method on what a person wants, addressing a channel at the account, firing a notifier from a domain event, scheduling daily and weekly digest runs, reading a person's inbox, and reading their channels and window — MUST BE USED instead of hand-rolling preference checks, per-account delivery addresses, or a digest loop.
3
+ description: Use PROACTIVELY for defining a notification type, sending one to the recipients your own code resolved, sending one when a domain event fires, addressing a channel at a person, scheduling daily and weekly digest runs, reading a person's inbox, and reading their channels and window — MUST BE USED instead of hand-rolling preference checks, per-person delivery addresses, or a digest loop.
4
4
  tools: Read, Write, Edit, Grep
5
- scope: notifications — a catalog of notification types, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a mapping from domain events to notifiers
5
+ scope: notifications — application-wide channel registrations, notification types that name no channel, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a record of what was sent on each channel
6
6
  ---
7
7
 
8
8
  This local follows the steps below exactly and invents no others. Where a step
@@ -12,28 +12,31 @@ picking one.
12
12
  ## What notey is
13
13
 
14
14
  notey is the notification layer for a multi-tenant Rails app on top of Noticed.
15
- It holds which notification types exist, which channels each person wants in
16
- each account they belong to, and whether they want them as they happen or in a
17
- daily or weekly digest. Fire this local when a notifier is being written or
18
- changed, when a domain event should send one, when digests need running, or when
19
- the host's own code needs to read a person's notifications or their preferences.
15
+ It holds which channels the application can send on, which channels each person
16
+ wants in each account they belong to, and whether they want them as they happen
17
+ or in a daily or weekly digest. Fire this local when a notification type is
18
+ being written or changed, when a domain event should send one, when digests need
19
+ running, or when the host's own code needs to read a person's notifications or
20
+ their preferences.
20
21
 
21
22
  This local assumes the gem is already hooked into the app; if it is not, that is
22
23
  `notey-install`'s work and it comes first.
23
24
 
24
25
  ## Interface
25
26
 
26
- - `notey_type` — declared inside a notifier class, names which of the catalog's
27
- notification types that class delivers.
28
- - `Notey.wanted(type, on:)` — returns a condition for one delivery method that
29
- is true only when the person wants that type on that channel and wants it
30
- immediately.
31
- - `Notey.deliver_on(event_name, notifier)` maps a domain event to a notifier,
32
- so the notifier is delivered whenever that event fires.
33
- - `Notey.destination_address(channel)` returns the stored address for the
34
- channel on the account the notification belongs to.
35
- - `Notey.addressed(channel)` returns a condition that is true only when that
36
- account has a stored address for the channel.
27
+ - `Notey::Notification` — the class a notification type inherits, which builds
28
+ its delivery list from the registered channels and decides each one itself.
29
+ - `notey_type` — declared inside a notification type, names the key a person's
30
+ stored preferences are held under, so renaming the class keeps them.
31
+ - `required_params` — declared inside a notification type, names the information
32
+ a caller must pass, and a call that omits any of it is refused.
33
+ - `notify(recipients, **information)` sends one notification to the recipients
34
+ the caller resolved, recording one row for each.
35
+ - `Notey.channel(name, delivery_method:, addressed:)` registers one channel
36
+ the application can send on, for the whole application.
37
+ - `Notey::Destinations.for(account_id, channel, member:)` returns the stored
38
+ destination for that person on that channel, which a delivery method reads to
39
+ learn where to send.
37
40
  - `Notey::DigestRun` — sends one window's digests, with `call` for everyone on
38
41
  that window and `deliver_to_member(member, account_id)` for one person in one
39
42
  account.
@@ -45,65 +48,76 @@ This local assumes the gem is already hooked into the app; if it is not, that is
45
48
  person gets that type on in that account.
46
49
  - `digest_window_for(type, account_id:)` — on the recipient model, `immediate`,
47
50
  `daily` or `weekly` for that type in that account.
48
- - `Notey.reset!` — drops the catalog and the event-to-notifier mappings, for
49
- tests that declare their own.
51
+ - `Notey.reset!` — drops the registered channels and notification types, for
52
+ tests that register their own.
50
53
 
51
54
  ## How to use it
52
55
 
53
- 1. Name the type at the top of each notifier class:
56
+ 1. Write a notification type that says nothing about channels:
54
57
 
55
58
  ```ruby
56
- class CommentNotifier < Noticed::Event
59
+ class CommentNotification < Notey::Notification
57
60
  notey_type :comment
61
+
62
+ required_params :comment_id
63
+
64
+ def title
65
+ "New comment"
66
+ end
58
67
  end
59
68
  ```
60
69
 
61
- The name must be one the catalog declares, or the app raises when it boots
62
- with eager loading on. Two notifier classes may name the same type.
70
+ Ask the developer what information the notification carries and what a person
71
+ should read on it. Two types may name the same `notey_type`, and renaming the
72
+ class keeps the preferences people have already stored.
63
73
 
64
- 2. Gate every delivery method on what the person wants:
74
+ 2. Send it by naming the recipients your own code resolved:
65
75
 
66
76
  ```ruby
67
- deliver_by :email do |config|
68
- config.if = Notey.wanted(:comment, on: :email)
69
- end
77
+ CommentNotification.notify(recipients, comment_id: comment.id)
70
78
  ```
71
79
 
72
- Pass the same type the class names and the channel this delivery method
73
- sends on. Without this the delivery runs for everyone regardless of their
74
- preferences.
80
+ Working out who should receive it, and removing duplicates from that list, is
81
+ the caller's and never notey's. The call is refused before anything is
82
+ recorded when it omits information the type requires.
75
83
 
76
- 3. Leave the digest to notey. `Notey.wanted` is false when the person's window
77
- for that type is daily or weekly, so nothing sends at the time and the
78
- notification waits in their inbox for the next run. Never write a second
79
- condition for the window.
84
+ 3. Write no condition on any channel. notey builds the delivery list from the
85
+ registered channels and answers three things per recipient per channel before
86
+ sending: the person wants that channel for that type, their window is
87
+ immediate, and they have an address if the channel needs one. A type that
88
+ tries to gate its own channels is doing notey's job twice.
80
89
 
81
- 4. For a channel addressed at the account rather than at the person, read the
82
- stored address and skip the delivery when there is none:
90
+ 4. For a channel that needs an address, read it inside the delivery method:
83
91
 
84
92
  ```ruby
85
- deliver_by :webhook, class: "WebhookDeliveryMethod" do |config|
86
- config.url = Notey.destination_address(:webhook)
87
- config.if = Notey.addressed(:webhook)
93
+ class WebhookDeliveryMethod < Noticed::DeliveryMethod
94
+ def deliver
95
+ post_to Notey::Destinations.for(event.account_id, :webhook, member: recipient).address
96
+ end
88
97
  end
89
98
  ```
90
99
 
91
- Ask the developer which channels are addressed at the account, since the two
92
- kinds of channel are gated differently and only they know which is which. A
93
- channel can carry both conditions, one from step 2 and one from here.
100
+ notey has already refused the delivery when no address is set, so the lookup
101
+ never comes back empty here. Ask the developer which channels need an address,
102
+ since that is named once on the registration and only they know which.
94
103
 
95
- 5. Map a domain event to a notifier in an initializer, one line per mapping:
104
+ 5. Send from a domain event in the host's own subscriber:
96
105
 
97
106
  ```ruby
98
- Notey.deliver_on :comment_posted, CommentNotifier
107
+ def handle(event)
108
+ payload = event.payload.to_h.symbolize_keys
109
+
110
+ Notey::Current.set(account_id: payload[:account_id]) do
111
+ CommentNotification.notify(User.where(id: payload[:user_ids]), comment_id: payload[:comment_id])
112
+ end
113
+ end
99
114
  ```
100
115
 
101
- The subscriber sets the account from the event's payload, delivers the
102
- notifier with the whole payload as its params, and puts back the account that
103
- was set before. An event with no mapping delivers nothing and raises nothing.
104
- Ask the developer which events map to which notifiers; nothing in the gem
105
- infers it. The payload must carry `account_id`, or the notification is stored
106
- against no account and never reaches an inbox.
116
+ notey holds no mapping from an event to a notification and depends on no event
117
+ pipeline. The subscriber resolves the recipients and sets the account, because
118
+ a delivery runs in a job where the current account is gone. Without
119
+ `account_id` the notification is stored against no account and never reaches
120
+ an inbox.
107
121
 
108
122
  6. Schedule the digest windows. notey registers no schedule of its own, so a
109
123
  window nobody runs never sends:
@@ -139,27 +153,26 @@ This local assumes the gem is already hooked into the app; if it is not, that is
139
153
  ```
140
154
 
141
155
  All three take the account from the current request when it is left out. All
142
- three read a person's stored row for that type, and fall back to the
143
- catalog's declared defaults when they have never stored one.
156
+ three read a person's stored row for that type, and fall back to email and
157
+ in-app when they have never stored one.
144
158
 
145
- 9. In tests that declare their own notification types or event mappings, call
159
+ 9. In tests that register their own channels or notification types, call
146
160
  `Notey.reset!` in teardown. Both are held for the life of the process, so a
147
- test that skips this leaves its types and mappings in place for every test
161
+ test that skips this leaves its channels and types in place for every test
148
162
  after it.
149
163
 
150
164
  ## Conventions
151
165
 
152
- - **Every notifier names a type.** A notifier without one delivers, but no
153
- digest ever includes it, because a digest picks its rows by the type the
154
- notifier names.
155
- - **Every delivery method carries a condition.** An ungated one ignores the
156
- person's channels and their window, which is the whole point of the gem.
157
- - **Every channel the catalog offers is delivered by some notifier.** A channel
158
- the catalog offers that no notifier has a delivery method for raises when the
159
- app boots with eager loading on.
160
- - **Only channels the catalog offers for that type may be gated on.** Gating on
161
- a channel the type does not offer makes a condition that is always false, and
162
- the delivery silently never runs.
166
+ - **Every notification type names a `notey_type`.** Without one no digest ever
167
+ includes it, because a digest picks its rows by that name.
168
+ - **No notification type names a channel.** Channels are registered once for the
169
+ whole application, and a type that declares one is describing something it
170
+ does not decide.
171
+ - **Every registered channel is offered for every type.** A type cannot be kept
172
+ off a channel, so a channel nobody should get for a given notification is a
173
+ channel that should not be registered.
174
+ - **Email and in-app exist without being registered.** An application that
175
+ registers nothing can still send on both.
163
176
  - **A digest is mailed to the person's `email`.** A recipient model without one
164
177
  raises when its digest is sent, not when the run starts.
165
178
  - **A run sends one window once.** Overlapping runs of the same window send one
@@ -170,9 +183,9 @@ This local assumes the gem is already hooked into the app; if it is not, that is
170
183
  nothing.
171
184
  - **A digest sends nothing when there is nothing to send** — no empty mail, and
172
185
  no row saying it went out.
173
- - **Notifiers, event mappings and digest schedules all belong in the host's
174
- code**, never in the gem.
175
- - **Out of scope.** Declaring the notification types and their channels, making
176
- a model a recipient, setting the current person and account, storing an
177
- account's addresses, and the pages a person picks their own channels on all
178
- belong to `notey-install`.
186
+ - **Notification types, subscribers and digest schedules all belong in the
187
+ host's code**, never in the gem.
188
+ - **Out of scope.** Registering the channels the application has, making a model
189
+ a recipient, setting the current person and account, storing an account's
190
+ addresses, and the pages a person picks their own channels on all belong to
191
+ `notey-install`.
@@ -2,7 +2,7 @@
2
2
  name: notey-info
3
3
  description: Use to learn what notey offers — notification types and the channels they allow, per-person per-account delivery preferences, digest windows, an in-app inbox, and per-account destinations.
4
4
  tools: Read
5
- scope: notifications — a catalog of notification types, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a mapping from domain events to notifiers
5
+ scope: notifications — application-wide channel registrations, notification types that name no channel, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a record of what was sent on each channel
6
6
  ---
7
7
 
8
8
  This local explains what notey is and which of its other two locals you need. It
@@ -52,15 +52,12 @@ has already been sent, you are building, and `notey-develop` owns it.
52
52
  ## Conventions
53
53
 
54
54
  - **Notification type** — the name of one kind of notification, as a string. It
55
- is the unit everything else is keyed by: a person's preference, a notifier's
56
- declaration, and the channels on offer.
57
- - **Catalog** — the one declaration of which notification types exist, which
58
- channels each offers, and which of those channels apply when a person has
59
- stored nothing. A type that is not in it is unknown to notey, and a channel a
60
- type does not offer cannot be stored against it.
61
- - **Channel** — a named way of delivering, such as email or a chat webhook. The
62
- catalog offers them per notification type; a notifier is what actually
63
- delivers on one.
55
+ is the unit everything else is keyed by: a person's preference and the name a
56
+ notification type declares.
57
+ - **Channel** — a named way of delivering, such as email or a chat webhook,
58
+ registered once for the whole application. Email and in-app exist without
59
+ being registered; anything else exists only if the application registered it.
60
+ Every registered channel is offered for every notification type.
64
61
  - **Member** — the person a preference belongs to. It is polymorphic, so a host
65
62
  app names whichever model of its own receives notifications.
66
63
  - **Account** — the tenant. Preferences, notifications, digests and destinations
@@ -72,9 +69,15 @@ has already been sent, you are building, and `notey-develop` owns it.
72
69
  notification happens; the other two hold it for a grouped email covering the
73
70
  current day or week.
74
71
  - **Destination** — one address per account per channel, with an optional stored
75
- credential, for channels addressed at the account rather than at a person.
72
+ credential, optionally owned by one person. A notification for a person uses
73
+ the address that person set, never the account's.
76
74
  - **Inbox** — the notifications a member has received within one account, which
77
75
  is what the in-app list reads and what a digest gathers from.
78
- - **Notifier** — the Noticed class that delivers one notification type. Every
79
- notifier names a type the catalog holds, and every channel the catalog offers
80
- is delivered on by some notifier.
76
+ - **Notification type** — the class describing what one notification carries and
77
+ what a person reads on it. It names no channel, because notey builds the
78
+ delivery list from the registered channels and decides each one against the
79
+ recipient's stored preference.
80
+ - **Attempt** — one row per notification per outbound channel, claimed before the
81
+ send and marked after, holding whether it was sent and what a failure said. It
82
+ is what answers why one person was not reached, and it is what stops the same
83
+ channel sending twice for the same notification.
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: notey-install
3
- description: Use to hook notey into a project — installing its tables, mounting its engine, declaring the notification catalog, making a model a recipient, setting the current person and account, putting the account on Noticed's rows, supplying a notification url, checking the catalog against the notifiers, and reaching the preferences, inbox and destinations pages.
3
+ description: Use to hook notey into a project — installing its tables, mounting its engine, registering the channels the application can send on, making a model a recipient, setting the current person and account, putting the account on Noticed's rows, supplying a notification url, and reaching the preferences, inbox and destinations pages.
4
4
  tools: Bash, Read, Edit
5
- scope: notifications — a catalog of notification types, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a mapping from domain events to notifiers
5
+ scope: notifications — application-wide channel registrations, notification types that name no channel, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a record of what was sent on each channel
6
6
  ---
7
7
 
8
8
  This local follows the steps below exactly and invents no others. Where a step
@@ -17,31 +17,32 @@ different notifications in each.
17
17
 
18
18
  ## Interface
19
19
 
20
- - `bin/rails notey:install:migrations` — copies notey's six migrations into the
20
+ - `bin/rails notey:install:migrations` — copies notey's migrations into the
21
21
  host's `db/migrate`.
22
22
  - `mount Notey::Engine` — in the host's `config/routes.rb`, putting notey's
23
23
  three pages under a path the host chooses.
24
- - `Notey.catalog` — declares every notification type, the channels each type may
25
- be delivered on, and the channels a person gets before they have stored
26
- anything.
24
+ - `Notey.channel` — registers one channel the application can send on, naming
25
+ the delivery method that sends it and whether it needs an address. Email and
26
+ in-app exist without being registered.
27
27
  - `Notey::Recipient` — included in the host model that receives notifications,
28
28
  which gives that model its stored preferences.
29
- - `Notey::Notifier` — included in each Noticed event class, which registers it
30
- so notey can check it when the app boots.
29
+ - `Notey::Notification` — the class each notification type inherits, which
30
+ registers the type and builds its delivery list from the registered channels.
31
31
  - `Notey::Current` — holds the person and the account id for the current
32
32
  request, and every preference read, preference save and inbox read goes
33
33
  through it.
34
34
  - `Notey.notification_url=` — takes a lambda returning the host's own url for
35
35
  one notification, used to link the rows in a digest email.
36
- - `Notey.check!` — raises when a notifier names a type the catalog does not
37
- hold, or the catalog offers a channel no notifier delivers on, and notey runs
38
- it itself after initialization when the app eager loads.
36
+ - `Notey.check!` — raises when a registered channel names a delivery method that
37
+ does not exist or needs an option notey does not supply, and when notey has no
38
+ sender address for its digests. notey runs it itself after initialization when
39
+ the app eager loads.
39
40
  - `/notey/preferences` — the page a person picks their channels and their
40
41
  immediate, daily or weekly window on, per notification type.
41
42
  - `/notey/notifications` — the person's inbox for the account they are in, 25
42
43
  rows newest first with an unread count, and a button that marks one read.
43
44
  - `/notey/destinations` — the page an account's address and credential are
44
- stored on, one pair per channel the catalog offers.
45
+ stored on, one pair per registered channel that needs an address.
45
46
 
46
47
  The three paths assume the engine is mounted at `/notey`; a different mount path
47
48
  moves all three.
@@ -51,8 +52,8 @@ moves all three.
51
52
  1. Add `gem "notey"` to the host's `Gemfile` and run `bundle install`.
52
53
 
53
54
  2. Run `bin/rails notey:install:migrations` and then `bin/rails db:migrate`.
54
- This creates `notey_preferences`, `notey_digests` and `notey_destinations` in
55
- the host's database.
55
+ This creates `notey_preferences`, `notey_digests`, `notey_destinations` and
56
+ `notey_attempts` in the host's database.
56
57
 
57
58
  3. Mount the engine in the host's `config/routes.rb`:
58
59
 
@@ -63,18 +64,18 @@ moves all three.
63
64
  Ask the developer which path to mount at if the app already has a convention
64
65
  for engine paths, since the path prefixes all three pages.
65
66
 
66
- 4. Declare the catalog in `config/initializers/notey.rb`:
67
+ 4. Register the channels the application sends on, in
68
+ `config/initializers/notey.rb`:
67
69
 
68
70
  ```ruby
69
- Notey.catalog do
70
- notification :comment, channels: %w[email sms], default: %w[email]
71
- end
71
+ Notey.channel :sms, delivery_method: "Noticed::DeliveryMethods::TwilioMessaging", addressed: true
72
72
  ```
73
73
 
74
- Ask the developer which notification types exist, which channels each one
75
- offers, and which of those channels apply before a person has chosen — there
76
- is no default for any of the three. Declare only channels a notifier in the
77
- app actually delivers on, or the app will refuse to boot at step 10.
74
+ Every application has email and in-app already, so register nothing to get
75
+ those two. Ask the developer which other channels this application sends on,
76
+ which delivery method sends each one, and which of them need an address
77
+ before anything can go out. A channel registered here is offered for every
78
+ notification type; there is no way to keep a type off one.
78
79
 
79
80
  5. Include the recipient concern in the model that receives notifications:
80
81
 
@@ -99,7 +100,7 @@ moves all three.
99
100
 
100
101
  Ask the developer what supplies the signed-in person and the current account
101
102
  in this app, since both names above are guesses about the host. A read with
102
- no account set returns the catalog's default channels and an empty inbox,
103
+ no account set returns email and in-app and an empty inbox,
103
104
  never another account's rows, and a save on the preferences page fails
104
105
  because a stored preference needs an account.
105
106
 
@@ -125,17 +126,18 @@ moves all three.
125
126
  Without these columns the inbox is empty and every digest is, because a
126
127
  delivery runs in a job where the current account is gone.
127
128
 
128
- 8. Include the notifier concern in each Noticed event class that delivers one of
129
- the catalog's types:
129
+ 8. Define one notification type so there is something to send:
130
130
 
131
131
  ```ruby
132
- class CommentNotifier < Noticed::Event
133
- include Notey::Notifier
132
+ class CommentNotification < Notey::Notification
133
+ notey_type :comment
134
+
135
+ required_params :comment_id
134
136
  end
135
137
  ```
136
138
 
137
- Naming which type the class delivers, and gating each delivery method on what
138
- the person wants, are `notey-develop`'s steps rather than this local's.
139
+ What each type carries, what a person reads on it, and sending one are
140
+ `notey-develop`'s steps rather than this local's.
139
141
 
140
142
  9. Point digest links at the host's own pages in
141
143
  `config/initializers/notey.rb`:
@@ -150,8 +152,9 @@ moves all three.
150
152
  set, a digest still sends and its rows are plain text instead of links.
151
153
 
152
154
  10. Boot the app with eager loading on and watch it raise or come up. notey runs
153
- `Notey.check!` itself at that point; call it directly in a test or a console
154
- to check the same thing without a boot.
155
+ `Notey.check!` itself at that point, which refuses a channel that cannot
156
+ send and refuses to run with no sender address for its digests; call it
157
+ directly in a test or a console to check the same thing without a boot.
155
158
 
156
159
  11. Decide the pages' layout. They render in notey's own layout, which loads
157
160
  notey's stylesheet and nothing of the host's, unless the host defines
@@ -159,8 +162,7 @@ moves all three.
159
162
  the engine's copy. Ask the developer whether these pages should carry the
160
163
  app's navigation.
161
164
 
162
- 12. Set up destinations only if a channel in the catalog is addressed at the
163
- account rather than at a person. The stored credential is encrypted, so
165
+ 12. Set up destinations only if a registered channel needs an address. The stored credential is encrypted, so
164
166
  Active Record encryption keys must be configured in the host's credentials
165
167
  (`bin/rails db:encryption:init` generates a set) or saving a destination
166
168
  raises. Ask the developer whether any channel needs this before doing it.
@@ -168,16 +170,18 @@ moves all three.
168
170
  ## Conventions
169
171
 
170
172
  - **Check each page while signed in with an account set.** `/notey/preferences`
171
- lists one panel per declared type, `/notey/notifications` lists that person's
172
- notifications for the current account, and `/notey/destinations` lists one
173
- address and credential field per channel the catalog offers.
173
+ lists one panel per notification type with every registered channel against
174
+ it, `/notey/notifications` lists that person's notifications for the current
175
+ account, and `/notey/destinations` lists one address and credential field per
176
+ registered channel that needs an address.
174
177
  - **A preferences page that will not save is the symptom of a missing account.**
175
- Reads fall back to the catalog's default channels when
176
- `Notey::Current.account_id` is nil and a save is refused, so check step 6
177
- before anything else.
178
- - **Boot with eager loading after every catalog change.** The catalog and the
179
- notifiers are checked against each other only then, and an empty catalog
180
- skips the check entirely.
178
+ Reads fall back to email and in-app when `Notey::Current.account_id` is nil
179
+ and a save is refused, so check step 6 before anything else.
180
+ - **A registration for a channel notey already has replaces it.** Registering
181
+ `:email` with no delivery method leaves notey's own email delivery behind, and
182
+ the app refuses to boot rather than sending nothing, so register a channel
183
+ notey supplies only to replace it deliberately and with a delivery method that
184
+ can send.
181
185
  - **Authentication on the three pages is the host's.** Its controllers inherit
182
186
  from the host's `ApplicationController`, so the host's own filters run and
183
187
  notey adds no authorization of its own — restricting who may set an account's
@@ -186,6 +190,6 @@ moves all three.
186
190
  that changes it.
187
191
  - **Re-run `bin/rails notey:install:migrations` after upgrading the gem.** It
188
192
  copies only the migrations the host does not already have.
189
- - **Out of scope.** Scheduling the digest runs, tying a notifier to a
190
- notification type, delivering one when a domain event fires, and reading the
191
- inbox from the host's own code all belong to `notey-develop`.
193
+ - **Out of scope.** Scheduling the digest runs, writing a notification type,
194
+ sending one when a domain event fires, and reading the inbox from the host's
195
+ own code all belong to `notey-develop`.
@@ -1,11 +1,11 @@
1
- scope: notifications — a catalog of notification types, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a mapping from domain events to notifiers
1
+ scope: notifications — application-wide channel registrations, notification types that name no channel, per-person per-account channel preferences, digest windows, an in-app inbox, per-account destinations, and a record of what was sent on each channel
2
2
 
3
3
  install:
4
4
  - bin/rails notey:install:migrations
5
5
  - mount Notey::Engine
6
- - Notey.catalog
6
+ - Notey.channel
7
7
  - Notey::Recipient
8
- - Notey::Notifier
8
+ - Notey::Notification
9
9
  - Notey::Current
10
10
  - Notey.notification_url=
11
11
  - Notey.check!
@@ -15,10 +15,9 @@ install:
15
15
 
16
16
  develop:
17
17
  - notey_type
18
- - Notey.wanted
19
- - Notey.deliver_on
20
- - Notey.destination_address
21
- - Notey.addressed
18
+ - required_params
19
+ - notify
20
+ - Notey::Destinations.for
22
21
  - Notey::DigestRun
23
22
  - Notey::Inbox.for
24
23
  - wants?
@@ -29,20 +28,23 @@ develop:
29
28
  sources:
30
29
  - lib/notey.rb
31
30
  - lib/notey/engine.rb
32
- - lib/notey/catalog.rb
33
31
  - lib/notey/channels.rb
34
32
  - lib/notey/destinations.rb
35
33
  - lib/notey/digest_run.rb
36
- - lib/notey/event_subscriber.rb
37
34
  - lib/notey/inbox.rb
35
+ - lib/notey/records_attempt.rb
38
36
  - app/models/concerns/notey/recipient.rb
39
- - app/models/concerns/notey/notifier.rb
37
+ - app/models/notey/notification.rb
40
38
  - app/models/notey/current.rb
41
39
  - app/models/notey/preference.rb
42
40
  - app/models/notey/destination.rb
43
41
  - app/models/notey/digest.rb
42
+ - app/models/notey/attempt.rb
43
+ - app/delivery_methods/notey/email.rb
44
+ - app/delivery_methods/notey/in_app.rb
44
45
  - app/jobs/notey/digest_job.rb
45
46
  - app/mailers/notey/digest_mailer.rb
47
+ - app/mailers/notey/notification_mailer.rb
46
48
  - app/controllers/notey/preferences_controller.rb
47
49
  - app/controllers/notey/notifications_controller.rb
48
50
  - app/controllers/notey/destinations_controller.rb
@@ -50,3 +52,4 @@ sources:
50
52
  - db/migrate/20260917000002_create_notey_preferences.rb
51
53
  - db/migrate/20260918030002_create_notey_digests.rb
52
54
  - db/migrate/20260918050001_create_notey_destinations.rb
55
+ - db/migrate/20260921000001_create_notey_attempts.rb
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: notey
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -84,7 +84,6 @@ files:
84
84
  - app/mailers/notey/application_mailer.rb
85
85
  - app/mailers/notey/digest_mailer.rb
86
86
  - app/mailers/notey/notification_mailer.rb
87
- - app/models/concerns/notey/notifier.rb
88
87
  - app/models/concerns/notey/recipient.rb
89
88
  - app/models/notey/application_record.rb
90
89
  - app/models/notey/attempt.rb
@@ -115,12 +114,10 @@ files:
115
114
  - db/migrate/20260919010001_add_member_to_notey_destinations.rb
116
115
  - db/migrate/20260921000001_create_notey_attempts.rb
117
116
  - lib/notey.rb
118
- - lib/notey/catalog.rb
119
117
  - lib/notey/channels.rb
120
118
  - lib/notey/destinations.rb
121
119
  - lib/notey/digest_run.rb
122
120
  - lib/notey/engine.rb
123
- - lib/notey/event_delivery.rb
124
121
  - lib/notey/inbox.rb
125
122
  - lib/notey/records_attempt.rb
126
123
  - lib/notey/version.rb
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Notey
4
- module Notifier
5
- extend ActiveSupport::Concern
6
-
7
- included do
8
- class_attribute :notey_notification_type, instance_writer: false
9
-
10
- Notey.register_notifier(self)
11
- end
12
-
13
- class_methods do
14
- def notey_type(name)
15
- self.notey_notification_type = name.to_s
16
- end
17
- end
18
- end
19
- end
data/lib/notey/catalog.rb DELETED
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Notey
4
- class Catalog
5
- Notification = Struct.new(:channels, :default, keyword_init: true)
6
-
7
- def initialize
8
- @notifications = {}
9
- @addressed = []
10
- end
11
-
12
- def addressed(*channels)
13
- return @addressed if channels.empty?
14
-
15
- @addressed |= channels.map(&:to_s)
16
- end
17
-
18
- def notification(name, channels: [], default: [])
19
- @notifications[name.to_s] = Notification.new(
20
- channels: Array(channels).map(&:to_s),
21
- default: Array(default).map(&:to_s)
22
- )
23
- end
24
-
25
- def notifications
26
- @notifications
27
- end
28
-
29
- def channels
30
- @notifications.values.flat_map(&:channels).uniq
31
- end
32
-
33
- def declared?(notification_type)
34
- @notifications.key?(notification_type.to_s)
35
- end
36
-
37
- def channels_for(notification_type)
38
- declared(notification_type)&.channels || []
39
- end
40
-
41
- def default_channels_for(notification_type)
42
- declared(notification_type)&.default || []
43
- end
44
-
45
- private
46
-
47
- def declared(notification_type)
48
- @notifications[notification_type.to_s]
49
- end
50
- end
51
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Notey
4
- class EventDelivery
5
- def self.call(event)
6
- notifier = Notey.notifier_for(event.event_name)
7
- return if notifier.nil?
8
-
9
- payload = event.payload.to_h.symbolize_keys
10
-
11
- Current.set(account_id: payload[:account_id]) { notifier.with(**payload).deliver }
12
- end
13
- end
14
- end