ruby

System Notifications with Noticed and CableReady in Rails

Julian Rubisch

Julian Rubisch on

System Notifications with Noticed and CableReady in Rails

This article has been partly inspired by an upcoming chapter of the author's Advanced CableReady book, and tailored to fit this guest post for AppSignal.

Notifications are a typical cross-cutting concern shared by many web applications.

The Noticed gem makes developing notifications fantastically easy by providing a database-backed model and pluggable delivery methods for your Ruby on Rails application. It comes with built-in support for mailers, websockets, and a couple of other delivery methods.

We'll also examine the merits of using the CableReady gem for triggering system notifications in your Ruby on Rails application.

Let's get into it!

Prerequisites and Requirements

Every now and then, you might need to trigger system notifications from your app. You can achieve this with the Notifications API.

For example, let's say your application allows users to upload large files which take a long time to be transcoded. You may want to send users a notification once their video upload completes. This means they can switch to a new task in the meantime and don't have to keep your application open for several minutes.

Luckily, it's easy to implement and flesh out system notifications with these two prerequisites:

  • Noticed has support for custom delivery methods (Noticed exposes a simple API to implement any transport mechanism you like ā€” for example, posting to Discord servers).
  • CableReady has a notification operation arrow in its quiver.

The list of requirements for CableReady and Noticed is short ā€” you simply need the following:

  • an ActionCable server running
  • an ActiveJob backend (typically used by Noticed)

Note: The entire sample code for this project is available on GitHub. You can also follow along below ā€” we will guide you step-by-step.

A CableReady Primer for your Ruby on Rails Application

Let's start by making ourselves familiar with CableReady. Released in 2017, it can be thought of as the "missing ActionCable standard library".

Before the advent of Turbo, the only way to craft real-time applications in Ruby on Rails was through ActionCable. ActionCable is the native Rails wrapper around WebSockets, providing both a server-side and client-side API to send messages through a persistent connection in both directions at any time.

The downside to this approach was (and is) that you have to write a lot of boilerplate code to make it happen.

This is where CableReady helps by providing an abstraction layer around a multitude of DOM operations to be triggered on the server. A few examples include:

  • DOM mutations (inner_html, insert_adjacent_html, morph, etc.)
  • DOM element property mutations (add_css_class, remove_css_class, set_dataset_property, etc.)
  • dispatching arbitrary DOM events
  • browser history manipulations
  • notifications (which we will make use of)

The CableReady Server and Client

How does CableReady work its magic? In a nutshell, it consists of a server-side and client-side part.

On the server side, the module CableReady::Broadcaster can be included in any part of your application that calls for it. This could be in a job, a model callback, or a plain controller. But first, an ActionCable channel has to be in place. To cite CableReady's official documentation:

class ExampleChannel < ApplicationCable::Channel
  def subscribed
    stream_from "visitors"
  end
end

Note that visitors is called a stream identifier. It can be used to target either a broad audience or only specific clients subscribed to your channel. To conclude the example, we can include the broadcaster module in a model and send a console.log to the client after sign-up:

class User < ApplicationRecord
  include CableReady::Broadcaster
 
  after_create do
    cable_ready["visitors"].console_log(message: "Welcome #{self.name} to the site!")
    cable_ready.broadcast # send queued console_log operation to all ExampleChannel subscribers
  end
end

On the client side, the logic is simple. Create a subscription to the aforementioned channel. Then, in its received hook, call CableReady.perform on all operations passed over the wire:

import CableReady from "cable_ready";
import consumer from "./consumer";
 
consumer.subscriptions.create("ExampleChannel", {
  received(data) {
    if (data.cableReady) CableReady.perform(data.operations);
  },
});

CableReady vs. Turbo for Rails

Summing up, when should you use CableReady, and when should you avoid it?

With the introduction of Turbo, the web development community received a powerful toolbox to craft server-rendered reactive applications. Being essentially a frontend technology with powerful server-side bindings for Rails, it fits well into the standard Model-View-Controller (MVC) stack. Thus, it can cover most of your typical app's requirements.

CableReady, on the other hand, is the Swiss army knife of real-time Rails development and should be used with care. It is a powerful abstraction that can seem very inviting to use pervasively. But if you imagine that every part of your DOM can be mutated from any location in your app, you'll understand that this can lead to race conditions and hard-to-track-down bugs.

There are cases like the one at hand, though, where CableReady makes perfect sense because it allows for more fine-grained control over the DOM.

Asked for a simple TLDR, I would respond that Turbo is for application developers, while CableReady is for library builders. But as we will see, there are gray areas between the two.

Noticed ā€” Simple Notifications for Ruby on Rails Applications

The second library we will apply to deliver system notifications is Chris Oliver's Noticed gem. At its heart, it is built upon an ActiveRecord model that models a single notification to a recipient. It holds common metadata, such as:

  • who a notification was sent to (the recipient)
  • when the notification was read
  • any parameters the notification is associated with (typically a reference to another model)

If you are familiar with how the ActiveStorage/ActionText meta-tables work, this is very similar.

Adjacent to this, Noticed employs POROs (Plain Old Ruby Objects, i.e., objects without any connection to Rails or other frameworks), which serve as blueprints for actual notifications. These are, somewhat misleadingly, also called Notifications and carry logic about how to render and distribute them. Here is an example from the README:

class CommentNotification < Noticed::Base
  deliver_by :database
  deliver_by :action_cable
  deliver_by :email, mailer: 'CommentMailer', if: :email_notifications?
 
  # I18n helpers
  def message
    t(".message")
  end
 
  # URL helpers are accessible in notifications
  # Don't forget to set your default_url_options so Rails knows how to generate urls
  def url
    post_path(params[:post])
  end
 
  def email_notifications?
    !!recipient.preferences[:email]
  end
end

We will see this at work shortly. Of special interest are the deliver_by invocations, as they determine which delivery methods this notification should use:

  • deliver_by :database stores a Notification record (of the model mentioned above) for later access
  • deliver_by :action_cable sends it via a defined ActionCable channel and stream (default Noticed::NotificationChannel)
  • deliver_by :email specifies a mailer used to send the notification. The example displays how to factor in any preferences the recipient(s) might have set.

Our goal for the remainder of this article is to implement a custom delivery method that will send our system notifications.

A Custom Delivery Method: System Notifications via the Notifications API

Before we set out to do this, let's clear the ground by creating a new Rails application. Because integrating with CableReady is easier this way, I opted for the esbuild JavaScript option over importmaps:

$ rails new noticed-cableready --javascript=esbuild

Note: At the time of writing, the current Rails version is 7.0.4. If you have an existing Rails application, you can skip the next step, but make sure you have a User model or similar concept to act as a notification recipient.

1. Prepare Recipients

Noticed needs a User model to act as recipients, so to be concise, pull in Devise and generate a User model.

Afterward, open the Rails console and create a sample user:

$ bundle add devise
$ bin/rails generate devise:install
$ bin/rails generate devise User
$ bin/rails db:migrate
 
$ bin/rails c
irb(main):001:1* User.create(
irb(main):002:1*   email: "julian@example.com",
irb(main):003:1*   password: "mypassword",
irb(main):004:1*   password_confirmation: "mypassword"
irb(main):005:1> )

2. Add Noticed

Next, let's add Noticed to our bundle and generate the database model.

$ bundle add noticed
$ bin/rails generate noticed:model
    generate  model
       rails  generate model Notification recipient:references{polymorphic} type params:json read_at:datetime:index
      invoke  active_record
      create    db/migrate/20221026184101_create_notifications.rb
      create    app/models/notification.rb
      invoke    test_unit
      create      test/models/notification_test.rb
      create      test/fixtures/notifications.yml
      insert  app/models/notification.rb
      insert  db/migrate/20221026184101_create_notifications.rb
 
šŸšš Your notifications database model has been generated!
 
Next steps:
1. Run "rails db:migrate"
2. Add "has_many :notifications, as: :recipient, dependent: :destroy" to your User model(s).
3. Generate notifications with "rails g noticed:notification"

