Notifications Developer Guide - edmozley/freeitsm GitHub Wiki

Notifications โ€” Developer Guide

Why the bell is a subscriber rather than a new instrumentation layer, where the four noise rules are enforced, and the four bugs the build surfaced โ€” three of which would have presented as "notifications just never arrive". The plain-language version is Notifications.

Asked for in discussion #55. Shipped in bbd49c50, deep-link fix in 75803509.


1. ๐Ÿ“ The files involved

๐ŸŸข The feature

File Role
includes/services/notifications.php NotificationsService โ€” the write path and all four noise rules
includes/notifications_router.php Event โ†’ recipient โ†’ display text

๐ŸŸ  Where it hooks in

File Role
workflow/includes/engine.php One hook at the top of dispatch() โ€” the single funnel
api/tickets/bulk_update_tickets.php Wraps its loop in duringBulk()
api/tickets/bulk_delete_tickets.php Same

๐Ÿ”ต New events

File Role
includes/services/tickets.php createNote() now dispatches ticket.note_added
api/self-service/reply_ticket.php ticket.reply_received, source portal
api/tickets/check_mailbox_email.php ticket.reply_received, source email, non-initial only
includes/services/tasks.php assignedDispatch() fires task.assigned from both createTask and updateTask (GH #110)

โšช UI and API

File Role
includes/waffle-menu.php renderNotificationBell() โ€” on every module header; now just the analyst URLs
includes/notification_bell.php notificationBellRender() โ€” the bell's markup, shared with the self-service portal
assets/js/notification-bell.js ยท assets/css/notification-bell.css the bell's behaviour and look, moved out of waffle-menu.php (#62) so the portal uses the same code
api/self-service/notifications.php the portal's endpoint โ€” the same three shapes
api/notifications/get_notifications.php List, plus ?count_only=1 for the badge poll
api/notifications/mark_read.php {ids:[โ€ฆ]} or {all:true}
system/preferences/index.php Per-type switches
lang/en/common.php notifications.event.* and notifications.pref.*

๐Ÿ”ด Schema

Table Role
notifications One row per thing an analyst is told, with event_count for coalescing and read_datetime (NULL = unread)
portal_notifications The same, for self-service portal users (user_id). A sibling table, not a column: notifications.analyst_id is NOT NULL with a foreign key to analysts

Two bells, one service. Every NotificationsService method takes an analyst id as before, or NotificationsService::portalUser($userId); who() picks the table. The portal bell is used by portal managers (new team tickets). Per-type preferences (rule 4) are analyst-only.


2. ๐Ÿง  The bell is a SUBSCRIBER, not instrumentation

This is the decision that made a large-sounding feature small.

WorkflowEngine::dispatch() was already a system-wide event bus: ~32 event types fired from 48 call sites across tickets, tasks, changes, problems, knowledge, assets, SLA, forms, service status and suppliers. Four of the six things Daniel asked for were already being announced.

So the bell hooks that one funnel and answers two questions per event:

public static function dispatch(string $event, array $payload): void
{
    // Its own try/catch, and BEFORE the workflow loop, so neither can stop the
    // other: a broken workflow must not cost somebody their notification.
    try {
        require_once __DIR__ . '/../../includes/notifications_router.php';
        notificationsHandleEvent($event, $payload);
    } catch (Throwable $nEx) { โ€ฆ }

    try { /* existing workflow dispatch */ }
}

โš ๏ธ Adding a notification type should never mean editing call sites. If the event already dispatches, add it to NotificationsService::types() and give it a case in the router. If it does not dispatch yet, add the dispatch โ€” workflows gain the trigger too, which is the right trade either way.


3. ๐Ÿง  The noise rules, and where they live

All four are enforced inside NotificationsService::notify(), not at the call sites, so nothing can bypass them by accident.

Rule 1 โ€” never notify you about your own action

The single biggest source of noise. It needed something that did not exist: no dispatch payload carried an actor. The ticket payload has owner_id, created_by, assigned_analyst_id โ€” nothing saying who made this change.

Rather than threading an actor through 48 call sites, it is resolved centrally from the session:

function notificationsCurrentActor(): array
{
    $id   = isset($_SESSION['analyst_id']) ? (int)$_SESSION['analyst_id'] : 0;
    $name = isset($_SESSION['analyst_name']) ? (string)$_SESSION['analyst_name'] : '';
    return [$id, $name !== '' ? $name : null];
}

Two properties fall out of that, both wanted:

  • A web request is caused by whoever is signed in. Correct.
  • A cron run has no session, so the actor is nobody โ€” which is why a system-generated SLA breach still reaches the assignee even though they own the ticket. Correct, and it would have been fiddly to special-case.

Safe after session_start(['read_and_close' => true]): the array stays in memory for the rest of the request even though the lock is gone.

Rule 2 โ€” bulk

โš ๏ธ Both bulk endpoints loop the service one record at a time, deliberately (see the long comment at the top of bulk_update_tickets.php) so every record gets the same validation, audit and dispatch a single edit would. That also means fifty tickets fire fifty events.

NotificationsService::duringBulk(function () use (โ€ฆ) {
    foreach ($ids as $rawId) { TicketsService::updateTicket(โ€ฆ); }
});

duringBulk() restores the previous state in a finally, so an exception mid-loop cannot leave suppression stuck on for the rest of the request.

Rule 3 โ€” coalesce

Keyed on the object, not the event type:

WHERE analyst_id = ? AND entity_type = ? AND entity_id = ?
  AND read_datetime IS NULL
  AND updated_datetime >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? MINUTE)

