Repository navigation
Projects Internals 10 Contractors
How a task is given to an outside firm (3.3.0): a supplier from Contracts, and optionally one person there, held on the task beside the analyst who owns it. The feature crosses four modules - Tasks (where it is stored and chosen), Projects (where it is planned, counted and chased), People (where a supplier's page lists its tasks) and Contracts (where suppliers and contacts live) - so it has a page of its own.
The user guide is Projects - Contractors.
Pages in this series: 1 Architecture Β· 2 Schema Β· 3 Services and API Β· 4 Plan, Timeline and dependencies Β· 5 Health and charts Β· 6 Governance Β· 7 Alerts, reports and AI Β· 8 Connections, templates, people Β· 9 Demo data, testing and traps Β· 10 Contractors
A contractor is who does the work, not who owns it. tasks.assigned_analyst_id stays the person here who owns the task and chases the firm; tasks.assigned_supplier_id (and assigned_contact_id) say which firm, and which person there, is doing it. So a contractor task normally has both. Everything else follows from that split: Capacity counts the hours against the firm, never against the analyst; the overdue digest and the AI say "contractor X" next to the owner; the email (when switched on) tells the contact who to reply to - the analyst. Suppliers and contacts are install-wide (Contracts has no companies), so a task in any company may name any supplier.
| File | What it holds |
|---|---|
includes/task_contractors.php |
Every rule. Readiness probe, the supplier-name SQL, reading contractors for a set of tasks, validating a supplier / contact pair, the choices list, the email and the reminders |
includes/services/tasks.php |
createTask() / updateTask() accept assigned_supplier_id / assigned_contact_id; contractorAllowed() is the Contracts rule |
api/tasks/contractors.php |
GET: the suppliers and their active contacts, for the pickers - allowed: false without Contracts |
api/tasks/list.php, api/tasks/get.php
|
Add supplier_id, supplier_name, contact_id, contact_name to each task |
api/v1/resources/tasks.php |
REST: contractor and contractor_contact on every task (apiTaskContractor()) |
assets/js/tasks.js |
The card chip, the Contractor filter, the two selects in the task window (contractorFieldHtml(), setTaskContractor()) |
tasks/index.php |
The #contractorFilterSection sidebar block |
includes/projects/read.php |
The Plan's tasks go through tasksWithContractors()
|
assets/js/projects-view.js, projects-timeline.js
|
The .prj-task-ctr chip on a Plan row; the firm in a Timeline bar's tooltip |
includes/services/project_tools.php |
addContractorMember(), contractorMemberSql(); members() and memberName() name contractor members |
assets/js/projects-tools.js, projects/view.php
|
The Contractor kind in Add someone (#pmCtrWrap, #pmSupplier, #pmContact) |
includes/projects/capacity.php, assets/js/projects-capacity.js
|
Contractor work out of people's load; $out['contractors'] and its table |
includes/projects/ai.php, includes/projects/nudges.php
|
AI facts and the overdue digest name the contractor |
includes/projects/assistant_chat.php |
Ask AI: list_suppliers, propose_task with a supplier, propose_task_contractor
|
includes/projects/alerts.php |
The full hourly scan calls tasksContractorReminders()
|
includes/projects/settings.php |
project_contractor_email (off / on, rule onoff) |
includes/people.php, people/includes/render.php
|
peopleContractorTasks() and pplSectionContractorTasks() - "Tasks with them" |
includes/email_log.php |
Send-log route task_contractor - "Task for a contractor" |
scripts/gen_projects_demo.php, api/system/import_demo_data.php
|
Demo contractor tasks; _optional_fields
|
-- tasks (3.3.0)
`assigned_supplier_id` INT NULL, -- the supplier doing the work
`assigned_contact_id` INT NULL, -- the person there (a contacts row of that supplier)
KEY `ix_tasks_supplier` (`assigned_supplier_id`),
CONSTRAINT `fk_tasks_supplier` FOREIGN KEY (`assigned_supplier_id`) REFERENCES `suppliers` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_tasks_contact` FOREIGN KEY (`assigned_contact_id`) REFERENCES `contacts` (`id`) ON DELETE SET NULL,
-- project_members (3.3.0): a firm, or one person there, on a project team
`supplier_id` INT NULL,
`contact_id` INT NULL,
CONSTRAINT `fk_pmem_supplier` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_pmem_contact` FOREIGN KEY (`contact_id`) REFERENCES `contacts` (`id`) ON DELETE CASCADE,-
Deleting a supplier or contact takes it off its tasks (
SET NULL- the task stays) and takes the member row off the team (CASCADE- a member with nobody behind it means nothing). - The task FKs are added by an inline block in
api/system/db_verify.php(next to the recurrence ones); the member FKs are in the$projectFkslist. Both column pairs are inincludes/db_verify_schema.php. -
tasksis created beforesuppliersindatabase/freeitsm.sql; that works because the file runs withFOREIGN_KEY_CHECKS = 0. - A member row has exactly one of
analyst_id,team_id,user_id,supplier_id;contact_idonly ever goes withsupplier_id.members()reports itskindascontractor.
The guard. Code must work on an install that has the new code but has not run Database Verification:
function tasksContractorReady(PDO $conn): bool
{
static $ready = null;
if ($ready === null) {
try { $conn->query("SELECT assigned_supplier_id, assigned_contact_id FROM tasks LIMIT 0"); $ready = true; }
catch (Throwable $e) { $ready = false; }
}
return $ready;
}Every reader checks it (or, for the member columns, contractorMemberSql() does its own probe and returns NULL AS ... columns).
| Function | Does |
|---|---|
tasksSupplierNameSql($alias) |
COALESCE(NULLIF(sp.trading_name, ''), sp.legal_name) - the name a supplier goes by, everywhere |
tasksContractors($conn, $taskIds) |
[task_id => {supplier_id, supplier_name, contact_id, contact_name, contact_email}] in one query; tasks with none are absent |
tasksWithContractors($conn, $rows) |
Adds supplier_id, supplier_name, contact_id, contact_name to rows that each have an id - used by the Tasks list and get, and the Plan |
tasksContractorValidate($conn, $supplierId, $contactId) |
Returns [sid, cid]. The supplier must exist; a contact must exist, work for a supplier, and work for that supplier; a contact alone brings its supplier. Throws ServiceError('validation', 'invalid_field', ...)
|
tasksContractorChoices($conn) |
Every supplier with its active contacts {id, name, job_title, has_email} - for the pickers and Ask AI |
tasksContractorEmailOn($conn) |
projectSetting('project_contractor_email') === 'on' |
tasksContractorEmail($conn, $taskId, $kind) |
assigned, due or overdue. Quiet: returns false when the setting is off, there is no contact or no valid email, or the send fails; never throws |
tasksContractorReminders($conn) |
From the hourly scan: due in the next two days, or missed in the last seven - each once per due date |
Create and update both go through the same validation, and both check the Contracts rule only when a contractor is set or changed, so a client that sends back an unchanged task body is never refused:
// updateTask() - a supplier change drops a contact who works for the old one
$sIn = array_key_exists('assigned_supplier_id', $in) ? $in['assigned_supplier_id'] : $curS;
$cIn = array_key_exists('assigned_contact_id', $in) ? $in['assigned_contact_id']
: (($sIn === '' || $sIn === null || (int)$sIn !== $curS) ? null : $curC);
[$newS, $newC] = tasksContractorValidate($conn, $sIn, $cIn);
if ($newS !== $curS || $newC !== $curC) {
self::contractorAllowed($conn, $ctx); // 403 without Contracts
$updates[] = 'assigned_supplier_id = ?'; $args[] = $newS;
$updates[] = 'assigned_contact_id = ?'; $args[] = $newC;
$emailContractor = $newC !== null && $newC !== $curC;
}private static function contractorAllowed(PDO $conn, ActorContext $ctx): void
{
if ($ctx->actorId > 0 && !analystCanAccessModule($conn, $ctx->actorId, 'contracts')) {
throw new ServiceError('forbidden', 'forbidden', 'Choosing a contractor needs access to Contracts.');
}
}The rule lives in the service, not the endpoint, because three callers reach it: the Tasks board (api/tasks/save.php), the REST API (api/v1/resources/tasks.php) and Ask AI (projectChatApplyOne()). The system (actorId 0) may always.
| Body sent | Result |
|---|---|
{assigned_supplier_id: 3} |
Supplier 3, no contact |
{assigned_supplier_id: 3, assigned_contact_id: 5} |
Both - contact 5 must work for supplier 3 |
{assigned_contact_id: 5} (supplier unchanged or absent) |
Contact 5 and its supplier |
{assigned_supplier_id: 1} on a task with supplier 3, contact 5 |
Supplier 1, contact cleared |
{assigned_supplier_id: null, assigned_contact_id: null} |
Both cleared |
{assigned_contact_id: 1} where 1 works for another supplier |
422 invalid_field
|
| Any change, by an analyst without Contracts | 403 forbidden
|
createTask() validates before anything is written and sets the pair with an UPDATE after the insert, next to the estimate. ProjectsService::createTaskInProject() strips the pair from the create, sets the project, then saves the pair as an update - so the assignment email can name the project.
Seeing a contractor needs nothing beyond seeing the task, like a team or project name. Choosing one needs Contracts. api/tasks/contractors.php returns {allowed: false} without it, and the task window then shows the firm as read-only text.
-
contractorChoicesis loaded once per page byloadContractorChoices()(alongsideloadProjectChoices()inopenDetailPanel()):null= not loaded,false= may not choose, otherwise the list. -
contractorFieldHtml(task)draws Contractor and Contact there under the assignee row; contacts without an email say no email. Changing the supplier callssetTaskContractor(id, supplierId, null), which clears the contact. - The card chip (
.task-card-contractor) shows the firm and, after a dot, the person. - The filter mirrors the Project filter:
currentContractorFilter('',any,noneor a supplier id),taskMatchesContractor()in the three places that filter the board and list,refreshContractorFilter()after each load - the section is hidden until a loaded task has a contractor.
Plan and Timeline. projectRead() passes its tasks through tasksWithContractors(); a Plan row adds <span class="prj-task-ctr"> before the due date (the avatar stays the owner); the Timeline adds the firm to the bar's tooltip.
Members. addMember() hands any body with supplier_id or contact_id to addContractorMember(), which checks readiness, the Contracts rule, validates the pair (wrapping the error as a ServiceError), refuses a duplicate (supplier_id = ? AND contact_id <=> ?) and inserts. A contractor member's name is the contact's name, or the supplier's when it is the firm as a whole; supplier_name and the contact's job_title come with it. RACI letters and the stakeholder map key on the member id, so they work unchanged. The Add someone dialog shows the Contractor kind only when api/tasks/contractors.php says allowed.
Capacity. projectCapacity() selects t.assigned_supplier_id and the supplier name (when ready), includes tasks that have only a supplier, and takes them out before the analyst branch:
if ($t['assigned_supplier_id'] !== null) {
$sid = (int)$t['assigned_supplier_id'];
$c = &$out['contractors'][$sid];
$c ??= ['supplier_id' => $sid, 'name' => (string)$t['supplier_name'], 'tasks' => 0, 'hours' => 0.0, 'late' => 0, 'no_estimate' => 0];
$c['tasks']++;
if ($remaining !== null) $c['hours'] += $remaining; else $c['no_estimate']++;
if ($t['due_date'] !== null && $t['due_date'] < $today) $c['late']++;
unset($c);
continue; // never in an analyst's load
}$out['contractors'] is a list sorted by name; the page draws it as a table under the people.
Digest, facts and Ask AI. The overdue digest payload gains tasks[].contractor_name. projectAiFacts() appends contractor <name> to each task line and names a contractor stakeholder by its supplier. Ask AI (part 7):
| Tool | |
|---|---|
list_tasks |
Each line ends ; contractor <name>
|
list_suppliers |
#id name - contacts: #id name (job) [no email]. Refused in the run closure when the user cannot open Contracts, so the model is told not to offer contractors |
propose_task |
Takes supplier_id and contact_id; the card reads ... - done by Nexus IT (Mark Evans)
|
propose_task_contractor |
{task_id, supplier_id, contact_id} - give an existing task to a firm; supplier_id 0 takes it back |
propose_member |
Takes supplier_id / contact_id as well as analyst_id, so the firm can go on the team. The prompt tells the model that giving tasks does not add a member - the first real conversation gave ten tasks to a firm and left the People tab empty, which read as "nothing happened" |
The run closure validates the pair and looks up the names (_supplier, _label) when the proposal is made; projectChatApplyOne() checks Contracts again when it is applied, because with shared memory the person applying may not be the one who asked. The apply rank puts task_contractor with task_dates, after new tasks.
project_contractor_email (off / on) is a Projects setting because the reminders run on the Projects scan, but it covers every task with a contractor, in a project or not.
| Kind | Sent when | Subject |
|---|---|---|
assigned |
A contact is set or changed (create or update), never on an unrelated edit | New task: <title> |
due |
Due in one or two days, open, with a contact | Due soon: <title> |
overdue |
Missed in the last seven days (not today), open, with a contact | Overdue: <title> |
The body is built in tasksContractorEmail(): a greeting by first name, the task, the project, the firm, start and due dates, the description as plain text, and "Your contact at is (). Please reply to them, not to this email." It is sent with ssSendSystemEmail(..., 'task_contractor'), so a failure (no mailbox, a bad token) is a send-log row, not an error.
Each reminder is claimed in the ledger before it is sent, so it goes once per due date:
$claim = $conn->prepare("INSERT IGNORE INTO workflow_scheduled_emissions (trigger_event, entity_key, fingerprint, emitted_datetime)
VALUES ('task.contractor_reminder', ?, ?, UTC_TIMESTAMP())");
$claim->execute(['task_contractor:' . $taskId . ':' . $kind, $dueDate]); // moving the date re-arms bothprojectAlertsScan() calls tasksContractorReminders() on the full scan only ($projectId === null) - the reminders are install-wide, and the per-project scan after a save would otherwise run them for everybody.
supplierDetail() and supplierContactDetail() add sections['tasks'] from peopleContractorTasks($conn, $analystId, ['supplier' => id]) or ['contact' => id] - when the reader can open Tasks. Rows are scoped to the reader's companies (t.tenant_id) and leave out tasks in a members-only project the reader cannot see; open first, then by due date. pplSectionContractorTasks() draws the card (Task, Status, Due, Project, Person there, Chased by), and pplStats() adds an open of total tile.
Every task in REST API: Tasks carries:
"contractor": { "id": 3, "name": "Nexus IT" },
"contractor_contact": { "id": 5, "name": "Mark Evans" }(both null when there is none). Create and update accept assigned_supplier_id and assigned_contact_id with the rules in section 4; the catalogue (api/v1/spec.json) lists the 422 and 403 cases, and api/v1/lib/openapi_schemas.php has both properties on the Task schema. That file was edited by hand: openapi_fix.php rewrites it without its comments (see the TRAP in part 9).
scripts/gen_projects_demo.php gives the Bradford move's cabling, Wi-Fi and internet line to Nexus IT (Mark Evans on two of them), the switch order to TechDirect (Rachel Green), and puts Nexus on the team with Mark as the contact. The suppliers and contacts are Contracts' demo rows, matched by name and email (_skip_insert).
Contracts' demo data may not be imported, and a demo task must not vanish because of that, so the importer gained a per-field opt-out:
{ "_ref": "po_cabling", "title": "Cabling installed and tested on every floor",
"assigned_supplier_id": "@suppliers.sup_nexus", "assigned_contact_id": "@contacts.con_mark",
"_optional_fields": ["assigned_supplier_id", "assigned_contact_id"] }// api/system/import_demo_data.php
foreach ((array)($record['_optional_fields'] ?? []) as $f) {
$v = $record[$f] ?? null;
if (is_string($v) && strpos($v, '@') === 0 && !isset($idMap[substr($v, 1)])) unset($record[$f]);
}
unset($record['_optional_fields']);_optional (the whole record goes) is still right for the member row: a contractor member with no supplier is nothing.
-
Never send a real email. Test the email path on a scratch database with no mailbox: with the setting on,
ssSendSystemEmail()logs a failed row with routetask_contractor, which proves the gate, the route and the ledger without anything leaving the machine. RememberprojectSettings()caches per request - passtrueto refresh it after writing the setting in a test. - Test the Contracts rule as an analyst without Contracts (the scratch demo's analysts 2 and 3).
- The checks that ran for 3.3.0: validation (wrong supplier's contact, contact alone, supplier change clears the contact, clearing), the rule (create and update refused, unchanged body allowed), the REST serializer, members (name, duplicate), Ask AI apply and take-back, the FK
SET NULLwhen a supplier is deleted, Capacity (only open tasks, out of the load), the People sections, the facts andlist_*tools, and the demo import with and without Contracts' demo data.
Contractors cannot sign in or update their own tasks (there is no supplier portal); the REST API cannot filter tasks by contractor; Capacity has no hours-per-week for a firm, so it lists their work rather than a load; the Tasks table and timeline views do not have a contractor column or filter; the email is English only (the contact has no language), and its wording is fixed; a contractor member cannot be the owner of a RAID entry or a benefit; templates do not carry contractors.
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: Projects
- β³ π 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
- π Projects
-
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
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96