Skip to content

People on a Task Developer Guide

Ed Mozley edited this page Sep 2, 2026 · 1 revision

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


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

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'          => 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. πŸ§ͺ 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.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally