Skip to content

Ticket Watchers Developer Guide

Ed Mozley edited this page Nov 11, 2026 · 2 revisions

πŸ› οΈ Watching a ticket - Developer Guide (1 of 2): the data and the rules

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 TicketWatchers service and its rules, who is told (the notifications router and ticket.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.


1. The files

πŸ—„οΈ 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)

2. The schema

ticket_watchers - one row per analyst watching a ticket

-- 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`)'],

The setting: system_settings.ticket_watch_events

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.

The setting: system_settings.ticket_watch_inactive

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).


3. The service - TicketWatchers

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).
 */

The events a watcher can hear about

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).

ready() and events()

    /** 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_intersect with EVENTS. 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() returns false for no row, so never saved means all of them. A saved empty string (every box unticked) gives an empty list: watchers hear nothing.

list() - who watches, oldest first

    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().

add() - idempotent

    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.

remove()

    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.

canSee() - the TRAP

    /**
     * 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.

audience() - for the router

    /**
     * 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_inactive is set: the JOIN analysts ... a.is_active = 1 drops them before the access check. Before changelog #2802 this select read ticket_watchers alone, so a deactivated watcher's row could still produce a notification for someone who could no longer sign in.

inactivePolicy() and analystDeactivated() - a watcher who is deactivated

    /**
     * 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 remove is stop. 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.php relies 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".

4. Who is told - the notifications router

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.

Watchers join the audience

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".

"Reassigned", not "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.

The whole filter, in order

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)

5. The setting - Tickets β†’ Settings β†’ General β†’ Watching tickets

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 about

The 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.assigned tick is labelled The ticket is reassigned (watch_event_ticket_assigned), because that is what it means to a watcher.

When a watcher is deactivated - ticket_watch_inactive

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 watches

A 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's tickets.help.watch.leaver.

6. Merge, split, trash, delete and the analyst lifecycle

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()

Merge

// 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.

Delete

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 #277

TicketWatchers::deleteForTickets() exists for the same job and never throws, but the three paths above use inline SQL like their neighbours.

The analyst lifecycle

// 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_id is personal: a membership that is useless without the person. Removed when an analyst is deleted or anonymised, always, whatever the setting.
  • added_by_id is 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 ($a is the row as it was before the update). save_analyst.php has 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() checks is_active); only the clean-up would be missed.
  • Reactivation does nothing here. Under stop the rows were never deleted, so list() and audience() 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

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally