Skip to content

Projects Internals 10 Contractors

Ed Mozley edited this page Oct 9, 2026 · 2 revisions

πŸ—οΈ 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


1. The idea in one paragraph

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.

2. Files

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

3. Schema

-- 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 $projectFks list. Both column pairs are in includes/db_verify_schema.php.
  • tasks is created before suppliers in database/freeitsm.sql; that works because the file runs with FOREIGN_KEY_CHECKS = 0.
  • A member row has exactly one of analyst_id, team_id, user_id, supplier_id; contact_id only ever goes with supplier_id. members() reports its kind as contractor.

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

4. The rules - includes/task_contractors.php

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

Saving - TasksService

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.

5. The Tasks screen - assets/js/tasks.js

  • contractorChoices is loaded once per page by loadContractorChoices() (alongside loadProjectChoices() in openDetailPanel()): 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 calls setTaskContractor(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, none or 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.

6. Projects

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.

7. Email - off by default

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 both

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

8. People

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.

9. REST API

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

10. Demo data

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.

11. Testing

  • 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 route task_contractor, which proves the gate, the route and the ledger without anything leaving the machine. Remember projectSettings() caches per request - pass true to 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 NULL when a supplier is deleted, Capacity (only open tasks, out of the load), the People sections, the facts and list_* tools, and the demo import with and without Contracts' demo data.

12. Not built yet

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

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally