Skip to content

Splitting Tickets Developer Guide

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

Splitting Tickets β€” Developer Guide

How splitting works, and why it is deliberately not the mirror image of merging. Shipped as #914, with undo in #915.

The user-facing page is Splitting tickets.


1. πŸ“ The files involved

Colour key: πŸ—„οΈ schema Β· βš™οΈ engine Β· πŸ”Œ API Β· πŸ–₯️ UI Β· 🎨 CSS Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What it does
πŸ—„οΈ database/freeitsm.sql the ticket_splits table + its FKs
πŸ—„οΈ includes/db_verify_schema.php the same table in $schema
πŸ—„οΈ api/system/db_verify.php the three ticket_splits foreign keys
βš™οΈ includes/ticket_split.php the engine. splitMessagesFrom(), splitTicket(), undoSplit(), splitInfoFor()
βš™οΈ includes/services/tickets.php reused β€” loadTicket() for scope, deleteTicket() to trash an undone split
πŸ”Œ api/tickets/split_ticket_preview.php what a split would move
πŸ”Œ api/tickets/split_ticket.php does it; holds no policy of its own
πŸ”Œ api/tickets/undo_split.php reverses it
πŸ”Œ api/tickets/get_email_detail.php returns split_out / split_from for the banners
πŸ–₯️ tickets/index.php the split dialog
🎨 assets/js/inbox.js the per-message control, dialog, preview, banners, splitPlural()
🎨 assets/css/inbox.css .thread-split-btn, .split-preview-*
🌍 lang/en/tickets.php + lang/pt-BR/tickets.php the split block, same commit
πŸ“„ CHANGELOG.local.md, this wiki #914 / #915

Note what is absent: no capability, and no settings tab. Splitting cannot orphan a reference a customer holds, so there is no install-wide policy to decide β€” unlike merging, which needed both.


2. πŸ”‘ Why this is not the mirror of merging

Merge Split
The reference at risk one the customer already holds and may reply to one nobody has ever seen
Therefore needs merged_into_id + an inbound-mail redirect nothing β€” no pointer, no redirect
The other ticket ends up closed, a redirect live and independent
Linked as duplicate_of (they were the same issue) related (they are different things β€” the entire point)
Install policy three settings none

That asymmetry is why this is a separate engine rather than a flag on the merge one.


3. βš™οΈ Choosing what moves

splitMessagesFrom(PDO $conn, int $ticketId, int $fromEmailId, bool $includeNewer): array

Ordered by (received_datetime, id) β€” the id breaks ties so two messages that arrived in the same second still split deterministically rather than in whatever order the storage engine felt like returning them.

πŸ”‘ The preview endpoint and the split itself call this same function. A dialog that counted messages separately in JavaScript would eventually disagree with the server, and the moment it did an analyst would confirm a move they had not been shown.

The guard that matters

if (count($moving) >= $total) {
    throw new Exception('That would move every message and leave ' . $ref . ' empty…');
}

A ticket with no messages is a broken ticket: nothing renders, the requester's original request is gone, and the reference they hold points at an empty shell. Anyone who wants that wants a merge. The preview returns would_empty so the dialog disables Split rather than letting the analyst discover this after committing.


4. πŸ–₯️ Two UI decisions worth keeping

The control lives on the message, not the toolbar. A split starts from a message; a toolbar button would have to ask "which one?" first.

⚠️ The thread renders NEWEST-FIRST. "Everything after" in data terms means later in time, which appears above the chosen message on screen. Every user-facing string says "newer", never "after" β€” the wrong word points the analyst's eye the wrong way down the page. Keep that if you touch the copy.

Two things learned by getting them wrong:

  • The control was originally hover-only (opacity: 0 until .thread-meta:hover). Ed went looking for split, tried right-clicking, and never found it. A control nobody can find is not a subtle control, it is a missing feature. It is now always visible at opacity: 0.72, full on hover β€” and right-clicking the message header opens the dialog, because that is what people reach for. Deliberately not bound on the message body: analysts copy text out of messages constantly.
  • 1 message(s) reads as a bug. splitPlural(n, baseKey) picks <key>_one when the count is 1 and falls back if that key is missing. Use it for any new counted string.

5. ↩️ Undo (#915)

ticket_splits records moved_email_ids (a JSON array) and marker_email_id.

πŸ”‘ A count cannot be undone. message_count alone would leave the undo guessing which rows to send back, and guessing wrong scatters a conversation permanently.

Stored as text rather than a child table: the list is only ever read whole, never joined or filtered, and an FK to emails would CASCADE the record away the day one of those messages is deleted β€” precisely when you most want to know what happened.

undoSplit() returns the messages, deletes the marker, removes the related link, soft-deletes the emptied ticket through the service (its reference may have been quoted somewhere in the minutes it existed), and stamps undone_datetime β€” the row stays as history but is filtered out of the banners by splitInfoFor().

It refuses rather than guesses

An undo is only safe while the split is still the last thing that happened:

Refused when Because
newer messages on the new ticket that reply was written to a different ticket, about a different problem
notes or time entries on it they would be lost when it is trashed
the recorded messages moved on again this is no longer a reversal
already undone β€”
no recorded ids (pre-#915) nothing identifies which messages to return

Each refusal names what is in the way β€” "There is 1 newer message on X β€” undoing would drag it back". That is a sentence an analyst can act on; "could not undo" is not.

Contrast unmerge, which deliberately does not refuse on activity: a merge survivor is the live ticket and collects replies within the hour.


6. βœ… How this was verified

  1. Preview counts β€” 1 of 5, then 3 of 5 with "include newer".
  2. The empty-ticket guard, by splitting from the first message.
  3. A real split, checking the marker landed in the gap and the requester, company and department were inherited.
  4. The whole flow driven through the actual UI in headless Chrome β€” hover control, checkbox, custom subject, landing on the new ticket with its banner.
  5. Undo: full reversal (4 messages β†’ split 2 β†’ undo β†’ 4 again, marker gone, ticket trashed, link removed), undo-twice refused, and each of the three guards refused with its specific wording.
  6. Discoverability re-checked without any CSS override β€” the earlier screenshots had forced the control visible, which is exactly how the hover-only problem escaped.

7. Extending it

  • Arbitrary message selection (tick any three) would mean a checkbox per message and a different preview shape; the engine already takes a list, so the change is mostly UI.
  • Splitting notes is unbuilt β€” MERGE_MOVE_TABLES shows the shape it would take.
  • AI "suggest where to split" β€” detect where the conversation changes subject. The natural sibling of the merge briefing, and as far as we can tell no mainstream tool does it.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally