Notifications Developer Guide - edmozley/freeitsm GitHub Wiki
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.
| File | Role |
|---|---|
includes/services/notifications.php |
NotificationsService โ the write path and all four noise rules
|
includes/notifications_router.php |
Event โ recipient โ display text |
| 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 |
| 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) |
| 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.*
|
| 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.
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 */ }
}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.
All four are enforced inside NotificationsService::notify(), not at the call sites, so nothing can bypass them by accident.
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.
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.
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.
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.
This is the part worth reading. Every one of these failed silently โ the feature appeared to work and simply produced no notification.
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.
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.
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.
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.
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. analyst_name โ ActorContext and the router both read it, and its absence produces a notification with no actor rather than an obvious error.
-
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 inavailableTriggers()and its payload fields โ workflows benefit too โ thentypes()and the two lang keys (nested โ see theI18n::t()bug above). Check the router before writing a case for it:notificationsRecipientFor()andnotificationsEntityFor()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.assignedneeded none. -
A different recipient rule:
notificationsRecipientFor()is the only function that decides. Aticket_watcherstable would change that one function and nothing else.
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.
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.
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
|
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, notidโ because of Rule 3. A coalesced row keeps its id and gets a newerupdated_datetime; keyed on id, the second change to a ticket would never pop up. -
localStorage, notsessionStorageโ 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. Thetag(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:SSUTC โ 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.
// 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:
-
Not your own action โ
$assignee === $actorIdreturns. (Rule 1, applied by hand because this is notnotify().) -
Bulk โ already handled:
notificationsHandleEvent()returns before this point wheninBulk(). -
Opted in โ
user_preferences.task_assigned_email = 'on'. Independent of the bell'stask.assignedtype switch, deliberately: "email me" and "ring the bell" are different questions. - Active analyst with a valid email.
-
Language โ the recipient's
interface_language, viaI18n::tFor().I18n::t()would use the actor's session locale. -
Link โ
publicAbsoluteUrl($conn, NotificationsService::linkFor('task', $id)), so it honours the configured public web address and is right from cron. -
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.
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.
-
War Room mentions are a separate widget.
warroom_mentionshas 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.
- Notifications โ the plain-language version
- Workflows ยท Workflow and Webhook Pitfalls
- War Room โ Developer Guide