-
-
Notifications
You must be signed in to change notification settings - Fork 27
People on a Task 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".
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 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' => 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;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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- 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: 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
- 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
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« 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: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ 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
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π 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
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
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
- β³ π’ 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