Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save kengreeff/3f9687bf6c1189c625eaa6ed4940f8f0 to your computer and use it in GitHub Desktop.
Save kengreeff/3f9687bf6c1189c625eaa6ed4940f8f0 to your computer and use it in GitHub Desktop.
Batch/Debounce Notifications for Noticed Gem
# db/migrate/20250303023359_create_notification_batches.rb
class CreateNotificationBatches < ActiveRecord::Migration[8.0]
def change
create_table :notification_batches do |t|
t.belongs_to :recipient, polymorphic: true, null: false
t.string :key
t.string :delivery_status, default: "pending"
t.integer :inactive_wait_seconds
t.integer :max_wait_seconds
t.jsonb :options, default: {}
t.datetime :delivered_at
t.datetime :read_at
t.timestamps
t.index [ :key ]
t.index [ :recipient_type, :recipient_id, :key, :delivery_status ], name: "index_notification_batches_for_lookup"
end
end
end
# db/migrate/20250303023405_create_notification_batch_items.rb
class CreateNotificationBatchItems < ActiveRecord::Migration[8.0]
def change
create_table :notification_batch_items do |t|
t.belongs_to :notification_batch
t.belongs_to :notification
t.timestamps
end
end
end
# app/notifiers/application_delivery_method.rb
class ApplicationDeliveryMethod < Noticed::DeliveryMethod
end
# app/notifiers/delivery_methods/batch.rb
class DeliveryMethods::Batch < ApplicationDeliveryMethod
required_options :batch_key, :deliver_by
def deliver
batch_key = evaluate_option(:batch_key)
NotificationBatch.transaction do
notification_batch = NotificationBatch.find_or_create_by!(recipient: recipient, key: batch_key, delivery_status: "pending") do |batch|
batch.inactive_wait_seconds = evaluate_option(:inactive_wait) || 10.minutes
batch.max_wait_seconds = evaluate_option(:max_wait) || 1.hour
batch.deliver_by = evaluate_option(:deliver_by)
end
NotificationBatchItem.find_or_create_by!(batch: notification_batch, notification: notification)
end
# Don't deliver anything, we will do with a CRON job
end
end
# app/notifiers/notification_batch/batch_notifier.rb
class NotificationBatch::BatchNotifier < Noticed::Ephemeral
required_params :notification_batch
deliver_by :email do |config|
config.mailer = -> { params[:notification_batch].deliver_by&.dig("email", "mailer") }
config.method = -> { params[:notification_batch].deliver_by&.dig("email", "method") }
config.params = -> {
{
notification_batch: params[:notification_batch],
user: recipient,
}
}
config.if = -> { params[:notification_batch].deliver_by&.dig("email").present? }
end
end
# app/notifiers/comment/comment_created_notifier.rb
class Comment::CommentCreatedNotifier < ApplicationNotifier
set_event_type "comment.created"
deliver_by :batch, class: "DeliveryMethods::Batch" do |config|
config.batch_key = "comments"
config.deliver_by = {
email: {
mailer: "CommentMailer",
method: :batch_notification,
},
}
config.inactive_wait = 5.minutes
config.max_wait = 15.minutes
end
end
# config/initializers/noticed.rb
module EventExtensions
extend ActiveSupport::Concern
included do
belongs_to :actor, polymorphic: true, optional: true
end
end
module NotificationExtensions
extend ActiveSupport::Concern
included do
has_many :batch_items, class_name: "Notification::BatchItem", dependent: :destroy
end
end
Rails.application.config.to_prepare do
Noticed::Event.include EventExtensions
Noticed::Notification.include NotificationExtensions
end
# app/models/notification_batch.rb
class NotificationBatch < ApplicationRecord
store_accessor :options, :deliver_by
belongs_to :recipient, polymorphic: true
has_many :items, class_name: "NotificationBatchItem", dependent: :destroy
has_many :notifications, through: :items
enum :delivery_status, %w[ pending delivered cancelled ].index_by(&:itself)
validates :key, presence: true
validate :validate_deliver_by_options, on: :create
def deliver
if unread?
NotificationBatch::BatchNotifier.with(notification_batch: self).deliver(self.recipient)
mark_as_status!(:delivered)
else
mark_as_status!(:cancelled)
end
end
def mark_as_status!(status)
self.update(delivery_status: status, delivered_at: Time.current)
end
def should_deliver?
inactive_wait_exceeded? || max_wait_exceeded?
end
def unread?
notifications.read.size == 0
end
private
def inactive_wait_exceeded?
(Time.current - updated_at) > inactive_wait_seconds
end
def max_wait_exceeded?
(Time.current - created_at) > max_wait_seconds
end
def validate_deliver_by_options
errors.add(:options, "must include `deliver_by` options") unless options.has_key?("deliver_by")
end
end
# == Schema Information
#
# Table name: notification_batches
#
# id :bigint not null, primary key
# delivered_at :datetime
# delivery_status :string default("pending")
# inactive_wait_seconds :integer
# key :string
# max_wait_seconds :integer
# options :jsonb
# read_at :datetime
# recipient_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# recipient_id :bigint not null
#
# Indexes
#
# index_notification_batches_for_lookup (recipient_type,recipient_id,key,delivery_status)
# index_notification_batches_on_recipient (recipient_type,recipient_id)
#
# app/models/notification_batch_item.rb
class NotificationBatchItem < ApplicationRecord
belongs_to :batch, class_name: "NotificationBatch", foreign_key: :notification_batch_id, touch: true
belongs_to :notification, class_name: "Noticed::Notification"
end
# == Schema Information
#
# Table name: notification_batch_items
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# notification_batch_id :bigint
# notification_id :bigint
#
# Indexes
#
# index_notification_batch_items_on_notification_batch_id (notification_batch_id)
# index_notification_batch_items_on_notification_id (notification_id)
#
# app/jobs/notification_batch/process_batch_job.rb
class NotificationBatch::ProcessBatchJob < ApplicationJob
queue_as :default
def perform(params = {})
NotificationBatch.pending.each do |notification_batch|
NotificationBatch::ProcessDeliveryJob.perform_later({ notification_batch: notification_batch })
end
end
end
# app/jobs/notification_batch/process_delivery_job.rb
class NotificationBatch::ProcessDeliveryJob < ApplicationJob
queue_as :default
def perform(params = {})
notification_batch = params[:notification_batch]
return unless notification_batch&.should_deliver?
notification_batch.deliver
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment