-
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 and individual-message selection in #926.
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(), 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.
| 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.
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): arrayBoth 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=1returns 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.
$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.
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.
-
#926 (individual selection):
php -lon every changed PHP file, andinbox.jscompiled 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 threwCannot set properties of nullβ the browser was running the cachedinbox.js?v=71; the fix was bumping the cache-buster, not the code. Bump?v=NNwhenever inbox.js changes.
-
Arbitrary message selectionβ done in #926. The checklist posts explicit ids tosplitMessagesByIds(); 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, mirroringmoved_email_idsβfreeitsm.sqlanddb_verify), the mover re-pointingticket_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_relatedin 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.
- 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)