Mandatory Fields Developer Guide - edmozley/freeitsm GitHub Wiki
How the mandatory-fields close gate is built, where it is enforced, and the traps that shaped it.
The user-facing page is Mandatory fields. It is the third close gate, alongside unfinished tasks (#83, warn only) and mandatory SOP steps (Checklists & SOPs - Developer Guide ยง5), and deliberately built the same way as the second.
Colour key: โ๏ธ shared ยท ๐ API ยท ๐ฅ๏ธ page ยท ๐ i18n ยท ๐ permissions
| ๐จ | File | What it does |
|---|---|---|
| โ๏ธ | includes/services/mandatory_fields.php |
The rule. MandatoryFieldsService: the field registry, settings, missing(), assertClosureAllowed(), afterClosure()
|
| โ๏ธ | includes/tenant_settings.php |
The four SETTING_TICKET_MANDATORY_* keys |
| โ๏ธ | includes/services/tickets.php |
The hook in updateTicket(), and rowAfterUpdates()
|
| โ๏ธ | workflow/includes/engine.php |
The hook in action_set_ticket_status() - which does not use updateTicket()
|
| โ๏ธ | includes/template_email.php |
internalTicketEmail() - shared with SLA alerts |
| โ๏ธ | includes/email_log.php |
The closure_alert route |
| ๐ | api/tickets/save_mandatory_fields_settings.php |
Saves the tab |
| ๐ | api/tickets/get_mandatory_fields_check.php |
"What would be empty if this closed now?" - for the inbox dialog |
| ๐ฅ๏ธ | tickets/settings/index.php |
The tab |
| ๐ฅ๏ธ | assets/js/inbox.js |
assignStatus() + mandatoryFieldsCheck()
|
| ๐ |
tickets/settings/manifest.php, includes/capabilities.php
|
The tab, Cap::TICKETS_MANDATORY_FIELDS (sensitive), and the setting keys it owns |
| ๐ | lang/en/tickets.php |
settings.mandatory.*, mandatory_close.*, help.settings.card_mandatory_*
|
No schema change: the settings live in system_settings.
| Key | Value | Default |
|---|---|---|
ticket_mandatory_fields |
comma-separated keys of MandatoryFieldsService::FIELDS
|
empty - nothing required |
ticket_mandatory_mode |
warn / notify / block
|
warn |
ticket_mandatory_notify |
comma-separated addresses, validated, de-duplicated, at most 10 | empty |
ticket_mandatory_record |
1 / 0
|
1 |
All four are read through tenantSetting(), so a per-company override works on the reading side already. Only the tab is install-wide. Adding a company column means writing tenant_settings rows; nothing that reads them changes.
The save endpoint refuses an unknown field key or an invalid address rather than dropping it. A list that quietly saved as something other than what was typed is how an administrator comes to believe a rule is in force when it is not.
MandatoryFieldsService::FIELDS = [
'priority' => ['priority_id', 'Priority'],
...
'owner' => ['owner_id', 'Owner'],
...
];Key โ tickets column and the English label used in the note and the email. The tab and the inbox use tickets.settings.mandatory.fields.<key> instead.
๐ด Owner, not analyst.
ticketsstores the assigned person twice, asassigned_analyst_idandowner_id.owner_idis what the screen reads and calls Owner, and every writer sets both. Listing both would offer one field twice under two names. Do not addassigned_analyst_id.
"Empty" is NULL or ''. For the two yes/no fields that means unanswered: 0 (No) is an answer and counts as filled in.
fieldAvailable():
| Key | Skipped when |
|---|---|
category / closure_category / resolution_code
|
the company's switch is off (ticketCategoryOn() and friends) |
team |
the install has no active teams - the inbox does not draw the picker |
Requiring a field the analyst has no way to fill would trap every ticket behind it. Add a row here if a new field ever gains its own switch.
The inbox, bulk actions, assign/schedule and the v1 REST API all close tickets through it.
// after every field has been resolved, BEFORE the UPDATE
$closing = $newStatusId !== null && $newIsClosed && !$oldIsClosed;
$afterRow = self::rowAfterUpdates($current, $updates, $args);
...
if ($closing) {
$emptyOnClose = MandatoryFieldsService::missing($conn, $closeTenant, $afterRow);
MandatoryFieldsService::assertClosureAllowed($conn, $closeTenant, $emptyOnClose); // may throw
}
// ... UPDATE ...
MandatoryFieldsService::afterClosure($conn, $ctx, $ticketId, $closeTenant, $emptyOnClose); // note + email๐ด Checked against the row AS IT WILL BE, not as it is. Against the stored row, a request that sets the resolution code and closes the ticket would be refused over the field it is filling in.
rowAfterUpdates()reads the values back out of the SET list, so it also sees what the service decides on its own: the category a type change clears, andowner_idwritten alongside the analyst. It skips values for fragments that are not plaincolumn = ?while keeping the arguments aligned.
๐ด Checked BEFORE the write, as the checklist gate is. Checked after, a
blockinstall throws with the ticket already closed.
The company also comes from $afterRow, so a ticket moved to another company in the same save is judged by that company's rules.
missing() is computed once, before the write, and handed to afterClosure(). Recomputing afterwards would describe the ticket after the save, which for a close is the same thing today. It stops being the same the moment anything else is written in between.
๐ด This action writes
status_iditself and never went throughupdateTicket(). So until this change the SOP checklist gate did not apply to workflows either, although its help card said it did. Both gates are now called here, before and after the write, in the same order.
A workflow has no analyst, so it acts as new ActorContext(0, null, 'workflow'). ticket_notes.analyst_id is NOT NULL with a foreign key to analysts, so actor 0 cannot write there: both services write a Workflow Note entry to ticket_audit with a NULL analyst instead. That is where the engine's own add note action already writes.
Under block the action throws ServiceError. The engine records it as a failed run with the message.
| Path | Why |
|---|---|
mergeTickets() |
closes the tickets merged away; refusing a merge over a ticket that is about to disappear helps nobody |
reopenTicketForCustomerReply() |
opens, never closes |
| Self-service | the portal cannot change a status |
Every way of closing a ticket calls confirmCloseWithMandatoryFields(ticketIds) first, which asks get_mandatory_fields_check.php and shows Close with fields empty? or Fill these in first:
| Path | Function |
|---|---|
| reading-pane status dropdown | assignStatus() |
| right-click โ Status |
setStatusFromContext() (a selection goes through bulkSetField()) |
| drag onto a closed status |
handleTicketDrop() (several dragged go through bulkSetField()) |
| bulk action |
bulkSetField(), whenever fields.status is a closed status |
๐ด It first shipped wired to the dropdown alone (#1715), and Ed closed a ticket from the right-click menu within minutes with no warning (#1721). The server still enforced the rule, so the close was recorded, but the analyst was never asked. A new way of closing a ticket must call this helper, and
isClosedStatusName()is the test for whether a status change is a close.
The endpoint takes ticket_id or ticket_ids (up to 500; the inbox asks 100 at a time) and reports mode per ticket, because it resolves per company. For several tickets the dialog lists each with its empty fields. Where only some are refused it offers Close the rest: the server refuses the others and the bulk result names them. The inbox saves each field the moment it changes, so the stored ticket is what the close will be judged on.
โ ๏ธ If the question fails, the inbox says nothing and sends the close anyway. The server enforces the rule again, so a failed check can never turn into a refusal nobody can clear. That is the same principle as the task warning: "a warning that cannot be shown must not become a block that cannot be cleared."
Any refused save now also puts the status dropdown back, so it never shows a status the ticket does not have.
internalTicketEmail($conn, $ticketId, $recipients, $subject, $html, $route) in includes/template_email.php was lifted out of sla_send_breach_email(), which now calls it. It picks the ticket's mailbox (or the first active one), handles Graph, Gmail and SMTP, does not save to Sent Items, and logs every recipient under $route.
It tries every recipient and throws the first failure at the end. Previously the SLA sender stopped at the first failure, so one mistyped address hid the alert from everybody listed after it.
afterClosure() never throws: a failed note or email must not turn a close that happened into an error. Failures go to error_log() and, for email, the send log.
Tests can replace the transport with MandatoryFieldsService::$mailer = fn($conn, $ticketId, $to, $subject, $html) => ....
Cap::TICKETS_MANDATORY_FIELDS, declared on the tab in tickets/settings/manifest.php with 'sensitive' => true, because the tab sends ticket details to any address typed into it. The four keys are listed as that tab's setting_keys, so settingKeyOwners() derives their owner from the same declaration and capSelfCheck() would flag a mismatch.
The tab's JavaScript lives in its own IIFE with the checklist handler, not in the Categories block. That block returns early when the Categories tab is absent, which had left the checklist Save button dead for any role granted Checklists but not Categories.
- Mandatory fields - the user page
- Checklists & SOPs - Developer Guide - the other close gate
- Ticket categories - Developer Guide - the per-company switches this respects