That is the point: "this ticket moved three times" is one piece of news. Keying on event type would give three rows that happen to be about one ticket, which is the thing being avoided.

The row keeps the most recent event type, body and title (COALESCE(new, old)), so an assignment followed by a reply shows the reply. The title matters for domains and projects, whose title says what happened (Bradford move: amber to red); before #2199 a folded row kept its first title and showed yesterday's news under today's count. A ticket's or task's title is its subject, so for them it only stays current.

Rule 4 โ€” per-type defaults

NotificationsService::types() is the single registry: the type list, its default, and its entity kind. The Preferences page renders from it, so adding a type there adds the switch. Overrides are one JSON row in user_preferences, because this is read on every event written and one row keeps that to a single lookup.


4. ๐Ÿ› Four bugs, three of which looked like "nothing happened"

This is the part worth reading. Every one of these failed silently โ€” the feature appeared to work and simply produced no notification.

I18n::t() splits keys on every dot

The lang file had:

'event' => [ 'ticket.assigned' => 'Assigned to you by {actor}', โ€ฆ ]

I18n::t() does explode('.', $path) over the whole key, so notifications.event.ticket.assigned looks for event โ†’ ticket โ†’ assigned. A literal 'ticket.assigned' key at that level is unreachable.

The bell fell back to showing a bare name โ€” "Sarah Williams" with no indication of what she had done โ€” and the reply notification, which has no actor, rendered a blank line. Only caught by looking at a screenshot.

โš ๏ธ Event types contain dots. Lang keys built from them must be NESTED, never flat.

requester_email is not a column on tickets

It lives on the user and reaches the payload through a join in loadTicket(). Both new dispatch sites selected it directly from tickets, which throws โ€” and the throw landed in the dispatch's own catch, so the symptom was that replies never notified anyone. Both now LEFT JOIN users.

The portal reply loaded only id, subject

So the payload had no assigned_analyst_id, the router found no recipient, and the single most-wanted notification in the whole feature reached nobody. The query is now widened, with a comment saying why those columns are there.

The deep link used a parameter nothing reads

Three spellings exist in the codebase โ€” ?ticket_id=, ?id= and ?ticket= โ€” and assets/js/inbox.js reads exactly one of them:

const ticketId = urlParams.get('ticket_id');
if (ticketId) setTimeout(() => loadTicketById(ticketId), 500);

The wrong parameter still returns HTTP 200 with the inbox rendered. It looks like a working link right up until you notice the ticket never opened.

๐Ÿ”ด Two other places in the codebase still use tickets/?id= (api/integrations/escalate_ticket.php, workflow/includes/engine.php) and one uses tickets/?ticket= with a ticket number (forms/approvals.php). Those links do not open a ticket either. Not fixed here โ€” noted so somebody can.


5. โš ๏ธ Verifying this feature

Every failure mode looks identical to success. A suppressed notification, a rejected one, a thrown-and-swallowed one and a correctly-quiet one all present as an empty table. So every rule needs a positive control, not just a negative one:

Rule Negative Positive
Own action Admin edits admin's ticket โ†’ 0 rows Admin edits another analyst's ticket โ†’ 1 row
Bulk Bulk of 3 โ†’ 0 rows Same change made singly โ†’ 1 row
Coalesce โ€” 3 changes โ†’ 1 row, event_count = 3
Type off Type disabled โ†’ 0 rows Type re-enabled โ†’ 1 row

Forging a session to act as another analyst is the practical way to test rule 1. โš ๏ธ Include analyst_name โ€” ActorContext and the router both read it, and its absence produces a notification with no actor rather than an obvious error.


