Skip to content

Collision Detection Developer Guide

Ed Mozley edited this page Jul 28, 2026 · 1 revision

Collision Detection β€” Developer Guide

How presence is built: one table, one endpoint, and a definition of "here" that needs nothing to clean up after it. Shipped as #934.

The analyst-facing page is Collision detection.

This is the first thing in FreeITSM that has to feel live, so it also sets the pattern for anything later that wants the same.


1. πŸ“ The files involved

Colour key: 🧠 shared rule Β· πŸ”Œ API Β· ⌨️ client Β· 🎨 CSS Β· πŸ—„οΈ schema Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What it does
🧠 includes/ticket_presence.php The whole rule. The schema gate, heartbeat upsert, the freshness query, leave, the opportunistic purge, and initials
πŸ”Œ api/tickets/ticket_presence.php The single endpoint: heartbeat and read in one request
⌨️ assets/js/inbox.js startPresence / stopPresence / beatPresence / leavePresence / setPresenceComposing, the strip renderer and the composer warning
🎨 assets/css/inbox.css .presence-strip, .presence-strip-composing, .presence-face, .composer-collision β€” all theme.css tokens
⌨️ tickets/index.php The #composerCollisionWarning slot in the compose modal
πŸ—„οΈ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php The ticket_presence table, its unique key and its index
🌍 lang/en/tickets.php, lang/pt-BR/tickets.php tickets.presence.*, EN and pt-BR in the same commit
πŸ“„ CHANGELOG.local.md, README.md, this wiki logged as #934

Note what is not there: no cron, no queue, no WebSocket, no third-party service.


2. πŸ—„οΈ The table

