Repository navigation
Ticket Watchers Developer Guide
How an analyst who does not own a ticket is told about it: the watch list, how watchers join the notification audience, the per-install choice of events, the "reassigned" wording, the Watch button, the right-click menu and the REST API. Built for #277 (5.0.0). User guide: Tickets β Watching a ticket.
-
Page 1 (this page): the files, the schema, the
TicketWatchersservice and its rules, who is told (the notifications router andticket.reassigned), the events setting, and merge, delete and the analyst lifecycle. - Page 2: the browser endpoint, the Watch button and panel, the right-click menu, the REST API, the traps and the tests.
The one-line version: a watcher is a row in ticket_watchers, put there by hand. notificationsAudienceFor() adds a ticket's watchers to the audience of seven ticket events, filtered by the install's ticked events (ticket_watch_events) and by who can still see the ticket. From there each watcher is an ordinary recipient: their own preferences decide, nobody hears about their own change, and a reassignment reaches them as the new type ticket.reassigned, never "assigned to you". Nothing at a dispatch call site changed.
Tasks already had this: task_collaborators (GH #89), the people Involved in a task. Tickets had nobody but the assignee (and a team for a queue), so a ticket went quiet for you the moment it was handed on. Notifications said a Watch button would be the answer, and the notifications developer guide said the audience function would be the only place to change. That held.
ποΈ schema Β· βοΈ service Β· π endpoint Β· π₯οΈ UI Β· π cross-module Β· π§ͺ tests
| File | π¨ | What happens there |
|---|---|---|
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php (generated) |
ποΈ | the ticket_watchers table, its unique key and its analyst index |
includes/services/ticket_watchers.php |
βοΈ |
class TicketWatchers: the rules, the list, add(), remove(), audience(), events(), canSee(), inactivePolicy(), analystDeactivated()
|
includes/analyst_lifecycle.php (analystDeactivate()), includes/services/analysts.php (AnalystsService::update()), api/tickets/save_analyst.php
|
π | the three places an analyst is deactivated; each calls TicketWatchers::analystDeactivated() (section 6) |
includes/notifications_router.php |
π | watchers join the audience of the ticket events; ticket.assigned becomes ticket.reassigned for anyone but the new assignee |
includes/services/notifications.php |
π | the new type ticket.reassigned in NotificationsService::types(), on by default |
lang/*/common.php |
π |
common.notifications.event.ticket.reassigned (the bell) and common.notifications.pref.ticket.reassigned (Preferences); the "or one I watch" wording on the other ticket switches |
api/tickets/watchers.php |
π | the browser endpoint: GET the list; POST watch, unwatch, watch_many (page 2) |
assets/js/ticket-watchers.js |
π₯οΈ | the Watch button and its panel; Watch / Stop watching in the right-click menu (page 2) |
assets/js/inbox.js, tickets/index.php
|
π₯οΈ |
displayEmail() calls loadTicketWatchers(); openTicketContextMenu() calls populateContextWatchItem(); the #ctxWatchItem menu button; the script tag (page 2) |
tickets/settings/index.php, tickets/settings/manifest.php
|
π₯οΈ | Tickets β Settings β General β Watching tickets: a tick per event, saved as ticket_watch_events; When a watcher is deactivated, saved as ticket_watch_inactive
|
includes/ticket_merge.php |
π | MERGE_MOVE_DEDUPE['ticket_watchers'] = 'analyst_id' |
api/tickets/permanently_delete_ticket.php, api/tickets/empty_trash.php, api/system/debug-tools/D002_delete_ticket.php
|
π | remove the rows (empty trash now removes checklists too) |
includes/analyst_lifecycle.php |
π |
ticket_watchers.analyst_id personal, added_by_id history |
api/v1/resources/tickets.php, api/v1/lib/routes.php, lib/permissions.php, lib/modules.php, lib/openapi_schemas.php, api/v1/index.php, api/v1/spec.json
|
π |
GET/POST /tickets/{id}/watchers, DELETE /tickets/{id}/watchers/{analyst_id}; permission ticket_watchers (page 2) |
tickets/help.php, lang/*/tickets.php
|
π₯οΈ | the help section #watching; tickets.watch.*; tickets.settings.general.watch_*
|
tests/ticket-watchers.php |
π§ͺ | 33 checks: rules, who is told, the setting, the browser endpoint, the REST API, a watcher who is deactivated, merge (page 2) |
-- GH #277: analysts WATCHING a ticket - told about it as if it were theirs, through their
-- own preferences, for the events Tickets -> Settings -> General ticks. Put there by
-- hand (themselves, or a colleague with write access), never inferred from activity.
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
`id` INT NOT NULL AUTO_INCREMENT,
`ticket_id` INT NOT NULL,
`analyst_id` INT NOT NULL, -- who watches
`added_by_id` INT NULL, -- who put them there (themselves, a colleague; NULL = the API with no analyst)
`created_datetime` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_ticket_watchers` (`ticket_id`, `analyst_id`),
KEY `ix_ticket_watchers_analyst` (`analyst_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;| Column / key | Meaning |
|---|---|
analyst_id |
who is told |
added_by_id |
who put them there: themselves, or a colleague. Shown as added by β¦ in the panel and returned by the API |
created_datetime |
UTC. The list is ordered by it, oldest first |
UNIQUE (ticket_id, analyst_id) |
one watch per person per ticket. add() checks first, so watching twice is a quiet no-op, not a duplicate-key error |
KEY (analyst_id) |
the analyst lifecycle removes a leaver's rows by this column |
π΄ No foreign keys, as for task_collaborators. Every delete path removes the rows by hand (section 6).
Database Verify creates the table on an existing install from includes/db_verify_schema.php:
// GH #277: who watches a ticket (unique per ticket + analyst - db_verify_indexes.php).
'ticket_watchers' => [
'id' => 'INT NOT NULL AUTO_INCREMENT',
'ticket_id' => 'INT NOT NULL',
'analyst_id' => 'INT NOT NULL',
'added_by_id' => 'INT NULL',
'created_datetime' => 'DATETIME NOT NULL',
],The indexes come from includes/db_verify_indexes.php, generated by php scripts/gen_db_verify_indexes.php:
['ticket_watchers', 'uq_ticket_watchers', 'unique', '(`ticket_id`,`analyst_id`)'],
['ticket_watchers', 'ix_ticket_watchers_analyst', 'key', '(`analyst_id`)'],A comma-separated list of event names, for example ticket.reply_received,ticket.status_changed. No row means every event (see events() below). An empty string means none. No seed in db_verify.php: never saved is the default, and nobody watches anything until they choose to, so an upgrade changes nothing.
What happens to a deactivated analyst's watches: stop or remove (changelog #2802). No row means stop, and there is no seed either, so an upgrade changes nothing. Read by TicketWatchers::inactivePolicy() and only acted on by TicketWatchers::analystDeactivated() (section 3 and section 6).
includes/services/ticket_watchers.php follows the service rules: no superglobals, no transport, a typed ServiceError. The browser endpoint and the REST API are both thin adapters over it. The class comment states the rules:
/**
* The rules:
* - Anyone who can SEE a ticket can watch it - read-only Tickets access too:
* following a ticket changes nothing on it.
* - Putting someone ELSE on it, or taking them off, needs write access to
* Tickets; anyone can always take themselves off.
* - Only an analyst who can see the ticket can be put on it, and the router
* checks again at every notification (a ticket moved to another company, an
* analyst whose access changed): a watcher who cannot see it hears nothing.
* - The assignee may watch too (to keep following a ticket after handing it on);
* nobody is ever notified twice about one event.
*
* A deactivated analyst is never told anything, whatever the setting. Tickets -> Settings ->
* General -> "When a watcher is deactivated" decides what happens to their watches:
* - stop (the default): kept, but ignored - hidden from the ticket's watcher list and never
* notified - and back as they were if the analyst is made active again;
* - remove: deleted when the analyst is deactivated (analystDeactivated(), called from every
* place an analyst is switched off: analystDeactivate(), AnalystsService::update(),
* api/tickets/save_analyst.php). Anonymising always removes them (registry: personal).
*/class TicketWatchers
{
/** The ticket events a watcher can be told about - the ticket ones the owner gets, less "a new ticket" and "your team's queue". */
public const EVENTS = [
'ticket.reply_received', 'ticket.note_added', 'ticket.status_changed', 'ticket.priority_changed',
'ticket.assigned', 'sla.warning', 'sla.breached',
];ticket.created and ticket.team_assigned are left out on purpose. Nobody can watch a ticket before it exists, and a team's queue already has its own audience (every member of the team).
/** Is the table there yet (before Database Verify it is not)? Cached per request. */
public static function ready(PDO $conn): bool
{
static $ok = null;
if ($ok !== null) return $ok;
try { $conn->query("SELECT 1 FROM ticket_watchers LIMIT 0"); return $ok = true; }
catch (Throwable) { return $ok = false; }
}
/**
* The events watchers are told about on this install (Tickets -> Settings -> General).
* Never saved = all of them.
*/
public static function events(PDO $conn): array
{
try {
$s = $conn->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'ticket_watch_events'");
$s->execute();
$v = $s->fetchColumn();
} catch (Throwable) {
$v = false;
}
if ($v === false || $v === null) return self::EVENTS;
return array_values(array_intersect(self::EVENTS, array_map('trim', explode(',', (string)$v))));
}-
array_intersectwithEVENTS. A stored name that is not a watchable event is dropped, so a hand-edited row can't widen the audience to an event watchers were never meant to hear. -
No row vs empty string.
fetchColumn()returnsfalsefor no row, so never saved means all of them. A saved empty string (every box unticked) gives an empty list: watchers hear nothing.
public static function list(PDO $conn, int $ticketId): array
{
if (!self::ready($conn)) return [];
$st = $conn->prepare(
"SELECT w.analyst_id, a.full_name AS name, w.added_by_id, b.full_name AS added_by_name, w.created_datetime
FROM ticket_watchers w
JOIN analysts a ON a.id = w.analyst_id
LEFT JOIN analysts b ON b.id = w.added_by_id
WHERE w.ticket_id = ? AND a.is_active = 1
ORDER BY w.created_datetime ASC, w.id ASC");
$st->execute([$ticketId]);
return array_map(fn($r) => [
'analyst_id' => (int)$r['analyst_id'],
'name' => (string)$r['name'],
'added_by' => $r['added_by_id'] !== null ? (int)$r['added_by_id'] : null,
'added_by_name' => $r['added_by_name'] !== null ? (string)$r['added_by_name'] : null,
'added_at' => (string)$r['created_datetime'],
], $st->fetchAll(PDO::FETCH_ASSOC));
}list() does not check access itself. Its callers do: the browser endpoint calls canSee() first, and the REST API calls apiLoadTicket().
Active analysts only (a.is_active = 1, changelog #2802). Under the default stop setting a deactivated analyst's row is still in ticket_watchers; this join is what hides it from the Watch panel, the Watching Β· N count and GET /tickets/{id}/watchers. Reactivating the analyst brings the row straight back into the list, unchanged. A raw SELECT ... FROM ticket_watchers is therefore not the same as "who is watching" - always go through list() or audience().
public static function add(PDO $conn, ActorContext $ctx, int $ticketId, int $analystId, bool $canWrite): array
{
self::assertReady($conn);
self::assertActorSees($conn, $ctx, $ticketId);
if ($analystId !== $ctx->actorId && !$canWrite) {
throw new ServiceError('forbidden', 'read_only', 'Adding a colleague needs write access to Tickets. You can still watch it yourself.');
}
$a = $conn->prepare("SELECT is_active FROM analysts WHERE id = ?");
$a->execute([$analystId]);
$active = $a->fetchColumn();
if ($active === false || (int)$active !== 1) {
throw new ServiceError('validation', 'invalid_field', 'That analyst does not exist or is inactive.');
}
if (!self::canSee($conn, $analystId, $ticketId)) {
throw new ServiceError('validation', 'invalid_field', 'That analyst cannot see this ticket, so cannot watch it.');
}
$ex = $conn->prepare("SELECT 1 FROM ticket_watchers WHERE ticket_id = ? AND analyst_id = ?");
$ex->execute([$ticketId, $analystId]);
if ($ex->fetchColumn()) return ['added' => false];
$conn->prepare("INSERT INTO ticket_watchers (ticket_id, analyst_id, added_by_id, created_datetime) VALUES (?, ?, ?, UTC_TIMESTAMP())")
->execute([$ticketId, $analystId, $ctx->actorId > 0 ? $ctx->actorId : null]);
return ['added' => true];
}The checks run in this order, and the order matters:
| # | Check | Fails with |
|---|---|---|
| 1 | the table exists |
validation / not_ready: Run Database Verifyβ¦
|
| 2 | the actor can see the ticket, and it exists |
not_found: 404, never "not yours" |
| 3 | someone else needs write access |
forbidden / read_only
|
| 4 | the watcher exists and is active |
validation / invalid_field
|
| 5 | the watcher can see the ticket |
validation / invalid_field
|
| 6 | already watching | returns ['added' => false]
|
Check 2 comes before 3 so a ticket the actor can't see is a 404 whatever they asked for. $canWrite is passed in, not looked up, because the two callers answer it differently: the browser endpoint asks analystCanWriteModule(), and the REST API passes true because the key's own ticket_watchers.create permission is the write access there.
public static function remove(PDO $conn, ActorContext $ctx, int $ticketId, int $analystId, bool $canWrite): array
{
self::assertReady($conn);
self::assertActorSees($conn, $ctx, $ticketId);
if ($analystId !== $ctx->actorId && !$canWrite) {
throw new ServiceError('forbidden', 'read_only', 'Taking a colleague off needs write access to Tickets.');
}
$st = $conn->prepare("DELETE FROM ticket_watchers WHERE ticket_id = ? AND analyst_id = ?");
$st->execute([$ticketId, $analystId]);
return ['removed' => $st->rowCount() > 0];
}removed: false is not an error in the service. The REST API turns it into a 404; the browser endpoint just returns the list.
/**
* Can this analyst see this ticket - and does it exist?
*
* TRAP: analystCanAccessTicket() is a COMPANY check. On a single-company install
* it is true for any id at all, so on its own it let a watch be stored against a
* ticket that does not exist (found by tests/ticket-watchers.php).
*/
public static function canSee(PDO $conn, int $analystId, int $ticketId): bool
{
if ($ticketId <= 0 || $analystId <= 0) return false;
$s = $conn->prepare("SELECT 1 FROM tickets WHERE id = ?");
$s->execute([$ticketId]);
return (bool)$s->fetchColumn() && analystCanAccessTicket($conn, $analystId, $ticketId);
}
/** 404 for a ticket the actor cannot see - "not found", never "not yours". */
private static function assertActorSees(PDO $conn, ActorContext $ctx, int $ticketId): void
{
if (!self::canSee($conn, $ctx->actorId, $ticketId)) {
throw new ServiceError('not_found', 'not_found', 'Ticket not found.');
}
}Why: analystCanAccessTicket() in includes/tenancy.php starts with if (!isMultiTenant($conn)) return true;. It never reads the ticket on a single-company install. The test watched ticket 999999999 and it was stored. Forms on tickets reuses the same fix in TicketForms::guardWrite().
π΄ Any new code that asks "can this analyst see ticket N?" must also check the row exists.
analystCanAccessTicket()answers "may this analyst see that ticket's company?", nothing more.
Note what canSee() does not check: the ticket being in the trash, and the analyst's module access. A trashed ticket keeps its watchers (it can be restored), and module access to Tickets is checked by the endpoint (requireModuleAccessJson('tickets')) for the person making the request, not for a colleague being added.
/**
* Who of a ticket's watchers should hear about this event: none unless watchers
* are told about it here, and only those who are active and can still see the ticket.
* For the notifications router. Never throws.
*
* @return list<int>
*/
public static function audience(PDO $conn, string $event, int $ticketId): array
{
if ($ticketId <= 0 || !in_array($event, self::events($conn), true)) return [];
try {
$st = $conn->prepare("SELECT w.analyst_id FROM ticket_watchers w JOIN analysts a ON a.id = w.analyst_id WHERE w.ticket_id = ? AND a.is_active = 1");
$st->execute([$ticketId]);
$ids = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable) {
return []; // before Database Verify: nobody watches yet
}
return array_values(array_filter($ids, fn($id) => analystCanAccessTicket($conn, $id, $ticketId)));
}- Access is checked at every notification, not only when the watch was added. A ticket moved to another company, or an analyst whose company access was taken away, means that watcher hears nothing, without anyone having to tidy the list.
- Never throws. A notification must never break the action that caused it (the router's own rule). A missing table narrows the audience to the assignee, never empties it.
-
analystCanAccessTicket()alone is enough here: the event is about a ticket that exists. -
A deactivated analyst is never in the audience, whichever way
ticket_watch_inactiveis set: theJOIN analysts ... a.is_active = 1drops them before the access check. Before changelog #2802 this select readticket_watchersalone, so a deactivated watcher's row could still produce a notification for someone who could no longer sign in.
/**
* What happens to a deactivated analyst's watches: 'stop' (kept, ignored, back on
* reactivation) or 'remove'. Never saved = 'stop'.
*/
public static function inactivePolicy(PDO $conn): string
{
try {
$s = $conn->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'ticket_watch_inactive'");
$s->execute();
return $s->fetchColumn() === 'remove' ? 'remove' : 'stop';
} catch (Throwable) {
return 'stop';
}
}
/**
* An analyst has just been deactivated: with the 'remove' setting, take them off every
* ticket they watch. With 'stop' nothing is deleted - list() and audience() skip inactive
* analysts anyway. Never throws (it runs inside saving an analyst).
*
* @return int watches removed
*/
public static function analystDeactivated(PDO $conn, int $analystId): int
{
if ($analystId <= 0 || !self::ready($conn) || self::inactivePolicy($conn) !== 'remove') return 0;
try {
$st = $conn->prepare("DELETE FROM ticket_watchers WHERE analyst_id = ?");
$st->execute([$analystId]);
return $st->rowCount();
} catch (Throwable) {
return 0;
}
}-
Anything but the exact string
removeisstop. No row, an empty value or a typo all keep the watches - the safe direction, since stop can always be undone and remove cannot. - Never throws. It runs in the middle of saving an analyst; a missing table or a bad setting must not stop the analyst being deactivated.
-
Idempotent. Calling it for an analyst with no watches, or calling it twice, removes nothing more.
api/tickets/save_analyst.phprelies on this: it calls it on every save of an inactive analyst, not only on the change. - Not a sweep. It acts on the one analyst being deactivated. Switching the setting to remove does not touch analysts who were deactivated earlier; their rows stay (hidden, as under stop) until they are reactivated and deactivated again, or anonymised. The setting's help says "Removing applies from when this is saved".
Every event in the product passes through WorkflowEngine::dispatch(), which hands it to notificationsHandleEvent() in includes/notifications_router.php. See the notifications developer guide. Watching changed two places in that file and no dispatch call site.
At the end of notificationsAudienceFor(), after the task and team branches:
$one = notificationsRecipientFor($event, $payload);
$ids = $one > 0 ? [$one] : [];
// GH #277: the ticket's WATCHERS hear about it too, for the events Tickets ->
// Settings -> General ticks - each through their own preferences (applied by
// the caller, as for the assignee), and only while they can still see the
// ticket. array_unique: an assignee who also watches is told once.
if (in_array($event, TicketWatchers::EVENTS, true)) {
$ticketId = (int)($payload['ticket']['id'] ?? $payload['ticket_id'] ?? 0);
$ids = array_merge($ids, TicketWatchers::audience($conn, $event, $ticketId));
}
return array_values(array_unique(array_filter($ids)));The function's doc comment records why this is not a firehose:
* Tickets now have that deliberate list too: WATCHERS (GH #277, ticket_watchers),
* put there by hand and removable - never "who has touched it", the firehose that
* was rejected. They join the ticket events below that the install chose
* (Tickets -> Settings -> General), and a reassignment reaches them as
* 'ticket.reassigned', never as "assigned to you".In notificationsHandleEvent(), the loop over recipients:
// GH #277: "Assigned to you" is news for the new assignee alone. A watcher
// told about the same reassignment gets its own type, worded "reassigned",
// with its own switch in their preferences.
$newAssignee = $event === 'ticket.assigned' ? notificationsRecipientFor($event, $payload) : 0;
foreach ($recipients as $recipientId) {
$type = ($event === 'ticket.assigned' && $recipientId !== $newAssignee) ? 'ticket.reassigned' : $event;
// β¦ (Rule 1: never tell people about their own action)
if ($actorId > 0 && $actorId === $recipientId) {
continue;
}
// Each person's OWN toggles decide. β¦
if (!NotificationsService::typeEnabled($conn, $recipientId, $type)) {
continue;
}
NotificationsService::notify($conn, [
'analyst_id' => $recipientId,
'event_type' => $type,
// β¦
]);
}notificationsRecipientFor('ticket.assigned', β¦) is the new assignee ($payload['analyst_id']). Everyone else on the audience for that event is a watcher, so they get ticket.reassigned. A watcher who is also the new assignee gets ticket.assigned once.
ticket.reassigned is a notification type, not a workflow event. Nothing dispatches it; the router derives it. It is registered so it has a preference switch and a bell wording:
// includes/services/notifications.php - NotificationsService::types()
'ticket.assigned' => ['default' => true, 'entity' => 'ticket'],
// GH #277: a ticket you WATCH moved to someone else. Not "assigned to you" -
// that is news for the new assignee alone - and its own switch.
'ticket.reassigned' => ['default' => true, 'entity' => 'ticket'],// lang/en/common.php - common.notifications
'event' => [
'ticket' => [
'assigned' => 'Assigned to you by {actor}',
'reassigned' => '{actor} reassigned it', // GH #277: to a watcher
// β¦
'pref' => [
'ticket' => [
'assigned' => 'A ticket is assigned to me',
'reassigned' => 'A ticket I watch is reassigned',
'reply_received' => 'A requester replies to my ticket, or one I watch',
'note_added' => 'Someone adds a note to my ticket, or one I watch',The Preferences page (system/preferences/index.php) draws a switch for every key in types(), so the new switch appeared with no change there. The bell (assets/js/notification-bell.js) looks up 'common.notifications.event.' + n.event_type, so it picked up the wording the same way. The other ticket switches gained or one I watch, because they now reach watchers as well.
| Step | Where | Drops |
|---|---|---|
| 1 | audience() |
every watcher, unless the event is ticked in ticket_watch_events
|
| 1b | audience() |
a deactivated watcher (analysts.is_active = 0), whatever ticket_watch_inactive says |
| 2 | audience() |
a watcher who can no longer see the ticket's company |
| 3 | notificationsAudienceFor() |
duplicates (an assignee who also watches) |
| 4 | notificationsHandleEvent() |
the person who made the change |
| 5 | NotificationsService::typeEnabled() |
a watcher who has switched that type off in Preferences |
| 6 | NotificationsService::notify() |
anything in bulk mode, and the actor again (authoritative) |
tickets/settings/manifest.php lists the key on the General tab, which is what lets api/settings/save_system_settings.php write it, guarded by that tab's grant (Manage general ticket settings):
'setting_keys' => ['reopen_on_customer_reply', 'snooze_wake_hour', 'ticket_workspace_mode',
'ticket_watch_events', // GH #277: what watchers are told aboutThe ticks are drawn from TicketWatchers::EVENTS, so a new watchable event appears here by itself (it needs a watch_event_* string):
<!-- GH #277: what WATCHERS are told about. A tick per ticket event; all ticked
when never saved, and nobody watches anything until someone chooses to,
so an upgrade changes nothing. Each watcher's own notification settings
still apply on top. -->
<h3 style="margin-top: 34px;"><?php echo htmlspecialchars(t('tickets.settings.general.watch_heading')); ?></h3>
<div class="form-group" id="watchEventsGroup">
<?php foreach (TicketWatchers::EVENTS as $__ev): ?>
<label style="display:block;margin-top:6px;font-weight:400;">
<input type="checkbox" class="watch-event" value="<?php echo htmlspecialchars($__ev); ?>" checked>
<?php echo htmlspecialchars(t('tickets.settings.general.watch_event_' . str_replace('.', '_', $__ev))); ?>
</label>
<?php endforeach; ?>Loading and saving, in the General tab's existing load and save functions:
// load - GH #277: never saved = every event ticked (TicketWatchers::events()).
const watchEv = data.settings.ticket_watch_events;
if (watchEv !== null && watchEv !== undefined) {
const on = String(watchEv).split(',').map(s => s.trim());
document.querySelectorAll('#watchEventsGroup .watch-event').forEach(cb => { cb.checked = on.includes(cb.value); });
}
// save
ticket_watch_events: Array.from(document.querySelectorAll('#watchEventsGroup .watch-event:checked')).map(cb => cb.value).join(',')- Why a setting. Some desks want watchers told about everything; others only replies and status. Where opinions differ it becomes a setting, with today's behaviour (all events) as the default.
- Why per install, not per watcher. Each watcher already has their own switches in Preferences, which apply on top (step 5 above). The install setting decides what watching means; the person decides what reaches them.
- The
ticket.assignedtick is labelled The ticket is reassigned (watch_event_ticket_assigned), because that is what it means to a watcher.
The second setting under the same heading (changelog #2802), on the same General tab and the same grant:
'ticket_watch_events', // GH #277: what watchers are told about
'ticket_watch_inactive', // ...and what happens to a deactivated analyst's watchesA dropdown, not a typed value:
<!-- A deactivated analyst is never told anything; this decides whether their
watches wait for them (stop, the default) or go (remove). TicketWatchers. -->
<div class="form-group">
<label for="watchInactive"><?php echo htmlspecialchars(t('tickets.settings.general.watch_inactive_label')); ?></label>
<select id="watchInactive" style="max-width: 420px;">
<option value="stop"><?php echo htmlspecialchars(t('tickets.settings.general.watch_inactive_stop')); ?></option>
<option value="remove"><?php echo htmlspecialchars(t('tickets.settings.general.watch_inactive_remove')); ?></option>
</select>Loading and saving both fold anything unexpected to stop:
// load
document.getElementById('watchInactive').value = data.settings.ticket_watch_inactive === 'remove' ? 'remove' : 'stop';
// save
ticket_watch_inactive: document.getElementById('watchInactive').value === 'remove' ? 'remove' : 'stop',| Value | A deactivated analyst's watches | Told anything? | Reactivated |
|---|---|---|---|
stop (default, never saved) |
kept in ticket_watchers, hidden by list()
|
no - audience() skips them |
the watches are back, unchanged |
remove |
deleted by analystDeactivated() at the moment of deactivation |
no | they watch nothing; anyone can add them again |
- Why a setting. Some desks want a returning analyst (a long leave, a contractor back next quarter) to pick up where they left off; others want a leaver's name gone from every watch list. Today's code behaviour (keep the rows) is the default.
-
The strings:
tickets.settings.general.watch_inactive_label,watch_inactive_stop,watch_inactive_remove,watch_inactive_help, and the help page'stickets.help.watch.leaver.
| What happens to the ticket | What happens to its watchers | Where |
|---|---|---|
| Merged into another | move to the survivor, once each | MERGE_MOVE_DEDUPE |
| Unmerged | the moved rows go back | the merge record's movedRelated
|
| Split | stay on the original | nothing copies them |
| Moved to trash | kept (it can be restored) | nothing |
| Deleted for good, or trash emptied | removed | the three delete paths |
| An analyst deleted or anonymised | their watches removed | analyst_lifecycle.php |
| An analyst deactivated | stop (default): kept, hidden, never notified, back on reactivation. remove: deleted |
ticket_watch_inactive, TicketWatchers::analystDeactivated()
|
// includes/ticket_merge.php
const MERGE_MOVE_DEDUPE = [
'ticket_cmdb_objects' => 'cmdb_object_id',
'ticket_assets' => 'asset_id',
'problem_tickets' => 'problem_id',
'change_tickets' => 'change_id',
'ticket_watchers' => 'analyst_id', // GH #277: watchers follow the conversation, once each
'ticket_forms' => 'form_id', // GH #272: forms the work needs, once each (the survivor's own wins)
];The shared loop deletes the source's rows for anyone already watching the target, records what is left, then moves it:
foreach (MERGE_MOVE_DEDUPE as $table => $col) {
try {
// Note the ordering: the de-dupe DELETE runs first, so what is recorded
// afterwards is exactly the set that actually moves. Recording before the
// delete would promise an unmerge rows that no longer exist.
$conn->prepare(
"DELETE FROM `$table`
WHERE ticket_id = ?
AND `$col` IN (SELECT * FROM (SELECT `$col` FROM `$table` WHERE ticket_id = ?) x)"
)->execute([$sourceId, $targetId]);
$q = $conn->prepare("SELECT id FROM `$table` WHERE ticket_id = ?");
$q->execute([$sourceId]);
$rows = array_map('intval', $q->fetchAll(PDO::FETCH_COLUMN));
if ($rows) $movedRelated[$table] = $rows;
$conn->prepare("UPDATE `$table` SET ticket_id = ? WHERE ticket_id = ?")->execute([$targetId, $sourceId]);
} catch (Exception) { /* absent */ }
}Without the de-dupe, the UPDATE would hit uq_ticket_watchers for anyone watching both tickets. One consequence: someone who watched both tickets is not put back on the source by an unmerge, because their source row was deleted, not moved. They still watch the survivor. The file's comment warns that a wrong column name here is swallowed by the catch and the move silently doesn't happen, so analyst_id was checked against the schema. See Merging tickets.
There are no foreign keys, so each permanent delete path removes the rows by hand. Each tolerates 42S02 (table not found) so a delete still works before Database Verify.
// api/tickets/permanently_delete_ticket.php
// 6b. Watchers (GH #277) - no foreign key either. Tolerant: no table before Database Verify.
try { $conn->prepare("DELETE FROM ticket_watchers WHERE ticket_id = ?")->execute([$ticketId]); } catch (PDOException $e) { if ((string)$e->getCode() !== '42S02') throw $e; }// api/tickets/empty_trash.php
// Checklists and watchers have no foreign key to cascade. Checklists were left behind here
// (permanently_delete_ticket.php removed them; this did not); watchers are GH #277.
// A table that is not there yet (42S02) is nothing to remove.
foreach (["DELETE FROM ticket_checklist_items WHERE ticket_checklist_id IN (SELECT id FROM ticket_checklists WHERE ticket_id IN ($place))",
"DELETE FROM ticket_checklists WHERE ticket_id IN ($place)",
"DELETE FROM ticket_watchers WHERE ticket_id IN ($place)"] as $sql) {
try { $conn->prepare($sql)->execute($ids); } catch (PDOException $e) { if ((string)$e->getCode() !== '42S02') throw $e; }
}Adding the watchers line to empty_trash.php showed that it had never removed a ticket's checklists, which permanently_delete_ticket.php did. That was fixed in the same change (changelog #2786).
// api/system/debug-tools/D002_delete_ticket.php
['ticket_watchers', "DELETE FROM ticket_watchers WHERE ticket_id = ?", [$ticketId]], // GH #277TicketWatchers::deleteForTickets() exists for the same job and never throws, but the three paths above use inline SQL like their neighbours.
// includes/analyst_lifecycle.php - analystReferenceRegistry()
['ticket_watchers', 'analyst_id', 'personal'], // GH #277: anonymise removes them; deactivate follows the ticket_watch_inactive setting
// β¦
['ticket_watchers', 'added_by_id', 'history'],-
analyst_idis personal: a membership that is useless without the person. Removed when an analyst is deleted or anonymised, always, whatever the setting. -
added_by_idis history: who put someone there. Kept, and still credited.
Deactivation is not handled by the registry (the registry is for anonymise and delete). It follows ticket_watch_inactive. (This page used to say a deactivated analyst simply kept their rows, and the user guide said a leaver stopped watching everything; the code kept the rows, with nothing in list() or audience() looking at is_active. Changelog #2802 made the behaviour a setting, and made list() and audience() skip inactive analysts either way.)
There are three places an analyst is switched off, and each calls TicketWatchers::analystDeactivated(), which does nothing unless the setting is remove:
// includes/analyst_lifecycle.php - the leaving screen's Deactivate
function analystDeactivate(PDO $conn, ActorContext $ctx, int $analystId): void {
analystLcAssertActionable($conn, $ctx, $analystId);
$conn->prepare("UPDATE analysts SET is_active = 0, last_modified_datetime = UTC_TIMESTAMP() WHERE id = ?")->execute([$analystId]);
require_once __DIR__ . '/services/ticket_watchers.php';
TicketWatchers::analystDeactivated($conn, $analystId); // the 'remove' setting: off every ticket they watched
analystLcLog($conn, $ctx, $analystId, 'deactivated');
}// includes/services/analysts.php - AnalystsService::update(): System β Analysts and the REST API
if (array_key_exists('is_active', $in) && !self::flag($in['is_active'], 'is_active') && (int)$a['is_active'] === 1) {
require_once __DIR__ . '/ticket_watchers.php';
TicketWatchers::analystDeactivated($conn, $id); // the 'remove' setting (Tickets -> Settings -> General)
}// api/tickets/save_analyst.php - the older analyst editor
if (!$isActive) {
// Deactivated (or saved while inactive): the 'remove' setting takes them off every
// ticket they watch; 'stop' keeps the watches, ignored. Idempotent.
require_once __DIR__ . '/../../includes/services/ticket_watchers.php';
TicketWatchers::analystDeactivated($conn, $analystId);
}-
AnalystsService::update()only calls it on the change from active to inactive ($ais the row as it was before the update).save_analyst.phphas no "before" to hand, so it calls it on any save of an inactive analyst - harmless, because the call is idempotent. -
A new place that deactivates an analyst must call it too. Otherwise remove silently becomes stop for that path. The notifications are safe regardless (
audience()checksis_active); only the clean-up would be missed. -
Reactivation does nothing here. Under stop the rows were never deleted, so
list()andaudience()pick them up again on their own.
tests/analyst-lifecycle.php fails for any analyst column that isn't registered. See Analysts leaving.
Next: page 2 - the endpoint, the screens, the REST API, the traps and the tests.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- π PDF exports moved to PDFKit (#254)
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- β³ π Switching modules off
- β³ π οΈ Module availability β Developer Guide
- β³ π Module availability β design (#217)
- 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
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
-
π¦ Microsoft Entra ID
- β³ π₯ Importing people and groups
- β³ π» Intune: assets to their primary user
- β³ π’ Organisation chart
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π οΈ Entra import - review (#235)
- β³ π οΈ Entra follow-ups - review (#237-#240)
- β³ π οΈ Intune primary user - review (#236)
- β³ π οΈ Organisation chart - reviews (#242, #265)
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π οΈ Empty Procedures fold - review (#247)
- β³ π« 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: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π REST API: Chart of accounts
- β³ π REST API: Projects
- β³ π‘οΈ API threat protection
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Live updates
- β³ π οΈ Watching a ticket - Developer Guide (1 of 2)
- β³ π οΈ CSAT delay and reminder - Developer Guide
- β³ π οΈ Ticket checklist step types - Developer Guide (1 of 2)
- β³ π οΈ Right-click Attach checklist - Developer Guide
- β³ π οΈ Checklists in the REST API - Developer Guide
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π Forms on tickets
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ π Ticket dashboard β Developer Guide
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
-
β³ Basic IMAP mailboxes
- β³ π Basic IMAP without the PHP extension (#257)
- β³ π οΈ Basic IMAP client β Developer Guide
- β³ π§ͺ Basic IMAP client β Testing and verifying
- β³ π οΈ Basic IMAP - Sent copy - Developer Guide (#270)
- β³ π οΈ Verifying a mail folder β Developer Guide
- β³ π οΈ Mailbox providers β Developer Guide
- β³ π οΈ How Gmail mail collection works β Developer Guide
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
-
Tasks
- β³ π οΈ Task board and subtasks β Developer Guide
- β³ π§βπ€βπ§ People on a task ("Involved")
- β³ π Repeating tasks
- β³ π’ Moving a task between companies
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
-
β³ π·οΈ QR asset labels
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
-
CMDB
- β³ π οΈ CMDB β Developer Guide
- β³ π οΈ Impact analysis β Developer Guide
- β³ π οΈ Data quality β Developer Guide
- β³ π Linked assets
- β³ π Linking an asset to its CI - design (#201)
- β³ π Discovery and redundancy-aware impact - design (#199)
- β³ π οΈ Relationship wording - review (#297)
- Network Mapper
-
Workflows
- β³ π οΈ Workflow engine β Developer Guide
- β³ π οΈ Workflows for tasks β Developer Guide
- β³ π οΈ Workflow notes on a ticket β Developer Guide
- β³ π οΈ Workflow category routing β Developer Guide
- β³ β° Time-Based Triggers
- β³ πͺ Webhooks
- β³ π¬ Webhook Message Formats
- β³ π HTTPS Certificates & CA Bundles
- β³ π οΈ SSL Verification β the Global Setting
- β³ π Workflow & Webhook Pitfalls
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π§Ή Maintenance
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π Chart of accounts
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ One company supporting another
- β³ π’ One board across every company
- β³ 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)
- What this is
-
π Bugs resolved
- β³ π Small fixes in 5.1.0
- β³ π CMDB relationships read backwards (5.1.0)
- β³ π #301 CSAT delay was never applied
- β³ π #281 Comments imported from Jira never arrived
- β³ π #288 A change failed to open with Enforce approvals on
- β³ π Long non-Latin articles never got an embedding
- β³ π« #280 Email lookups scanned the whole table
- β³ β #266 Dragging a task told nobody
- β³ π #255 Article previews showed HTML codes
- β³ π #252 A link to a Knowledge article did not open
- β³ π #254 PDFs could not print Polish
- β³ π #250 Clicking a folder left the open article on screen
- β³ π« #260 The dashboard cog did nothing
- β³ π« #248 A department rule never matched on four routes
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π Mailbox sign-in was not checked
- β³ π Four weaknesses around signing in
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #214 A "contains" condition did not match capitals
- β³ βοΈ #216 A workflow email from a chosen mailbox always failed
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96