Ticket Categories Developer Guide - edmozley/freeitsm GitHub Wiki
How categories, the closure category and resolution codes are modelled, and the four rules the schema cannot enforce on its own. Shipped as #1540โ#1548 in 1.5.0.
The user-facing page is Ticket categories.
Colour key: ๐๏ธ schema ยท โ๏ธ shared ยท ๐ API ยท ๐ฅ๏ธ page ยท ๐จ CSS ยท ๐ i18n ยท ๐ docs
| ๐จ | File | What it does |
|---|---|---|
| ๐๏ธ | database/freeitsm.sql |
ticket_categories, ticket_resolution_codes, three columns on tickets
|
| ๐๏ธ | includes/db_verify_schema.php |
The same columns for an existing install. Must agree with the SQL โ dbVerifyColumnSelfCheck() red-cards drift |
| ๐๏ธ | api/system/db_verify.php |
The FK group, and the resolution-code seed (empty table only) |
| โ๏ธ | includes/ticket_categories.php |
THE shared reader. Paths, depth, roll-up, cycle detection, subtree depth |
| โ๏ธ | includes/tenant_settings.php |
The three on/off switches, per company over an install default |
| โ๏ธ | includes/services/tickets.php |
Create + update, the audit trail, and the type-change auto-clear |
| ๐ | api/tickets/get_ticket_classification.php |
Everything the feature needs in one call |
| ๐ |
api/tickets/save_ticket_category.php / delete_ticket_category.php
|
The category rules live here |
| ๐ |
api/tickets/save_ticket_resolution_code.php / delete_ticket_resolution_code.php
|
Flat list CRUD |
| ๐ | api/tickets/save_ticket_classification_settings.php |
The three switches |
| ๐ | api/tickets/get_ticket_widget_data.php |
Three new dashboard dimensions |
| ๐ | api/self-service/get_ticket_categories.php |
The portal's narrower list |
| ๐ฅ๏ธ | tickets/settings/index.php |
The Categories tab + two dialogs |
| ๐ฅ๏ธ | assets/js/inbox.js |
The three reading-pane fields |
| ๐ฅ๏ธ | self-service/new-ticket.php |
The portal picker |
| ๐จ | assets/css/inbox.css |
.tc-*, .settings-table-select
|
| ๐ |
lang/en/tickets.php, lang/en/self-service.php
|
in the same commit |
A ticket stores the LEAF category id and nothing else.
There is no parent_category_id on tickets and there must never be one. Every ancestor โ the path on screen, the root used for roll-up, the ticket type a category belongs to โ is derived from parent_id in includes/ticket_categories.php.
That is not tidiness. tickets already stores the assignee twice, in assigned_analyst_id and owner_id, and on a real installation 93 of 110 rows disagree with themselves. Two columns holding one fact end up holding two different facts.
`category_id` INT NULL, -- what it was REPORTED as
`closure_category_id` INT NULL, -- what it TURNED OUT to be (same tree)
`resolution_code_id` INT NULL, -- HOW it endedAll three are NULL on every existing ticket and nothing is backfilled. NULL means "never categorised", which readers render as Not categorised rather than guessing.
TICKET_CATEGORY_MAX_DEPTH. In code, not in the schema โ an unbounded tree is unreportable, because nobody reads a chart with two hundred leaves and the roll-up has no level to stop at.
ticketCategoryDepthUnder() answers "what level would a new child of X sit at". On a re-parent, ticketCategorySubtreeDepth() is also needed: the category itself may fit under its new parent while its own children fall off the end.
ticket_type_id is only meaningful where parent_id IS NULL. A child inherits its root's, and save_ticket_category.php refuses one on a child. If a sub-category could name its own type, "Hardware โ Printer" could be an Incident while "Hardware" was a Service request and neither answer would be wrong.
categoryEffectiveTypeId() in includes/services/tickets.php climbs to the root to answer it.
// โ ๏ธ THE CYCLE CHECK COMES FIRST, before depth.
if ($id && ticketCategoryWouldCycle($conn, $id, $parentId)) { โฆ }
$depth = ticketCategoryDepthUnder($conn, $parentId);Dropping a category under its own descendant is also too deep, so a depth check placed first catches it โ and then reports "Categories can go 3 levels deep" for what is actually a loop. Right refusal, wrong reason, and it sends whoever hits it to fix the wrong thing. Caught in testing; the order is load-bearing.
The unique key is (tenant_id, parent_id, name), but MySQL allows unlimited NULLs in a unique key โ two global roots with the same name both slip straight through it. The real rule is enforced in the save endpoint's clash query. The same split ticket_types has always lived with.
Every ticket dashboard widget is a COUNT(*) over a single LEFT JOIN (api/tickets/get_ticket_widget_data.php). Join a many-to-many map in and a ticket with three categories counts three times: the slices stop summing to the ticket count and every "X% of tickets were printing" on the page is wrong.
Cross-cutting labels are what tags are for (task_tags / task_tag_map is the existing pattern). Hard rule: tags filter and search, tags never reach the count-by charts.
COALESCE(croot.name, cmid.name, cleaf.name, 'Not categorised') AS label
LEFT JOIN ticket_categories cleaf ON cleaf.id = t.category_id
LEFT JOIN ticket_categories cmid ON cmid.id = cleaf.parent_id
LEFT JOIN ticket_categories croot ON croot.id = cmid.parent_idTwo self-joins, and COALESCE picks the highest one that exists โ so a level-1, level-2 and level-3 category all roll up to their top-level ancestor in one expression. Verified: three tickets at three different depths under one root all counted against that root, and the slices summed exactly to the ticket count.
โ ๏ธ The empty label is "Not categorised", not "Unknown". Unknown suggests the value is unreadable; these tickets simply predate the field. See the house rule on naming the case.
Changing a ticket's type can orphan its category. TicketsService::updateTicket() clears it rather than leaving "Incident / User onboarding" sitting on the ticket, wrong and invisible until a report is read.
Three details matter:
- An explicit category in the same request always wins โ the cleanup only runs for fields the caller did not set itself.
- A category with
effective_type_id === nullis offered whatever the type is, so it survives. - ๐ด The audit entry is forced through even when
$writeAuditis false.
foreach ($audits as $entry) {
[$field, $old, $new] = $entry;
if ($writeAudit || !empty($entry[3])) { โฆ } // [3] = forced
}The ticket UI audits client-side, so the service normally stays quiet for it. That works because the UI knows what it asked for โ but this clear is a change the server makes on its own. The client cannot log what it does not know happened, so without the force the field would empty itself with nothing in the trail to say why.
Reuses includes/tenant_settings.php โ per company, falling back to an install-wide default, falling back to the caller's. Three new keys, not a new mechanism. That file's own header says it was "built for ONE setting and expected to serve many".
Two deliberate differences from the time-tracking pair beside it:
- One switch each, not the UI/API pair. These are plain columns rather than a panel with its own endpoints, so a hidden field simply never gets set and the REST API returns null on its own. There is no endpoint to quietly empty out from under an integration.
- ๐ด All three default to OFF, where time tracking defaults to ON. An upgrade must not grow three empty dropdowns on everybody's ticket page before a single category exists.
The keys are declared as setting_keys on the Categories tab in tickets/settings/manifest.php and derived by settingKeyOwners() โ not listed in the explicit block in includes/settings_keys.php, which is documented as System-only. That way the tab showing a setting and the capability guarding it are one declaration that cannot disagree.
The real toggle classes are toggle-switch / toggle-slider. There is no .switch / .slider rule anywhere in FreeITSM โ the Time tracking tab had been shipping bare checkboxes because of it (fixed as #1547). Grep before assuming a class exists.
Delegated handlers on icon buttons need closest(), not classList. Swapping text buttons for inline <svg> broke every row action silently, because a click lands on the <svg> or its <path> and never on the button:
if (e.target.closest('.tc-edit-cat')) openCategory(cat); // not e.target.classList.containsapi/self-service/get_ticket_categories.php is deliberately narrower than the analyst endpoint in three ways: portal-visible only, active only (and a child whose ancestor is hidden goes with it), and untied categories only โ the portal form has no ticket type field, so a category tied to one has no context there.
๐ด The posted id is re-validated server-side in api/self-service/create_ticket.php against that same list. A narrowed dropdown has never been a check; without it a hand-posted id would let a requester file against an internal-only category.
ss_user_id, not analyst_id โ and the portal and the analyst app share one session, which is how an earlier feature leaked an admin's own data into the portal.
- 30 unit-style assertions over
includes/ticket_categories.php: paths, depth, inherited type, cycles, and thatactiveOnly/portalOnlydrop a child whose ancestor is filtered out (otherwise it surfaces at the top level wearing a path that no longer resolves). - Every guard exercised through the real endpoints: depth cap, type-on-child, cycle ordering, duplicate names, delete-with-children, and delete-when-used-only-as-a-closure-category.
- The roll-up proved to sum exactly to the ticket count.
- The reading pane driven in a real browser, confirming the fields stay hidden while the switches are off โ the shipped default.
- Ticket categories โ the user-facing page
- All companies ticket view โ Developer Guide โ why these lists become per-ticket on a consolidated board
- Multi-Tenancy โ Settings