notey 0.1.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 +7 -0
- data/LICENSE.txt +20 -0
- data/README.md +260 -0
- data/Rakefile +11 -0
- data/app/assets/stylesheets/notey/application.css +15 -0
- data/app/controllers/notey/application_controller.rb +7 -0
- data/app/controllers/notey/destinations_controller.rb +23 -0
- data/app/controllers/notey/notifications_controller.rb +26 -0
- data/app/controllers/notey/preferences_controller.rb +26 -0
- data/app/delivery_methods/notey/email.rb +9 -0
- data/app/delivery_methods/notey/in_app.rb +12 -0
- data/app/helpers/notey/application_helper.rb +4 -0
- data/app/jobs/notey/application_job.rb +4 -0
- data/app/jobs/notey/digest_job.rb +9 -0
- data/app/mailers/notey/application_mailer.rb +6 -0
- data/app/mailers/notey/digest_mailer.rb +12 -0
- data/app/mailers/notey/notification_mailer.rb +11 -0
- data/app/models/concerns/notey/notifier.rb +19 -0
- data/app/models/concerns/notey/recipient.rb +23 -0
- data/app/models/notey/application_record.rb +5 -0
- data/app/models/notey/attempt.rb +7 -0
- data/app/models/notey/current.rb +7 -0
- data/app/models/notey/destination.rb +11 -0
- data/app/models/notey/digest.rb +7 -0
- data/app/models/notey/kept.rb +9 -0
- data/app/models/notey/notification.rb +43 -0
- data/app/models/notey/preference.rb +21 -0
- data/app/models/notey/refusal.rb +13 -0
- data/app/models/notey/save_destination.rb +37 -0
- data/app/models/notey/save_my_destination.rb +11 -0
- data/app/models/notey/save_preferences.rb +37 -0
- data/app/views/notey/_destinations.html.erb +13 -0
- data/app/views/notey/_my_destinations.html.erb +12 -0
- data/app/views/notey/_preferences.html.erb +25 -0
- data/app/views/notey/destinations/show.html.erb +7 -0
- data/app/views/notey/digest_mailer/digest.html.erb +8 -0
- data/app/views/notey/notification_mailer/notification.html.erb +6 -0
- data/app/views/notey/notifications/index.html.erb +17 -0
- data/app/views/notey/preferences/show.html.erb +7 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260917000002_create_notey_preferences.rb +17 -0
- data/db/migrate/20260918020001_add_digest_window_to_notey_preferences.rb +7 -0
- data/db/migrate/20260918030002_create_notey_digests.rb +20 -0
- data/db/migrate/20260918050001_create_notey_destinations.rb +16 -0
- data/db/migrate/20260918060001_index_digest_window_on_notey_preferences.rb +7 -0
- data/db/migrate/20260919010001_add_member_to_notey_destinations.rb +11 -0
- data/db/migrate/20260921000001_create_notey_attempts.rb +16 -0
- data/lib/notey/catalog.rb +51 -0
- data/lib/notey/channels.rb +41 -0
- data/lib/notey/destinations.rb +11 -0
- data/lib/notey/digest_run.rb +73 -0
- data/lib/notey/engine.rb +20 -0
- data/lib/notey/event_delivery.rb +14 -0
- data/lib/notey/inbox.rb +11 -0
- data/lib/notey/records_attempt.rb +37 -0
- data/lib/notey/version.rb +3 -0
- data/lib/notey.rb +166 -0
- data/lib/tasks/notey_tasks.rake +4 -0
- data/the_local/agents/notey-develop.md +178 -0
- data/the_local/agents/notey-info.md +80 -0
- data/the_local/agents/notey-install.md +191 -0
- data/the_local/interface.yml +52 -0
- metadata +159 -0
data/lib/notey.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require "notey/version"
|
|
2
|
+
require "notey/engine"
|
|
3
|
+
require "notey/catalog"
|
|
4
|
+
require "notey/channels"
|
|
5
|
+
require "notey/destinations"
|
|
6
|
+
require "notey/event_delivery"
|
|
7
|
+
require "notey/inbox"
|
|
8
|
+
require "notey/digest_run"
|
|
9
|
+
require "notey/records_attempt"
|
|
10
|
+
|
|
11
|
+
module Notey
|
|
12
|
+
ALWAYS_ON_DELIVERY = { "email" => "Notey::Email", "in_app" => "Notey::InApp" }.freeze
|
|
13
|
+
ALWAYS_ON = ALWAYS_ON_DELIVERY.keys.freeze
|
|
14
|
+
|
|
15
|
+
RegisteredChannel = Struct.new(:name, :delivery_method, :addressed, keyword_init: true) do
|
|
16
|
+
def addressed?
|
|
17
|
+
addressed == true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class UndeclaredType < StandardError; end
|
|
22
|
+
class UndeliverableChannel < StandardError; end
|
|
23
|
+
class MissingSender < StandardError; end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
attr_writer :notification_url, :mailer_sender
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.mailer_sender
|
|
30
|
+
@mailer_sender
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.notification_url
|
|
34
|
+
@notification_url || ->(notification) { nil }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.register_notifier(notifier)
|
|
38
|
+
notifiers << notifier unless notifiers.include?(notifier)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.notifiers
|
|
42
|
+
@notifiers ||= []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.channels
|
|
46
|
+
registered_channels.map(&:name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.notification_types
|
|
50
|
+
notifiers.filter_map(&:notey_notification_type).uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.default_channels
|
|
54
|
+
channels & ALWAYS_ON
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.channel(name, delivery_method: nil, addressed: false)
|
|
58
|
+
host_channels << RegisteredChannel.new(
|
|
59
|
+
name: name.to_s, delivery_method: delivery_method, addressed: addressed
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.registered_channels
|
|
64
|
+
always_on_channels + host_channels
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.addressed_channels
|
|
68
|
+
registered_channels.select(&:addressed?).map(&:name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.always_on_channels
|
|
72
|
+
ALWAYS_ON_DELIVERY.filter_map do |name, delivery_method|
|
|
73
|
+
next if host_channels.any? { |channel| channel.name == name }
|
|
74
|
+
|
|
75
|
+
RegisteredChannel.new(name: name, delivery_method: delivery_method)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.host_channels
|
|
80
|
+
@host_channels ||= []
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.forget_notifiers
|
|
84
|
+
@notifiers = []
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.check!
|
|
88
|
+
return if catalog.notifications.empty?
|
|
89
|
+
|
|
90
|
+
check_declared_types!
|
|
91
|
+
check_deliverable_channels!
|
|
92
|
+
check_sender!
|
|
93
|
+
end
|
|
94
|
+
|
|
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
|
|
130
|
+
|
|
131
|
+
def self.notifier_for(event_name)
|
|
132
|
+
event_notifiers[event_name.to_s]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.event_notifiers
|
|
136
|
+
@event_notifiers ||= {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.catalog(&block)
|
|
140
|
+
@catalog ||= Catalog.new
|
|
141
|
+
@catalog.instance_eval(&block) if block
|
|
142
|
+
@catalog
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.wanted(notification_type, on:)
|
|
146
|
+
sends(notification_type, on: on)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.sends(notification_type, on:, addressed: false)
|
|
150
|
+
lambda do
|
|
151
|
+
decision = Channels.decision_for(recipient, notification_type, account_id: event.account_id)
|
|
152
|
+
|
|
153
|
+
next false unless decision.window == "immediate" && decision.channels.include?(on.to_s)
|
|
154
|
+
next true unless addressed
|
|
155
|
+
|
|
156
|
+
Destinations.for(event.account_id, on, member: recipient).present?
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.reset!
|
|
161
|
+
@catalog = nil
|
|
162
|
+
@event_notifiers = nil
|
|
163
|
+
@host_channels = nil
|
|
164
|
+
forget_notifiers
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
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.
|
|
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
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This local follows the steps below exactly and invents no others. Where a step
|
|
9
|
+
names a decision, put it to the developer and wait for an answer rather than
|
|
10
|
+
picking one.
|
|
11
|
+
|
|
12
|
+
## What notey is
|
|
13
|
+
|
|
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.
|
|
20
|
+
|
|
21
|
+
This local assumes the gem is already hooked into the app; if it is not, that is
|
|
22
|
+
`notey-install`'s work and it comes first.
|
|
23
|
+
|
|
24
|
+
## Interface
|
|
25
|
+
|
|
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.
|
|
37
|
+
- `Notey::DigestRun` — sends one window's digests, with `call` for everyone on
|
|
38
|
+
that window and `deliver_to_member(member, account_id)` for one person in one
|
|
39
|
+
account.
|
|
40
|
+
- `Notey::Inbox.for(member, account_id:)` — returns that person's notifications
|
|
41
|
+
for that account, as a relation.
|
|
42
|
+
- `wants?(type, on:, account_id:)` — on the recipient model, true when that
|
|
43
|
+
person wants that type on that channel in that account.
|
|
44
|
+
- `channels_for(type, account_id:)` — on the recipient model, the channels that
|
|
45
|
+
person gets that type on in that account.
|
|
46
|
+
- `digest_window_for(type, account_id:)` — on the recipient model, `immediate`,
|
|
47
|
+
`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.
|
|
50
|
+
|
|
51
|
+
## How to use it
|
|
52
|
+
|
|
53
|
+
1. Name the type at the top of each notifier class:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class CommentNotifier < Noticed::Event
|
|
57
|
+
notey_type :comment
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
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.
|
|
63
|
+
|
|
64
|
+
2. Gate every delivery method on what the person wants:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
deliver_by :email do |config|
|
|
68
|
+
config.if = Notey.wanted(:comment, on: :email)
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
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.
|
|
75
|
+
|
|
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.
|
|
80
|
+
|
|
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:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
deliver_by :webhook, class: "WebhookDeliveryMethod" do |config|
|
|
86
|
+
config.url = Notey.destination_address(:webhook)
|
|
87
|
+
config.if = Notey.addressed(:webhook)
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
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.
|
|
94
|
+
|
|
95
|
+
5. Map a domain event to a notifier in an initializer, one line per mapping:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Notey.deliver_on :comment_posted, CommentNotifier
|
|
99
|
+
```
|
|
100
|
+
|
|
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.
|
|
107
|
+
|
|
108
|
+
6. Schedule the digest windows. notey registers no schedule of its own, so a
|
|
109
|
+
window nobody runs never sends:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
Notey::DigestRun.new(window: "daily").call
|
|
113
|
+
Notey::DigestRun.new(window: "weekly").call
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Run daily once a day and weekly once a week; those are the only two windows a
|
|
117
|
+
run accepts. Each run enqueues one job per person per account, so one failing
|
|
118
|
+
send does not stop the rest. Ask the developer what schedules recurring work
|
|
119
|
+
in this app — cron, a scheduler gem, or a platform scheduler — and at what
|
|
120
|
+
hour each window should go out.
|
|
121
|
+
|
|
122
|
+
7. Read a person's notifications through the inbox rather than querying Noticed
|
|
123
|
+
directly:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
Notey::Inbox.for(current_user, account_id: current_account.id)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
It returns a relation, so chain `.unread`, `.order` and `.limit` onto it. With
|
|
130
|
+
no account it returns an empty relation, never another account's rows.
|
|
131
|
+
|
|
132
|
+
8. Read a person's preferences from the host's own code when the app needs to
|
|
133
|
+
branch on them:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
user.wants?(:comment, on: :email, account_id: account.id)
|
|
137
|
+
user.channels_for(:comment, account_id: account.id)
|
|
138
|
+
user.digest_window_for(:comment, account_id: account.id)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
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.
|
|
144
|
+
|
|
145
|
+
9. In tests that declare their own notification types or event mappings, call
|
|
146
|
+
`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
|
|
148
|
+
after it.
|
|
149
|
+
|
|
150
|
+
## Conventions
|
|
151
|
+
|
|
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.
|
|
163
|
+
- **A digest is mailed to the person's `email`.** A recipient model without one
|
|
164
|
+
raises when its digest is sent, not when the run starts.
|
|
165
|
+
- **A run sends one window once.** Overlapping runs of the same window send one
|
|
166
|
+
digest, and a send that fails releases the window so the next run picks it up
|
|
167
|
+
again.
|
|
168
|
+
- **A digest covers the current day or week to the moment it runs**, so a run
|
|
169
|
+
late in the period covers everything and a second run in the same period sends
|
|
170
|
+
nothing.
|
|
171
|
+
- **A digest sends nothing when there is nothing to send** — no empty mail, and
|
|
172
|
+
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`.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: notey-info
|
|
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
|
+
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
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This local explains what notey is and which of its other two locals you need. It
|
|
9
|
+
changes nothing and gives no steps.
|
|
10
|
+
|
|
11
|
+
## What notey is
|
|
12
|
+
|
|
13
|
+
notey is the notification layer for a multi-tenant Rails app, built on top of
|
|
14
|
+
Noticed. Noticed delivers a notification on whatever channels a notifier
|
|
15
|
+
declares. notey decides which of those channels a given person actually gets,
|
|
16
|
+
per notification type and per account they belong to, stores that decision, and
|
|
17
|
+
gives them screens to change it. It also scopes the in-app inbox to the account
|
|
18
|
+
the person is currently in, holds notifications back into daily or weekly
|
|
19
|
+
digests when that is what the person asked for, keeps a per-account address for
|
|
20
|
+
channels that deliver to the account rather than to a person, and delivers a
|
|
21
|
+
notification when a domain event the host publishes says to.
|
|
22
|
+
|
|
23
|
+
Reach for it when the same person belongs to more than one account and wants
|
|
24
|
+
different notifications in each, when notifications should arrive grouped rather
|
|
25
|
+
than one at a time, or when a channel's address belongs to the account rather
|
|
26
|
+
than to the person. It is not worth reaching for to send one transactional email
|
|
27
|
+
to one address.
|
|
28
|
+
|
|
29
|
+
## Interface
|
|
30
|
+
|
|
31
|
+
The manifest declares no commands for this local, so nothing here is called.
|
|
32
|
+
|
|
33
|
+
Standing notey up in a host app — the tables, the engine, the declaration of
|
|
34
|
+
which notification types exist and which channels each one offers, the models
|
|
35
|
+
that receive and send, and the screens people set their own preferences on — is
|
|
36
|
+
the install local, `notey-install`.
|
|
37
|
+
|
|
38
|
+
Building on notey once it is installed — tying a notifier to a notification
|
|
39
|
+
type, delivering one when a domain event fires, holding a delivery back unless
|
|
40
|
+
the person wants it on that channel, addressing a channel from the account's
|
|
41
|
+
destination, reading the inbox, and running the digests — is the develop local,
|
|
42
|
+
`notey-develop`.
|
|
43
|
+
|
|
44
|
+
## How to use it
|
|
45
|
+
|
|
46
|
+
Two questions settle which local you need. If the host app does not yet have
|
|
47
|
+
notey's tables, engine and declared notification types, you are installing, and
|
|
48
|
+
`notey-install` owns every step of that. If it does and you are adding a new
|
|
49
|
+
notification, changing when an existing one goes out, or reading what a person
|
|
50
|
+
has already been sent, you are building, and `notey-develop` owns it.
|
|
51
|
+
|
|
52
|
+
## Conventions
|
|
53
|
+
|
|
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.
|
|
64
|
+
- **Member** — the person a preference belongs to. It is polymorphic, so a host
|
|
65
|
+
app names whichever model of its own receives notifications.
|
|
66
|
+
- **Account** — the tenant. Preferences, notifications, digests and destinations
|
|
67
|
+
are all scoped to one, so the same person in two accounts has two independent
|
|
68
|
+
sets of preferences and two separate inboxes.
|
|
69
|
+
- **Preference** — one stored row per member, per account, per notification
|
|
70
|
+
type, holding the channels chosen and the digest window.
|
|
71
|
+
- **Digest window** — `immediate`, `daily` or `weekly`. Immediate sends as the
|
|
72
|
+
notification happens; the other two hold it for a grouped email covering the
|
|
73
|
+
current day or week.
|
|
74
|
+
- **Destination** — one address per account per channel, with an optional stored
|
|
75
|
+
credential, for channels addressed at the account rather than at a person.
|
|
76
|
+
- **Inbox** — the notifications a member has received within one account, which
|
|
77
|
+
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.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
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.
|
|
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
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This local follows the steps below exactly and invents no others. Where a step
|
|
9
|
+
names a decision, put it to the developer and wait for an answer rather than
|
|
10
|
+
picking one.
|
|
11
|
+
|
|
12
|
+
## What notey is
|
|
13
|
+
|
|
14
|
+
notey is the notification layer for a multi-tenant Rails app on top of Noticed.
|
|
15
|
+
Hook it in when the same person belongs to more than one account and should get
|
|
16
|
+
different notifications in each.
|
|
17
|
+
|
|
18
|
+
## Interface
|
|
19
|
+
|
|
20
|
+
- `bin/rails notey:install:migrations` — copies notey's six migrations into the
|
|
21
|
+
host's `db/migrate`.
|
|
22
|
+
- `mount Notey::Engine` — in the host's `config/routes.rb`, putting notey's
|
|
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.
|
|
27
|
+
- `Notey::Recipient` — included in the host model that receives notifications,
|
|
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.
|
|
31
|
+
- `Notey::Current` — holds the person and the account id for the current
|
|
32
|
+
request, and every preference read, preference save and inbox read goes
|
|
33
|
+
through it.
|
|
34
|
+
- `Notey.notification_url=` — takes a lambda returning the host's own url for
|
|
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.
|
|
39
|
+
- `/notey/preferences` — the page a person picks their channels and their
|
|
40
|
+
immediate, daily or weekly window on, per notification type.
|
|
41
|
+
- `/notey/notifications` — the person's inbox for the account they are in, 25
|
|
42
|
+
rows newest first with an unread count, and a button that marks one read.
|
|
43
|
+
- `/notey/destinations` — the page an account's address and credential are
|
|
44
|
+
stored on, one pair per channel the catalog offers.
|
|
45
|
+
|
|
46
|
+
The three paths assume the engine is mounted at `/notey`; a different mount path
|
|
47
|
+
moves all three.
|
|
48
|
+
|
|
49
|
+
## How to use it
|
|
50
|
+
|
|
51
|
+
1. Add `gem "notey"` to the host's `Gemfile` and run `bundle install`.
|
|
52
|
+
|
|
53
|
+
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.
|
|
56
|
+
|
|
57
|
+
3. Mount the engine in the host's `config/routes.rb`:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
mount Notey::Engine => "/notey"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Ask the developer which path to mount at if the app already has a convention
|
|
64
|
+
for engine paths, since the path prefixes all three pages.
|
|
65
|
+
|
|
66
|
+
4. Declare the catalog in `config/initializers/notey.rb`:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
Notey.catalog do
|
|
70
|
+
notification :comment, channels: %w[email sms], default: %w[email]
|
|
71
|
+
end
|
|
72
|
+
```
|
|
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.
|
|
78
|
+
|
|
79
|
+
5. Include the recipient concern in the model that receives notifications:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
class User < ApplicationRecord
|
|
83
|
+
include Notey::Recipient
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Ask the developer which model that is. It must respond to `email`, because
|
|
88
|
+
that is the address a digest is sent to.
|
|
89
|
+
|
|
90
|
+
6. Set the current person and account on every request, in the host's
|
|
91
|
+
`ApplicationController`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
before_action do
|
|
95
|
+
Notey::Current.member = current_user
|
|
96
|
+
Notey::Current.account_id = current_account&.id
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Ask the developer what supplies the signed-in person and the current account
|
|
101
|
+
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
|
+
never another account's rows, and a save on the preferences page fails
|
|
104
|
+
because a stored preference needs an account.
|
|
105
|
+
|
|
106
|
+
7. Add the account to Noticed's own rows with a host migration:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
add_column :noticed_events, :account_id, :bigint
|
|
110
|
+
add_column :noticed_notifications, :account_id, :bigint
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Then fill them in `config/initializers/noticed.rb`:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
ActiveSupport.on_load :noticed_event do
|
|
117
|
+
after_initialize { self.account_id ||= Notey::Current.account_id }
|
|
118
|
+
|
|
119
|
+
def recipient_attributes_for(recipient)
|
|
120
|
+
super.merge(account_id: account_id)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Without these columns the inbox is empty and every digest is, because a
|
|
126
|
+
delivery runs in a job where the current account is gone.
|
|
127
|
+
|
|
128
|
+
8. Include the notifier concern in each Noticed event class that delivers one of
|
|
129
|
+
the catalog's types:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
class CommentNotifier < Noticed::Event
|
|
133
|
+
include Notey::Notifier
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
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
|
+
|
|
140
|
+
9. Point digest links at the host's own pages in
|
|
141
|
+
`config/initializers/notey.rb`:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
Notey.notification_url = lambda do |notification|
|
|
145
|
+
Rails.application.routes.url_helpers.notification_url(notification)
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Ask the developer which of their pages shows one notification. With nothing
|
|
150
|
+
set, a digest still sends and its rows are plain text instead of links.
|
|
151
|
+
|
|
152
|
+
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
|
+
|
|
156
|
+
11. Decide the pages' layout. They render in notey's own layout, which loads
|
|
157
|
+
notey's stylesheet and nothing of the host's, unless the host defines
|
|
158
|
+
`app/views/layouts/notey/application.html.erb`, which takes precedence over
|
|
159
|
+
the engine's copy. Ask the developer whether these pages should carry the
|
|
160
|
+
app's navigation.
|
|
161
|
+
|
|
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
|
|
164
|
+
Active Record encryption keys must be configured in the host's credentials
|
|
165
|
+
(`bin/rails db:encryption:init` generates a set) or saving a destination
|
|
166
|
+
raises. Ask the developer whether any channel needs this before doing it.
|
|
167
|
+
|
|
168
|
+
## Conventions
|
|
169
|
+
|
|
170
|
+
- **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.
|
|
174
|
+
- **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.
|
|
181
|
+
- **Authentication on the three pages is the host's.** Its controllers inherit
|
|
182
|
+
from the host's `ApplicationController`, so the host's own filters run and
|
|
183
|
+
notey adds no authorization of its own — restricting who may set an account's
|
|
184
|
+
destinations is the host's to write.
|
|
185
|
+
- **Digest emails send from `from@example.com`** and notey exposes no setting
|
|
186
|
+
that changes it.
|
|
187
|
+
- **Re-run `bin/rails notey:install:migrations` after upgrading the gem.** It
|
|
188
|
+
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`.
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
- bin/rails notey:install:migrations
|
|
5
|
+
- mount Notey::Engine
|
|
6
|
+
- Notey.catalog
|
|
7
|
+
- Notey::Recipient
|
|
8
|
+
- Notey::Notifier
|
|
9
|
+
- Notey::Current
|
|
10
|
+
- Notey.notification_url=
|
|
11
|
+
- Notey.check!
|
|
12
|
+
- /notey/preferences
|
|
13
|
+
- /notey/notifications
|
|
14
|
+
- /notey/destinations
|
|
15
|
+
|
|
16
|
+
develop:
|
|
17
|
+
- notey_type
|
|
18
|
+
- Notey.wanted
|
|
19
|
+
- Notey.deliver_on
|
|
20
|
+
- Notey.destination_address
|
|
21
|
+
- Notey.addressed
|
|
22
|
+
- Notey::DigestRun
|
|
23
|
+
- Notey::Inbox.for
|
|
24
|
+
- wants?
|
|
25
|
+
- channels_for
|
|
26
|
+
- digest_window_for
|
|
27
|
+
- Notey.reset!
|
|
28
|
+
|
|
29
|
+
sources:
|
|
30
|
+
- lib/notey.rb
|
|
31
|
+
- lib/notey/engine.rb
|
|
32
|
+
- lib/notey/catalog.rb
|
|
33
|
+
- lib/notey/channels.rb
|
|
34
|
+
- lib/notey/destinations.rb
|
|
35
|
+
- lib/notey/digest_run.rb
|
|
36
|
+
- lib/notey/event_subscriber.rb
|
|
37
|
+
- lib/notey/inbox.rb
|
|
38
|
+
- app/models/concerns/notey/recipient.rb
|
|
39
|
+
- app/models/concerns/notey/notifier.rb
|
|
40
|
+
- app/models/notey/current.rb
|
|
41
|
+
- app/models/notey/preference.rb
|
|
42
|
+
- app/models/notey/destination.rb
|
|
43
|
+
- app/models/notey/digest.rb
|
|
44
|
+
- app/jobs/notey/digest_job.rb
|
|
45
|
+
- app/mailers/notey/digest_mailer.rb
|
|
46
|
+
- app/controllers/notey/preferences_controller.rb
|
|
47
|
+
- app/controllers/notey/notifications_controller.rb
|
|
48
|
+
- app/controllers/notey/destinations_controller.rb
|
|
49
|
+
- config/routes.rb
|
|
50
|
+
- db/migrate/20260917000002_create_notey_preferences.rb
|
|
51
|
+
- db/migrate/20260918030002_create_notey_digests.rb
|
|
52
|
+
- db/migrate/20260918050001_create_notey_destinations.rb
|