Skip to content

Splitting Tickets Developer Guide

Ed Mozley edited this page Jul 23, 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 and individual-message selection in #926.

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(), splitMessagesByIds() (#926), splitMarkerEmailIds() / splitMovableCount() (#926), 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; ?list_all=1 returns the whole movable set for the checklist (#926)
πŸ”Œ api/tickets/split_ticket.php does it; accepts email_ids (#926) or the legacy anchor; 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 + the inbox.js?v=NN cache-buster β€” bump it whenever you touch inbox.js
🎨 assets/js/inbox.js the per-message control, the checklist dialog + selection helpers (#926), banners, splitPlural()
🎨 assets/css/inbox.css .thread-split-btn, .split-preview-*, .split-pick-* (#926)
🌍 lang/en/tickets.php + lang/pt-BR/tickets.php the split block, same commit
πŸ“„ CHANGELOG.local.md, this wiki #914 / #915 / #926

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

Two ways to name the messages. The checklist dialog sends an explicit list of ticked ids (#926); the anchor form stays for older clients and for the "This and newer" helper.

// explicit β€” what the checklist posts (#926)
splitMessagesByIds(PDO $conn, int $ticketId, array $emailIds): array
// legacy anchor β€” "this message, optionally everything newer"
splitMessagesFrom(PDO $conn, int $ticketId, int $fromEmailId, bool $includeNewer): array

Both return rows oldest-first, ordered by (received_datetime, id) β€” the id breaks ties so two messages that arrived in the same second split deterministically. Oldest-first is also what makes the marker land in the gap and the subject default to the first moved message, whichever path built the list. splitTicket() takes an optional $emailIds and chooses:

$moving = ($emailIds !== null)
    ? splitMessagesByIds($conn, $ticketId, $emailIds)
    : splitMessagesFrom($conn, $ticketId, $fromEmailId, $includeNewer);

πŸ”‘ The checklist is fetched from the server and the split accepts exactly those ids. split_ticket_preview.php?list_all=1 returns the movable set; the dialog ticks a subset and posts it back. splitMessagesByIds() refuses rather than silently drops β€” a marker id, an id from another ticket, or one that has since moved away throws, because a split that quietly moved fewer messages than were ticked would be its own small betrayal.

The guard that matters β€” now marker-aware (#926)

$total = splitMovableCount($conn, $ticketId);   // excludes this ticket's split markers
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 subtlety #926 fixed: a split marker ("3 messages moved to ABC") is a real emails row, so a naΓ―ve COUNT(*) counted it as content β€” and a ticket left holding only a marker is still empty. splitMarkerEmailIds() lists this ticket's markers (via ticket_splits.marker_email_id); both splitMovableCount() and the list_all checklist exclude them, so a marker is neither offered to move nor counted towards emptiness. That closed a latent hole on the anchor path too. The client mirrors the guard (disabling Split when every listed message is ticked) so the analyst learns before clicking, not after.


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. #926 (individual selection): php -l on every changed PHP file, and inbox.js compiled in headless Chrome with a passing negative control (a deliberately broken file that did report an error, proving the check was real). ⚠️ First live attempt threw Cannot set properties of null β€” the browser was running the cached inbox.js?v=71; the fix was bumping the cache-buster, not the code. Bump ?v=NN whenever inbox.js changes.

7. Extending it

  • Arbitrary message selection β€” done in #926. The checklist posts explicit ids to splitMessagesByIds(); the anchor path stays for older clients.
  • Splitting notes is still unbuilt, and is the obvious next step. It needs a schema change (ticket_splits.moved_note_ids, mirroring moved_email_ids β†’ freeitsm.sql and db_verify), the mover re-pointing ticket_notes.ticket_id, and β€” the sharp edge β€” undoSplit() learning to tell a moved note from a newly-added one, since today any note on the new ticket blocks undo. MERGE_MOVE_TABLES / moved_related in the merge guide shows the shape. The #926 checklist is where tickable notes would slot in.
  • 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