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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +20 -0
  3. data/README.md +260 -0
  4. data/Rakefile +11 -0
  5. data/app/assets/stylesheets/notey/application.css +15 -0
  6. data/app/controllers/notey/application_controller.rb +7 -0
  7. data/app/controllers/notey/destinations_controller.rb +23 -0
  8. data/app/controllers/notey/notifications_controller.rb +26 -0
  9. data/app/controllers/notey/preferences_controller.rb +26 -0
  10. data/app/delivery_methods/notey/email.rb +9 -0
  11. data/app/delivery_methods/notey/in_app.rb +12 -0
  12. data/app/helpers/notey/application_helper.rb +4 -0
  13. data/app/jobs/notey/application_job.rb +4 -0
  14. data/app/jobs/notey/digest_job.rb +9 -0
  15. data/app/mailers/notey/application_mailer.rb +6 -0
  16. data/app/mailers/notey/digest_mailer.rb +12 -0
  17. data/app/mailers/notey/notification_mailer.rb +11 -0
  18. data/app/models/concerns/notey/notifier.rb +19 -0
  19. data/app/models/concerns/notey/recipient.rb +23 -0
  20. data/app/models/notey/application_record.rb +5 -0
  21. data/app/models/notey/attempt.rb +7 -0
  22. data/app/models/notey/current.rb +7 -0
  23. data/app/models/notey/destination.rb +11 -0
  24. data/app/models/notey/digest.rb +7 -0
  25. data/app/models/notey/kept.rb +9 -0
  26. data/app/models/notey/notification.rb +43 -0
  27. data/app/models/notey/preference.rb +21 -0
  28. data/app/models/notey/refusal.rb +13 -0
  29. data/app/models/notey/save_destination.rb +37 -0
  30. data/app/models/notey/save_my_destination.rb +11 -0
  31. data/app/models/notey/save_preferences.rb +37 -0
  32. data/app/views/notey/_destinations.html.erb +13 -0
  33. data/app/views/notey/_my_destinations.html.erb +12 -0
  34. data/app/views/notey/_preferences.html.erb +25 -0
  35. data/app/views/notey/destinations/show.html.erb +7 -0
  36. data/app/views/notey/digest_mailer/digest.html.erb +8 -0
  37. data/app/views/notey/notification_mailer/notification.html.erb +6 -0
  38. data/app/views/notey/notifications/index.html.erb +17 -0
  39. data/app/views/notey/preferences/show.html.erb +7 -0
  40. data/config/routes.rb +5 -0
  41. data/db/migrate/20260917000002_create_notey_preferences.rb +17 -0
  42. data/db/migrate/20260918020001_add_digest_window_to_notey_preferences.rb +7 -0
  43. data/db/migrate/20260918030002_create_notey_digests.rb +20 -0
  44. data/db/migrate/20260918050001_create_notey_destinations.rb +16 -0
  45. data/db/migrate/20260918060001_index_digest_window_on_notey_preferences.rb +7 -0
  46. data/db/migrate/20260919010001_add_member_to_notey_destinations.rb +11 -0
  47. data/db/migrate/20260921000001_create_notey_attempts.rb +16 -0
  48. data/lib/notey/catalog.rb +51 -0
  49. data/lib/notey/channels.rb +41 -0
  50. data/lib/notey/destinations.rb +11 -0
  51. data/lib/notey/digest_run.rb +73 -0
  52. data/lib/notey/engine.rb +20 -0
  53. data/lib/notey/event_delivery.rb +14 -0
  54. data/lib/notey/inbox.rb +11 -0
  55. data/lib/notey/records_attempt.rb +37 -0
  56. data/lib/notey/version.rb +3 -0
  57. data/lib/notey.rb +166 -0
  58. data/lib/tasks/notey_tasks.rake +4 -0
  59. data/the_local/agents/notey-develop.md +178 -0
  60. data/the_local/agents/notey-info.md +80 -0
  61. data/the_local/agents/notey-install.md +191 -0
  62. data/the_local/interface.yml +52 -0
  63. 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,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :notey do
3
+ # # Task goes here
4
+ # 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