Ascent

Building a Rails Notification Queue 2: The Notification Class

As the title suggests, this is the second post in a series on building a flexible notification queue in Rails. I'd suggest starting with the first post: on the database table (it's quite short).

Our apps create notifications in many places. These notifications are usually tied to lots of different objects or interactions. Unmanaged, this intense coupling becomes a deep dark hole where code quality goes to die.

This post is meant to set up some guiding abstractions so you can keep notifications from becoming a plague on your codebase. A theme you will find is that, by design, this solution is flexible. I urge you to think critically about my decisions as you read through it. While the details below have worked wonderfully for me, your implementation will benefit from some customization.

The class

Enough chat, here is the heart of the app/models/notification.rb file.

class Notification < ActiveRecord::Base  
    # Notification Reasons
    ACCOUNT_CREATED = 'account_created'.freeze
    NEW_MESSAGE = 'new_message'.freeze

    belongs_to :user

    scope :unsent, -> {
         where( cancelled_fl: false, sent_at: nil )
    }

    def self.schedule( method, to_user_id, reason, scheduled_for, from_user_id=nil, additional_attributes=nil )
        Notification.create!(
            method: method,
            reason: reason,
            user_id: to_user_id,
            from_user_id: from_user_id,
            scheduled_for: scheduled_for,
            additional_attributes: additional_attributes
        )
    end

    # Cancels any notifications of a specified reason
    #    - Use when a user turns off a notification preference
    def self.cancel( user_id, reason )
        unsent
            .where( user_id: user_id, reason: reason )
            .update_all( cancelled_fl: true )
    end

    # Cancels all incoming or outgoing emails from a user
    #    - Best used when a user deletes
    def self.cancel_all_for( user_id )
        unsent
            .where( 'user_id = ? OR from_user_id = ?', user_id, user_id )
            .update_all( cancelled_fl: true )
    end

    # Cancel a notification instance. ymmv
    def cancel!()
        self.update_attributes( cancelled_fl: true )
    end

    def send()
        case reason.to_s
        when ACCOUNT_CREATED
            MailerService.unconfirmed_email( self )
        when NEW_MESSAGE
            if method == 'push'
                PushService.send_new_message( self )
            elsif method == 'email'
                MailerService.send_new_message( self )
            end
        else
            raise "Unsupported Notification reason: #{reason.inspect}"
        end

        self.update_attributes( sent_at: Time.now )
    end
end  

Implementation notes

Skipping the helper methods for cancellations, you will find the real meat of this class is in 2 methods. Notification.schedule() and notification.send() do all the heavy listing, so let's explore them.

Notification.schedule() should handle all inserts into the notification table. Not only is the call shorter than typing Notification.create( {big ass hash} ), it will give you a single function call that every schedule action goes through. This will pay dividends in the future. The rest of the function is pretty self explanatory.

Also notable is that, in my systems, the from_user_id is nullable and thus optional while scheduling notifications. Your system may not have a nullable sender, or may not have a sender at all. Adjust this function accordingly.

notification.send() handles the shepherding of all notifications to the appropriate notification service. This does not actually send emails or pushes. Remember that this is a queue we are trying to process. This class is not responsible for managing vendor api implementations.

Actually scheduling notifications

So now that we have a class to process these notifications, how about a couple of examples of scheduling notifications.

# Send the account created email after a bit
Notification.schedule( :email, user.id, Notification::ACCOUNT_CREATED, Time.now + 30.minutes )

# Send a message email with included message relation
Notification.schedule( :push, user.id, Notification::NEW_MESSAGE, Time.now,  
                        sender.id, {message_id: message.id} )

The first example shows us the power of being able to very easily schedule a notification for arbitrary times in the future. The second example we start to see the power of the additional_attributes column. Moving forward, new notifications that attach to other tables or objects will not need any database migrations.

Also, does your app tend to use emails and pushes in unison? Make Notification.schedule() accept an array as the method. I think you can start to see the flexibility this abstraction provides.

Is that it?

Mostly. The beauty of this class is it's flexibility and how cleanly it organizes the entire notification landscape. In some projects i have found it so useful that i have leveraged it for in app notification and alerts instead of simply outgoing messages.

In the final and all but certainly shorter, post, we will look at building a simple task we can schedule to process this queue in the background, or on another server entirely. You many have been wondering why we haven't called send() yet. We will.

td;dr: Using a notification class we can abstract all of the scheduling and cancelling logic to a central place.

Building great products is hard. Let's get better at it.
Author image
Written by Ben
Denver, CO https://benroux.me
VP of Engineering at MeetMindful. Have feedback or questions? Want to chat? Send me an email.