Skip to content

Ticket Categories Developer Guide

Ed Mozley edited this page Sep 10, 2026 · 1 revision

πŸ› οΈ Ticket categories β€” Developer Guide

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.


1. πŸ“ The files involved

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

2. 🌳 The one rule this exists to enforce

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 ended

All three are NULL on every existing ticket and nothing is backfilled. NULL means "never categorised", which readers render as Not categorised rather than guessing.


3. ⚠️ The four rules the schema cannot enforce

3.1 Depth, capped at 3

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.

3.2 The type link lives on the ROOT

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.

3.3 No cycles β€” and check for them FIRST

// ⚠️ 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.

3.4 Sibling-name uniqueness

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.


4. πŸ“Š ONE category, and the evidence

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.

The roll-up

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_id

Two 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.


5. πŸ” The type-change auto-clear

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:

  1. An explicit category in the same request always wins β€” the cleanup only runs for fields the caller did not set itself.
  2. A category with effective_type_id === null is offered whatever the type is, so it survives.
  3. πŸ”΄ The audit entry is forced through even when $writeAudit is 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.


6. 🎚️ The three switches

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.


7. 🎨 Two UI notes worth keeping

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.contains

8. πŸ™‹ The portal side

api/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.

⚠️ The portal session key is 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.


9. βœ… How it was verified

  • 30 unit-style assertions over includes/ticket_categories.php: paths, depth, inherited type, cycles, and that activeOnly/portalOnly drop 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.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally