Notifications System
November 1, 2025 · View on GitHub
Listopia uses the Noticed gem to manage notifications with user preference controls. The system automatically notifies collaborators about list and item activity while respecting individual user preferences.
Architecture
Core Components
- Noticed Gem - Notification delivery engine
- NotificationSetting - User preferences (channels, types, frequency)
- ApplicationNotifier - Base notifier class for all custom notifiers
- Model Callbacks - Automatic notification triggers on data changes
- NotificationsController - View/manage notifications
Database Tables
- noticed_notifications - Stored notifications for each user
- noticed_events - Event records that trigger notifications
- notification_settings - User preference configuration
See NotificationSetting model for schema details.
Notification Types
Listopia triggers notifications for:
Collaboration Events
- User receives collaboration invitation
- Collaboration invitation accepted
- User removed from list collaboration
List Activity
- List title/description changes
- List status changes (draft → active → completed → archived)
Item Activity
- New item added to list
- Item content/priority/due date updated
- Item marked complete or uncompleted
- Item deleted from list
User Preferences
Each user has a NotificationSetting with controls for:
Delivery Channels
email_notifications(default: true)sms_notifications(default: false)push_notifications(default: true)
Notification Types
collaboration_notifications- Invitations, collaborators added/removedlist_activity_notifications- Title, description, status changesitem_activity_notifications- Item creation, updates, deletionstatus_change_notifications- Completion/uncompleted status
Frequency Control
notification_frequency- 'immediate', 'daily_digest', 'weekly_digest', 'disabled'quiet_hours_start/quiet_hours_end- Time-based suppressiontimezone- User's timezone for delivery scheduling
How Notifications Work
Trigger Pattern (Model Callbacks)
# app/models/list.rb
class List < ApplicationRecord
after_commit :notify_title_change, on: :update, if: :saved_change_to_title?
private
def notify_title_change
return unless Current.user # Only notify if user context exists
# Get recipients (exclude the person who made the change)
recipients = collaborators.where.not(id: Current.user.id)
return if recipients.empty?
# Send notification with context
ListTitleChangedNotifier.with(
actor_id: Current.user.id,
list_id: id,
previous_title: title_before_last_save,
new_title: title
).deliver_to_enabled_users(recipients)
end
end
Custom Notifier Example
# app/notifiers/list_title_changed_notifier.rb
class ListTitleChangedNotifier < ApplicationNotifier
deliver_by :database
deliver_by :email, mailer: "NotificationMailer"
def message
"#{actor.name} renamed list from '#{params[:previous_title]}' to '#{params[:new_title]}'"
end
def notification_type
"list_activity"
end
private
def actor
User.find(params[:actor_id])
end
end
View Notifications
Users can view notifications via NotificationsController:
GET /notifications # List all notifications
GET /notifications/:id # View specific notification
PATCH /notifications/:id/mark_as_read
PATCH /notifications/mark_all_as_read
Checking Preferences
User Preference Helpers
# Check if user wants a notification type
user.wants_notification?("collaboration") # => true/false
user.wants_notification?("item_activity", :email) # => true/false
# Check specific channels
user.notification_preferences.email_notifications? # => true/false
user.notification_preferences.push_notifications? # => true/false
# Check delivery frequency
user.notification_preferences.notification_frequency # => "immediate"
Preference Filtering in Code
# Always use this pattern for notifications
recipients = list.collaborators.where.not(id: Current.user.id)
return if recipients.empty?
NotifyCollaborators.with(params).deliver_to_enabled_users(recipients)
# This automatically filters based on each user's preferences
Common Implementation Patterns
1. Exclude the Actor
# ✅ Correct - collaborators minus the person making the change
recipients = list.collaborators.where.not(id: Current.user.id)
# ❌ Wrong - would send notification to the actor
recipients = list.collaborators
2. Check Current.user Context
def notify_change
# ✅ Correct - prevents notifications from console/seeds/migrations
return unless Current.user
# ❌ Wrong - triggers in background jobs without user context
# (missing the check)
end
3. Conditional Callbacks
# ✅ Correct - only notify on relevant changes
after_commit :notify_change, on: :update, if: :saved_change_to_title?
def saved_change_to_title?
saved_changes.key?("title")
end
# ❌ Wrong - notifies on any change (including timestamps)
after_commit :notify_change, on: :update
4. Only Notify When Recipients Exist
def notify_collaborators
recipients = list.collaborators.where.not(id: Current.user.id)
# ✅ Correct - don't create notifications with no recipients
return if recipients.empty?
# Proceed with notification
end
5. Deletion Notifications
# ✅ Correct - object still exists and has data
before_destroy :notify_deletion
# ❌ Wrong - object already deleted, can't access attributes
after_destroy :notify_deletion
Testing Notifications
Test Model Callbacks
# spec/models/list_spec.rb
describe List do
describe "#notify_title_change" do
let(:user) { create(:user) }
let(:list) { create(:list, owner: user) }
let(:collaborator) { create(:user) }
before do
Current.user = user
list.collaborators << collaborator
end
it "sends notification when title changes" do
expect {
list.update!(title: "New Title")
}.to have_enqueued_job(Noticed::DeliveryJob)
end
it "doesn't notify when updated_at only changes" do
expect {
list.touch # Only updates timestamp
}.not_to have_enqueued_job(Noticed::DeliveryJob)
end
end
end
Test Notifiers
# spec/notifiers/list_title_changed_notifier_spec.rb
describe ListTitleChangedNotifier do
it "generates correct message" do
notifier = described_class.with(
actor_id: user.id,
list_id: list.id,
previous_title: "Old",
new_title: "New"
)
expect(notifier.message).to include("Old")
expect(notifier.message).to include("New")
end
it "has correct notification type" do
notifier = described_class.with(params)
expect(notifier.notification_type).to eq("list_activity")
end
end
Notification Views
User Settings
Users configure preferences via /settings:
<!-- Delivery Channels -->
<%= f.check_box :email_notifications %>
<%= f.check_box :sms_notifications %>
<%= f.check_box :push_notifications %>
<!-- Notification Types -->
<%= f.check_box :collaboration_notifications %>
<%= f.check_box :list_activity_notifications %>
<%= f.check_box :item_activity_notifications %>
<%= f.check_box :status_change_notifications %>
<!-- Frequency -->
<%= f.select :notification_frequency,
['immediate', 'daily_digest', 'weekly_digest', 'disabled'] %>
Notification Inbox
Notifications appear at /notifications:
# Controller loads and filters notifications
@notifications = current_user.notifications
.order(created_at: :desc)
.includes(:event)
.limit(50)
# Each notification delegates to its event
notification.title
notification.message
notification.actor
notification.url
Configuration
Initializer
The Noticed gem is configured in config/initializers/noticed.rb:
# Convenience methods added to Noticed::Notification
Noticed::Notification.class_eval do
delegate :title, :message, :url, :notification_type, to: :event
def read?
read_at.present?
end
def mark_as_read!
update!(read_at: Time.current)
end
end
Delivery Methods
Notifications can be delivered via multiple channels:
class ApplicationNotifier < Noticed::Base
deliver_by :database # Store in database
deliver_by :email # Send email notification
# deliver_by :sms # Send SMS (future)
end
Troubleshooting
Notifications not sending?
- Check
Current.useris set in ApplicationController - Verify recipient has
email_notifications?true - Check notification type matches user preferences
- View job queue:
Noticed::DeliveryJob.queue_adapter.enqueued_jobs
Current.user not available?
- Set in ApplicationController:
Current.user = current_user - Won't work in background jobs without explicit context
- Won't work in console unless manually set
Missing object data in destroy notifications?
- Use
before_destroynotafter_destroy - Capture data before object deletion
Infinite notification loops?
- Always exclude actor from recipients:
.where.not(id: Current.user.id) - Use conditional callbacks:
if: :saved_change_to_title?
Debugging
# Check user preferences
user.notification_preferences
user.wants_notification?("collaboration")
# View recent notifications
user.notifications.order(created_at: :desc).limit(10)
# Check queued jobs
Noticed::DeliveryJob.queue_adapter.enqueued_jobs
# Generate test token in console
Current.user = User.first
list = List.first
list.update!(title: "Test") # Triggers notification
Performance Notes
- Notifications are async via background jobs (Solid Queue)
- Only notifications with recipients are created
- Early exit conditions prevent unnecessary queries
- Seen/read tracking via
seen_atandread_attimestamps
Future Enhancements
- SMS delivery - Add SMS channel for critical notifications
- Webhooks - External system notifications
- Custom frequency - Per-notification-type frequency settings
- Rich notifications - Attachments, action buttons, deep links
- Bulk operations - Notify about batch changes