People on a Task Developer Guide - edmozley/freeitsm GitHub Wiki
How more-than-one-person-on-a-task is built: one table that deliberately does not hold the owner, four filters that must move together, and a notification router that had to learn to talk to more than one person. Written to be followed by somebody who has not worked on FreeITSM before.
The user-facing page is People on a task β "Involved".
Section 8 covers the other switch on the same Involved settings tab: Contractor fields on tasks: Show / Hide (4.9.0).
The screen says "Involved". The code says collaborator. That is deliberate, not drift.
The direct cognate of "collaborator" means somebody who collaborated with an occupying enemy in German (Kollaborateur), Dutch, Danish, both Norwegians, French, Polish and Russian β and in Ukrainian (ΠΊΠΎΠ»Π°Π±ΠΎΡΠ°Π½Ρ) it is a current criminal charge rather than a piece of history. That is nine of the twenty-four languages FreeITSM ships in, and translations are machine-assisted, so the word would have been produced unprompted and shipped to all nine without anybody reading it.
So:
| Layer | Word | Why |
|---|---|---|
| Table, column, API field, workflow event, PHP method | collaborator |
An identifier is never translated. Precise for a developer. |
| Anything a person reads | Involved | Translates to an ordinary everyday word in every locale. |
The English string lives at tasks.detail.involved with a translator note beside it. See Internationalisation.
π Generalise this: a word can be perfectly safe in English and unsafe as a translation seed. Check a new user-facing noun against the occupied-Europe locales before it becomes the schema, when changing it is free.
Colour key: ποΈ schema Β· βοΈ engine Β· π API Β· π₯οΈ UI Β· π notifications Β· π§ͺ tests
| π¨ | File | What it does |
|---|---|---|
| ποΈ | database/freeitsm.sql |
task_collaborators |
| ποΈ | includes/db_verify_schema.php |
same columns, so an upgrade creates the table |
| ποΈ | includes/db_verify_indexes.php |
generated β php scripts/gen_db_verify_indexes.php
|
| βοΈ | includes/services/tasks.php |
every rule: add, remove, tick, gate, dispatch |
| π | api/tasks/collaborators.php |
GET list + candidates Β· POST add/remove/done |
| π | api/tasks/list.php |
the filter β owner OR involved |
| π | api/v1/resources/tasks.php |
the filters β involved_analyst_id, collaborator_id
|
| π |
api/tasks/get.php, api/tickets/get_ticket_tasks.php
|
display joins |
| π |
api/tasks/get_settings.php Β· api/tasks/save_settings.php
|
tasks_collaborator_completion |
| π | includes/services/notifications.php |
the toggles β the Preferences screen is generated from here |
| π | includes/notifications_router.php |
who gets told |
| π | workflow/includes/engine.php |
the new trigger catalogue |
| π₯οΈ | assets/js/tasks.js |
chips, picker, card marks, the closing warning |
| π₯οΈ | assets/css/tasks.css |
.involved-* Β· tasks/settings/index.php for the settings tab |
| π§ͺ | tests/task-collaborators/run.php |
43 assertions |
What you do not touch is as instructive: tasks.assigned_analyst_id is untouched, api/v1's assigned_analyst still serialises one person, and no stored workflow needed migrating. That is a consequence of Β§2, not a coincidence.
CREATE TABLE IF NOT EXISTS `task_collaborators` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`analyst_id` INT NOT NULL,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`completed_datetime` DATETIME NULL,
`added_by_id` INT NULL,
`added_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`is_demo` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_task_collaborator` (`task_id`, `analyst_id`),
KEY `ix_task_collaborators_analyst` (`analyst_id`),
CONSTRAINT `fk_task_collab_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_task_collab_analyst` FOREIGN KEY (`analyst_id`) REFERENCES `analysts` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_task_collab_added_by` FOREIGN KEY (`added_by_id`) REFERENCES `analysts` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;π΄ The owner is not in this table. Who is accountable stays in tasks.assigned_analyst_id and nowhere else. Two homes for one fact are two things that can disagree.
The pay-off is large and worth spelling out, because it is the reason this feature was small: because that column keeps its exact meaning, the REST contract, every stored workflow, the board's grouping and the calendar sync are unchanged by definition rather than by inspection. Nobody had to audit them.
The shape is copied from change_cab_members. Change Management had already solved "several named people on one record, each with their own state", so this is the same shape rather than a new one β which also made the optional per-person tick almost free.
Two indexes, and the second is not redundant. The unique key is task-first. "My Tasks" now asks which tasks is this analyst on, which reads analyst-first and the composite key cannot serve it β without ix_task_collaborators_analyst the new filter is a full table scan on every board load.
analyst_id CASCADEs, where change_cab_members does not. A CAB row records a vote β a decision somebody made, worth keeping after they leave. A collaborator row is a membership, and a membership held by an account that no longer exists means nothing. It also keeps api/tickets/delete_analyst.php working, which a restricting constraint would have blocked the first time anybody deleted an analyst who was helping on a task.
Every rule lives in TasksService so the UI endpoint and the REST API cannot disagree. See Service Layer Architecture.
public static function addCollaborator(PDO $conn, ActorContext $ctx, int $taskId, int $analystId): array
{
$task = self::loadTaskRow($conn, $ctx, $taskId); // 404 + company scope
self::assertCollaboratorsAllowed($task); // top-level tasks only
self::resolveAnalyst($conn, $analystId); // exists and is active
if ((int)($task['assigned_analyst_id'] ?? 0) === $analystId) {
throw new ServiceError('validation', 'invalid_field', 'That analyst already owns this task.');
}
self::assertAnalystInTaskCompany($conn, $task, $analystId);
// INSERT IGNORE would hide a genuine failure as a no-op, so ask first.
$exists = $conn->prepare("SELECT id FROM task_collaborators WHERE task_id = ? AND analyst_id = ?");
$exists->execute([$taskId, $analystId]);
if ($exists->fetchColumn()) {
return ['added' => false];
}
...
}Reading that top to bottom is the design: the owner can never also be involved (one person in two roles makes every count ambiguous), subtasks are refused (a subtask already carries its own assignee, so allowing both would be two overlapping ways to say the same thing with no rule for which wins), and adding twice is idempotent rather than an error.
loadTaskRow() is the choke point every by-id path already went through, so scoping is inherited rather than re-implemented. That is why the read endpoint calls it too:
public static function taskForCollaborators(PDO $conn, ActorContext $ctx, int $taskId): array
{
return self::loadTaskRow($conn, $ctx, $taskId);
}Letting the endpoint run its own SELECT * FROM tasks would have been the one by-id route not covered by the company check β which is exactly how a child endpoint gets missed.
private static function assertAnalystInTaskCompany(PDO $conn, array $taskRow, int $analystId): void
{
if (!isMultiTenant($conn)) return;
$tenantId = $taskRow['tenant_id'] ?? null;
$tenantId = ($tenantId === null) ? getDefaultTenantId($conn) : (int)$tenantId;
if (!analystCanAccessTenant($conn, $analystId, (int)$tenantId)) {
throw new ServiceError('validation', 'invalid_field',
'That analyst does not work for the company this task belongs to.');
}
}api/tasks/collaborators.php filters its candidate list through the same check rather than validating only on save. See Multi-Tenancy β Isolation.
public static function collaboratorsForMany(PDO $conn, array $taskIds): arrayThe board renders every task on the desk. A per-task lookup would add a query per card to the module's busiest endpoint, so this returns [task_id => [rows]] from a single IN (...). The JOIN analysts is inner on purpose: a row whose analyst has vanished simply does not come back, because a chip reading "undefined" is worse than a person quietly missing.
The whole thing is wrapped in a try/catch returning [] β an install that has not run Database Verification since upgrading has no table, and that must not take the board down.
π΄ This is the part that looks like "the feature doesn't work". A task you are involved in that fails to appear in one list is indistinguishable, to the person looking, from your never having been added.
There are exactly four places that can hide a task. Everything else touching assignment is a display join β it can leave a name off a card, but it cannot make the card disappear.
// api/tasks/list.php β used by BOTH the "my" and "analyst" branches
$ownerOrCollaborator = '(t.assigned_analyst_id = ?
OR EXISTS (SELECT 1 FROM task_collaborators tc
WHERE tc.task_id = t.id AND tc.analyst_id = ?))';EXISTS, not a JOIN. A join to a one-to-many table returns the task once per person, so a task with three people would appear three times on the board β and anything counting rows would count it three times. In the REST API that also means total never reconciles with the pages a client actually receives.
// api/v1/resources/tasks.php β two new filters, neither touching assigned_analyst_id
if (isset($_GET['collaborator_id']) && $_GET['collaborator_id'] !== '') { ... }
if (isset($_GET['involved_analyst_id']) && $_GET['involved_analyst_id'] !== '') { ... }And one filter deliberately left alone:
// β οΈ `unassigned` STILL means "no owner", even when the task has collaborators.
if (($_GET['unassigned'] ?? '') === 'true') {
$where[] = 't.assigned_analyst_id IS NULL';
}assigned_analyst_id is nullable, so a task really can end up with two people helping and nobody accountable β and that is exactly what this filter exists to surface. Treating it as assigned because somebody is helping would let work vanish from the queue that catches it.
$viewedAs = ($filter === 'analyst' && isset($_GET['analyst_id']))
? (int)$_GET['analyst_id']
: (int)$analystId;
$task['viewer_is_owner'] = (int)$task['assigned_analyst_id'] === $viewedAs;Not in the browser β the browser would have to know which analyst the list was filtered for, and gets it wrong the moment the analyst dropdown is pointed at somebody else.
includes/notifications_router.php used to carry this note:
Who gets told. Currently: the assignee, and only the assignee. Deliberate limitation, chosen over "everyone who ever touched it" because the second is impossible to switch off and turns the bell into a firehose. A watch/follow table is the answer if that becomes a problem β this function is the only place that would need to change.
β task_collaborators is that table: a deliberate list, put there by hand, and removable. So the prediction was cashed in.
function notificationsAudienceFor(PDO $conn, string $event, array $payload): array
{
$taskWide = ['task.completed', 'task.comment_added',
'task.status_changed', 'task.due_date_changed'];
if (in_array($event, $taskWide, true)) {
$ids = [];
if ($owner > 0) $ids[] = $owner;
$stmt = $conn->prepare("SELECT analyst_id FROM task_collaborators WHERE task_id = ?");
$stmt->execute([$taskId]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) $ids[] = (int)$id;
return array_values(array_unique(array_filter($ids)));
}
$one = notificationsRecipientFor($event, $payload);
return $one > 0 ? [$one] : [];
}π΄ The distinction that stops it becoming the firehose: an event about one person's place on a task goes to that person alone β being handed a task, or being added to one, is news for the individual, and sending "a task was assigned to me" to four other people would be both noisy and false. Events about the task go to everyone on it.
Tickets are untouched: there is no equivalent list of people on a ticket, and inventing one from "who has touched it" is the firehose that was rejected.
task.collaborator_added deliberately carries the owner in assignee_id β that is what keeps stored workflows reading the field they have always read. But the router's fallback reads exactly that field:
// π΄ Named EXPLICITLY, and BEFORE the assignee_id fallback below.
if (($event === 'task.collaborator_added' || $event === 'task.collaborator_removed')
&& isset($payload['task']['collaborator_id'])) {
return (int)$payload['task']['collaborator_id'];
}
if (isset($payload['task']['assignee_id'])) {
return (int)$payload['task']['assignee_id'];
}Without the first branch, "you were added to a task" goes to the owner instead of the person added. Both are real analysts, the notification looks entirely normal, and only the recipient is wrong β the kind of bug that survives for months.
foreach ($recipients as $recipientId) {
if ($actorId > 0 && $actorId === $recipientId) continue; // your own action
if (!NotificationsService::typeEnabled($conn, $recipientId, $event)) continue;
NotificationsService::notify($conn, [...]);
}Applied to the whole event β as it was when there was only ever one recipient β commenting on your own task would have silenced the other four people rather than just you. And typeEnabled is per person, so everyone's own switches decide.
'task.comment_added' => ['default' => true, 'entity' => 'task'],system/preferences/index.php renders the Notifications tab from NotificationsService::types(), so a registry entry plus a common.notifications.pref.<key> string is the whole job β no second list to keep in step. That is why six task toggles appeared without touching the preferences screen at all.
TasksService. Workflows gain the triggers for free, which is the right trade either way.
if ($field === 'due_date' && $newDate !== ($current['due_date'] ?? null)) {
$dueChanged = true;
}The detail panel posts the due date on every save of that field, so "it was in the payload" would have told everybody on the task the date had moved each time somebody re-picked the day it already was.
The board's menu (assets/js/tasks-ctx-menu.js, markup in tasks/index.php) has an item that puts the current analyst on the task or takes them off. It calls the same endpoint the panel does, so every rule stays in TasksService:
fetch(cfg.apiBase + 'collaborators.php', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: id, analyst_id: me, action }) }) // action: 'add' | 'remove'-
Which way it goes comes from the card's own task object:
list.phpalready sendscollaboratorswith every task, sobuildSubmenus()checks whethercfg.currentAnalystIdis among them and sets the label anddata-involved. -
Hidden, not refused, where the service would say no: on a task you own (
assigned_analyst_id === me) and on a subtask (parent_task_id). - Only the board passes
currentAnalystIdtoTasksCtxMenu.init(); the table and timeline menus show neither Assign to me nor Involve me.
TRAP: a menu item that acts on "me" needs
currentAnalystIdin the page'sinit()config. Without itsetField('assigned_analyst_id', null)would unassign rather than assign - which is why the personal items exist only in the board's markup.
The panel section is fetched separately from the task, because the same call answers a different question β who could be added depends on who is already on it:
async function loadInvolved(taskId) {
const data = await fetch(API_BASE + 'collaborators.php?task_id=' + taskId).then(r => r.json());
involvedState = { taskId, rows: data.collaborators || [],
candidates: data.candidates || [],
completion: !!data.completion_enabled, ownerId: involvedState.ownerId };
renderInvolved();
}data.candidates, never from the module's own analysts array. That array is everybody; the candidates have already had the owner, the people already on the task, and analysts from other companies removed. Offering a name the save would refuse is the smaller problem β offering a name from another company is the disclosure.
Every write re-reads the whole list rather than patching the local copy, which is how a chip survives two people editing the same task at once:
await loadInvolved(involvedState.taskId);
loadTasks(); // the board's chips and marks move with itThe closing warning is a confirm(), and only asked when the tick setting is on:
async function confirmCloseWithInvolved(newStatusName) {
if (!involvedState.completion || involvedState.taskId !== selectedTaskId) return true;
const status = (statusList || []).find(s => s.name === newStatusName);
if (!status || !status.is_closed) return true;
const outstanding = involvedState.rows.filter(r => !r.is_completed).length;
if (outstanding === 0) return true;
return window.confirm(window.t('tasks.detail.involved_outstanding', { n: outstanding }));
}π΄ A warning, never a block. A gate would mean one person leaving makes a task permanently uncloseable, and would hand everybody on it a veto β co-ownership again, the thing one-owner exists to avoid.
The card mark and the outline avatars are pure CSS:
.assignee-badge.involved-badge {
background: transparent;
border: 1px dashed var(--border, #c9c9c9);
margin-left: -4px; /* a slight overlap, so a row reads as a group */
}Outlined rather than filled so the person accountable still reads first at a glance.
β οΈ Editingassets/js/tasks.jsorassets/css/tasks.cssmeans bumping?v=NNin all five task pages:tasks/index.php,tasks/table/,tasks/timeline/,tasks/calendar/,tasks/settings/.
tasks_collaborator_completion, off by default, read through one function so the rule has one home:
public static function collaboratorCompletionEnabled(PDO $conn): bool
{
try {
$stmt = $conn->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'tasks_collaborator_completion'");
$stmt->execute();
$v = $stmt->fetchColumn();
} catch (Exception $e) {
return false; // pre-upgrade DB: behave as shipped
}
return is_string($v) && in_array(strtolower(trim($v)), ['1', 'on', 'true', 'yes'], true);
}is_completed is recorded whether or not the setting is on. Turning the setting off must hide ticks, never destroy them β the same rule that governs narrowing tasks_time_scope when somebody has already logged hours.
The tab is declared once in tasks/settings/manifest.php, which is what generates both the tab bar and the capability:
[
'id' => 'involved',
'cap' => capKey(Cap::TASKS_INVOLVED),
'label_key' => 'tasks.settings.tab_involved',
'grant' => 'Configure whether people on a task tick off their own part',
'setting_keys' => ['tasks_collaborator_completion'],
],Note what is not behind that capability: adding people. That is everyday work, the same call that keeps creating a tag off the tags capability. The capability guards the one administrative choice. See Admin Access Control.
box.disabled = true;
const data = await fetch(API_BASE + 'get_settings.php').then(r => r.json());
if (!data.success) return;
box.checked = String(data.settings.collaborator_completion || '0') === '1';
box.disabled = false;The Involved tab also holds the second "who is on this task" switch: whether the task screen offers the Contractor and Contact there pair. Built for #218 part A slice 1 (changelog #2721); the agreed spec is Simpler ticket and task forms - design, 2.4, and the user wording is in Tasks - Contractors. How contractors themselves are stored, chosen and emailed is Projects internals 10 - Contractors.
π It is presentation only. Nothing is refused, nothing is cleared. The service layer, the REST API and Projects never read it. Switching contractors off for real is switching the Contracts module off, because contractors are Contracts suppliers.
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/tenant_settings.php |
SETTING_TASK_CONTRACTOR_FIELDS and taskContractorFieldsOn()
|
| π | api/tasks/contractor_fields.php |
new. GET the install default and each company's answer; POST either |
| π | api/tasks/get.php |
sends contractor_fields with each task, resolved for that task's company |
| π₯οΈ | tasks/index.php |
window.TASK_CONTRACTOR_FIELDS, for the active company - only the sidebar filter reads it |
| π₯οΈ | assets/js/tasks.js |
contractorFieldHtml() (the read-only branch), refreshContractorFilter()
|
| π₯οΈ | assets/css/tasks.css |
.detail-field .detail-hint - the note under the read-only contractor |
| π₯οΈ |
tasks/settings/index.php Β· tasks/settings/manifest.php
|
the Show / Hide select and the per-company table; the key under the Involved tab |
No schema change: the install default is a system_settings row and each company's answer a tenant_settings row, both of which already exist.
const SETTING_TASK_CONTRACTOR_FIELDS = 'tasks_contractor_fields';
/** Should the task screen offer the Contractor and Contact there fields for this company? */
function taskContractorFieldsOn(PDO $conn, ?int $tenantId): bool
{
return tenantSettingOn($conn, $tenantId, SETTING_TASK_CONTRACTOR_FIELDS, true);
}tenantSettingOn() is the shared per-company yes/no reader. In SQL terms it asks, in order, and stops at the first answer:
-- 1. the company's own answer (only when a company id is given)
SELECT setting_value FROM tenant_settings WHERE tenant_id = ? AND setting_key = 'tasks_contractor_fields';
-- 2. the install-wide default
SELECT setting_value FROM system_settings WHERE setting_key = 'tasks_contractor_fields';
-- 3. neither: the caller's default, trueand anything but '0' is on. So an install that has never touched it shows the fields, and a company with no row of its own follows the install default rather than being "off". $tenantId = null skips step 1 and asks the install default only - which is what the settings screen's default select and the all-companies view use.
api/tasks/contractor_fields.php sits behind the same two gates as the rest of the tab:
requireModuleAccessJson('tasks');
requireCapabilityJson(Cap::TASKS_INVOLVED);The write keeps the two levels apart:
if (array_key_exists('default', $in)) {
$conn->prepare("INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)")
->execute([SETTING_TASK_CONTRACTOR_FIELDS, !empty($in['default']) ? '1' : '0']);
}
foreach ((array) ($in['companies'] ?? []) as $c) {
$tid = (int) ($c['id'] ?? 0);
// Only companies this analyst may administer.
if ($tid <= 0 || !array_key_exists('value', (array) $c) || !analystCanAccessTenant($conn, $analystId, $tid)) continue;
setTenantSetting($conn, $tid, SETTING_TASK_CONTRACTOR_FIELDS, $c['value'] === null ? null : (!empty($c['value']) ? '1' : '0'));
}β οΈ nullfor a company is notfalse.setTenantSetting()turns anullinto aDELETEof that company's row, which hands the company back to the install default.falsestores'0'- "this company hides them, whatever the default says". The screen's Follow the default option sendsnull.-
array_key_exists, notisset, both fordefaultand for each company'svalue- becausenullis a meaningful value, andisset()would treat it as absent and skip it. -
Companies are filtered on the way out as well as in. The GET lists only companies the analyst can reach (
analystCanAccessTenant()), and the POST silently skips any other id. A crafted request cannot set another company's answer. -
tenantSettingForget()after the commit empties the per-request cache, so anything later in the same request reads the new value. - The GET's
valueistrue,falseornull, wherenullmeans "no row - follows the default":
$companies[] = ['id' => $tid, 'name' => $t['name'],
'value' => array_key_exists($tid, $overrides) ? $overrides[$tid] !== '0' : null];The task window asks about the task's own company. api/tasks/get.php resolves it per task:
// A task with no company belongs to the Default company, which can have its own answer.
$task['contractor_fields'] = taskContractorFieldsOn($conn, isset($task['tenant_id']) ? (int)$task['tenant_id'] : getDefaultTenantId($conn));So in the all-companies view, a task from a company that hides the pair shows it hidden, and the task beside it from a company that shows it shows the dropdowns. A task with no company is not asked about the install default; it belongs to the Default company, which can have its own row.
The sidebar filter asks about the active company. It is built from the whole loaded list, not one task, so it cannot use a per-task answer. tasks/index.php works out one answer for the page:
$taskContractorFields = true;
try {
require_once __DIR__ . '/../includes/tenant_settings.php';
$__c = connectToDatabase();
$__t = getActiveTenantId($__c, (int) ($_SESSION['analyst_id'] ?? 0));
$taskContractorFields = taskContractorFieldsOn($__c, isActiveTenantAll($__c) ? null : $__t);
} catch (Throwable) { /* keep them shown */ } window.TASK_CONTRACTOR_FIELDS = <?php echo json_encode($taskContractorFields); ?>;In one company it is that company's answer. In the all-companies view there is no one company, so it is the install default - which can disagree with individual companies in the list. A failure keeps the fields shown, the behaviour before 4.9.0.
function contractorFieldHtml(task) {
const label = `<label>${esc(window.t('tasks.detail.contractor'))}</label>`;
// GH #218: the company hides the pair. Nothing to choose - but a contractor already
// on the task stays on show, read-only, rather than counting where nobody can see it.
if (task.contractor_fields === false) {
if (!task.supplier_id) return '';
return `<div class="detail-field">${label}<div class="detail-project-readonly">${esc(task.supplier_name)}${task.contact_name ? ' · ' + esc(task.contact_name) : ''}</div>
<div class="detail-hint">${esc(window.t('tasks.detail.contractor_hidden_note'))}</div></div>`;
}
if (!contractorChoices) {
if (!task.supplier_id) return '';
return `<div class="detail-field">${label}<div class="detail-project-readonly">${esc(task.supplier_name)}${task.contact_name ? ' · ' + esc(task.contact_name) : ''}</div></div>`;
}-
Hidden, no contractor: an empty string. The pair is one
.detail-rowin the panel, so returning nothing removes both fields together with no gap left behind. -
Hidden, with a contractor: the firm (and person) as read-only text in the same
.detail-project-readonlystyle the Project field uses, plus the note "Contractor fields are hidden for this company. This one stays recorded." Hiding it completely would leave a task assigned to an outside firm with nobody on screen able to see that. -
Shown - as before 4.9.0: read-only for someone who cannot choose a contractor (no Contracts access,
contractorChoices === false), otherwise the two dropdowns.
=== false, strictly. A task object without the key - built by anything other than get.php - gives undefined and falls through to the old behaviour. Only an explicit "hidden" hides anything.
The note's style is new and small:
/* GH #218: a note under a read-only field, e.g. a contractor kept while contractor fields are hidden. */
.detail-field .detail-hint { margin-top: 4px; font-size: 12px; color: var(--text-muted, #666); }Why saves never touch the hidden contractor. The only thing in the task screen that writes a contractor is setTaskContractor(), called from the two dropdowns' onchange. With the pair hidden there are no dropdowns, so nothing sends assigned_supplier_id or assigned_contact_id. And the update in includes/services/tasks.php is patch-style - it only looks at the contractor when one of those keys is present:
if ((array_key_exists('assigned_supplier_id', $in) || array_key_exists('assigned_contact_id', $in)) && array_key_exists('assigned_supplier_id', $current)) {So changing the status, the title or anything else on a task with a hidden contractor leaves tasks.assigned_supplier_id and assigned_contact_id exactly as they were. Switching the fields back on shows the dropdowns with the same firm selected.
The sidebar filter is emptied rather than removed:
const seen = new Map();
// GH #218: the company hides contractor fields, so the filter goes with them.
if (window.TASK_CONTRACTOR_FIELDS !== false) {
tasks.forEach(t => { if (t.supplier_id && !seen.has(t.supplier_id)) seen.set(t.supplier_id, t.supplier_name); });
}
section.style.display = seen.size ? '' : 'none';
if (!seen.size && currentContractorFilter) currentContractorFilter = '';With nothing in seen, the existing "no task has a contractor" path does the rest: the section hides, and a contractor filter that was already chosen is cleared - so the board is not left filtered by a control nobody can see.
The same code draws the task opened inside a ticket and the task on a phone, so both follow the setting with nothing extra.
| Place | With the fields hidden |
|---|---|
| The board card | Still shows the contractor chip (task-card-contractor in tasks.js) for a task that has one - the switch hides the fields and the filter, not the record |
includes/services/tasks.php |
Never reads the setting: create and update still accept a contractor from any caller |
| The REST API | Still reads and writes assigned_supplier_id / assigned_contact_id
|
| Projects | A contractor can still be a project member, and the Projects AI assistant can still assign one to a task |
| Contractor emails | Still sent to the contact, if Projects -> Settings has them on |
<select id="contractorFieldsDefault" class="settings-table-select" style="max-width: 320px;" disabled>The default select starts disabled and is only enabled once the GET succeeds - the same rule as the unloaded-checkbox trap above. A select that failed to load still shows its first option, Show, and a change would save that guess as a decision. The per-company table (#contractorFieldsCompanies) is only filled and un-hidden when the install has more than one company; each row has Follow the default ('', sent as null), Show and Hide, and saves on change with just that company in the body. A single-company install sees one select and no table.
The key is listed in the manifest beside tasks_collaborator_completion, so the tab's capability owns it:
'setting_keys' => ['tasks_collaborator_completion', 'tasks_contractor_fields'],There is no automated test: nothing under tests/ refers to tasks_contractor_fields, taskContractorFieldsOn() or contractor_fields. It was checked on a test install. To check it again:
- Give a task a contractor and a contact. On another task leave it empty.
- Tasks -> Settings -> Involved -> Contractor fields on tasks: Hide. Reload Tasks.
- The empty task has no Contractor or Contact there fields and no gap where they were. The task with a contractor shows it read-only with the note. The sidebar Contractor filter is gone.
- On the task with a contractor, change its status and its title. Then set the switch back to Show and reload: the same firm and person are selected.
- On a multi-company install, set the default to Show and one company to Hide. In that company the fields are hidden; in another they show; in the all-companies view each task follows its own company, and the filter follows the default. Set the company to Follow the default and check its
tenant_settingsrow is gone:
SELECT tenant_id, setting_value FROM tenant_settings WHERE setting_key = 'tasks_contractor_fields';- With the fields hidden, give the task a contractor through the REST API: it is saved, and the task shows it read-only.
php tests/task-collaborators/run.php β 43 assertions, and two habits worth copying.
Assert every surface separately.
ok('module list ("My Tasks") shows it', in_array($taskId, $listFilter($helperId), true));
ok('REST ?involved_analyst_id= shows it', in_array($taskId, $restInvolved($helperId), true));
ok('REST ?collaborator_id= shows it', in_array($taskId, $restCollaborator($helperId), true));A single assertion against api/tasks/list.php would pass while the REST API still hid the task.
Pair every positive with a control. Each of the three above would also pass if the filters simply returned every task:
ok('CONTROL β somebody not on it does NOT see it', !in_array($taskId, $listFilter($strangerId), true));
ok('CONTROL β removing somebody shrinks the audience', $aud('task.comment_added', $p) === $smaller);
ok('CONTROL β a task with nobody involved reaches the owner alone', ...);And prove a guard is load-bearing by removing it and watching the suite go red. The router branch in Β§5 was checked that way: the two audience assertions failed, and only those two.
User-facing page: People on a task β "Involved". Related: Tasks, Notifications β Developer Guide, REST API β Tasks, Service Layer Architecture.