Skip to content

Notification centre

The notification center keeps one EasyNotification per user and entity (an issue) and shows it in the service-bar bell. Every change that concerns the user is an immutable EasyNotificationEvent under that notification, so a user who is assigned, then sees a due date change and a mention gets one bell entry with three events, not three entries. Nothing is snapshotted: cards render from the live issue and journals. E-mail notifications are a separate channel (see Issue email notifications).

Tables

easy_notifications: one row per (user, entity):

Column Meaning
user_id recipient
entity_type, entity_id polymorphic grouping key (Issue)
actor_id user behind the latest event
last_event_at time of the latest event, taken from the source (created_on), not from the job clock
seen_at when the user last viewed it; never reset by new events
created_at, updated_at Rails timestamps

Unique index on (user_id, entity_type, entity_id); (user_id, last_event_at, seen_at) serves the list and the badge.

easy_notification_events: one immutable row per (notification, source):

Column Meaning
easy_notification_id parent; deleted through the association, no FK
source_type, source_id Journal, or the Issue itself for its creation
actor_id user who caused the event
kind create_issue, edit_issue, mention, or a plugin kind
mailed_at reserved, not written by any code yet
created_at source time

A mentioned user gets kind mention for that event even when they are also an ordinary recipient, and regardless of their mail_notification setting: easy_notifiable_mentions checks only visibility (and view_private_notes on journals), while e-mail keeps the stricter notified_mentions. The actor never gets an event.

Seen state

