-
Notifications
You must be signed in to change notification settings - Fork 15
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.
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.
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 CASCADEon 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
ticketsandanalystsinfreeitsm.sql, because it points at both. It sits immediately after theticketstable.
In
db_verify_schema.phpthe entry is columns + primary key only β the unique key and the foreign keys live infreeitsm.sql, and the index list is regenerated withphp scripts/gen_db_verify_indexes.php. See the Database Verification Developer Guide.
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.
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.
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.
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.
| 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.
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.
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.
-
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. - The unique key holding β twenty beats from two analysts left exactly two rows.
- 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.
- The purge β a row aged past 10 minutes was gone after one further heartbeat.
- 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.
-
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. - 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.
- 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-budgetmakes the page's timers race ahead of the wall clock, so interleaving externalcurlwith 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 clean0 β 1 β 0. Anything timing-based needs the same treatment.A smaller one:
frame.contentWindow.someVariableisundefinedfor a top-levellet, becauseletnever lands onwindow. Useframe.contentWindow.eval('someVariable')β this looked like two different bugs before it was recognised.
- 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 arecord_type/record_idpair 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.
- Collision detection β the analyst-facing page
- Snoozing tickets β Developer Guide β the same "let the clock do it" reasoning
-
Database Verification Developer Guide β
$schema, and regenerating the index list - Multi-Tenancy: worked examples β the scoping the guard relies on
- Roles and Permissions β the rung the endpoint sits on
- Theming & Dark Mode β the tokens the strip is built from
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)