name: rails-37-style-real-time-and-communication-notifications description: Time window bundling, user preferences, real-time license: MIT
Notifications
Time window bundling, user preferences, and real-time updates.
Read State Management with Timestamps
Pattern: Use read_at timestamps instead of boolean read flags.
Why it matters: Timestamps provide both state AND temporal information, enabling time-based queries and analytics without additional columns.
# From PR [#208](https://github.com/basecamp/fizzy/pull/208)
class Notification < ApplicationRecord
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
def read?
read_at.present?
end
def self.read_all
update!(read_at: Time.current)
end
def read
update!(read_at: Time.current)
end
end
Benefits:
- Know WHEN something was read, not just IF it was read
- Enables "show me notifications read in the last week" type queries
- Supports features like "mark as unread" (just nil out the timestamp)
- Trivial to upgrade from boolean (add timestamp, backfill with
created_atfor read items)
2. Notification Bundling with Time Windows
Pattern: Bundle notifications into time windows rather than linking individual notifications to bundles.
Why it matters: Lightweight, immutable design that avoids complex many-to-many relationships.
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
class Notification::Bundle < ApplicationRecord
belongs_to :user
enum :status, %i[ pending processing delivered ]
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
scope :containing, ->(notification) {
where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at)
}
# Query notifications in the window dynamically - no foreign keys needed!
def notifications
user.notifications.where(created_at: window).unread
end
private
def window
starts_at..ends_at
end
end
Key insights:
- No foreign key from Notification to Bundle (simpler schema)
- Notifications are immutable, so querying by time window is reliable
- Window boundaries prevent overlaps (enforced via validation)
- Status enum tracks lifecycle:
pending→processing→delivered
Validation to prevent overlapping bundles:
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
validate :validate_no_overlapping
def validate_no_overlapping
if overlapping_bundles.exists?
errors.add(:base, "Bundle window overlaps with an existing pending bundle")
end
end
def overlapping_bundles
user.notification_bundles
.where.not(id: id)
.overlapping_with(self)
end
3. User Preference Architecture with Settings Model
Pattern: Extract user settings into a separate model rather than adding columns to User.
Why it matters: Keeps User model focused, makes settings easier to test, and enables settings-specific logic.
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
module User::Configurable
extend ActiveSupport::Concern
included do
has_one :settings, class_name: "User::Settings", dependent: :destroy
after_create :create_settings, unless: :system?
end
end
class User::Settings < ApplicationRecord
belongs_to :user
enum :bundle_email_frequency, %i[ never every_few_hours daily weekly ],
default: :every_few_hours, prefix: :bundle_email
# Settings-specific business logic lives here
def bundle_aggregation_period
case bundle_email_frequency
when "every_few_hours" then 4.hours
when "daily" then 1.day
when "weekly" then 1.week
else 1.day
end
end
def bundling_emails?
!bundle_email_never?
end
end
Benefits:
- Avoids bloating User table with preference columns
- Settings logic is encapsulated and testable
- Easy to add new settings without touching User model
- Clean separation of concerns
Reactive settings changes:
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
after_update :review_pending_bundles, if: :saved_change_to_bundle_email_frequency?
def review_pending_bundles
if bundling_emails?
flush_pending_bundles # Deliver all pending
else
cancel_pending_bundles # Delete all pending
end
end
4. Automatic Bundling via Callbacks
Pattern: Automatically bundle new notifications via after_create callback.
Why it matters: Zero-touch bundling - developers creating notifications don't need to remember to bundle.
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
class Notification < ApplicationRecord
after_create :bundle
private
def bundle
user.bundle(self) if user.settings.bundling_emails?
end
end
module User::Notifiable
def bundle(notification)
transaction do
find_or_create_bundle_for(notification)
end
end
private
def find_or_create_bundle_for(notification)
find_bundle_for(notification) || create_bundle_for(notification)
end
def find_bundle_for(notification)
notification_bundles.pending.containing(notification).last
end
def create_bundle_for(notification)
notification_bundles.create!(starts_at: notification.created_at)
end
end
Key insight: Bundle creation is idempotent - finds existing bundle in same time window or creates new one.
5. Background Job Pattern for Batch Delivery
Pattern: Use nested jobs - one coordinator job that spawns individual delivery jobs.
Why it matters: Parallelizes delivery while managing memory and avoiding timeouts.
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
class Notification::Bundle::DeliverAllJob < ApplicationJob
queue_as :backend
def perform
ApplicationRecord.with_each_tenant do |tenant|
Notification::Bundle.deliver_all
end
end
end
class Notification::Bundle
def self.deliver_all
due.in_batches do |batch|
jobs = batch.collect { DeliverJob.new(it) }
ActiveJob.perform_all_later jobs # Bulk enqueue for efficiency
end
end
end
class Notification::Bundle::DeliverJob < ApplicationJob
queue_as :backend
def perform(bundle)
bundle.deliver
end
end
Benefits:
- Coordinator job handles multi-tenancy and batching logic
- Individual delivery jobs are small, fast, and retriable
perform_all_laterbulk-enqueues for better performance- Easy to monitor/retry individual deliveries
Recurring job configuration:
# config/recurring.yml (Solid Queue)
deliver_bundled_notifications:
command: "Notification::Bundle.deliver_all_later"
schedule: every 30 minutes
6. Turbo Streams for Real-Time Notification UI
Pattern: Broadcast notification changes to update UI across all user's tabs.
Why it matters: Notifications stay in sync across browser tabs without polling.
# From PR [#475](https://github.com/basecamp/fizzy/pull/475)
class Notification < ApplicationRecord
after_create_commit :broadcast_unread
def read
update!(read_at: Time.current)
broadcast_read # Added in PR [#475](https://github.com/basecamp/fizzy/pull/475)
end
private
def broadcast_unread
broadcast_prepend_to user, :notifications, target: "notifications"
end
def broadcast_read
broadcast_remove_to user, :notifications
end
end
View setup:
<%= turbo_stream_from Current.user, :notifications %>
<dialog id="notification-tray">
<%= turbo_frame_tag "notifications", src: tray_notifications_path %>
</dialog>
Key insight: Broadcasting on read ensures that marking a notification as read in one tab removes it from the tray in all tabs.
7. Pagination with Infinite Scroll via Intersection Observer
Pattern: Use Stimulus controller with IntersectionObserver for automatic pagination.
Why it matters: Progressive loading without "Load More" buttons or complex state management.
// From PR [#208](https://github.com/basecamp/fizzy/pull/208)
import { Controller } from "@hotwired/stimulus"
import { get } from "@rails/request.js"
export default class extends Controller {
static values = { url: String }
connect() {
this.#observe()
}
#observe() {
const observer = new IntersectionObserver((entries) => {
const visible = !!entries.find(entry => entry.isIntersecting)
if (visible) {
this.#fetch()
}
})
observer.observe(this.element)
}
#fetch() {
get(this.urlValue, { responseKind: "turbo-stream" })
}
}
View usage:
<!-- From PR [#208](https://github.com/basecamp/fizzy/pull/208) -->
<%= tag.div id: "next_page", data: {
controller: "fetch-on-visible",
fetch_on_visible_url_value: notifications_path(page: @page.next_param)
} %>
Controller response:
def index
set_page_and_extract_portion_from Current.user.notifications.read.ordered
respond_to do |format|
format.turbo_stream if current_page_param # Pagination requests
format.html # Initial page load
end
end
8. Client-Side Notification Grouping
Pattern: Group notifications by subject (e.g., card) on the client side with Stimulus.
Why it matters: Reduces clutter in notification tray, works dynamically with new notifications.
// From PR [#1448](https://github.com/basecamp/fizzy/pull/1448)
export default class extends Controller {
static targets = [ "notification", "hiddenNotifications" ]
static classes = [ "grouped" ]
connect() {
this.group()
}
// Group existing notifications on initial load
group() {
const notificationsByCardId = this.#groupNotificationsByCardId()
for (const cardId in notificationsByCardId) {
const notifications = notificationsByCardId[cardId]
if (notifications.length > 1) {
this.#renderGroup(notifications)
}
}
this.grouped = true
}
// Group new notifications as they arrive via Turbo Streams
notificationTargetConnected(notification) {
if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) {
this.#groupNotification(notification)
}
}
#renderGroup(groupedNotifications) {
// Sort by timestamp, show oldest first
groupedNotifications.sort((a, b) =>
parseInt(a.dataset.timestamp) - parseInt(b.dataset.timestamp)
)
// Show first notification with count badge
this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length)
// Hide the rest
groupedNotifications.slice(1).forEach(n => this.#hideInGroup(n))
}
#setGroupCount(notification, count) {
notification.querySelector("[data-group-count]").textContent = count
}
}
Why client-side grouping?:
- Works dynamically with real-time notifications arriving via Turbo Streams
- Server-side grouping is complex when notifications have polymorphic sources
- Avoids database queries to unify notifications by subject
- UI-only concern - no need to persist grouping state
9. RESTful Controller Design for Notification Actions
Pattern: Model notification actions as resources with dedicated controllers.
Why it matters: Follows Rails conventions, clearer routing, easier to test.
# From PR [#405](https://github.com/basecamp/fizzy/pull/405) - Refactored from custom actions to resourceful routes
# BEFORE: Custom actions on NotificationsController
post "notifications/:id/mark_read"
post "notifications/mark_all_read"
# AFTER: RESTful resources
resources :notifications, only: [:index] do
resource :reading, only: [:create], module: :notifications
end
namespace :notifications do
resource :readings, only: [] do
post :create_all
end
end
# Dedicated controllers
class Notifications::ReadingsController < ApplicationController
def create
@notification = Current.user.notifications.find(params[:id])
@notification.read
end
def create_all
Current.user.notifications.unread.read_all
redirect_to notifications_path
end
end
Benefits:
readingsresource models "the act of reading"- Clearer intent: creating a reading vs. marking as read
- Easier to add before/after actions specific to reading
- Follows REST principles
10. Email Unsubscribe with Signed Tokens
Pattern: Use Rails 7.1+ generates_token_for for secure, self-contained unsubscribe links.
Why it matters: Stateless tokens that expire automatically, no database lookups needed for validation.
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
module User::Notifiable
included do
generates_token_for :unsubscribe, expires_in: 1.month
end
end
class Notification::BundleMailer < ApplicationMailer
include Mailers::Unsubscribable
def notification(bundle)
@user = bundle.user
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
mail to: bundle.user.email_address, subject: "Latest Activity"
end
end
module Mailers::Unsubscribable
included do
after_action :set_unsubscribe_headers
end
def set_unsubscribe_headers
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
headers["List-Unsubscribe"] = "<#{notifications_unsubscribe_url(access_token: @unsubscribe_token)}>"
end
end
Unsubscribe controller:
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
class Notifications::UnsubscribesController < ApplicationController
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
before_action :set_user
def create
@user.settings.bundle_email_never!
redirect_to notifications_unsubscribe_path(access_token: params[:access_token])
end
private
def set_user
unless @user = User.find_by_token_for(:unsubscribe, params[:access_token])
redirect_to root_path, alert: "Invalid unsubscribe link"
end
end
end
Key features:
- RFC-compliant
List-Unsubscribeheaders for email clients - Self-expiring tokens (no manual cleanup needed)
- Works without authentication session
- CSRF protection skipped (POST with signed token is sufficient)
11. Email Layout with Inline Styles
Pattern: Use table-based layout with inline styles for email compatibility.
Why it matters: Ensures emails render consistently across all email clients.
<!-- From PR [#974](https://github.com/basecamp/fizzy/pull/974) -->
<table>
<tr>
<td class="avatar__container">
<%= mail_avatar_tag(notification.creator) %>
</td>
<td>
<%= render "notification/bundle_mailer/#{notification.source_type.underscore}/body",
notification: notification %>
</td>
</tr>
</table>
CSS in layout:
<style>
.avatar__container {
vertical-align: top;
width: 3em;
}
.notification__author {
font-size: 0.8em;
opacity: 0.66;
margin: 0;
}
</style>
Group notifications by subject in email (PR #1574):
<!-- Aggregate by card to reduce email clutter -->
<% @notifications.group_by(&:card).each do |card, notifications| %>
<h2 class="notification__board"><%= card.board.name %></h2>
<%= link_to "##{ card.id } #{ card.title }", card, class: "card__title" %>
<%= render partial: "notification/bundle_mailer/notification",
collection: notifications, as: :notification %>
<% end %>
12. Testing Notification Delivery
Pattern: Test time windows, bundling behavior, and state transitions.
# From PR [#974](https://github.com/basecamp/fizzy/pull/974)
class Notification::BundleTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@user.settings.bundle_email_every_few_hours!
end
test "notifications are bundled within 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.days.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)
assert_includes bundle_1.notifications, notification_1
assert_includes bundle_1.notifications, notification_2
assert_includes bundle_2.notifications, notification_3
end
test "overlapping bundles are invalid" do
bundle_1 = @user.notification_bundles.create!(
starts_at: Time.current,
ends_at: 1.hour.from_now
)
bundle_2 = @user.notification_bundles.build(
starts_at: 30.minutes.from_now,
ends_at: 90.minutes.from_now
)
assert_not bundle_2.valid?
assert_includes bundle_2.errors[:base], "Bundle window overlaps with an existing pending bundle"
end
end
Key testing insights:
- Use
travel_toto test time-based bundling - Test both positive (included) and negative (excluded) cases
- Verify window overlap validations
- Test settings changes trigger appropriate actions
Summary: Key Architectural Decisions
| Decision | Pattern | Benefit |
|---|---|---|
| Read state | read_at timestamp |
Temporal queries, analytics-ready |
| Bundling | Time window queries | Lightweight, no foreign keys |
| Settings | Dedicated Settings model | Keeps User focused, extensible |
| Automation | after_create bundling |
Zero-touch for developers |
| Delivery | Nested background jobs | Parallelized, retriable |
| Real-time | Turbo Streams broadcasts | Multi-tab sync without polling |
| Pagination | IntersectionObserver | Progressive loading, no buttons |
| Grouping | Client-side Stimulus | Dynamic, works with real-time |
| Controllers | RESTful resources | Clean routing, testable |
| Unsubscribe | Signed tokens | Stateless, self-expiring |
| Grouped by subject | Reduced clutter | |
| Testing | Time travel + windows | Verify bundling logic |
Further Reading
- PR #208: Notification index with pagination
- PR #306: Quieter notifications (subscription vs. watching)
- PR #405: Refactored to RESTful controllers
- PR #475: Broadcasting notification reads
- PR #974: Full bundled email implementation
- PR #1448: Client-side grouping by card
- PR #1574: Email grouping by card