Ticket Watchers Developer Guide - edmozley/freeitsm GitHub Wiki
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.