Project

notey

0.0
The project is in a healthy, maintained state
Notey is the notification layer for multi-tenant apps. Noticed delivers a notification on whatever channels a notifier declares; Notey stores which channels each person wants per notification type, gives them screens to set that, scopes an inbox to the account they are in, and groups notifications into digests instead of sending each one as it happens.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 3.0
>= 8.1, < 9
 Project Readme

Notey

Notey is the notification layer for multi-tenant Rails apps. It sits on top of Noticed and supplies the parts Noticed leaves to the host.

What Noticed already does

  • Recipients. A notifier declares recipients, and delivery inserts one noticed_notifications row per recipient.
  • In-app records. Every notification is written to the database, with read/unread and seen/unseen state.
  • Channels. Email, SMS through Twilio or Vonage, iOS, FCM, Action Push, Slack, Discord, Microsoft Teams, webhooks, and Action Cable.

What Notey adds

  • Channels. One registration per channel the application can send on, made once for the whole application rather than on every notification.
  • Preferences. Which channels a person wants for each type, per account, with a page they set them on.
  • Digest windows. A type set to daily or weekly is held back and sent as one email covering the window.
  • An inbox. In-app notification pages scoped to the account the person is in.
  • Destinations. An address and an encrypted credential per account per channel, so an account points a channel at its own workspace or endpoint.
  • Notification types. A class describing what a notification carries and nothing about how it is sent.

Installation

gem "notey"
bundle install
bin/rails notey:install:migrations
bin/rails db:migrate

Mount the engine:

# config/routes.rb
mount Notey::Engine => "/notey"

That gives you /notey/preferences, /notey/notifications and /notey/destinations.

Wiring

Notey owns its own tables and never owns your person or account records. Six things connect it to yours.

1. Register the channels your application has

Every application has email and in-app without registering anything. Register a channel for anything else you send on, once, for the whole application.

# config/initializers/notey.rb
Notey.channel :sms, delivery_method: "Noticed::DeliveryMethods::TwilioMessaging", addressed: true

A registration names the channel, the delivery method that sends it, and whether it needs an address before anything can go out on it. Nothing else names a channel: a notification type does not, and a person's preferences offer exactly what is registered.

2. Make your person model a recipient

class User < ApplicationRecord
  include Notey::Recipient
end

3. Set the current person and account per request

class ApplicationController < ActionController::Base
  before_action do
    Notey::Current.member = current_user
    Notey::Current.account_id = current_account&.id
  end
end

A read with no account set returns the declared defaults and an empty inbox, never another account's rows.

4. Put the account on Noticed's rows

Notey reads the account from the Noticed event, because a delivery runs in a job where the current account is gone. Noticed does not add that column, so your app does.

# a migration
add_column :noticed_events, :account_id, :bigint
add_column :noticed_notifications, :account_id, :bigint
# config/initializers/noticed.rb
ActiveSupport.on_load :noticed_event do
  after_initialize { self.account_id ||= Notey::Current.account_id }

  def recipient_attributes_for(recipient)
    super.merge(account_id: account_id)
  end
end

5. Define a notification type

A notification type describes what the notification carries and says nothing about channels. Notey decides which channels each recipient gets.

class CommentNotification < Notey::Notification
  notey_type :comment

  required_params :comment_id

  def title
    "New comment"
  end

  def body
    "Someone replied to you"
  end
end

notey_type is the name a person's stored preferences are keyed by, so renaming the class does not orphan what they chose. Send it by naming the recipients your own code resolved:

CommentNotification.notify(recipients, comment_id: comment.id)

Notey builds the delivery list from the registered channels each time, so a channel registered later needs no change here. For each recipient and each channel it answers three things before sending: the person wants that channel for that type, their window is immediate, and they have an address if the channel needs one.

6. Tell Notey where a notification lives

Digest emails link to each notification through a lambda you supply, so the link points at your own page rather than one this engine picks.

# config/initializers/notey.rb
Notey.notification_url = lambda do |notification|
  Rails.application.routes.url_helpers.notification_url(notification)
end

Settings sections

Notey ships its screens as partials and their saving as action objects, so a settings shell renders them inside its own page frame rather than linking away. With bureau:

# config/initializers/bureau.rb
Bureau.section :notifications, area: :user, title: "Notifications",
  renders: "notey/preferences", runs: "Notey::SavePreferences"

Bureau.section :notification_destinations, area: :account, title: "Notification destinations",
  renders: "notey/destinations", runs: "Notey::SaveDestination", capability: :configure_site

A section registered this way is served by the settings shell, so the shell's own capability check guards it. A section that only links to a mounted path is not guarded, because the shell never renders it.

The inbox

The inbox is the same shape and is not a setting, so render it wherever a person's notifications belong in your own pages:

<%= render "notey/inbox",
      person: current_user,
      account: current_account.id,
      submit_url: notifications_path %>

It lists the notifications that person received in that account, newest first, marking the unread ones. Marking one read posts to the url you named, so the person stays on your page, and the action behind it runs:

Notey::MarkRead.new(person: current_user, account: current_account.id, values: params).call

It marks only a notification that person received in that account, so an id from anywhere else does nothing.

Notey::SavePreferences and Notey::SaveDestination take person:, account: and values:, and answer with an object responding to ok? and message. The engine's own pages call the same two actions.

Live updates

An open inbox can show a new notification the moment it is delivered, without a reload. It is off until you turn it on, because it needs Turbo and a running Action Cable server in production.

# config/initializers/notey.rb
Notey.live_updates = true
Notey.mark_read_url = ->(notification) { Rails.application.routes.url_helpers.notifications_path }

mark_read_url is the url a pushed row's Mark read button posts to. A push is rendered in a background job, which has no request to read that url from, so you name it here. It is usually the same url you pass the inbox as submit_url.

With it on, the inbox and the engine's notifications page subscribe to one stream per person per account, and:

  • a notification delivered in-app appears at the top of that person's open inbox in that account,
  • marking one read shows it as read in the person's other open tabs,
  • the unread count changes in every open tab of that person in that account.

Show the unread count anywhere on your page, and a push keeps it current:

<%= render "notey/unread_count", person: current_user, account: current_account.id %>

Notey does not install Turbo for you. Add turbo-rails to your Gemfile and load its JavaScript. With live updates on and Turbo or mark_read_url missing, the app refuses to boot and names what is missing. A push that fails, such as when the cable server is down, is logged, and the read or delivery it came from still goes through.

Sending digests

A type set to daily or weekly sends nothing when it happens. Run the window on a schedule — Notey does not register one:

Notey::DigestRun.new(window: "daily").call
Notey::DigestRun.new(window: "weekly").call

Each run enqueues one job per person, so one failing send does not stop the rest. A window is sent once even if the run overlaps itself; a send that fails releases the window so it can be sent again.

Deleting old delivery records

Notey writes one record per notification per channel that leaves the application, and nothing deletes them on its own. Say how long to keep them:

# config/initializers/notey.rb
Notey.attempt_retention = 90.days

Then run the deletion on a schedule, the way you run the digest windows:

Notey::DeleteOldAttempts.new.call

Everything older than the period goes and everything inside it stays, so running it twice leaves the same records as running it once. With no period set it refuses to run rather than deleting nothing quietly.

Destinations

A channel like email reaches a person at an address the app already holds. A channel like SMS, a webhook, Slack or Discord does not, so somebody has to say where it goes. Say so when you register the channel:

Notey.channel :sms, delivery_method: "Noticed::DeliveryMethods::TwilioMessaging", addressed: true

A person sets their own. Their phone number, their endpoint. A notification addressed to them goes only to an address they set — never to one the account set.

Bureau.section :my_notification_addresses, area: :user, title: "Where notifications reach me",
  renders: "notey/my_destinations", runs: "Notey::SaveMyDestination"

An account sets its own, for notifications that belong to the whole account rather than to one person — a team Slack or Discord channel.

Bureau.section :notification_destinations, area: :account, title: "Notification destinations",
  renders: "notey/destinations", runs: "Notey::SaveDestination", capability: :configure_site

Notey sends nothing on an addressed channel until the person has set an address. The delivery method reads that address when it sends:

class WebhookDeliveryMethod < Noticed::DeliveryMethod
  def deliver
    post_to Notey::Destinations.for(event.account_id, :webhook, member: recipient).address
  end
end

A credential is encrypted at rest, which needs Active Record encryption keys configured in your app. A channel with no address set sends nothing on it.

Nothing in this engine restricts who may set an account's address. Register it as a settings section so the shell's capability check guards it.

Domain events

Notey does not depend on any event pipeline and holds no mapping from an event to a notification. Your subscriber resolves who should hear about something and calls the notification type.

# app/subscribers/notey_notifications.rb
class NoteyNotifications < EventEngine::Subscribers::Base
  subscribes_to :comment_posted

  def handle(event)
    payload = event.payload.to_h.symbolize_keys

    Notey::Current.set(account_id: payload[:account_id]) do
      CommentNotification.notify(User.where(id: payload[:user_ids]), comment_id: payload[:comment_id])
    end
  end
end

Who receives a notification is decided outside notey; how each of them receives it is decided inside.

Development

After checking out the repo, run bin/setup to install dependencies, then bundle exec rake test to run the tests and bin/rubocop for the linter.

License

The gem is available as open source under the terms of the MIT License.