-
Notifications
You must be signed in to change notification settings - Fork 15
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.
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.
| 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.
splitMessagesFrom(PDO $conn, int $ticketId, int $fromEmailId, bool $includeNewer): arrayOrdered 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.
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.
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.
Two things learned by getting them wrong:
-
The control was originally hover-only (
opacity: 0until.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 atopacity: 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>_onewhen the count is 1 and falls back if that key is missing. Use it for any new counted string.
ticket_splits records moved_email_ids (a JSON array) and marker_email_id.
π A count cannot be undone.
message_countalone 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().
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.
- Preview counts β 1 of 5, then 3 of 5 with "include newer".
- The empty-ticket guard, by splitting from the first message.
- A real split, checking the marker landed in the gap and the requester, company and department were inherited.
- The whole flow driven through the actual UI in headless Chrome β hover control, checkbox, custom subject, landing on the new ticket with its banner.
- 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.
- Discoverability re-checked without any CSS override β the earlier screenshots had forced the control visible, which is exactly how the hover-only problem escaped.
- 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_TABLESshows 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.
- Splitting tickets β the analyst-facing page
- Merging Tickets β Developer Guide β the sibling, and the contrast on undo
- Service Layer Architecture β why the service is the only write path
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)