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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ class SaveDestination
5
+ def initialize(values:, person: nil, account: nil)
6
+ @person = person
7
+ @account_id = account.respond_to?(:id) ? account.id : account
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ refusal = submitted.filter_map { |channel, attributes| store(channel, attributes) }.first
13
+
14
+ refusal || Kept.new
15
+ end
16
+
17
+ private
18
+
19
+ def owner
20
+ nil
21
+ end
22
+
23
+ def submitted
24
+ @values.fetch(:destinations, {}).slice(*Notey.addressed_channels)
25
+ end
26
+
27
+ def store(channel, attributes)
28
+ destination = Destination.find_or_initialize_by(
29
+ account_id: @account_id, channel: channel.to_s, member: owner
30
+ )
31
+ destination.address = attributes["address"]
32
+ destination.credential = attributes["credential"] if attributes["credential"].present?
33
+
34
+ Refusal.new(destination.errors.full_messages.first) unless destination.save
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ class SaveMyDestination < SaveDestination
5
+ private
6
+
7
+ def owner
8
+ @person
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ class SavePreferences
5
+ def initialize(values:, person: nil, account: nil)
6
+ @person = person
7
+ @account_id = account.respond_to?(:id) ? account.id : account
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ refusal = chosen.filter_map { |notification_type, channels| store(notification_type, channels) }.first
13
+
14
+ refusal || Kept.new
15
+ end
16
+
17
+ private
18
+
19
+ def chosen
20
+ @values.fetch(:preferences, {}).select { |type, _| Notey.notification_types.include?(type.to_s) }
21
+ end
22
+
23
+ def windows
24
+ @values.fetch(:windows, {})
25
+ end
26
+
27
+ def store(notification_type, channels)
28
+ preference = Preference.find_or_initialize_by(
29
+ member: @person, account_id: @account_id, notification_type: notification_type.to_s
30
+ )
31
+ preference.channels = Array(channels).map(&:to_s).reject(&:blank?)
32
+ preference.digest_window = windows.fetch(notification_type.to_s, "immediate")
33
+
34
+ Refusal.new(preference.errors.full_messages.first) unless preference.save
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,13 @@
1
+ <% stored = Notey::Destination.where(account_id: account, member_id: nil).index_by(&:channel) %>
2
+
3
+ <%= ui_form(action: submit_url, method: :patch) do %>
4
+ <% Notey.addressed_channels.each do |channel| %>
5
+ <%= ui_panel do %>
6
+ <%= ui_section(title: channel.humanize) do %>
7
+ <%= ui_input(name: "destinations[#{channel}][address]", value: stored[channel]&.address) %>
8
+ <%= ui_input(name: "destinations[#{channel}][credential]", type: :password) %>
9
+ <% end %>
10
+ <% end %>
11
+ <% end %>
12
+ <%= ui_button(label: "Save") %>
13
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <% stored = Notey::Destination.where(account_id: account, member: person).index_by(&:channel) %>
2
+
3
+ <%= ui_form(action: submit_url, method: :patch) do %>
4
+ <% Notey.addressed_channels.each do |channel| %>
5
+ <%= ui_panel do %>
6
+ <%= ui_section(title: channel.humanize) do %>
7
+ <%= ui_input(name: "destinations[#{channel}][address]", value: stored[channel]&.address) %>
8
+ <% end %>
9
+ <% end %>
10
+ <% end %>
11
+ <%= ui_button(label: "Save") %>
12
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <% stored = Notey::Preference.where(member: person, account_id: account).index_by(&:notification_type) %>
2
+
3
+ <%= ui_form(action: submit_url, method: :patch) do %>
4
+ <% Notey.notification_types.each do |name| %>
5
+ <%= ui_panel do %>
6
+ <%= ui_section(title: name.humanize) do %>
7
+ <%= hidden_field_tag "preferences[#{name}][]", "", id: nil %>
8
+ <% Notey.channels.each do |channel| %>
9
+ <%= ui_checkbox_row(
10
+ name: "preferences[#{name}][]",
11
+ value: channel,
12
+ label: channel.humanize,
13
+ checked: Notey::Channels.channels_of(stored[name], name).include?(channel)
14
+ ) %>
15
+ <% end %>
16
+ <%= ui_select(
17
+ name: "windows[#{name}]",
18
+ options: [ [ "Immediately", "immediate" ], [ "Once a day", "daily" ], [ "Once a week", "weekly" ] ],
19
+ selected: stored[name]&.digest_window || "immediate"
20
+ ) %>
21
+ <% end %>
22
+ <% end %>
23
+ <% end %>
24
+ <%= ui_button(label: "Save") %>
25
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%= ui_page(max_width: :lg) do %>
2
+ <%= ui_section(title: "Destinations") do %>
3
+ <%= render partial: "notey/destinations",
4
+ locals: { person: Notey::Current.member, account: Notey::Current.account_id,
5
+ selection: {}, submit_url: destinations_path } %>
6
+ <% end %>
7
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <h1>Your <%= @window %> notifications</h1>
2
+
3
+ <ul>
4
+ <% @notifications.each do |notification| %>
5
+ <% url = Notey.notification_url.call(notification) %>
6
+ <li><%= url ? link_to(notification.event.class.name, url) : notification.event.class.name %></li>
7
+ <% end %>
8
+ </ul>
@@ -0,0 +1,6 @@
1
+ <h1><%= @notification.event.title %></h1>
2
+
3
+ <p><%= @notification.event.body %></p>
4
+
5
+ <% url = Notey.notification_url.call(@notification) %>
6
+ <%= link_to "View it", url if url %>
@@ -0,0 +1,17 @@
1
+ <%= ui_page(max_width: :lg) do %>
2
+ <%= ui_section(title: "Notifications", subtitle: "#{@unread_count} unread") do %>
3
+ <% @notifications.each do |notification| %>
4
+ <%= ui_panel do %>
5
+ <div data-notification-id="<%= notification.id %>">
6
+ <%= notification.event.class.name %>
7
+ <%= ui_badge(label: notification.read? ? "Read" : "Unread") %>
8
+ <% unless notification.read? %>
9
+ <%= ui_form(action: notification_path(notification), method: :patch) do %>
10
+ <%= ui_button(label: "Mark read") %>
11
+ <% end %>
12
+ <% end %>
13
+ </div>
14
+ <% end %>
15
+ <% end %>
16
+ <% end %>
17
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%= ui_page(max_width: :lg) do %>
2
+ <%= ui_section(title: "Notifications") do %>
3
+ <%= render partial: "notey/preferences",
4
+ locals: { person: Notey::Current.member, account: Notey::Current.account_id,
5
+ selection: {}, submit_url: preferences_path } %>
6
+ <% end %>
7
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Notey::Engine.routes.draw do
2
+ resource :preferences, only: [ :show, :update ]
3
+ resources :notifications, only: [ :index, :update ]
4
+ resource :destinations, only: [ :show, :update ]
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateNoteyPreferences < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :notey_preferences do |t|
6
+ t.references :member, polymorphic: true, null: false
7
+ t.bigint :account_id, null: false
8
+ t.string :notification_type, null: false
9
+ t.json :channels, null: false, default: []
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :notey_preferences, %i[member_type member_id account_id notification_type],
15
+ unique: true, name: "index_notey_preferences_unique"
16
+ end
17
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddDigestWindowToNoteyPreferences < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :notey_preferences, :digest_window, :string, null: false, default: "immediate"
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateNoteyDigests < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :notey_digests do |t|
6
+ t.references :member, polymorphic: true, null: false
7
+ t.bigint :account_id, null: false
8
+ t.string :digest_window, null: false
9
+ t.datetime :period_start, null: false
10
+ t.integer :notifications_count, null: false, default: 0
11
+ t.datetime :sent_at
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :notey_digests,
17
+ %i[member_type member_id account_id digest_window period_start],
18
+ unique: true, name: "index_notey_digests_unique"
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateNoteyDestinations < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :notey_destinations do |t|
6
+ t.bigint :account_id, null: false
7
+ t.string :channel, null: false
8
+ t.string :address, null: false
9
+ t.text :credential
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :notey_destinations, %i[account_id channel], unique: true
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IndexDigestWindowOnNoteyPreferences < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_index :notey_preferences, :digest_window
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddMemberToNoteyDestinations < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_reference :notey_destinations, :member, polymorphic: true, null: true
6
+
7
+ remove_index :notey_destinations, %i[account_id channel]
8
+ add_index :notey_destinations, %i[member_type member_id account_id channel],
9
+ unique: true, name: "index_notey_destinations_unique"
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateNoteyAttempts < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :notey_attempts do |t|
6
+ t.references :notification, null: false, foreign_key: { to_table: :noticed_notifications, on_delete: :cascade }
7
+ t.string :channel, null: false
8
+ t.string :state, null: false, default: "claimed"
9
+ t.text :failure
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :notey_attempts, %i[notification_id channel], unique: true, name: "index_notey_attempts_unique"
15
+ end
16
+ end
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ class Channels
5
+ Decision = Struct.new(:channels, :window, keyword_init: true)
6
+
7
+ def self.decision_for(member, notification_type, account_id:)
8
+ return Decision.new(channels: [], window: "immediate") unless Notey.notification_types.include?(notification_type.to_s)
9
+
10
+ stored = stored_for(member, notification_type, account_id)
11
+
12
+ Decision.new(
13
+ channels: stored ? Array(stored.channels).map(&:to_s) : Notey.default_channels,
14
+ window: stored&.digest_window || "immediate"
15
+ )
16
+ end
17
+
18
+ def self.for(member, notification_type, account_id:)
19
+ decision_for(member, notification_type, account_id: account_id).channels
20
+ end
21
+
22
+ def self.window_for(member, notification_type, account_id:)
23
+ decision_for(member, notification_type, account_id: account_id).window
24
+ end
25
+
26
+ def self.channels_of(preference, _notification_type)
27
+ return Array(preference.channels).map(&:to_s) if preference
28
+
29
+ Notey.default_channels
30
+ end
31
+
32
+ def self.window_of(preference)
33
+ preference.digest_window || "immediate"
34
+ end
35
+
36
+ def self.stored_for(member, notification_type, account_id)
37
+ member.notey_preferences.find_by(account_id: account_id, notification_type: notification_type.to_s)
38
+ end
39
+ private_class_method :stored_for
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ class Destinations
5
+ def self.for(account_id, channel, member: nil)
6
+ return nil if account_id.blank?
7
+
8
+ Destination.find_by(account_id: account_id, channel: channel.to_s, member: member)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ class DigestRun
5
+ PERIODS = { "daily" => :day, "weekly" => :week }.freeze
6
+
7
+ def initialize(window:, now: Time.current)
8
+ @window = window.to_s
9
+ @now = now
10
+ end
11
+
12
+ def call
13
+ preferences.group_by { |preference| [ preference.member, preference.account_id ] }
14
+ .each { |(member, account_id), _| DigestJob.perform_later(member, account_id, window) }
15
+ end
16
+
17
+ def deliver_to_member(member, account_id)
18
+ deliver_to(member, account_id, preferences.where(member: member, account_id: account_id))
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :window, :now
24
+
25
+ def preferences
26
+ Preference.where(digest_window: window)
27
+ end
28
+
29
+ def deliver_to(member, account_id, grouped)
30
+ notifications = gathered(member, account_id, types_in_window(grouped))
31
+ return if notifications.empty?
32
+
33
+ digest = claim(member, account_id)
34
+ return if digest.nil?
35
+
36
+ send_digest(digest, member, notifications)
37
+ end
38
+
39
+ def send_digest(digest, member, notifications)
40
+ DigestMailer.digest(member, notifications, window).deliver_now
41
+ digest.update!(notifications_count: notifications.size, sent_at: Time.current)
42
+ rescue StandardError
43
+ digest.destroy
44
+ raise
45
+ end
46
+
47
+ def claim(member, account_id)
48
+ Digest.create!(member: member, account_id: account_id, digest_window: window, period_start: period_start)
49
+ rescue ActiveRecord::RecordNotUnique
50
+ nil
51
+ end
52
+
53
+ def types_in_window(grouped)
54
+ grouped.select { |preference| Channels.window_of(preference) == window }
55
+ .map(&:notification_type)
56
+ end
57
+
58
+ def gathered(member, account_id, notification_types)
59
+ Inbox.for(member, account_id: account_id)
60
+ .includes(:event)
61
+ .where(created_at: period_start..now)
62
+ .select { |notification| notification_types.include?(type_of(notification)) }
63
+ end
64
+
65
+ def type_of(notification)
66
+ notification.event.class.try(:notey_notification_type)
67
+ end
68
+
69
+ def period_start
70
+ @period_start ||= now.public_send("beginning_of_#{PERIODS.fetch(window)}")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,20 @@
1
+ require "noticed"
2
+ require "keystone_ui"
3
+
4
+ module Notey
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Notey
7
+
8
+ initializer "notey.filter_parameters" do |app|
9
+ app.config.filter_parameters += [ :credential ]
10
+ end
11
+
12
+ initializer "notey.forget_notifiers_on_reload" do
13
+ ActiveSupport::Reloader.before_class_unload { Notey.forget_notifiers }
14
+ end
15
+
16
+ config.to_prepare { Noticed::DeliveryMethod.include(Notey::RecordsAttempt) }
17
+
18
+ config.after_initialize { |app| Notey.check! if app.config.eager_load }
19
+ end
20
+ end
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ class Inbox
5
+ def self.for(member, account_id:)
6
+ return Noticed::Notification.none if account_id.blank?
7
+
8
+ Noticed::Notification.where(recipient: member, account_id: account_id)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notey
4
+ module RecordsAttempt
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ around_deliver :record_attempt
9
+ end
10
+
11
+ def outbound?
12
+ true
13
+ end
14
+
15
+ def record_attempt
16
+ channel = config[:notey_channel]
17
+ return yield if channel.blank? || !outbound?
18
+
19
+ attempt = claim_attempt(channel)
20
+ return if attempt.nil?
21
+
22
+ begin
23
+ yield
24
+ attempt.update!(state: "sent")
25
+ rescue StandardError => error
26
+ attempt.update!(state: "failed", failure: error.message)
27
+ raise
28
+ end
29
+ end
30
+
31
+ def claim_attempt(channel)
32
+ Attempt.create!(notification: notification, channel: channel)
33
+ rescue ActiveRecord::RecordNotUnique
34
+ nil
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module Notey
2
+ VERSION = "0.1.0"
3
+ end