We do as told, run db:migrate and add the polymorphic has_many association to our User model:

# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
 
  has_many :notifications, as: :recipient, dependent: :destroy
end

The final piece before we can put this to the test is to create a blueprint PORO:

$ bin/rails generate noticed:notification TestNotification
# app/notifications/test_notification.rb
class TestNotification < Noticed::Base
  deliver_by :database
 
  def message
    "A system notification"
  end
end

We tweak it a bit so that it just uses the database delivery method and a placeholder message for the moment. You would typically add required params here, like a model id, to construct the message and the URL to link to ā€” see the Noticed README for details.

Using only the Rails console, we can demonstrate how to deliver a notification based on this PORO now:

$ bin/rails c
irb(main):001:0> TestNotification.deliver(User.first)
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
Performing Noticed::DeliveryMethods::Database (Job ID: 32f27ac7-fb2e-42e4-9c5e-a3290d2d1297) from Async(default) enqueued at  with arguments: {:notification_class=>"TestNotification", :options=>{}, :params=>{}, :recipient=>#<GlobalID:0x00000001111e1718 @uri=#<URI::GID gid://noticed-cableready/User/1>>, :record=>nil}
  TRANSACTION (0.1ms)  begin transaction
  Notification Create (0.6ms)  INSERT INTO "notifications" ("recipient_type", "recipient_id", "type", "params", "read_at", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?)  [["recipient_type", "User"], ["recipient_id", 1], ["type", "TestNotification"], ["params", "{\"_aj_symbol_keys\":[]}"], ["read_at", nil], ["created_at", "2022-10-27 07:25:28.865982"], ["updated_at", "2022-10-27 07:25:28.865982"]]
  TRANSACTION (0.3ms)  commit transaction
Performed Noticed::DeliveryMethods::Database (Job ID: 32f27ac7-fb2e-42e4-9c5e-a3290d2d1297) from Async(default) in 29.93ms
=> [#<User id: 1, email: "julian@example.com", created_at: "2022-10-26 18:51:40.370635000 +0000", updated_at: "2022-10-26 18:51:40.370635000 +0000">]

As we can see, Noticed performs a database insert, so now we can grab all Notifications for a specified user:

irb(main):002:0> User.first.notifications
  User Load (0.6ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
  Notification Load (0.8ms)  SELECT "notifications".* FROM "notifications" WHERE "notifications"."recipient_type" = ? AND "notifications"."recipient_id" = ?  [["recipient_type", "User"], ["recipient_id", 1]]
=>
[#<Notification:0x00000001113e3b38
  id: 1,
  recipient_type: "User",
  recipient_id: 1,
  type: "TestNotification",
  params: {},
  read_at: nil,
  created_at: Thu, 27 Oct 2022 07:25:28.865982000 UTC +00:00,
  updated_at: Thu, 27 Oct 2022 07:25:28.865982000 UTC +00:00>]

This is enough for us to construct a simple index view that lists all delivered notifications for the current user:

$ bin/rails g controller Notifications index
# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
  def index
    @notifications = current_user.notifications
  end
end
<!-- app/views/notifications/index.html.erb -->
<h1>Notifications</h1>
 
<ul>
<% @notifications.each do |notification| %>
  <% instance = notification.to_notification %>
  <li>
    <p>Sent at: <%= notification&.created_at.to_s %></p>
    <p>Message: <%= instance.message %></p>
  </li>
<% end %>
</ul>

After spinning up the app with bin/dev, we can log in and browse to http://localhost:3000/notifications:

List of Notifications

3. Installing CableReady

To make use of CableReady, we need to install it. Luckily, this is quickly done:

$ bundle add cable_ready
$ yarn add cable_ready@4.5.0

Let's generate a NotificationChannel to deliver our messages:

$ bin/rails g channel Notification

This will add all the missing ActionCable (JavaScript) dependencies and scaffold the respective channel files, specifically app/channels/notification_channel.rb and app/javascript/channels/notification_channel.js.

For the server-side channel, we inherit from Noticed::NotificationChannel:

# app/channels/notification_channel.rb
class NotificationChannel < Noticed::NotificationChannel
end

Before we continue, we need to ensure our ActionCable is authenticated for Devise users. I won't go into details about that here. The necessary boilerplate looks like this:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user
 
    def connect
      self.current_user = find_verified_user
    end
 
    protected
 
    def find_verified_user
      if (current_user = env["warden"].user)
        current_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

Check out the StimulusReflex docs for other options.

On the client side, we need to add the tiny bit of setup code mentioned above:

// app/javascript/channels/notification_channel.js
import CableReady from "cable_ready";
import consumer from "./consumer";
 
consumer.subscriptions.create("NotificationChannel", {
  received(data) {
    if (data.cableReady) CableReady.perform(data.operations);
  },
});

Note: Redis is required for ActionCable to work, so the rest of this article assumes you have a local server running.

4. Delivery Method Implementation

To broadcast system notifications, let's generate a new delivery method:

$ bin/rails generate noticed:delivery_method System

The scaffolded class looks like this:

# app/notifications/delivery_methods/system.rb
class DeliveryMethods::System < Noticed::DeliveryMethods::Base
  def deliver
    # Logic for sending the notification
  end
 
  # You may override this method to validate options for the delivery method
  # Invalid options should raise a ValidationError
  #
  # def self.validate!(options)
  #   raise ValidationError, "required_option missing" unless options[:required_option]
  # end
end

Let's continue by drafting how we envision our deliver method to work.

  class DeliveryMethods::System < Noticed::DeliveryMethods::Base
+   include CableReady::Broadcaster
+
    def deliver
-     # Logic for sending the notification
+     cable_ready[channel].notification(
+       title: "My App",
+       options: {
+         body: notification.message
+       }
+     ).broadcast_to(recipient)
    end
 
+   def channel
+     @channel ||= begin
+       value = options[:channel]
+       case value
+       when String
+         value.constantize
+       else
+         Noticed::NotificationChannel
+       end
+     end
+   end
-   # You may override this method to validate options for the delivery method
-   # Invalid options should raise a ValidationError
-   #
-   # def self.validate!(options)
-   #   raise ValidationError, "required_option missing" unless options[:required_option]
-   # end
  end

We have borrowed the channel method in part from the built-in ActionCable delivery method. It allows us to pass in a channel via a class method option. Otherwise, it falls back to the provided Noticed::NotificationsChannel.

Then we use CableReady's notification method to broadcast the respective instance to the recipient.

To put it into action, we have to connect it to our notification PORO:

  class TestNotification < Noticed::Base
    deliver_by :database
+   deliver_by :system, class: "DeliveryMethods::System", channel: "NotificationChannel"
 
    def message
      "A system notification"
    end
  end

5. Putting It to Work

Now all that's left to do is try it out. We can simply run this again from the Rails console:

irb(main):001:0> TestNotification.deliver(User.first)

Assuming you are still logged in, the browser will first ask you for permission to receive notifications on behalf of the app:

Browser Permission Popup

Once you have confirmed this, you get this beautiful pop-up notification from your browser:

Browser Notification Popup

Wrapping Up

Taking a fast lane tour through CableReady and Noticed, we have demonstrated how to integrate a native browser API into your app. The result is a simple, coherent way to deliver system notifications to your users.

This use case is also meant to illustrate how easy it is to mix CableReady into your use case. If you think ahead one step, decoupling the drafted delivery method into a library is not hard.

I hope this inspires you to look for manifestations of vertical reactive problem domains in your app and give CableReady a try.

Read more by picking up my new book Advanced CableReady.

Use the coupon code APPSIGNAL-PROMO and save $10!

Happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Share this article

RSS
Julian Rubisch

Julian Rubisch

Our guest author Julian is a freelance Ruby on Rails consultant based in Vienna, specializing in Reactive Rails. Part of the StimulusReflex core team, he has been at the forefront of developing cutting-edge HTML-over-the-wire technology since 2020.

-> All articles by Julian Rubisch-> Become an AppSignal author

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps