Skip to content

Merging Tickets Developer Guide

Ed Mozley edited this page Jul 21, 2026 · 2 revisions

Merging Tickets β€” Developer Guide

How merging works, and the four decisions that shaped it. Shipped as #912.

The user-facing page is Merging tickets.


1. πŸ“ The files involved

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

2. πŸ”‘ The reference is the hard part

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 read ticket_statuses.is_closed rather than looking for the word "Closed".

The source is closed using the install's own first is_closed status (mergeFirstClosedStatusId()), for the same reason.


3. πŸ”‘ What moves and what stays

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.


4. πŸ” Access, atomicity, and the same-company rule

foreach ($all as $id) {
    $tickets[$id] = TicketsService::loadTicket($conn, $ctx, $id);   // 404s out of scope
    ...
}

Three things worth copying:

  1. 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.
  2. 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.
  3. 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.

⚠️ tickets.tenant_id NULL means the DEFAULT company, not "no company"

The first version of that check compared the raw values:

if ((string)$srcTenant !== (string)$targetTenant) { /* refuse */ }   // WRONG

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

Error messages in a multi-ticket action

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.


5. πŸ–₯️ Policy is a setting, not a decision in code

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


6. πŸ”’ The HTML snapshot β€” three layers, not one

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 onerror on the snapshot reports a hit β€” from the escaped ticket subject rendered as visible text. Load it into DOMDocument and look for live on* 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_attachments reached via emails.ticket_id, so mergeAttachSnapshot() 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.

7. πŸ€– The AI briefing

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.php commits 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_internal is 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.2 and an explicit "say it is unclear rather than guessing" instruction. An analyst will act on this.
  • No provider configured emits unconfigured, not error β€” merging is unaffected, so the UI treats it as "no summary", not "the merge failed".

8. βœ… How this was verified

  1. The upgrade path β€” ran db_verify on an install without them and confirmed "Added columns: merged_into_id", the table created, both indexes and all four FKs.
  2. Both reference modes end-to-end, checking row counts, the merge log, and that new mode folds in the nominated ticket too.
  3. 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 inside api/tickets/ so __DIR__ matched production.
  4. Guards β€” already-merged, self-merge, and nonexistent target all refused.
  5. Audit on both sides, reading Merged into X / Merged in Y.
  6. 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).
  7. Parse-checks in headless Chrome with a negative control for every new JS function.
  8. D005 β€” all five endpoints classified Module access: 'tickets'.

⚠️ Headless-Chrome trap, hit twice. --virtual-time-budget does not advance CSS transitions or requestAnimationFrame, so getComputedStyle(el).visibility on a just-opened modal reads hidden and looks like a bug. Inject .modal, .modal * { transition: none !important; } before measuring.


9. Extending it

  • A new table hanging off tickets β†’ add it to MERGE_MOVE_TABLES (or MERGE_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 an ActorContext, so an API key's scope is enforced the same way.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally