-
Notifications
You must be signed in to change notification settings - Fork 15
Merging Tickets Developer Guide
How merging works, and the four decisions that shaped it. Shipped as #912.
The user-facing page is Merging tickets.
Colour key: ποΈ schema Β· βοΈ engine Β· π API Β· π€ AI Β· π¬ mail Β· π₯οΈ UI Β· π¨ CSS Β· π permissions Β· π i18n Β· π docs
| π¨ | File | What it does |
|---|---|---|
| ποΈ | database/freeitsm.sql |
tickets.merged_into_id (self-FK, ON DELETE SET NULL) + the ticket_merges table |
| ποΈ | includes/db_verify_schema.php |
both, in $schema, so a grown install gains them on verification |
| ποΈ | api/system/db_verify.php |
the three ticket_merges FKs + the tickets self-FK β $schema never creates foreign keys |
| ποΈ | includes/db_verify_indexes.php |
generated β php scripts/gen_db_verify_indexes.php
|
| βοΈ | includes/ticket_merge.php |
the engine. Settings, redirect resolution, the merge itself, the HTML snapshot |
| βοΈ | includes/services/tickets.php |
loadTicket() went private β public so the engine reuses its scope rule |
| βοΈ | includes/html_sanitise.php |
reused β sanitiseUserHtml() cleans every body that goes into a snapshot |
| π | api/tickets/merge_tickets.php |
thin adapter; holds the 20-source cap |
| π | api/tickets/get_merge_settings.php |
policy read for the dialog (module access, no capability) |
| π€ | api/tickets/ai_merge_summary.php |
SSE stream of the briefing; reuses the tickets_reply_cleanup AI block |
| π€ | api/tickets/save_merge_summary.php |
commits the briefing as an internal note |
| π¬ | api/tickets/check_mailbox_email.php |
findTicketByNumber() now follows merges β the reason the whole design holds
|
| π | api/tickets/get_email_detail.php |
returns merged_away + merged_in for the banners |
| π | includes/capabilities.php |
Cap::TICKETS_MERGE |
| π | tickets/settings/manifest.php |
the merge-behaviour tab + its three setting_keys
|
| π₯οΈ | tickets/settings/index.php |
the Merge behaviour tab and its load/save JS |
| π₯οΈ | tickets/index.php |
the merge dialog + the context-menu item |
| π¨ | assets/js/inbox.js |
dialog, streaming consumer, banners |
| π¨ | assets/css/inbox.css |
.merge-banner-*, .merge-candidate*, .merge-ai-badge
|
| π |
lang/en/tickets.php + lang/pt-BR/tickets.php
|
the merge block, same commit
|
| π |
CHANGELOG.local.md, README.md, this wiki |
logged as #912 |
Start here, because every other decision follows from it.
Every notification FreeITSM has ever sent carries [SDREF:ABC-452-98881] in its subject, and check_mailbox_email.php routes inbound replies by matching it. Those emails live in customers' inboxes for years.
So a merged-away ticket can never cease to exist. It keeps its row, keeps its number, and gains a pointer:
tickets.merged_into_id INT NULL -- NULL = live. Set = this ticket was folded away.and the mail importer follows it:
// findTicketByNumber() β the single point every inbound reply passes through
$liveId = resolveMergedTicket($conn, (int)$result['id']);resolveMergedTicket() walks the chain (AβBβC all resolve to C) and is bounded to 10 hops, because a cycle should be impossible but a database is not a proof β and an unbounded loop here would hang a mailbox poller.
π
merged_into_id, never a "Merged" status. Statuses are user-configurable: an install can rename them or add its own. A rule keyed on a status name stops working silently the day somebody edits a list in settings. This is the same reasoning that makes reopen-on-reply readticket_statuses.is_closedrather than looking for the word "Closed".
The source is closed using the install's own first is_closed status (mergeFirstClosedStatusId()), for the same reason.
Fourteen tables hang off a ticket. The split is not arbitrary β the test is:
Does this row describe the conversation, or does it describe what happened to this ticket?
The first kind moves. The second kind would be falsified by moving.
| Moves | Stays |
|---|---|
emails (attachments follow via email_id) |
ticket_audit β the source's own history |
ticket_notes |
sla_notifications_sent β what the SLA engine did to that ticket |
ticket_time_entries |
ticket_csat_responses β a survey answered about that ticket |
ticket_recordings |
mailbox_activity_log β an append-only log |
tasks, form_submissions, webchat_conversations
|
|
ticket_cmdb_objects, problem_tickets, change_tickets (deduped on move)
|
ticket_links is a special case: rows are re-pointed at the target, then self-links and the duplicate pairs the re-point just created are deleted. The merge also writes a duplicate_of link so the Links section and the merge banner tell the same story.
The lists live in MERGE_MOVE_TABLES and MERGE_MOVE_DEDUPE at the top of the engine. Add to them when you add a table that hangs off a ticket β an orphaned table is invisible until somebody merges and their data appears to vanish.
foreach ($all as $id) {
$tickets[$id] = TicketsService::loadTicket($conn, $ctx, $id); // 404s out of scope
...
}Three things worth copying:
- Every ticket is checked BEFORE a single row moves. A merge is a multi-ticket write; a half-done one is far worse than a refused one.
-
loadTicket()was made public rather than reimplemented. A second copy of "may this actor see this ticket" is exactly the duplication that fails silently β nothing breaks when copies drift, one of them just quietly shows more. - The whole merge runs in one transaction, rolled back on any exception.
The same-company check is explicit and applies to every actor:
if ((string)$srcTenant !== (string)$targetTenant) {
throw new Exception('Tickets belong to different companies and cannot be merged');
}π A same-company invariant must bind all-access actors too. An administrator with every company in scope passes
loadTicket()for both tickets β so without this line they could fold one client's conversation into another client's ticket. See Multi-Tenancy Isolation.
The first version of that check compared the raw values:
if ((string)$srcTenant !== (string)$targetTenant) { /* refuse */ } // WRONGwhich rejected an ordinary merge between a tenant_id = NULL ticket and a tenant_id = 1 one as "different companies" β even though on a Default-company install those are the same company. On any instance that predates multi-tenancy, older tickets are NULL and newer ones carry the Default id, so this refused a large share of real merges. Found by Ed the first time he tried it on live data.
Both sides must be normalised through getDefaultTenantId() before comparison:
$normaliseTenant = fn($raw) => ($raw === null || $raw === '') ? getDefaultTenantId($conn) : (int)$raw;This is the scoped-data meaning of NULL. Config lists (ticket_types and friends) use the opposite convention, where NULL means "a global default shared by every company". Always establish which shape a table is before writing a comparison against tenant_id β see Multi-Tenancy Concepts.
TicketsService::loadTicket() throws a bare "Ticket not found.", which in a merge tells the analyst nothing about which of five tickets failed β and it means two different things (no such id, or out of your scope). The engine catches and re-throws with the id attached, and every other refusal names the ticket reference. A toast from a failed merge should be diagnosable on its own.
mergeSettings() reads three system_settings keys, registered to Cap::TICKETS_MERGE through tickets/settings/manifest.php (an unregistered key's write is refused):
| Key | Values | Default |
|---|---|---|
merge_reference_mode |
survivor | new
|
survivor |
merge_originals_mode |
thread | thread_html | html
|
thread |
merge_ai_summary |
1 | 0
|
1 |
Install-wide, not per-analyst β unlike the multi-select pane preference. Whether a requester's reference survives is a promise to the customer; two analysts on one mailbox must not be able to disagree about it.
β οΈ The dialog's defaults must matchmergeSettings(). A screen that defaulted differently from the engine would show the wrong answer on a fresh install where the rows don't exist yet.
get_merge_settings.php exists so the dialog can explain what will happen without depending on the settings-level get_system_settings.php read, which an ordinary analyst may not be entitled to.
In new mode, mergeCreateTargetTicket() inserts the ticket directly rather than calling TicketsService::createTicket() β the service would also raise an initial email row and fire workflow triggers, neither of which belongs to a merge. The nominated ticket donates its properties and is then folded in like any other source.
A snapshot is a self-contained HTML file of a source ticket's conversation, written when originals_mode is thread_html or html, and built before anything moves (once the messages are on the target there is no source conversation left to render).
It is safe because of three independent things, and the first matters most:
| Layer | Mechanism |
|---|---|
| It never renders on the app's origin |
get_attachment.php serves Content-Disposition: attachment for everything except image/audio/video/PDF. The browser downloads it; it opens later as a file:// origin with no cookies and no access to FreeITSM |
| No MIME confusion | X-Content-Type-Options: nosniff |
| The content is clean anyway | Every body goes through sanitiseUserHtml() β the customer allow-list. <script>, <style>, <iframe>, <img>, every on* handler and javascript: URLs are removed; paragraphs, lists, tables and links survive |
π Verify a sanitiser by PARSING, not grepping. A grep for
onerroron the snapshot reports a hit β from the escaped ticket subject rendered as visible text. Load it intoDOMDocumentand look for liveon*attributes and dangerous tags instead. That check found zero, and the grep was simply wrong.
Two more things to know:
-
<img>is stripped, deliberately. Permitting remote images would let a two-year-old snapshot beacon the reader's IP and open-time to a third-party server. -
A ticket has no standalone attachment table. Attachments are always
email_attachmentsreached viaemails.ticket_id, somergeAttachSnapshot()inserts a system-generated message carrying the file. That puts the snapshot in the conversation and in the Attachments list from one insert, downloadable through the existing endpoint.
ai_merge_summary.php streams SSE, reusing rfpAiCallAnthropicStreaming() and the tickets_reply_cleanup AI settings β one AI key for the Tickets module rather than a fourth provider block to configure.
Design points that are not incidental:
-
β οΈ Only Anthropic streams token-by-token. OpenAI and OpenRouter go through the shared one-shot client (aiProviderChat()), whose whole answer is emitted as a single SSE chunk at the end. On those providers the textarea sits empty for the entire call β about 30 seconds on a long thread, which reads as "it has crashed" and gets closed. So the liveness signal must never depend on tokens arriving: a spinner and an elapsed-second counter start with the request and run regardless of provider, and the copy says the call can take up to a minute. Streamed tokens are a bonus on top. (Shipped without this and Ed hit it immediately on OpenRouter.) -
Two endpoints, not one. The stream produces text;
save_merge_summary.phpcommits it. Close the tab halfway and nothing is written β a half-finished briefing saved into a ticket is worse than none, because the next reader takes it as complete. -
is_internalis hardcoded in the save endpoint, not taken from the request, so no future caller can make it customer-visible by accident. -
Labelled in the note TEXT, not only in the UI:
[AI summary of merged tickets]is prefixed to the body, so an export, the audit trail or a future portal reader all inherit the disclosure. -
Bodies are
strip_tags()-ed before they reach the model β markup is noise that costs tokens, and it means no stranger's HTML is echoed anywhere. -
temperature: 0.2and an explicit "say it is unclear rather than guessing" instruction. An analyst will act on this. -
No provider configured emits
unconfigured, noterrorβ merging is unaffected, so the UI treats it as "no summary", not "the merge failed".
-
The upgrade path β ran
db_verifyon an install without them and confirmed "Added columns: merged_into_id", the table created, both indexes and all four FKs. -
Both reference modes end-to-end, checking row counts, the merge log, and that
newmode folds in the nominated ticket too. -
The redirect β a subject quoting a merged-away ref resolved to the survivor; a live ref unchanged; an unknown ref still
NULL. Run from a probe placed insideapi/tickets/so__DIR__matched production. - Guards β already-merged, self-merge, and nonexistent target all refused.
-
Audit on both sides, reading
Merged into X/Merged in Y. -
Snapshot safety β hostile body, then a DOM parse of the file as delivered by
get_attachment.php, plus the response headers, plus an unauthenticated fetch (401). - Parse-checks in headless Chrome with a negative control for every new JS function.
-
D005 β all five endpoints classified
Module access: 'tickets'.
β οΈ Headless-Chrome trap, hit twice.--virtual-time-budgetdoes not advance CSS transitions orrequestAnimationFrame, sogetComputedStyle(el).visibilityon a just-opened modal readshiddenand looks like a bug. Inject.modal, .modal * { transition: none !important; }before measuring.
-
A new table hanging off tickets β add it to
MERGE_MOVE_TABLES(orMERGE_MOVE_DEDUPE) and decide, using the Β§3 test, whether it describes the conversation or the ticket. - Unmerge is not built. It would need the merge log to record which rows moved, which it currently does not β today it records the relationship, not an inventory.
- Split shares almost none of this: it mints a genuinely new reference (correctly β that ticket never existed), moves a chosen subset of messages out, and needs no redirect because nothing is folded away.
-
A merge action in the REST API would call
mergeTickets()directly; the engine takes anActorContext, so an API key's scope is enforced the same way.
- Merging tickets β the analyst-facing page
- Bulk Actions β Developer Guide β the multi-select this is reached from
- Service Layer Architecture β why the service is the only write path
- Multi-Tenancy Isolation β the same-company invariant
-
Database Verification β Developer Guide β
$schema, FKs, generated indexes
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
- β³ π 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)