-
-
Notifications
You must be signed in to change notification settings - Fork 29
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.
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
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- 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: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- 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
- β³ π’ One board across every company
- β³ 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)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96