GET /easy_notifications?seen=1 ("Show seen" toggle) also lists seen notifications whose last event lies within EasyNotification::SEEN_WINDOW (5 days); their cards show the events inside that window (#display_events) and carry no mark-seen button. The mark-seen forms pass seen=1 along so the streamed list keeps the mode.

Unseen means seen_at IS NULL OR last_event_at >= seen_at. Marking seen (PATCH /easy_notifications/:id/seen, PATCH /easy_notifications/seen_all, or opening the issue through ActsAsUserReadable#mark_as_read) stores the current time floored to the second; sources stamp events with whole seconds, so the compare is inclusive: a change in the same second is shown again rather than lost. A later event makes the notification unseen again without touching seen_at, and "what is new" is EasyNotification#new_events, the events at or after seen_at. History stays until cleanup.

Center recipients for issues come from Issue#easy_notifiable_recipients: author, assignee and watchers always; the former assignee, all project members and recipients of an already closed issue only when the user's rule is on. mail_notification, no_notification_ever, Setting.notified_events and the subtask-change setting are not consulted; e-mail keeps notified_users. The journal filter (notes or notified visible details, private-note permission) is shared with the mail path. Groups are expanded to their users, and every recipient must be active and see the issue. The creation of a closed issue and the journal that closes it count as closing, so they reach users with closed_issues off; only later edits of an already closed issue are filtered.

Registering a source

Include EasyNotifiable and declare one easy_notifiable per kind. Options accept a symbol (public method on the source) or a callable receiving the source.

class Issue < ApplicationRecord
  include EasyNotifiable

  easy_notifiable kind: "create_issue",
                  actor: :author,
                  if: ->(issue) { issue.notify? && issue.easy_notifiable_project? },
                  recipients: :easy_notifiable_recipients,
                  mentions: :easy_notifiable_mentions
end

class Journal < ApplicationRecord
  include EasyNotifiable

  easy_notifiable kind: "edit_issue",
                  actor: :user,
                  entity: :journalized,
                  if: :easy_notifiable_issue_journal?,
                  recipients: ->(journal) { journal.journalized.easy_notifiable_recipients(journal) },
                  mentions: :easy_notifiable_mentions
end
Option Required Runs in Meaning
kind yes none event kind for ordinary recipients; unique per class
recipients yes job users who get an event
entity no (the source) job record the notification is grouped under
actor no (:author) job excluded from recipients, stored on the event and the notification
event_at no (:created_at) job event time; Issue and Journal pass :created_on
if no after-commit callback guard; may read transient state such as notify?
mentions no callback users who get a mention event; runs before commit because acts_as_mentionable fills mentioned_users only on the saving instance, the job receives the ids

Every kind must also be registered in an initializer so the settings page can list it (config/initializers/09_easy_notification_kinds.rb; a plugin adds its own file):

require "easy_notification_kinds"

EasyNotificationKinds.register("create_easy_crm_case", label: :label_easy_notification_kind_create_easy_crm_case, position: 110)

position orders the page, label is the caption key. An unregistered kind is still written but cannot be switched off and does not appear on the page. The registry (easy_lib/easy_notification_kinds.rb) is not reloadable and is filled from initializers, so the page lists the kinds of every installed plugin before its models load. Core registers create_issue, edit_issue and mention; easy_crm registers create_easy_crm_case and edit_easy_crm_case in config/initializers/16_easy_notification_kinds.rb.

easy_notifiable installs has_many :easy_notification_events, as: :source, dependent: :delete_all plus destroy hooks that drop notifications left without events and re-align last_event_at, and an after_create_commit that enqueues EasyNotificationJob.perform_later(source, kind, mentioned_user_ids). A source grouped under itself (default entity) also gets has_many :easy_notifications, as: :entity, dependent: :destroy; a source grouped under another record (Journal under Issue) relies on that record being a source itself. Declare on the base class of an STI hierarchy. Plugins declare inside their model patches; the card renders the same for every kind, only mention gets a badge after the actor name.

Registered sources: Issue and its Journal (create_issue, edit_issue) in core; EasyCrmCase and its Journal (create_easy_crm_case, edit_easy_crm_case) in easy_crm, with the card partial easy_crm_2_0/easy_crm/app/views/easy_notifications/_easy_crm_case.html.erb. CRM recipients are the EasyCrmMailer set (notified_users | notified_watchers), private notes need view_private_notes on the case project; easy_notifiable_journal_recipients restores the former assignee from the journal's assigned_to_id detail because the queued job works on a reloaded record. Recipients resolved in the job see no dirty-tracking state in general: a source whose recipient rule depends on it must read the journal instead. Seen-on-read comes from acts_as_user_readable#mark_as_read, which every entity with that concern already calls on show.

Control panel

/my/notification_settings (EasyNotificationSettingsController, behind easy_notification_center_enabled, reachable from the bell header and from My account) stores EasyNotificationSetting rows (user_id, channel, name, enabled) only for values that differ from the default; a switch returned to its default deletes its row, unique on the triple, channel is center today. name is a registered kind or one of the scope rules previous_assignee (default on), all_project_changes (default off), closed_issues (default on); kinds default to on. EasyNotificationSetting.values_for(user) returns every name with its stored or default value; enabled_user_ids(ids, name) is the query the job and the issue resolver use for known recipients; the issue resolver reads all_project_changes (default off) through the enabled_for scope as a subquery on the project members. update_for assigns each posted name through safe_attributes (enabled is the only safe attribute), destroys the row when the value equals the default and saves it otherwise; unknown names in the form are ignored. The enabled column defaults to true, but every row is written with an explicit value.

The page holds the center switches only and needs nothing beyond login and the feature toggle. E-mail settings stay on My account (users/_mail_notifications, users.mail_notification, members.mail_notification and the UserPreference flags) until the e-mail channel moves onto EasyNotificationSetting; while the feature is on, My account shows a link to the page above that partial.

The data migration SeedEasyNotificationSettingsFromMailPreferences (easy_engines/easy_extensions/db/data/, because easy8:install runs only engine and plugin data migrations) seeded the rules from those flags once (all โ†’ all_project_changes, no_notified_as_previous_assignee โ†’ previous_assignee off, no_notified_if_issue_closing โ†’ closed_issues off). It writes nothing for users with default preferences and can run twice (insert_all skips duplicates through the unique index). Users with none, only_assigned or only_owner now get their own events in the center; selected users lose project-wide center rows until project-level settings exist.

closed? and the recipient set are read when the job runs, not when the change was made (the same model as phase 1). A comment followed quickly by a journal that closes the issue can therefore be dropped for users with closed_issues off.

Project-level rows (project_id) and global defaults are the planned next layers.

Job

EasyNotificationJob receives the source (GlobalID) and the mentioned user ids, resolves entity, actor and recipients, drops the actor, then keeps only recipients whose setting for the event's kind is on (enabled_user_ids), a mentioned user with mention off gets the ordinary kind when they are also an ordinary recipient, otherwise nothing, then runs three autocommit statements: upsert_all of the notifications (ON DUPLICATE KEY UPDATE with GREATEST so a retried or reordered job never moves last_event_at backwards), a pluck of their ids, and insert_all of the events (INSERT IGNORE through the unique index). Retries are no-ops; EasyActiveJob retries ActiveRecord::Deadlocked. A missing source or entity is ignored.

Cleanup

EasyNotificationsCleanupJob runs daily (EASY_NOTIFICATIONS_CLEANUP_CRON, default 30 3 * * *) and deletes, with their events, notifications seen more than 30 days ago without newer events and notifications whose last event is older than 90 days. Each batch deletes the notifications first (conditions re-checked in the DELETE) and then only the events whose notification is gone, so a notification renewed by a concurrent EasyNotificationJob keeps its new event.

UI

Feature toggle :easy_notification_center_enabled (registered in config/initializers/04_easy_features.rb, read through EasyNotification.center_enabled?) decides which bell the service bar shows: the notification center, or the toolbar activity feed (easy_activities#show_toolbar, easy_activity background service) it is meant to replace. Notifications and events are written regardless of the toggle, so both bells can run side by side during A/B testing. Enable per user with Flipper.enable_actor(:easy_notification_center_enabled, user) or globally with Flipper.enable(:easy_notification_center_enabled).

Turbo frame easy-notification-center in the service-bar sliding panel; EasyNotificationsController#index renders easy_notifications/_center (unseen notifications up to EasySetting.value("easy_select_limit"), latest event first) and is available to users with the activity_feed user type. Mark-seen actions answer with Turbo Streams that replace the #easy_notification_center wrapper and #easy_notification_center_badge; the full page /easy_notifications renders the same partial without a frame. The bell's inline onclick calls window.EasyNotificationCenter.open (app/frontend/src/easy_notification_center.js, loaded by the application entrypoint), which recreates the Turbo frame if the panel's close handler removed it, loads it through frame.src, and opens the legacy panel; before the JS is ready the link falls back to the full page.

_easy_notification.html.erb renders the partial named after the entity class (easy_notifications/_issue.html.erb, resolved by notification_card_partial, which raises EasyNotification::MissingCard when none exists); a plugin entity ships its own _<entity_type.underscore>.html.erb and wraps its body in render layout: "easy_notifications/card", locals: { notification:, events:, project: } for the common shell (actor with the mention badge, time, close button, project). The issue card reuses the former activity-feed markup (easy-activity-heading, link-issue, description) and shows: the latest actor (+N when several) followed by a mentioned you badge when any new event is a mention, time of the latest event, project, the issue link as the title (opens the issue page in a new window, anchored to the newest journal), one text excerpt (the newest visible journal notes, else the issue description when the creation is among the new events), and one summary line with the final value of every changed field (EasyNotification.latest_details keeps the last journal that touched each attribute or custom field; relation and attachment details stay one per event; three fields, then label_easy_notification_more_details). Visibility is re-checked at render (Issue#visible?, private notes, Journal#visible_details), so a note made private later disappears from the card; a card with nothing visible renders nothing.

Not implemented

  • Phase 2 (done 2026-10): notification control panel for the center channel; e-mail per-kind column, admin defaults and project-level rules follow.
  • Phase 3: period e-mail built from unseen events (mailed_at stamps what was sent; mail never changes seen state), honouring the control panel and replacing the journal mail queue for issues.
  • Phase 4: REST API and Swagger for notifications and events, EasyWebHook registration, push through the existing subscription channel.