People on a Task Developer Guide - edmozley/freeitsm GitHub Wiki

People on a task ("Involved") β€” Developer Guide

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


0. 🈯 A naming rule you need before you read the code

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.


1. πŸ“ The files involved

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.


2. πŸ—„οΈ The table, and the one decision everything else follows

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.


3. βš™οΈ The service layer holds the rules

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.

Company scope

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.');
    }
}

⚠️ The picker is the same question and must use the same answer. A list offering analysts from other companies is a disclosure on its own, before anybody is added and whether or not the save would refuse it β€” so api/tasks/collaborators.php filters its candidate list through the same check rather than validating only on save. See Multi-Tenancy β€” Isolation.

Reading, in one query rather than one per card

public static function collaboratorsForMany(PDO $conn, array $taskIds): array

The 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.


4. πŸ”Œ The four filters that must move together

πŸ”΄ 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.

The mark is worked out server-side

$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.


5. πŸ”” Notifications: the router learns to talk to more than one person

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.

The trap this design sets for itself

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.

Rule 1 moves inside the loop

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.

Adding a toggle is one line

'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.

⚠️ A new event has to be dispatched before it can be subscribed to. Nothing announced that a task had been commented on or moved, so three dispatches were added to TasksService. Workflows gain the triggers for free, which is the right trade either way.

⚠️ Compare, do not just detect presence:

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.


6. πŸ–₯️ The browser side

Involve me, from the board's right-click menu (#276)

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.php already sends collaborators with every task, so buildSubmenus() checks whether cfg.currentAnalystId is among them and sets the label and data-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 currentAnalystId to TasksCtxMenu.init(); the table and timeline menus show neither Assign to me nor Involve me.

TRAP: a menu item that acts on "me" needs currentAnalystId in the page's init() config. Without it setField('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();
}

⚠️ The picker is built from 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 it

The 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.

⚠️ Editing assets/js/tasks.js or assets/css/tasks.css means bumping ?v=NN in all five task pages: tasks/index.php, tasks/table/, tasks/timeline/, tasks/calendar/, tasks/settings/.


7. βš™οΈ The setting

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.

The unloaded-checkbox trap

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;

⚠️ An unloaded checkbox looks exactly like an unticked one, and the next save writes that guess back as fact. The box starts disabled and is only enabled once a real value has arrived; a failed fetch leaves it disabled rather than showing a confident "off".


8. πŸ—οΈ Contractor fields on tasks: Show / Hide (4.9.0)

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.

The files

🎨 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.

The setting: per company, over the install default

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, true

and 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.

The API: the default and the companies are different writes

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'));
        }
  • ⚠️ null for a company is not false. setTenantSetting() turns a null into a DELETE of that company's row, which hands the company back to the install default. false stores '0' - "this company hides them, whatever the default says". The screen's Follow the default option sends null.
  • array_key_exists, not isset, both for default and for each company's value - because null is a meaningful value, and isset() 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 value is true, false or null, where null means "no row - follows the default":
                $companies[] = ['id' => $tid, 'name' => $t['name'],
                                'value' => array_key_exists($tid, $overrides) ? $overrides[$tid] !== '0' : null];

Two answers on the task screen, for two different questions

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.

The browser: three branches in contractorFieldHtml()

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 ? ' &middot; ' + 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 ? ' &middot; ' + esc(task.contact_name) : ''}</div></div>`;
    }
  1. Hidden, no contractor: an empty string. The pair is one .detail-row in the panel, so returning nothing removes both fields together with no gap left behind.
  2. Hidden, with a contractor: the firm (and person) as read-only text in the same .detail-project-readonly style 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.
  3. 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.

What it does not change

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

The settings screen

            <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'],

Checking it by hand

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:

  1. Give a task a contractor and a contact. On another task leave it empty.
  2. Tasks -> Settings -> Involved -> Contractor fields on tasks: Hide. Reload Tasks.
  3. 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.
  4. 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.
  5. 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_settings row is gone:
SELECT tenant_id, setting_value FROM tenant_settings WHERE setting_key = 'tasks_contractor_fields';
  1. With the fields hidden, give the task a contractor through the REST API: it is saved, and the task shows it read-only.

9. πŸ§ͺ Tests

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.

⚠️ **GitHub.com Fallback** ⚠️