6. Extending it

  • A new type on an existing event: add to types(), add a case in the router's body/entity helpers, add the two lang keys (nested).
  • A new type on a new event: add the dispatch() call, register the trigger in availableTriggers() and its payload fields โ€” workflows benefit too โ€” then types() and the two lang keys (nested โ€” see the I18n::t() bug above). Check the router before writing a case for it: notificationsRecipientFor() and notificationsEntityFor() are written per entity, not per event, so a new verb on an entity that already has notifications usually needs no router change at all. task.assigned needed none.
  • A different recipient rule: notificationsRecipientFor() is the only function that decides. A ticket_watchers table would change that one function and nothing else.

โš ๏ธ A missing verb is invisible โ€” check the whole set, not the registry

The registry can only be wrong about types that exist. It cannot tell you about an event nobody ever dispatched, and that is the failure mode that reached a user (GH #110): tasks announced created, completed and deleted, and assigning one announced nothing, for as long as tasks had existed. There was no broken code to find โ€” the bell was subscribing correctly to a thing that was never published.

Before assuming an entity is covered, list what it actually dispatches and compare it against the equivalent entity:

grep -rn "dispatch('task\." --include=*.php .
grep -rn "dispatch('ticket\." --include=*.php .

And check every write path for the verb, not just the obvious one. ticket.assigned fires only on reassignment, because a ticket is normally raised unassigned and picked up afterwards. Copying that rule to tasks would have left the fix half-done: a task is usually handed out by creating it with an assignee, so the event has to fire from the create path too. The right question is not "where does the field change?" but "which of these paths is how the thing actually happens?" โ€” and for tasks that is the one a ticket-shaped fix would skip.

The quiet cases are part of the feature

A new notification type fails by being chatty, so decide up front what must not fire, and test those first โ€” they are easier to get wrong and harder to notice. For task.assigned: re-saving with the same assignee, clearing the assignee, and editing any other field all stay silent, and self-assignment is filtered by rule 1 in the router rather than in the service. Keep that filtering in the router: a workflow may want the event even when the notification is suppressed, and a second copy of the rule in a service is how the two drift apart.


7. ๐Ÿ–ฅ๏ธ Desktop notifications and the task email (2.10.0)

Two personal switches on Preferences โ†’ Notifications, both user_preferences rows, both off by default. Neither adds an event: they are two more ways of delivering what already flows through this page.

๐ŸŽจ File What it does
๐Ÿ–ฅ๏ธ system/preferences/index.php The two switches; asks the browser for permission inside the click; Test button
๐Ÿ–ฅ๏ธ includes/waffle-menu.php renderWaffleMenuJS() reads desktop_notifications with the toast/chime prefs and prints window.DESKTOP_NOTIFICATIONS
๐Ÿ–ฅ๏ธ assets/js/notification-bell.js (v2) desktopNotify() โ€” raises the browser notifications
๐ŸŸ  includes/notifications_router.php notificationsTaskAssignedEmail() โ€” called from notificationsHandleEvent() on task.assigned
๐Ÿ“ง includes/self_service_email.php ssSendSystemEmail() โ€” the system sender, unchanged
๐Ÿ“ง includes/email_log.php New send-log route 'task' => 'Task assigned'
๐ŸŒ lang/en/system.php, lang/en/tasks.php (email block), lang/en/common.php (notifications.desktop_more) Strings
โ“ system/help/preferences.php, system/help/_registry.php The notification bell section
๐Ÿงช includes/feature_bingo/cards/system-core.php, โ€ฆ/tasks.php system.desktop_notifications, tasks.assigned_email

Desktop notifications: piggy-backing on the chime

The bell already knew when something new arrived โ€” that is what sounds the chime (chimeIfNew(), which compares the unread count with the last one this tab saw). The desktop notification hangs off exactly the same moment:

if (unread > parseInt(prev, 10)) {
    if (typeof window.playNotificationSound === 'function') window.playNotificationSound();
    desktopNotify();
}

desktopNotify() returns at once unless window.DESKTOP_NOTIFICATIONS is true and Notification.permission === 'granted', so for everybody who has not opted in it costs nothing โ€” not even a fetch. When it does run it fetches the list (the count-only poll carries no rows) and shows the unread rows newer than a watermark.

The watermark is the interesting part. It lives in localStorage (nbDesktopShownUntil), which every tab of the same site shares, and holds the newest updated_datetime already shown:

const newest = items.reduce((m, n) => String(n.updated_datetime || '') > m ? String(n.updated_datetime) : m, shownUntil || '');
try { localStorage.setItem(DESK_KEY, newest); } catch (e) { /* ignore */ }
if (shownUntil === null) return;                    // first run: baseline only
const fresh = items.filter(n => String(n.updated_datetime || '') > shownUntil);
  • updated_datetime, not id โ€” because of Rule 3. A coalesced row keeps its id and gets a newer updated_datetime; keyed on id, the second change to a ticket would never pop up.
  • localStorage, not sessionStorage โ€” the chime's baseline is per tab on purpose (each tab chimes for itself), but a pop-up per tab would be three identical notifications. The tag (freeitsm-nb-<id>-<updated>) is a second guard: the browser replaces a notification carrying a tag it already shows.
  • The first run only records. Otherwise switching the setting on replays every unread notification at once. desktopNotify() is also called once on page load for exactly this reason, so the baseline exists before the first real arrival.
  • Strings compare correctly because the column is YYYY-MM-DD HH:MM:SS UTC โ€” lexical order is time order.
  • The portal bell runs the same script and never sets window.DESKTOP_NOTIFICATIONS, so it is unaffected.

Permission is asked on the Preferences page, never by the bell. Browsers only allow Notification.requestPermission() inside a user gesture, and an unprompted permission box on page load is the pattern every browser now penalises. So the switch's change handler asks, and puts itself back off if the answer is not granted.

โš ๏ธ This is the Notifications API, not Web Push. It works while a FreeITSM tab is open (minimised is fine). Delivering with the browser closed would need a service worker, a VAPID key pair, a stored push subscription per browser and a server that encrypts and posts to the push service โ€” a separate feature. Nothing here would need to change if it is built: it would be one more subscriber at the same point.

The task email: one more subscriber in the router

// notificationsHandleEvent(), before the per-recipient loop
if ($event === 'task.assigned') {
    notificationsTaskAssignedEmail($conn, $payload, $actorId, $actorName);
}

It sits inside notificationsHandleEvent() rather than in TasksService::assignedDispatch() for the same reason the bell does (section 2): every path that assigns a task already fires task.assigned โ€” board, table, API, workflow action, recurrence โ€” so hooking the event catches all of them, and a new path that dispatches gets the email for free.

The rules, in the order the function applies them:

  1. Not your own action โ€” $assignee === $actorId returns. (Rule 1, applied by hand because this is not notify().)
  2. Bulk โ€” already handled: notificationsHandleEvent() returns before this point when inBulk().
  3. Opted in โ€” user_preferences.task_assigned_email = 'on'. Independent of the bell's task.assigned type switch, deliberately: "email me" and "ring the bell" are different questions.
  4. Active analyst with a valid email.
  5. Language โ€” the recipient's interface_language, via I18n::tFor(). I18n::t() would use the actor's session locale.
  6. Link โ€” publicAbsoluteUrl($conn, NotificationsService::linkFor('task', $id)), so it honours the configured public web address and is right from cron.
  7. Send โ€” ssSendSystemEmail(..., 'task'), which logs sent and failed attempts.

๐Ÿ”ด The send is synchronous. A request that assigns a task to an opted-in analyst waits for the mailbox (Graph, Gmail API or SMTP). That is the price of not having a mail queue; it only applies when the recipient asked for the email. If a queue is ever added, this is the call to move onto it.

Verified

On a throwaway database (never Ed's real data), through the real endpoints: an opted-in analyst assigned a task โ†’ a task-route row in email_send_log with the right subject and address; reassignment โ†’ another row; the analyst assigning themselves โ†’ nothing; switch off โ†’ nothing. In headless Chrome with Notification replaced by a recorder: a bell item arriving after the baseline โ†’ exactly one notification with the right title, body and tag, and a second poll with nothing new โ†’ no repeat. The rig had no mailbox, so a delivered email has not been seen end to end.


8. ๐Ÿ”ด Outstanding

  • War Room mentions are a separate widget. warroom_mentions has no read/unread column, so folding it into the bell means giving it one. Daniel's "mentions of the user" bullet is really this.
  • No workflow action to raise a notification from a rule โ€” an admin cannot yet wire up an arbitrary one without code.
  • Browser notifications only while a tab is open (section 7). Web Push, for a closed browser, is not built; plain-http installs cannot offer either.
  • 23 locales. All new strings are English-only and fall back silently.
  • Nothing prunes old read notifications. Worth a retention sweep before this has been running a year.

Related pages

โš ๏ธ **GitHub.com Fallback** โš ๏ธ