-
Notifications
You must be signed in to change notification settings - Fork 15
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.
| 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 |
| File | Role |
|---|---|
includes/waffle-menu.php |
renderNotificationBell() β on every module header |
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) |
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 and body, so an assignment followed by a reply shows the reply.
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 and register the trigger inavailableTriggers()β workflows benefit too. -
A different recipient rule:
notificationsRecipientFor()is the only function that decides. Aticket_watcherstable would change that one function and nothing else.
-
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.
- No browser notifications. Requires a secure context; many installs are plain HTTP.
- 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
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)