CREATE TABLE IF NOT EXISTS `ticket_presence` (
    `id`           INT NOT NULL AUTO_INCREMENT,
    `ticket_id`    INT NOT NULL,
    `analyst_id`   INT NOT NULL,
    `last_seen`    DATETIME NULL,
    `is_composing` TINYINT(1) NOT NULL DEFAULT 0,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_ticket_presence` (`ticket_id`, `analyst_id`),
    KEY `ix_ticket_presence_last_seen` (`last_seen`),
    CONSTRAINT `fk_ticket_presence_ticket` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`id`) ON DELETE CASCADE,
    CONSTRAINT `fk_ticket_presence_analyst` FOREIGN KEY (`analyst_id`) REFERENCES `analysts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Three decisions worth knowing:

  • UNIQUE (ticket_id, analyst_id) is what makes the heartbeat an upsert. Without it, a ticket left open for an afternoon would insert a row every ten seconds β€” 2,880 of them per person per day.
  • ON DELETE CASCADE on both parents. Everywhere else in this schema a cascade would be dangerous, because the child rows are history worth keeping (a merged-away ticket, an audit trail). Here the child rows are worthless the moment either parent goes, so a cascade is exactly right.
  • It must be created after tickets and analysts in freeitsm.sql, because it points at both. It sits immediately after the tickets table.

In db_verify_schema.php the entry is columns + primary key only β€” the unique key and the foreign keys live in freeitsm.sql, and the index list is regenerated with php scripts/gen_db_verify_indexes.php. See the Database Verification Developer Guide.


3. ⏱️ A heartbeat, not a session

Someone is "here" if their last_seen is within PRESENCE_STALE_SECONDS (30). That is the entire definition.

There is no join, no leave that has to succeed, no session to reconcile. The client beats every 10 seconds β€” three beats inside the window, so one slow request or one throttled tab doesn't make a colleague flicker out.

This is the same reasoning that made snooze wake on the clock rather than a cron, and it is what makes the hard part of presence disappear:

What happens How it resolves
Analyst closes the tab leave beacon (instant) β€” or the stale window if it never arrives
Browser crashes / laptop sleeps Stale window, ~30s
Network drops mid-session Stale window, ~30s
Server restarts Nothing to restore; rows are just data with timestamps
The leave request is lost Nothing breaks; it was only an optimisation

A design that tracked sessions would need every one of those to be handled, and the failure mode of getting one wrong is a ghost β€” a name sitting on a ticket claiming to be there, which is worse than no feature at all, because it trains people to ignore the indicator.

The purge rides on the heartbeat

Stale rows are already invisible, so binning them is housekeeping rather than correctness. presencePurge() deletes anything older than PRESENCE_PURGE_MINUTES (10) on the back of each heartbeat β€” one indexed DELETE, and no cron, because if nothing is beating, nothing is accumulating either.


4. πŸ”Œ One endpoint, both halves

api/tickets/ticket_presence.php takes {ticket_id, composing?, leave?} and returns everyone else who is live.

Heartbeat and read are the same request on purpose: every client that wants to know who else is here is, by definition, here itself. Splitting them would double the request rate of the busiest poll in the product for nothing.

The read excludes you (p.analyst_id <> ?), joins analysts for the display name, and filters a.is_active = 1 so somebody deactivated mid-session stops being announced. Results are ordered is_composing DESC, last_seen DESC, which is what puts the writers first in the strip.

Guarding it

requireModuleAccessJson('tickets');
…
if (!analystCanAccessTicket($conn, $analystId, $ticketId)) { http_response_code(403); … }

Presence leaks the names of colleagues against a ticket reference, so the company scope has to hold here exactly as it does on the ticket itself. It is not "just a status ping".

The one path with no per-ticket check is leave with no ticket_id β€” the browser going away entirely β€” which can only ever delete your own rows.


5. ⌨️ The client

Function When
startPresence(ticketId) from displayEmail() β€” opening a ticket announces you
stopPresence() from clearReadingPaneIfTicket() β€” the pane emptied
setPresenceComposing(bool) reply / forward / note modal open and close
leavePresence(ticketId) pagehide, and visibilitychange β†’ hidden

startPresence() is idempotent for the same ticket and, when you move to a different one, leaves the old ticket first β€” so a colleague never sees you lingering somewhere you've left.

Three details that are easy to get wrong:

Hidden tabs stop beating. A ticket left open in a background tab all afternoon would otherwise announce someone who walked away hours ago. beatPresence() returns early when document.hidden, so they go stale naturally; the visibilitychange handler brings them straight back.

A late response must not paint. The reply to a beat can arrive after the analyst has moved to another ticket, which would put the previous ticket's faces on the current one:

if (!data.success || presenceTicketId !== id) return;

Composing beats immediately. setPresenceComposing() fires a beat rather than waiting for the next tick β€” "is writing a reply" is only useful while they're still writing it.

Rendering

The strip is an empty <div id="presenceStrip" hidden> in the rendered ticket, filled by the poll β€” so a re-render never flashes a stale set of faces. Names and initials both go through escapeHtml(): an analyst's display name can come from a directory (LDAP/SSO) and is not automatically safe.

Two visual states, deliberately different weights: viewing is the neutral --surface-2 treatment, composing uses the --warning-* trio that the merged-away banner uses. Someone reading is information; someone writing is the moment your work becomes duplicated effort.


6. 🚫 Why it warns and never blocks

A lock is the obvious idea and the wrong one. Two analysts legitimately do sometimes both write on one ticket β€” a holding reply while someone else escalates β€” so a lock would be wrong most of the times it fired. It would also need an owner, a timeout, a way to steal it from someone at lunch, and a way to explain a greyed-out button.

The rule to keep when extending this: presence informs the analyst; it never gates an action. If you find yourself adding if (someoneElseIsComposing) return;, that's a different feature and it needs a conversation first.

Related, and equally deliberate: this is not an audit trail. Rows are overwritten by the next heartbeat and deleted freely, so "who looked at this ticket last Tuesday" cannot be answered from this table β€” which is the honest position, because a who-read-what log is a surveillance feature with its own privacy questions and should never arrive as a side effect of a convenience one.


7. βœ… How this was verified

  1. A real two-analyst collision over the API β€” two forged sessions, one ticket: A sees nobody, B arrives and sees A, A's next beat sees B, B opens a composer and A sees composing: true.
  2. The unique key holding β€” twenty beats from two analysts left exactly two rows.
  3. Staleness with nothing running β€” a row aged 31 seconds by hand stopped being reported on the very next read, and remained in the table, proving invisibility comes from the time comparison and not from a cleanup.
  4. The purge β€” a row aged past 10 minutes was gone after one further heartbeat.
  5. The company boundary with a positive control β€” an analyst restricted to one company saw a colleague on a ticket in their company (control), and got a 403 on a ticket in another company where that same colleague was demonstrably present. A "denied" without the control proves only that something is broken.
  6. 28 client assertions in headless Chrome over the real inbox.js: the strip hidden when empty, one viewer, one composer, the mixed case, the composer warning appearing and clearing β€” and hostile display names (<img src=x onerror=…>) producing no nodes.
  7. The full round trip in the browser, plus a negative control screenshot with nobody else present, so the strip's absence is evidence rather than assumption.
  8. D005 confirming the endpoint lands on Module access: tickets with no new findings, and an EN ↔ pt-BR key-parity check.

⚠️ The trap that cost the most time. --virtual-time-budget makes the page's timers race ahead of the wall clock, so interleaving external curl with a virtual-time browser proves nothing β€” the composer had opened and closed before the first external poll ran, which looked exactly like the composing flag never reaching the server. The fix is to observe in page time: a temporary endpoint returning the raw row, fetched by the harness between its own actions, which gave a clean 0 β†’ 1 β†’ 0. Anything timing-based needs the same treatment.

A smaller one: frame.contentWindow.someVariable is undefined for a top-level let, because let never lands on window. Use frame.contentWindow.eval('someVariable') β€” this looked like two different bugs before it was recognised.


8. Extending it

  • Presence on the ticket list (a marker on rows others are viewing) β€” needs a batch read for the visible ids, not one request per row.
  • Presence in other modules β€” the table is ticket_-specific by name. Generalising means a record_type/record_id pair and a decision about where the access check lives, since each module scopes differently.
  • Something more live than polling β€” the client is deliberately a plain interval. If a future feature needs true push, this is the natural first customer, and the heartbeat model survives the change: "here" would still mean a recent timestamp.
  • A per-desk off switch β€” no setting today (see the user page). It would be an install setting, not per-analyst: a colleague who opted out would be invisible to everyone else, which quietly makes the feature untrustworthy for the whole desk.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally