Skip to content

Mandatory Fields Developer Guide

Ed Mozley edited this page Sep 16, 2026 · 2 revisions

πŸ› οΈ Mandatory fields - Developer Guide

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.


1. πŸ“ The files involved

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.


2. βš™οΈ The 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.


3. πŸ—‚οΈ The field registry

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. tickets stores the assigned person twice, as assigned_analyst_id and owner_id. owner_id is what the screen reads and calls Owner, and every writer sets both. Listing both would offer one field twice under two names. Do not add assigned_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.

Fields that cannot be filled are skipped

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.


4. πŸšͺ Where it is enforced

TicketsService::updateTicket()

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, and owner_id written alongside the analyst. It skips values for fragments that are not plain column = ? while keeping the arguments aligned.

πŸ”΄ Checked BEFORE the write, as the checklist gate is. Checked after, a block install 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.

WorkflowEngine::action_set_ticket_status()

πŸ”΄ This action writes status_id itself and never went through updateTicket(). 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.

Not enforced

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

5. πŸ–₯️ The inbox dialog

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.


6. πŸ“§ The email

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) => ....


7. πŸ” Permissions

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.


8. πŸ”— Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally