Repository navigation
Department Field Settings Developer Guide
How the Department field on tickets can be hidden, how a new ticket gets a default department, and the guards that stop those two settings fighting with Mandatory fields or with the Departments tab. Built for GitHub #218 Part A slice 3 (changelog #2724, #2725) in 4.9.0.
For what it does from a user's point of view, see Custom ticket fields - The Department field and Mandatory fields. The agreed spec, and the reviewer's answers that shaped it, is Form fields design section 2.2.
| File | Job |
|---|---|
includes/tenant_settings.php |
The two settings and the reading side. ticketDepartmentShown(), ticketDefaultDepartmentId(), ticketDepartmentForNewTicket(), ticketDepartmentNote(), and the department key in ticketClassificationSettings()
|
includes/ticket_department_rules.php |
The guards. ticketDepartmentConflicts(), ticketDepartmentDefaultUses(), ticketDepartmentPlaceList(), plus ticketDepartmentPlaces() and ticketDepartmentRequiredAtClose()
|
api/tickets/department_field_settings.php |
GET and POST for the settings panel, under Cap::TICKETS_CUSTOM_FIELDS
|
api/tickets/save_mandatory_fields_settings.php |
Refuses ticking Department where it is hidden |
api/tickets/save_department.php, delete_department.php
|
Refuse deactivating or deleting a department that is a default |
api/tickets/get_ticket_classification.php |
Carries the department switch to the inbox, per company in the all-companies view |
tickets/settings/index.php |
The Department field panel on the Custom fields tab (#deptFieldPanel) and its IIFE |
assets/js/inbox.js |
deptShownFor(), deptShownAnywhere(), applyDeptFolderRule(), syncNewTicketDepartment() and the places they are used |
assets/js/tickets-table.js |
Drops the Department column when no company shows it |
The channels that create tickets (analyst form, REST API, portal, email, messaging, web chat, catalogue approvals, the workflow create action) each make one call to ticketDepartmentForNewTicket(). They are listed under The default on every channel.
Hiding is presentation only. Nothing stored on a ticket changes, and nothing on the server refuses a department change because the field is hidden. A ticket keeps its department; workflows, SLA notification rules, department notifications and confidential departments still read it. Departments decide folders, rules and routing - not who may open a ticket. ticket_list.php has no department filter, and hiding the field does not add one.
There are no tests for any of this yet - see How to check it by hand.
Two setting keys, read through the generic per-company layer. Nothing new in the schema.
const SETTING_TICKET_DEPARTMENT_SHOWN = 'ticket_department_shown';
const SETTING_TICKET_DEPARTMENT_DEFAULT = 'ticket_department_default';| Key | Install-wide (system_settings) |
Per company (tenant_settings) |
|---|---|---|
ticket_department_shown |
'1' / '0'; no row = shown |
'1' / '0'; no row = follow the install |
ticket_department_default |
a department id, or '0' for no default; no row = no default |
a department id, '0' for an explicit No default, or no row = follow the install |
The per-company table is the one every tenantSetting() key uses:
CREATE TABLE IF NOT EXISTS `tenant_settings` (
`id` INT NOT NULL AUTO_INCREMENT,
`tenant_id` INT NOT NULL,
`setting_key` VARCHAR(100) NOT NULL,
`setting_value` TEXT NULL,
`updated_datetime` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_tenant_setting` (`tenant_id`,`setting_key`),
CONSTRAINT `fk_tenant_settings_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;This is the one thing to hold on to. tenantSetting() looks for the company's row first, then the install's, then the caller's default:
// 1. The company's own answer, if it has one and companies exist at all.
if ($tenantId !== null && $tenantId > 0) {
try {
if (isMultiTenant($conn) || tenancyTablesReady($conn)) {
$st = $conn->prepare("SELECT setting_value FROM tenant_settings WHERE tenant_id = ? AND setting_key = ?");
$st->execute([$tenantId, $key]);
$row = $st->fetchColumn();
if ($row !== false) $value = (string) $row;
}
} catch (Throwable) {
// Table absent on a part-migrated install: fall through to the
// install-wide value rather than failing the page.
}
}
// 2. The install-wide default.
if ($value === null) {So a company row of '0' for the default stops the fall-through and means "this company has no default, whatever the install says". A missing row means "whatever the install says". setTenantSetting() with null deletes the row, which is how Follow the default is stored:
function setTenantSetting(PDO $conn, int $tenantId, string $key, ?string $value): void
{
tenantSettingForget(); // a read later in this request must see the new value
if ($value === null) {
$conn->prepare("DELETE FROM tenant_settings WHERE tenant_id = ? AND setting_key = ?")
->execute([$tenantId, $key]);
return;
}If you ever store '' instead of deleting the row, a company can never be handed back to the install-wide answer.
tenantSettingsForKey() returns only the companies that have their own row. The settings endpoint and the guards use it to tell "follow" (absent) from an answer:
function tenantSettingsForKey(PDO $conn, string $key): array
{
$out = [];
try {
$st = $conn->prepare("SELECT tenant_id, setting_value FROM tenant_settings WHERE setting_key = ?");
$st->execute([$key]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[(int) $r['tenant_id']] = (string) $r['setting_value'];
}
} catch (Throwable) { /* table absent β everyone follows the default */ }
return $out;
}/** Is the Department field shown on tickets of this company? */
function ticketDepartmentShown(PDO $conn, ?int $tenantId): bool
{
return tenantSettingOn($conn, $tenantId, SETTING_TICKET_DEPARTMENT_SHOWN, true);
}
/** The company's default department id as stored, or null. Not checked - see ticketDepartmentForNewTicket(). */
function ticketDefaultDepartmentId(PDO $conn, ?int $tenantId): ?int
{
$v = (int) tenantSetting($conn, $tenantId, SETTING_TICKET_DEPARTMENT_DEFAULT, '');
return $v > 0 ? $v : null;
}-
tenantSettingOn()treats anything but'0'as on, and the default istrue- so an install that has never saved this setting shows Department, exactly as before. -
ticketDefaultDepartmentId()turns both'0'and "nothing anywhere" intonull. It does not check the department exists or is active. OnlyticketDepartmentForNewTicket()does that, because that is the moment it matters. -
$tenantId = nullasks the install only. The settings panel uses that for its top row.
function ticketClassificationSettings(PDO $conn, ?int $tenantId): array
{
return [
'category' => ticketCategoryOn($conn, $tenantId),
'closure_category' => ticketClosureCategoryOn($conn, $tenantId),
'resolution_code' => ticketResolutionCodeOn($conn, $tenantId),
// GH #218: travels with the other field switches, so a board of several
// companies can show Department for one ticket and hide it for the next.
'department' => ticketDepartmentShown($conn, $tenantId),
];
}Putting it here, rather than in an endpoint of its own, means the inbox already had the plumbing: get_ticket_classification.php returns settings for the active company and, in the all-companies view, settings_by_company for every company the analyst can reach:
foreach (getAccessibleTenantIds($conn, $analystId) as $tid) {
$tid = (int) $tid;
$catOpts = ['activeOnly' => true];
if (isset($opts['portalOnly'])) $catOpts['portalOnly'] = true;
$catsByCompany[$tid] = array_values(ticketCategoriesResolved($conn, $tid, $catOpts));
// Resolution codes are per-company too β same model, same treatment.
$codesByCompany[$tid] = ticketResolutionCodesResolved($conn, $tid, ['activeOnly' => true]);
$settingsByCompany[$tid] = ticketClassificationSettings($conn, $tid);
}See All companies ticket view - Developer Guide section 5 for why these are resolved per company and never flattened.
function ticketDepartmentForNewTicket(PDO $conn, ?int $tenantId, $explicit): array
{
if ($explicit !== null && $explicit !== '' && (int) $explicit > 0) return [(int) $explicit, null];
$id = ticketDefaultDepartmentId($conn, $tenantId);
if ($id === null) return [null, null];
try {
$st = $conn->prepare("SELECT name, is_active FROM departments WHERE id = ?");
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [null, null];
}
if ($row && (int) $row['is_active'] === 1) return [$id, null];
$name = $row ? (string) $row['name'] : ('#' . $id);
return [null, "Default department '{$name}' is no longer active, so it was not applied."];
}It returns a pair: the department to store, and a history note or null.
-
An explicit department wins, unchecked. Validating it is the caller's job, and a caller must refuse a bad one rather than let the default quietly replace it. That ordering is deliberate - see
TicketsService::createTicketbelow. -
No default -
[null, null]. The ticket has no department, as before this feature. - The default is re-checked at creation. The guards below stop a default being deactivated or deleted from the screen, but a database edit can still do it. A default that is gone or inactive is not applied, and the reason is returned so it lands on the ticket's history rather than vanishing.
function ticketDepartmentNote(PDO $conn, int $ticketId, ?string $note): void
{
if ($note === null) return;
try {
$conn->prepare("INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
VALUES (?, NULL, 'Department', NULL, ?, UTC_TIMESTAMP())")->execute([$ticketId, $note]);
} catch (Throwable $e) {
error_log('[department-default] could not note ticket ' . $ticketId . ': ' . $e->getMessage());
}
}A ticket_audit row with no analyst and field Department. It never throws: a ticket that has already been created must not fail because its note could not be written. Call it after the insert, once there is a ticket id.
Every channel that creates a ticket calls the resolver before its insert and the note after it. Merge and split carry an existing ticket forward and do not take a default. An edit never applies one either - a ticket with no department keeps having none until somebody gives it one.
| Channel | File |
$explicit passed |
|---|---|---|
Analyst New ticket form, REST API POST /api/v1/tickets
|
includes/services/tickets.php (TicketsService::createTicket) |
the request's department_id
|
| Portal | api/self-service/create_ticket.php |
null - a requester never chooses one |
| Email intake | api/tickets/check_mailbox_email.php |
null - a mailbox cannot set one |
| Messaging (WhatsApp, Telegram, Teams, Mattermost) | includes/messaging/ingest.php |
null |
| Web chat | includes/webchat/webchat.php |
null |
| Catalogue approvals | includes/catalogue_approvals.php |
the catalogue item's department |
| Workflow Create a ticket | workflow/includes/engine.php |
the step's department ($departmentId ?: null) |
The service is the one that shows the ordering best - validate the explicit one, then resolve, then everything that depends on the department:
self::validateLookupId($conn, 'departments', $departmentId, 'department');
// GH #218: none given, so the company's default department - checked here,
// after an explicit one was validated (a wrong one is refused above, never
// swapped for the default) and before anything that depends on it.
[$departmentId, $departmentNote] = ticketDepartmentForNewTicket($conn, $tenantId, $departmentId);
self::validateLookupId($conn, 'ticket_types', $typeId, 'ticket type');The note is written after the creation audit row:
self::auditWrite($conn, $ticketId, $ctx->actorId, 'Ticket Created', null, $creationNote);
ticketDepartmentNote($conn, $ticketId, $departmentNote);Email intake, for comparison, has no explicit department at all:
require_once __DIR__ . '/../../includes/tenant_settings.php';
[$departmentId, $departmentNote] = ticketDepartmentForNewTicket($conn, $ticketTenantId !== null ? (int) $ticketTenantId : null, null);The default and the ticket type rules. The type rules (ticketTypeAutoPick(), see Ticket types for new tickets - Developer Guide) can match on a department. Where a channel passes department_id in the rule facts - the service, catalogue approvals and the workflow action - the department is resolved first, so a rule sees the default. The portal, email, messaging and web chat pick their type before resolving the department and do not pass department_id in their facts, so a "departments" criterion never matches on those channels, default or not.
All three refuse with a message saying where the conflict is and how to fix it. None of them quietly corrects anything.
function ticketDepartmentPlaces(PDO $conn): array
{
$places = [['id' => null, 'name' => '']];
if (isMultiTenant($conn)) {
foreach (getAllTenants($conn) as $t) $places[] = ['id' => (int) $t['id'], 'name' => (string) $t['name']];
}
return $places;
}'' stands for the install. ticketDepartmentPlaceList() turns a list of these names into the text of a message, swapping '' for "the install-wide setting":
function ticketDepartmentPlaceList(array $names): string
{
$named = array_values(array_filter($names, fn($n) => $n !== ''));
if (count($named) < count($names)) $named[] = 'the install-wide setting';
return implode(', ', $named);
}The design first had Mandatory fields skip a hidden Department. The reviewer rejected that: a screen setting must not switch a closure rule off. So the conflicting combination cannot be saved from either side, and MandatoryFieldsService has no Department case.
function ticketDepartmentRequiredAtClose(PDO $conn, ?int $tenantId): bool
{
return in_array('department', MandatoryFieldsService::settings($conn, $tenantId)['fields'], true);
}Mandatory fields is install-wide today, but it is read through tenantSetting(), so this stays right if it gains a per-company column.
function ticketDepartmentConflicts(PDO $conn, ?bool $default, array $companyShown, ?bool $requiredOverride = null): array
{
$installShown = $default ?? ticketDepartmentShown($conn, null);
$stored = tenantSettingsForKey($conn, SETTING_TICKET_DEPARTMENT_SHOWN);
$out = [];
foreach (ticketDepartmentPlaces($conn) as $p) {
if ($p['id'] === null) {
// The install row only matters by itself on a single-company install;
// with companies, each company is checked instead.
if (isMultiTenant($conn)) continue;
$shown = $installShown;
} else {
$tid = $p['id'];
if (array_key_exists($tid, $companyShown)) $v = $companyShown[$tid];
else $v = array_key_exists($tid, $stored) ? $stored[$tid] !== '0' : null;
$shown = $v === null ? $installShown : (bool) $v;
}
$required = $requiredOverride ?? ticketDepartmentRequiredAtClose($conn, $p['id']);
if (!$shown && $required) $out[] = $p['name'];
}
return $out;
}It answers "if this change were saved, where would Department be hidden and required?". For each company the "shown" answer is, in order: the proposed value in $companyShown (where null means "will follow"), else its stored row, else the install value - proposed or stored. That is the same fall-through tenantSetting() does, worked out by hand because the change has not been saved yet.
The two callers ask it from opposite ends:
| Caller | $default |
$companyShown |
$requiredOverride |
|---|---|---|---|
department_field_settings.php (POST) |
the proposed install "shown", or null
|
the proposed per-company "shown" |
null - as stored |
save_mandatory_fields_settings.php |
null - as stored |
[] - as stored |
true - "suppose Department is ticked" |
The Mandatory fields side, which only runs when Department is among the ticked fields:
if (in_array('department', $fields, true)) {
require_once '../../includes/ticket_department_rules.php';
$hiddenFor = ticketDepartmentConflicts($conn, null, [], true);
if ($hiddenFor) {
echo json_encode(['success' => false, 'error' => 'Department is hidden for ' . ticketDepartmentPlaceList($hiddenFor)
. ' (Tickets -> Settings -> Custom fields -> Department field), so it cannot be required at closure. Show it there first.']);
exit;
}
}In the settings endpoint, before anything is saved:
$deptOk = fn($v) => $v === null || (int) $v === 0 || isset($active[(int) $v]);$active is the list of active departments the endpoint loaded first (SELECT id, name FROM departments WHERE is_active = 1 ...). null (follow) and 0 (no default) always pass.
function ticketDepartmentDefaultUses(PDO $conn, int $departmentId): array
{
$out = [];
$install = (int) tenantSetting($conn, null, SETTING_TICKET_DEPARTMENT_DEFAULT, '');
if ($install === $departmentId) $out[] = '';
$names = [];
foreach (ticketDepartmentPlaces($conn) as $p) if ($p['id'] !== null) $names[$p['id']] = $p['name'];
foreach (tenantSettingsForKey($conn, SETTING_TICKET_DEPARTMENT_DEFAULT) as $tid => $v) {
if ((int) $v === $departmentId) $out[] = $names[$tid] ?? ('#' . $tid);
}
return $out;
}save_department.php calls it only when an existing department is being saved as inactive:
if ($id && !(int) $is_active) {
// GH #218: never retire a department that is a default for new tickets - new
// tickets would silently start with none. Replace or clear the default first.
require_once '../../includes/ticket_department_rules.php';
$usedBy = ticketDepartmentDefaultUses($conn, (int) $id);
if ($usedBy) {
throw new Exception('This department is the default for new tickets for ' . ticketDepartmentPlaceList($usedBy)
. '. Choose another default, or No default, on Tickets -> Settings -> Custom fields -> Department field, then deactivate it.');
}
}delete_department.php makes the same check before its DELETE. The design had the Departments tab offer a replacement in its own dialog; the build points at the Department field panel instead (recorded in Form fields design 2.2 Built).
api/tickets/department_field_settings.php, gated by requireModuleAccessJson('tickets') and requireCapabilityJson(Cap::TICKETS_CUSTOM_FIELDS). The manifest's custom-fields tab lists both keys in setting_keys.
GET returns:
echo json_encode([
'success' => true, 'multi_tenant' => $multi,
'required_at_close' => ticketDepartmentRequiredAtClose($conn, null),
'default' => ['shown' => ticketDepartmentShown($conn, null), 'department_id' => $installDef],
'companies' => $companies,
'departments' => array_map(fn($id, $n) => ['id' => $id, 'name' => $n], array_keys($active), $active),
'stale' => $stale,
]);-
companiesholds only companies the analyst can reach (analystCanAccessTenant()), each withshownastrue/false/null(follow) anddepartment_idas an id,0ornull(follow). -
departmentsis the active ones only - the pickers never offer an inactive department. -
stalelists every place whose stored default is no longer an active department, so the panel can show the red warning the spec asks for.
POST takes { default: { shown, department_id }, companies: [{ id, shown, department_id }] }. In order:
-
csrfRequireWriteMethod(). - Guard 2 on the install default and on each company. A company the analyst cannot reach is skipped, not refused.
- Guard 1 on the proposed result:
$proposedShown = [];
foreach ($companies as $tid => $c) if (array_key_exists('shown', $c)) $proposedShown[$tid] = $c['shown'] === null ? null : (bool) $c['shown'];
$conflicts = ticketDepartmentConflicts($conn, $default !== null && array_key_exists('shown', $default) ? (bool) $default['shown'] : null, $proposedShown);- All writes in one transaction: the install values with an upsert into
system_settings, each company's withsetTenantSetting()(nulldeletes the row). Only keys present in the body are written, so a caller can send just one of the two. -
tenantSettingForget(), then{ success: true }.
The panel is the #deptFieldPanel block on the Custom fields tab of tickets/settings/index.php, filled by its own IIFE that returns early when the panel is not in the page.
// TRAP: the controls stay disabled until the load succeeds - a failed fetch must
// never be read as "Show / no default" and saved back as a decision.The dfShown, dfDefault and dfSave controls are rendered disabled and only enabled at the end of a successful GET. On a multi-company install the per-company table is drawn with a Follow the default option in both columns.
The plain warning (hidden with no default) is worked out in the browser on every change, using the same fall-through as the server:
function warn() {
const instHidden = $('dfShown').value === '0', instNone = $('dfDefault').value === '0';
let any = instHidden && instNone;
document.querySelectorAll('#dfCompanies tbody tr').forEach(tr => {
const s = tr.querySelector('.df-shown').value, d = tr.querySelector('.df-default').value;
const hidden = s === '' ? instHidden : s === '0', none = d === '' ? instNone : d === '0';
if (hidden && none) any = true;
});
$('dfWarn').hidden = !any;
}Hidden with no default is allowed - it is the right setting where departments are not used - so this only warns. Save sends '' as null (follow):
const val = (v, num) => v === '' ? null : (num ? Number(v) : v === '1');
const body = {
default: { shown: $('dfShown').value === '1', department_id: Number($('dfDefault').value) },
companies: [...document.querySelectorAll('#dfCompanies tbody tr')].map(tr => ({
id: Number(tr.dataset.id), shown: val(tr.querySelector('.df-shown').value, false), department_id: val(tr.querySelector('.df-default').value, true)
}))
};A refusal from the server is shown as it comes, in the toast.
All in assets/js/inbox.js. The switches arrive with the category switches in loadTicketClassification(): ticketClassification for the active company, and classificationByCompany (from settings_by_company) in the all-companies view.
function deptShownFor(email) {
// TRAP: keyed on classificationByCompany, NOT allCompaniesView. The per-company
// settings only arrive in the all-companies view, and they are loaded before the
// folder rule runs - allCompaniesView is set later, by loadEmails(), and never in
// table mode. Gating on it answered for the active company alone.
const s = (classificationByCompany && email)
? (classificationByCompany[ticketCompanyId(email)] || classificationByCompany[String(ticketCompanyId(email))] || ticketClassification)
: ticketClassification;
return !s || s.department !== false;
}function deptShownAnywhere() {
// Not gated on allCompaniesView - see the TRAP in deptShownFor().
if (classificationByCompany) return Object.values(classificationByCompany).some(s => !s || s.department !== false);
return !ticketClassification || ticketClassification.department !== false;
}-
deptShownFor(email)- one ticket. Used where the screen is about one ticket: the reading pane and workspace, the summary line, the right-click menu on one ticket. -
deptShownAnywhere()- any company on the board. Used for controls that act on many tickets: the bulk button, the right-click menu on a selection, the folder grouping, the table column.
Both test department !== false, never === true. The initial ticketClassification has no department key at all, and a switch that failed to load must never hide a field - unknown counts as shown.
| Screen | Code | Rule |
|---|---|---|
| Ticket panel and workspace |
propertyFields.department in displayEmail() is ''
|
deptShownFor(email) |
| Summary Dept line | the summaryDept span is not rendered |
deptShownFor(email) |
| Bulk Department button | left out of selectionActionsHtml()
|
deptShownAnywhere() |
| Right-click Set department | the whole item is hidden by populateContextDepartmentSubmenu()
|
one ticket: deptShownFor(); a selection: deptShownAnywhere()
|
| New ticket Department box | syncNewTicketDepartment() |
the company the ticket is for |
| Department folder grouping | applyDeptFolderRule() |
deptShownAnywhere() |
| Department table column |
tickets-table.js setup()
|
deptShownAnywhere() |
The panel and the workspace are both built from one propertyFields object, so one empty string covers both:
// GH #218: '' when this ticket's company hides Department - in the classic pane
// and the workspace alike, since both are filled from this one list.
department: !deptShownFor(email) ? '' : `<div class="toolbar-field">With the field absent there is no #departmentSelect. assignDepartment() only runs from that select's onchange, and setDepartmentFromContext() and updatePropertiesSummary() already check for a missing element (if (sel), if (summaryDept && currentEmail)), so nothing throws.
The right-click menu decides per call, because the same menu serves one ticket and a selection:
const target = !ctxActsOnSelection && Array.isArray(emails) ? emails.find(e => e.ticket_id == ctxTargetTicketId) : null;
const shown = target ? deptShownFor(target) : deptShownAnywhere();
const parentItem = sub.closest('.ticket-context-menu-item');
if (parentItem) parentItem.style.display = shown ? '' : 'none';
if (!shown) return;function applyDeptFolderRule() {
const shown = deptShownAnywhere();
const btn = document.querySelector('.folder-group-btn[data-group="department"]');
if (btn) btn.hidden = !shown;
if (!shown && folderGrouping === 'department') {
folderGrouping = 'analyst';
document.querySelectorAll('.folder-group-btn[data-group]').forEach(b => b.classList.toggle('active', b.dataset.group === folderGrouping));
}
}With nothing to group by, the Department button goes and an analyst grouped by department sees the Analyst grouping. In memory only - setFolderGrouping() is not called, so the saved tickets_folder_grouping preference is untouched and returns if Department is shown again. The same idea as loadTicketTeams() dropping back from a team grouping once the teams are gone.
It has to run before the first folders and list are drawn, so start-up waits for the classification alongside the two preferences it already waited for:
const classificationReady = loadTicketClassification(); const inboxPrefsReady = Promise.all([loadFolderGroupingPreference(), loadViewFilterPreference(), classificationReady]).then(applyDeptFolderRule);They are fetched in parallel, so this adds no wait of its own. applyDeptFolderRule() is called only here. That is safe in the all-companies view because classificationReady brings classificationByCompany with it - see Traps.
When some companies show Department and others hide it, the Department folders stay, for every company's tickets, and dragging a ticket onto a department folder still sets its department.
.filter(([key]) => key !== 'department_id' || typeof deptShownAnywhere !== 'function' || deptShownAnywhere())The column list is built once, the first time the table is needed. The typeof check keeps tickets-table.js working on a page without inbox.js.
function syncNewTicketDepartment() {
const sel = document.getElementById('newTicketDepartment');
if (!sel) return;
const group = sel.closest('.form-group');
const row = group && group.parentElement;
const coRow = document.getElementById('newTicketCompanyRow');
const asked = !!(coRow && !coRow.hidden);
const coId = asked ? (document.getElementById('newTicketCompany').value || '') : '';
let shown;
if (asked && !coId) shown = deptShownAnywhere();
else {
const s = (coId && classificationByCompany) ? (classificationByCompany[coId] || classificationByCompany[String(coId)] || ticketClassification) : ticketClassification;
shown = !s || s.department !== false;
}
group.style.display = shown ? '' : 'none';
if (!shown) sel.value = '';
if (row) row.style.gridTemplateColumns = 'repeat(' + Math.max(1, [...row.children].filter(c => c.style.display !== 'none').length) + ', 1fr)';
}- It follows the company the ticket is for: the one chosen on the form in the all-companies view, else the active company. Before a company is chosen it shows the box if any company would.
-
Hidden, the value is cleared.
createNewTicket()then sendsdepartment_id: null,TicketsService::createTicketgets no explicit department, andticketDepartmentForNewTicket()applies that company's default on the server. Nothing in the browser knows what the default is, and nothing needs to. - The Department / Type / Priority row is a CSS grid; its column count is re-counted so a hidden box leaves no gap.
It is called from openNewTicketModal() and from both branches of onNewTicketCompanyChange(), so it follows a company picked by hand or pre-selected from the analyst's preference (see All companies ticket view - Developer Guide section 6).
-
"No row" is not "0". For the default,
'0'intenant_settingsis an explicit No default that stops the fall-through to the install. Clearing a company's answer means deleting the row (setTenantSetting(..., null)), never writing''or'0'. -
Unknown counts as shown. Test
department !== falsein JavaScript. A load failure, or a payload from before this feature, must not hide the field. -
Validate the explicit department before resolving the default.
ticketDepartmentForNewTicket()trusts what it is given. A channel that resolves first and validates after would turn a wrong department into a refused request - and one that validates nothing would store it. -
A new channel must call both halves.
ticketDepartmentForNewTicket()before the insert andticketDepartmentNote()after it. Miss the first and the channel ignores the default; miss the second and a stale default goes silent. -
Do not add a Department case to
MandatoryFieldsService::fieldAvailable(). The conflict is prevented at save time from both screens; skipping it at closure would let a screen setting switch a closure rule off, which the reviewer ruled out. - The settings panel stays locked until it loads, for the reason the TRAP comment gives: a failed load would otherwise save "Show / no default" as a decision.
- A stale default is not in the picker. The GET only offers active departments, so a select whose stored default is inactive has nothing to match and shows its first option - No default on the install row, Follow the default on a company row. Pressing Save then stores that. The red stale warning is there so this is a choice and not an accident.
-
Key the Department checks on
classificationByCompany, never onallCompaniesView.allCompaniesViewis set byloadEmails()from the list response, which runs afterinboxPrefsReady, and table mode never sets it at all. Until 4.9.0 shipped (changelog #2731) both functions were gated on it, soapplyDeptFolderRule()and the table column answered for the active company only: an all-companies board whose active company hid Department lost the Department folders and column even though another company showed it.classificationByCompanycomes fromget_ticket_classification.phponly when the board is in the all-companies view (settings_by_company), and it has loaded beforeapplyDeptFolderRule()runs, so it is the reliable signal. Reproduced on a test install with a second company: the old code hid the button, the fixed code shows it, and with every company hiding Department it is still hidden. -
The generic writer would also accept these keys. The
custom-fieldstab liststicket_department_shownandticket_department_defaultin its manifestsetting_keys, soapi/settings/save_system_settings.phpwill write the install-wide values for anyone holdingTICKETS_CUSTOM_FIELDS- without Guard 1 or Guard 2, which live only indepartment_field_settings.php. Nothing in the app posts these keys there; keep it that way, and save them through the dedicated endpoint. -
ticketDepartmentConflicts()checks every company (getAllTenants()), not only the ones the analyst can reach, because an install-wide change reaches every company that follows it.
There are no automated tests for the Department settings, the guards, the default on each channel or the inbox hiding. tests/ has nothing that calls ticketDepartmentForNewTicket(), ticketDepartmentConflicts() or department_field_settings.php. Use a test install, never real data, and make it multi-company for the per-company checks.
The settings and guards
- Tickets -> Settings -> Custom fields -> Department field. Hide it install-wide with No default: the yellow warning appears. Save.
- Tick Department on Mandatory fields and save: refused, naming the place.
- Untick it, show Department, tick it on Mandatory fields, then try to hide Department: refused, naming the company (or the install-wide setting).
- Set a company's default to a department, then try to deactivate that department on the Departments tab, and to delete it: both refused, naming the company.
- Make the default inactive behind the screen's back (
UPDATE departments SET is_active = 0 WHERE id = ...on the test install). Reload the panel: the red stale warning names it.
The default on creation
- With a default set and Department hidden, create a ticket from New ticket, the portal, an email, the REST API without
department_id: each lands in the default department. - Through the REST API, send an explicit active department: it wins. Send one that does not exist: refused, not defaulted.
- With the default inactive (step 5), create a ticket: no department, and its history has "Default department '...' is no longer active, so it was not applied."
- Edit an existing ticket with no department: the default is not applied.
The screens
- One company hiding Department, another showing it, all-companies view: open a ticket from each. The hiding company's ticket has no Department field (panel and workspace), no Dept summary line and no right-click Set department; the other has all three. The bulk button and the Department folder grouping stay.
- Every company hiding it: the bulk button, the Department grouping button and the Department column go, and an analyst grouped by department sees the Analyst grouping. Show it again and reload: the saved grouping is back.
- New ticket in the all-companies view: choose the hiding company and the Department box disappears with no gap in the row; choose the other and it returns.
Read the results through the API or the DOM, not from window: inbox.js state lives in top-level lets, which are not properties of window.
- Custom ticket fields - the user page, Department field section
- Mandatory fields Β· Mandatory fields - Developer Guide
- Form fields design - the agreed spec, section 2.2
- All companies ticket view - Developer Guide - per-company switches, and the company on New ticket
- Tickets settings - Developer Guide - the settings page map
- Ticket types for new tickets - Developer Guide - the type rules that can match on a department
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- 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: Projects
- β³ ποΈ Mobile: Files
- β³ π 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
- β³ π Switching modules off
- β³ π οΈ Module availability β Developer Guide
- β³ π Module availability β design (#217)
- 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
- β³ π₯ Importing people from a directory
- β³ π₯ Importing people from Microsoft Entra ID
- β³ π³ OpenLDAP & Samba AD (Docker) setup
- β³ π οΈ LDAP β Developer Guide
- β³ π οΈ Directory sync β Developer Guide
- β³ π§© Extending directory sync
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π₯ Directory sync β design
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π οΈ Microsoft Entra ID import - review (#235)
- β³ π οΈ Assets from their Intune primary user - review (#236)
- β³ π οΈ Organisation chart - review (#242)
- β³ π« 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: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ 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
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
-
β³ π·οΈ QR asset labels
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π 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
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
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
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #214 A "contains" condition did not match capitals
- β³ βοΈ #216 A workflow email from a chosen mailbox always failed
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ 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