Deliver emails for bundled notifications (initial WIP)

This commit is contained in:
Jorge Manrubia
2025-08-26 13:22:22 +02:00
parent 218519d075
commit cfafc54a81
16 changed files with 271 additions and 6 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ You need to make sure those timestamps are always the same across executions.
#### Development
You can view email previews at http://know-it-all.localhost:3005/rails/mailers.
You can view email previews at http://fizzy.localhost:3006/rails/mailers.
You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to
open sent emails automatically with:
@@ -0,0 +1,5 @@
class Notification::Bundle::DeliverAllJob < ApplicationJob
def perform
Notification::Bundle.deliver_all
end
end
@@ -0,0 +1,5 @@
class Notification::Bundle::DeliverJob < ApplicationJob
def perform(bundle)
bundle.deliver
end
end
+11
View File
@@ -0,0 +1,11 @@
class Notification::BundleMailer < ApplicationMailer
def notification(bundle)
@bundle = bundle
@notifications = bundle.notifications
mail(
to: bundle.user.email_address,
subject: "You have #{@notifications.count} notifications"
)
end
end
+5
View File
@@ -10,6 +10,7 @@ class Notification < ApplicationRecord
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
after_create_commit :broadcast_unread
after_create :bundle
delegate :notifiable_target, to: :source
delegate :card, to: :source
@@ -40,4 +41,8 @@ class Notification < ApplicationRecord
def broadcast_read
broadcast_remove_to user, :notifications
end
def bundle
user.bundle(self)
end
end
+51
View File
@@ -0,0 +1,51 @@
class Notification::Bundle < ApplicationRecord
belongs_to :user
enum :status, %i[ pending processing delivered ]
before_create :set_default_window
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
class << self
def deliver_all
due.in_batches do |batch|
DeliverJob.perform_all_later batch
end
end
def deliver_all_later
DeliverAllJob.perform_later
end
end
def add(notification)
update! ends_at: [ ends_at, notification.created_at ].max
end
def notifications
user.notifications.where(created_at: window)
end
def deliver
processing!
BundleMailer.notification(self).deliver
delivered!
end
def deliver_later
DeliverJob.perform_later(self)
end
private
AGGREGATION_PERIOD = 4.hours
def set_default_window
self.starts_at ||= Time.current
self.ends_at ||= AGGREGATION_PERIOD.from_now
end
def window
starts_at..ends_at
end
end
+1 -3
View File
@@ -1,5 +1,5 @@
class User < ApplicationRecord
include Accessor, Attachable, Assignee, Mentionable, Named, Role, Searcher,
include Accessor, Attachable, Assignee, Mentionable, Named, Notifiable, Role, Searcher,
SignalUser, Staff, Transferable, Conversational
include Timelined # Depends on Accessor
@@ -10,8 +10,6 @@ class User < ApplicationRecord
has_many :comments, inverse_of: :creator, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy
has_many :closures, dependent: :nullify
has_many :pins, dependent: :destroy
+19
View File
@@ -0,0 +1,19 @@
module User::Notifiable
extend ActiveSupport::Concern
included do
has_many :notifications, dependent: :destroy
has_many :notification_bundles, class_name: "Notification::Bundle", dependent: :destroy
end
def bundle(notification)
transaction do
find_or_create_pending_bundle_for(notification).add(notification)
end
end
private
def find_or_create_pending_bundle_for(notification)
notification_bundles.pending.last || notification_bundles.create!(starts_at: notification.created_at)
end
end
@@ -0,0 +1,9 @@
<h2>Your Notifications</h2>
<p>You have <%= @notifications.count %> notifications:</p>
<ul>
<% @notifications.each do |notification| %>
<li>Notification ID: <%= notification.id %></li>
<% end %>
</ul>
@@ -0,0 +1 @@
PENDING...
+3
View File
@@ -17,6 +17,9 @@ production: &production
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
deliver_bundled_notifications:
command: "Notification::Bundle.deliver_all_later"
schedule: every 30 minutes
beta: *production
staging: *production
@@ -0,0 +1,15 @@
class CreateNotificationBundles < ActiveRecord::Migration[8.1]
def change
create_table :notification_bundles do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.datetime :starts_at, null: false
t.datetime :ends_at, null: false
t.integer :status, default: 0, null: false
t.timestamps
end
add_index :notification_bundles, %i[ user_id starts_at ends_at ]
add_index :notification_bundles, %i[ user_id status ]
end
end
Generated
+14 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_08_19_105245) do
ActiveRecord::Schema[8.1].define(version: 2025_08_26_084559) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -295,6 +295,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_19_105245) do
t.index ["source_type", "source_id"], name: "index_mentions_on_source"
end
create_table "notification_bundles", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "ends_at", null: false
t.datetime "starts_at", null: false
t.integer "status", default: 0, null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5"
t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status"
t.index ["user_id"], name: "index_notification_bundles_on_user_id", unique: true
end
create_table "notifications", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "creator_id"
@@ -451,6 +463,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_19_105245) do
add_foreign_key "events", "collections"
add_foreign_key "mentions", "users", column: "mentionee_id"
add_foreign_key "mentions", "users", column: "mentioner_id"
add_foreign_key "notification_bundles", "users"
add_foreign_key "notifications", "users"
add_foreign_key "notifications", "users", column: "creator_id"
add_foreign_key "pins", "cards"
+90 -1
View File
@@ -909,6 +909,41 @@ columns:
collation:
comment:
- *9
notification_bundles:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: ends_at
cast_type: *1
sql_type_metadata: *2
'null': false
default:
default_function:
collation:
comment:
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: starts_at
cast_type: *1
sql_type_metadata: *2
'null': false
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: status
cast_type: *3
sql_type_metadata: *4
'null': false
default: 0
default_function:
collation:
comment:
- *9
- *25
notifications:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -1284,6 +1319,7 @@ primary_keys:
filters_stages:
filters_tags:
mentions: id
notification_bundles: id
notifications: id
pins: id
push_subscriptions: id
@@ -1332,6 +1368,7 @@ data_sources:
filters_stages: true
filters_tags: true
mentions: true
notification_bundles: true
notifications: true
pins: true
push_subscriptions: true
@@ -2294,6 +2331,58 @@ indexes:
nulls_not_distinct:
comment:
valid: true
notification_bundles:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: notification_bundles
name: idx_on_user_id_starts_at_ends_at_7eae5d3ac5
unique: false
columns:
- user_id
- starts_at
- ends_at
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: notification_bundles
name: index_notification_bundles_on_user_id
unique: true
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: notification_bundles
name: index_notification_bundles_on_user_id_and_status
unique: false
columns:
- user_id
- status
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
notifications:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: notifications
@@ -2771,4 +2860,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250819105245
version: 20250826084559
@@ -0,0 +1,6 @@
class Notification::BundleMailerPreview < ActionMailer::Preview
def notification
ApplicationRecord.current_tenant = "1065895976"
Notification::BundleMailer.notification Notification::Bundle.take!
end
end
+35
View File
@@ -0,0 +1,35 @@
require "test_helper"
class Notification::BundleTest < ActiveSupport::TestCase
setup do
@user = users(:david)
end
test "new notifications are bundled" do
notification = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
bundle = @user.notification_bundles.pending.last
assert_includes bundle.notifications, notification
end
test "notifications are bundled withing the aggregation period" do
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
travel_to 3.hours.from_now
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
travel_to 3.hours.from_now
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
bundle_1, bundle_2 = @user.notification_bundles.last(2)
end
end