Projects Internals 10 Contractors - edmozley/freeitsm GitHub Wiki
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.