Repository navigation
Projects Internals 3 Services and API
Every write in the Projects module goes through two service classes, ProjectsService (includes/services/projects.php) and ProjectToolsService (includes/services/project_tools.php), helped by ProjectReportsService and ProjectTemplatesService. Four doors reach them: the screens' own JSON endpoints in api/projects/, the REST API v1 (api/v1/resources/projects.php), and - read-only - the MCP server and Warbot, which share one file of answers (includes/projects/assistant.php). This page documents the services method by method with the rule each one enforces, then every endpoint with its parameters, actions, permissions and response shape, then the REST routes, then the two assistant doors. Schema detail is on page 2; the plan-specific writes (stages, tasks, milestones, dependencies) are explained in depth on page 4.
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
- The shape of a write
- ProjectsService
- ProjectToolsService
- ProjectReportsService and ProjectTemplatesService
- The endpoint bootstrap
- Every endpoint in api/projects/
- api/projects/tools.php - every action
- Calling the endpoints from the page: P.api()
- REST API v1
- MCP and Warbot: one file of answers
- Adding an endpoint or an action
projects/*.php + assets/js/projects*.js
β fetch, JSON (X-CSRF-Token added by assets/js/csrf.js)
βΌ
api/projects/*.php ββ includes/projects/api_bootstrap.php
β (session, requireModuleAccessJson('projects'), $conn, $ctx, $analystId)
β
βββ ProjectsService ββββββββββΊ projects, project_stages, project_audit, tasks.project_id
β ββ createTaskInProject() ββΊ TasksService::saveTask()
βββ ProjectToolsService ββββββΊ members, items, RACI, RAID, tolerances, gates, budget,
β β milestones, dependencies, benefits, change control ...
β ββ every method: ProjectsService::loadForActor() + assertCanChange()
βββ ProjectReportsService ββββΊ project_reports (+ includes/projects/ai.php)
βββ ProjectTemplatesService ββΊ project_templates (+ includes/projects/templates.php)
api/v1/resources/projects.php βββΊ the same services, ActorContext::fromApiKey()
api/mcp/index.php + includes/mcp/tools.php ββ
includes/warbot/tools.php βββββββββββββββββββ΄βΊ includes/projects/assistant.php (read-only)
The rules that hold everywhere:
-
Writes are unified, reads stay per surface - the Service Layer rule. The services never emit HTTP. They return ids (or small arrays) or throw
ServiceError($kind, $code, $message). Each door turns that into its own error shape:projectApiRun()into{success:false, error},apiFailFromService()into the REST error body with an HTTP status, Warbot into a sentence for the room, MCP into a tool result withisError: true. -
Every by-id method starts with
ProjectsService::loadForActor(). A project outside the caller's companies, or a members-only project they may not see, is not found, never forbidden, so ids cannot be probed. -
Every change checks
ProjectsService::assertCanChange()(Projects β Settings β General β Who may change a project).ProjectToolsServicehas no permission rules of its own: its privatechangeable()is exactlyloadForActor()thenassertCanChange(). -
A system actor is not asked.
ActorContext::actorId0 (the demo importer, scheduled work, the form action that creates a proposal) passesassertCanChange()and the create policy, because it is not a person. -
sourceon the history row isapiwhen theActorContextcame from an API key, otherwiseapp(ProjectsService::source(),ProjectToolsService::src()). -
Writes are POST only in
api/projects/, so a link or an image tag can never change a project. CSRF is enforced centrally byincludes/request_guard.php; nothing in the module opts in. See CSRF protection.
ServiceError kinds and how they surface:
kind |
Thrown for | REST status (serviceErrorHttpStatus()) |
api/projects/ |
|---|---|---|---|
validation |
a bad or missing field, a rule refused (missing_field, invalid_field, checklist_open, not_ready, ambiguous) |
422 | HTTP 200, {success:false, error}
|
bad_request |
an unparseable date ("2026-13-01" is not a date.) |
400 | HTTP 200, {success:false, error}
|
not_found |
no such row, or out of scope | 404 | HTTP 200, {success:false, error}
|
forbidden |
a permission rule | 403 | HTTP 200, {success:false, error}
|
conflict |
a duplicate member, a lesson already an article, an issue that already has a ticket | 409 | HTTP 200, {success:false, error}
|
unavailable |
not_ready before Database Verification (a few methods) |
422 (the default branch) | HTTP 200, {success:false, error}
|
Note
The screens' endpoints answer a refusal with HTTP 200 and success: false - projectApiFail() only sets a status when one is passed (401 for no session, 403 from the settings and templates endpoints' capability checks, 405 for a GET to a POST-only endpoint). The page's P.api() reads success, not the status. The REST API is the one that maps kinds to statuses.
includes/services/projects.php - projects, their stages, putting tasks in and out, and the events and history every other write reuses.
The one list of what a person or an API key may set on a project, and how each is validated. createProject(), updateProject() and the REST apiProjectFields() all start from it, so a body can never reach a column that is not on it (tenant_id, created_by_id, approval_*, currency, the alert columns are all absent on purpose).
public static function fieldMap(): array
{
return [
'name' => ['type' => 'string', 'max' => 200, 'required' => true],
'summary' => ['type' => 'text', 'max' => 20000],
'goal' => ['type' => 'string', 'max' => 500],
'methodology' => ['type' => 'enum', 'values' => array_keys(projectMethodologies())],
'status' => ['type' => 'enum', 'values' => projectStatuses()],
'health' => ['type' => 'enum', 'values' => projectHealthValues()],
'health_note' => ['type' => 'string', 'max' => 500],
'priority' => ['type' => 'enum', 'values' => projectPriorities()],
'owner_analyst_id' => ['type' => 'analyst'],
'start_date' => ['type' => 'date'],
'target_end_date' => ['type' => 'date'],
'actual_end_date' => ['type' => 'date'],
'colour' => ['type' => 'enum', 'values' => array_keys(projectColours())],
'icon' => ['type' => 'enum', 'values' => projectIcons()],
'business_case' => ['type' => 'text', 'max' => 50000],
'tailoring' => ['type' => 'tailoring'],
// Intake (3.3.0): a proposal's own figures - includes/projects/intake.php.
'estimated_cost' => ['type' => 'money'],
'estimated_benefit'=> ['type' => 'text', 'max' => 5000],
// Members-only (3.3.0): includes/projects/visibility.php.
'visibility' => ['type' => 'enum', 'values' => ['everyone', 'members']],
];
}validateField() per type:
| Type | Rule |
|---|---|
string |
trimmed; blank = NULL unless required (then Give the project a name.); over max is refused, never cut |
text |
as string, no required
|
enum |
must be one of values exactly (case-sensitive) |
date |
Y-m-d and a real date (DateTime::createFromFormat('!Y-m-d') must round-trip), else bad_request
|
tailoring |
an array or a JSON string; only keys in projectToolDefinitions() survive, each cast to bool; nothing left = NULL ("the method's defaults") |
money (3.3.0)
|
commas and spaces stripped, ^\d{1,15}(\.\d{1,2})?$, stored as sprintf('%.2f') - the same two-decimal string MySQL hands back for a DECIMAL(18,2), so same() sees an unchanged value as unchanged |
analyst |
blank or <= 0 = NULL; otherwise an active analyst |
assertDates($start, $end) refuses an end before a start, for the project's start/target pair and for every stage.
public static function saveProject(PDO $conn, ActorContext $ctx, array $in, ?int $tenantId = null): array
// ['id' => int, 'created' => bool] - updateProject() when $in['id'] is set, else createProject()createProject(PDO $conn, ActorContext $ctx, array $in, ?int $tenantId): int, in order:
-
storeTenant()turns the Default company into NULL (the way every scoped table stores it). With acompanyScope, the effective company must be in it (You cannot add projects for that company.). -
projectCanCreate()for a person (create policy -anyoneormanagers). - A name is required.
- Defaults filled when the key is absent (an explicit value always wins):
methodologyfromproject_default_method;visibilityfromproject_default_visibility(3.3.0, only once the column exists);owner_analyst_id= the creator. -
Intake (3.3.0):
projectProposalNeedsApproval($conn, !empty($in['_from_form']))readsproject_proposal_approval(forms / all / off). When it says yes,statusis forced toproposedandapproval_status = 'pending'is written. - Every
fieldMap()key present is validated and inserted.estimated_*are skipped beforeprojectIntakeReady(),visibilitybeforeprojectVisibilityReady(), so a create never fails on an install that has not run Database Verification. - A finished status (
closed/cancelled) stampsclosed_datetime. - Afterwards, outside any transaction:
audit('project_created'),projectStampCurrency()(the budget currency is fixed now, see page 6),syncCalendar(),dispatch('project.created'),proposalEvent('project.proposal_submitted')when pending and not from a form (the form action fires it itself once the proposer is stamped), thenafterChange().
updateProject(PDO $conn, ActorContext $ctx, int $id, array $in): int:
-
loadForActor()+assertCanChange(). - For each
fieldMap()key present and present as a column on the row (array_key_exists($field, $cur)- a column Database Verification has not added yet is skipped rather than failing the save), validate, and keep it only ifsame()says it changed. Nothing changed = return early, no history, no events. -
Visibility (3.3.0): changing
visibilityneedsprojectIsTeam() || projectIsManager()on top of the change policy - under "anyone may change", an outsider must not be able to shut the door. -
Intake (3.3.0):
projectProposalBlocksStatus()- a pending proposal may only stayproposedor go tocancelled(This proposal is waiting for approval. It can start once it is approved.). - Dates checked as a pair, using the new value where one was sent.
- Status transitions: becoming finished stamps
closed_datetimeand, forclosedwith noactual_end_dateset or sent,actual_end_date = UTC_DATE(); leaving a finished status clearsclosed_datetime. - One transaction: the
UPDATE, andapplyMethodology()whenmethodologychanged (page 4 Β§2). - After commit: one
audit()row per changed field, withauditDisplay()values;projectBaselineAuto(..., 'start')when the status becameactive(change control, page 6);syncCalendar();dispatch('project.updated', ['changed' => 'status,target_end_date']);afterChange().
auditDisplay() writes names, not ids: owner_analyst_id becomes the analyst's name (#12 if gone), tailoring is stored as NULL, and summary / business_case are cut to 117 characters plus .... The History tab (assets/js/projects-view.js) then shows changed the business case with no text at all for colour, icon, tailoring and business_case.
Returns ['id' => $id, 'tasks_detached' => n]. projectCanDelete() - the owner, the creator, or a manager, never affected by a setting. The event payload is read before the delete (eventFor()), because afterwards there is nothing to read. Then one transaction:
$st = $conn->prepare("UPDATE tasks SET project_id = NULL, project_stage_id = NULL WHERE project_id = ?");
// Benefit measurements, then RAID action joins (both via their parents), then:
foreach (['project_raci', 'project_members', 'project_items', 'project_raid', 'project_tolerances', 'project_budget_lines',
'project_reports', 'project_milestones', 'project_change_requests', 'project_baselines', 'project_benefits',
'project_gate_items', 'project_task_flow'] as $t) {
try { $conn->prepare("DELETE FROM `$t` WHERE project_id = ?")->execute([$id]); } catch (Throwable $e) { /* not created yet */ }
}
foreach (['project_stages', 'project_audit'] as $t) { ... } // no try: these always exist
// The project's own hourly rates (3.3.0, #2242): project_labour_rates has no project FK (ref_id is polymorphic)
try { DELETE FROM project_labour_rates WHERE scope = 'project' AND ref_id = ? } catch (Throwable $e) {}
projectLinksDeleteAll($conn, $id); // the six link tables, each in its own try
UPDATE status_planned SET project_id = NULL WHERE project_id = ? // announced disruption stays, unlinked
DELETE FROM projects WHERE id = ?π Tasks are detached, never deleted - the work people did must survive a project being tidied away. Every child table is deleted by hand even though the foreign keys cascade, because an upgraded install whose FKs failed to add has no cascade to rely on; each in its own try, so a table not created yet never stops a delete. After commit: syncCalendar() and project.deleted with the payload read earlier.
| Method | What it does |
|---|---|
loadRow($conn, $id) |
SELECT * FROM projects WHERE id = ?, or not_found. No scope - only for internal use after a scope check |
loadForActor($conn, $ctx, $id) |
loadRow(), then assertScope(), then projectVisibleTo() (members-only, 3.3.0). Either failure is not_found (Project not found.) |
loadStage($conn, $id) |
one project_stages row or not_found; callers check project_id themselves |
assertScope() (private)
|
no-op with no companyScope or on a single-company install; otherwise the row's company (NULL read as Default) must be in $ctx->companyScope
|
assertCanChange($conn, $ctx, $project) |
public; actorId <= 0 passes; otherwise projectCanChange() or forbidden (Only this project's team, or someone who manages Projects, can change it.) |
sameTenant() (private)
|
true on a single-company install; otherwise the two companies equal, NULL read as Default |
storeTenant() (private)
|
NULL or <= 0 β NULL; the Default company β NULL; anything else unchanged |
saveStage(PDO $conn, ActorContext $ctx, int $projectId, array $in): int, deleteStage(..., int $projectId, int $stageId): void, reorderStages(..., int $projectId, array $ids): void. Their rules - the preset's timebox as the new stage's kind, single_active, the automatic baseline on start, project.stage_closed, milestones and gate items on delete - are on page 4 Β§2. In short: the stage must belong to the project (not_found otherwise), a name of at most 150 characters is required, goal is cut to 500, and every field left out keeps its stored value, so a body of {id, name, status} changes only the status.
-
createTaskInProject(PDO $conn, ActorContext $ctx, int $projectId, ?int $stageId, array $in): int- makes the task withTasksService::saveTask(), the one create path, then files it under the project. It stripsid,ticket_idandparent_task_idand passes the project's company explicitly. Full walk-through on page 4 Β§3. -
assignTask(PDO $conn, ActorContext $ctx, int $taskId, ?int $projectId, ?int $stageId = null): void- in, between stages, or out ($projectIdnull). A subtask is refused when put into a project (A subtask goes with its parent task - put the parent in the project., #2242); taking one out is allowed. The task must be in scope; the caller must be allowed to change the project it is leaving and the one it joins; the task and the project must be in the same company (sameTenant()); a stage must belong to the project.
public static function audit(PDO $conn, int $projectId, ?int $analystId, string $field, $old, $new, string $source = 'app'): voidWrites one project_audit row; analyst_id NULL for the system; old and new cut to 1000 characters. It never throws - a history row that cannot be written is logged, never a failed save. field_name is a free key (stage_status, raid_escalated, change_approved ...); the History tab turns each into words through history.* strings.
| Helper | What it does | Throws? |
|---|---|---|
touchProject($conn, $id) (public wrapper of touch()) |
updated_datetime = UTC_TIMESTAMP() - used by every tool write so the portfolio's "updated" is honest |
yes (a plain UPDATE) |
syncCalendar($conn) |
projectSyncCalendar() - the full redraw of the Calendar's project entries (page 4 Β§9). Call it after the write, outside any transaction |
never |
afterChange($conn, $projectId) |
projectAlertsScan($conn, $projectId) - fire what the project's health and tolerances now say, as the person who made the change (page 7) |
never |
dispatch() (private)
|
projectDispatch($event, ['project' => eventFor()] + $extra) |
no |
eventFor() (private)
|
projectAlertRows($conn, $id)[0] through projectEventPayload() - the project as its events carry it, with the health it shows; null if it is gone |
never |
All go through projectDispatch() (includes/projects/alerts.php) β WorkflowEngine::dispatch(), so one call reaches workflows, webhooks and the bell. Each helper wraps itself in try and logs: an event that cannot be built never fails the write that caused it.
| Method | Event | Payload beyond project
|
Fired from |
|---|---|---|---|
dispatch() |
project.created / project.updated (changed: comma list of fields) / project.deleted
|
- | the three project writes |
stageClosed($conn, $pid, $stageId, ?$decision, ?$next) |
project.stage_closed |
stage {id, name, kind, end_date, status}, gate_decision, next_stage, unapproved_changes (count from projectUnapprovedChanges()) |
saveStage() when status becomes closed (decision null); decideGate() when it closes the stage |
raidEscalated($conn, $pid, $r) (3.3.0)
|
project.raid_escalated |
raid {id, type, title, due_date, owner_analyst_id}, note
|
ProjectToolsService::escalateRaid() |
signoffEvent($conn, $pid, $itemId) (3.3.0)
|
project.signoff_requested |
item {id, title, analyst_id}, stage {id, name}, notify_ids: [analyst]
|
saveGateItem() when a sign-off item gets (or changes) its person |
proposalEvent($conn, $pid, $event) (3.3.0)
|
project.proposal_submitted / project.proposal_decided
|
proposal {status, estimated_cost, estimated_benefit, business_case, notes, decided_by_id, proposed_by_name, proposed_by_email, proposed_by_analyst_id, submission_id}, notify_ids (approvers from projectProposalApprovers(), or the creator) |
createProject(), decideProposal(), the form action |
changeEvent($conn, $pid, $event, $cr) (3.3.0)
|
project.change_raised / project.change_decided
|
change_request {id, number, reference: "CR-3", title, status, impact_days, impact_cost, impact_scope, raised_by_id, decided_by_id, decision_notes} |
saveChangeRequest() (create), decideChangeRequest()
|
milestoneReached($conn, $pid, $m) (3.3.0)
|
project.milestone_reached |
milestone (projectMilestoneEventFields()), late_days (0 when on time) |
saveMilestone() when done_date goes from empty to set |
The time-based events (health_changed, tolerance_breached, stage_due, milestone_due, milestone_missed, the nudges, benefit reviews, scheduled reports) come from the alert scan, not from here - see page 7.
public static function decideProposal(PDO $conn, ActorContext $ctx, int $projectId, string $decision, $notes): array
// ['status' => 'proposed' | 'active' | 'cancelled']loadForActor(); decision is approved or rejected; the project must be approval_status = 'pending'; projectCanDecideProposal() (the named approver while active, or Manage Projects); a reason is required to reject. Rejecting sets status = 'cancelled' and closed_datetime; approving leaves it proposed, or makes it active when project_proposal_on_approve = active (then projectBaselineAuto(..., 'start')). The UPDATE is compare-and-set (... WHERE id = ? AND approval_status = 'pending') and checks rowCount() === 1, so two approvers pressing at once decide it once. Audited proposal_approved / proposal_rejected (+ a status row when it changed). The intake rules are on page 6.
includes/services/project_tools.php. Kept apart from ProjectsService so neither grows into one enormous file, with no permission rules of its own. The pattern of every public write:
public static function deleteTarget(PDO $conn, ActorContext $ctx, int $projectId, int $targetId): void
{
self::changeable($conn, $ctx, $projectId); // loadForActor() + assertCanChange()
$t = self::target($conn, $projectId, $targetId); // the child must belong to THIS project
$conn->prepare("DELETE FROM project_asset_targets WHERE id = ?")->execute([$targetId]);
ProjectsService::audit($conn, $projectId, $ctx->actorId, 'target_removed', $t['name'], null, self::src($ctx));
ProjectsService::touchProject($conn, $projectId);
ProjectsService::afterChange($conn, $projectId); // only where health or a tolerance can move
}Every child is looked up as WHERE id = ? AND project_id = ? (member(), item(), raidRow(), target(), milestone(), gateItem(), benefit(), changeRequest()), so an id from another project is not part of this project, never a cross-project write. The private helpers gateItem(), benefit() and changeRequest() turn a missing table into not_ready (Run System - Database Verification first.).
afterChange() (the per-project alert scan) is called by the writes that can move health or breach a tolerance: RAID save and delete, tolerances, targets, milestones, task dates, budget lines, rates, currency, gate decisions and change-request decisions. Writes that cannot (members, scope, RACI, gate items, benefits) do not call it.
| Method | Rules | Audit |
|---|---|---|
addMember($conn, $ctx, $pid, $in): int |
exactly one of analyst_id / team_id / user_id (the rule is here, not a CHECK); analyst must be active; team must exist; a person from People must be in the project's company (NULL read as Default); no duplicate of the same column and id (conflict, They are already on this project.); role_id must exist in project_roles or be empty; notes cut to 255; position = max + 1 |
member_added |
updateMember($conn, $ctx, $pid, $memberId, $in): void |
only keys present change: role_id, notes, and (3.3.0, once stakeReady()) power and interest (1-5, empty = not placed), stance (one of STANCES), keep_informed (255) |
member_role when the role changed; stakeholder_saved when any stakeholder field was sent |
removeMember($conn, $ctx, $pid, $memberId): void |
deletes the member's project_raci rows first, by hand, then the member |
member_removed |
members($conn, $pid): array |
read: id, analyst_id, team_id, user_id, role_id, role_name, notes, position, power, interest, stance, keep_informed, name, kind (analyst / team / person), email, job_title; [] before Verification |
- |
stakeReady($conn): bool |
probes the four stakeholder columns once per request; stakeColumns() selects NULL AS ... before them |
- |
const STANCES = ['champion', 'supporter', 'neutral', 'sceptic', 'blocker'];| Method | Rules | Audit |
|---|---|---|
saveItem($conn, $ctx, $pid, $in): int |
create (no id) or update; title required, max 255; moscow one of MOSCOW or empty (case-insensitive in, lower-case stored); status one of ITEM_STATUSES (default proposed); stage_id must belong to the project (stageOf()); description and acceptance_criteria cut to 20,000; keys left out keep their stored value |
item_added; item_moscow when MoSCoW changed on an update |
deleteItem($conn, $ctx, $pid, $itemId): void |
deletes its RACI rows, re-parents children (parent_id = NULL), then the item |
item_removed |
moveItem($conn, $ctx, $pid, $itemId, $moscow, array $orderedIds): void |
the board's drag: sets moscow, then rewrites position 1..n for the ids given (only ids in this project are touched) |
item_moscow when the column changed |
items($conn, $pid): array |
read with stage_name, by position, id
|
- |
const MOSCOW = ['must', 'should', 'could', 'wont'];
const ITEM_STATUSES = ['proposed', 'agreed', 'in_progress', 'accepted', 'dropped'];setRaci($conn, $ctx, $pid, $itemId, $memberId, string $letter): array - one cell. The item and the member must both belong to the project. R, A, C, I (upper-cased) or '' to clear. Returns the whole row afterwards as {member_id: letter}, so the screen can redraw a demoted cell without a reload.
if ($letter === 'A') {
// One accountable person per deliverable: the previous A keeps doing the work, as R.
$conn->prepare("UPDATE project_raci SET letter = 'R' WHERE item_id = ? AND letter = 'A' AND member_id <> ?")->execute([$itemId, $memberId]);
}
$conn->prepare("INSERT INTO project_raci (project_id, item_id, member_id, letter) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE letter = VALUES(letter)")->execute([$projectId, $itemId, $memberId, $letter]);π A second A demotes the first to R rather than refusing: the person clicking has just said who is accountable, and the previous one is still doing the work. A row with no A or no R is allowed (a draft); the screen flags it. RACI writes are not audited (they would flood the history); they touch the project. Read: raci($conn, $pid) β {item_id: {member_id: letter}}.
const RAID_TYPES = ['risk', 'assumption', 'issue', 'dependency', 'decision', 'lesson']; // dependency: 3.3.0
const RAID_RESPONSES = ['avoid', 'reduce', 'transfer', 'accept', 'share'];saveRaid($conn, $ctx, $pid, $in): int - create or update. Every key left out keeps its stored value, so the REST PATCH needs no merge.
-
typerequired; title required, max 255. -
Fields by type - other types store NULL whatever is sent:
probabilityfor risks only,impactfor risks and issues (both 1-5),responsefor risks only (one ofRAID_RESPONSES). -
statusopen/closed;owner_analyst_idan active analyst;due_datea date. -
ticket_idmust passprojectLinkTargetOk($conn, $actor, 'ticket', $ticketId, $projectCompany)- the same rule as a Connections ticket link (page 8). -
Decision log (3.3.0), written only once
raidLogReady():decided_by(VARCHAR 150 - a name, because the decider is often a sponsor with no analyst account),decided_date,rationale, fortype = decisiononly (switching type away clears them). A futuredecided_dateis refused; a decision savedclosedwith no date is stamped today. -
Closing ends an escalation: when
statusisclosedthe same UPDATE clearsescalated_datetime,escalated_by_idandescalation_note. - On update:
closed_datetimestamped on open β closed, cleared on β open. - Audit:
raid_added(type: title);raid_open/raid_closedon a status change;decision_made(title (decided_by)) when a decision closes. - Then
touchProject()andafterChange().
The score is never stored: raid() computes it.
raid($conn, $pid): array - the read every door uses (page, export, REST, assistants):
SELECT r.*, a.full_name AS owner_name, t.ticket_number, t.subject AS ticket_subject,
CASE WHEN r.type = 'risk' AND r.probability IS NOT NULL AND r.impact IS NOT NULL THEN r.probability * r.impact END AS score,
ea.full_name AS escalated_by_name -- NULL before raidLogReady()
FROM project_raid r ...
ORDER BY r.status = 'closed', score IS NULL, score DESC, r.raised_datetime DESCOpen first, then by score. Each row gains actions (below), ticket_url, and - from a separate query, not a join, so the log still loads before the column is verified - article_url and article_published. Any failure returns [].
Escalation (3.3.0):
| Method | Rules |
|---|---|
escalateRaid($conn, $ctx, $pid, $raidId, ?string $note): void |
raidLogReady() or not_ready; open entries only; a note is required (500) - Say what is needed, and from whom. Stamps escalated_datetime, escalated_by_id, escalation_note; audit raid_escalated; fires project.raid_escalated (ProjectsService::raidEscalated()). Escalating again replaces the note and fires again |
deescalateRaid($conn, $ctx, $pid, $raidId): void |
no-op when not escalated; clears the three columns; audit raid_deescalated
|
Follow-up actions (3.3.0) - project_raid_tasks is a join (raid_id, task_id, unique pair), so tasks is untouched:
| Method | Rules |
|---|---|
addRaidAction($conn, $ctx, $pid, $raidId, $in): int |
raidActionsReady() or not_ready; a title is required. The task is made by ProjectsService::createTaskInProject() with no stage, assigned_analyst_id and due_date if given, and the description Follow-up to the risk "β¦" in the project's RAID log. - so the assigned email, the bell and task events happen as for any task. Then the join row; audit raid_action_added. Returns the task id |
removeRaidAction($conn, $ctx, $pid, $raidId, $taskId): void |
deletes the join only. The task stays - it is somebody's work |
deleteRaid($conn, $ctx, $pid, $raidId): void |
deletes the joins by hand first (the tasks stay), then the entry; audit raid_removed; afterChange()
|
raidActionsReady($conn) / raidLogReady($conn)
|
per-request probes of the join table and the escalation / decision columns |
raid() returns each entry's actions as [{id, title, due_date, status_name, status_colour, is_closed, assignee_name}].
A lesson into Knowledge, an issue into a ticket. Both go through the other module's own service, so nothing is created behind its back, and both are once only.
-
lessonToKnowledge($conn, $ctx, $pid, $raidId): arrayβ['id', 'url']. Lessons only; the column must exist (not_ready);conflictif already an article; needs the Knowledge module. CallsKnowledgeService::saveArticle()withis_published => false(a draft, as the Knowledge assistant makes),owner_idthe actor,tenant_idthe project's company on a multi-company install. The body is the lesson's description as<p>paragraphs (escaped) plus Learned on the project PRJ-0042 β¦ linked withpublicAbsoluteUrl(). Setsproject_raid.knowledge_article_id, links the article on Connections, auditsraid_to_knowledge. -
issueToTicket($conn, $ctx, $pid, $raidId): arrayβ['id', 'number', 'url']. Issues only;conflictif it already has a ticket; needs Tickets and a person (actorId > 0). The requester is the acting analyst's email - refused with a reason when missing or invalid (admin@localhostis invalid).TicketsService::createTicket()in the project's company, assigned to the issue's owner else the actor; description = detail +Impact: 4 - <word>+ Raised from the RAID log of project PRJ-0042 β¦. It firesticket.created, so workflows run as for any ticket. Setsproject_raid.ticket_id, links the ticket, auditsraid_ticket_raised(number title). - The Connections link is
linkQuietly(): the record exists and is remembered on the entry, so a link that cannot be written is logged, never thrown.
saveTolerances($conn, $ctx, $pid, $in): void - project-level rows only (stage_id IS NULL). For each dimension present in $in: blank deletes the row; otherwise a whole number in range, upserted.
$rules = ['time' => [0, 365], 'risk' => [1, 25], 'cost' => [0, 500]]; // days late, risk score, % over budgetAudit tolerances (no values); afterChange(). Read: tolerances($conn, $pid) β {time, risk, cost} with NULL for unset, never throws. What they do is on page 6.
const GATE_DECISIONS = ['go', 'go_with_conditions', 'stop'];decideGate($conn, $ctx, $pid, $stageId, string $decision, ?string $notes): array β ['closed' => bool, 'next' => ?string].
- The stage must belong to the project. A
plannedstage is refused (That stage has not started yet.) - a gate is the end of a stage, and closing one that never started would skip the one-active-stage rule. -
go_with_conditionsneeds notes. -
Checklist (3.3.0): for anything but
stop,projectGateOpenItems(); open items throwchecklist_opennaming them, or - withproject_gate_checklist = warn- are appended to the notes as Still open at the gate: β¦. - One transaction: write
gate_decision,gate_notes,gate_decided_by,gate_decided_datetime; then, only for a go on a stage that is not already closed, close it and set the nextplannedstage byposition(then id) toactive. - After: audit
gate(stage nameβ decision); when it closed:syncCalendar()andstageClosed(); when it started the next stage:projectBaselineAuto(..., 'stage', $nextId);afterChange().
// Only a decision that CLOSES the stage hands over to the next one: a go
// recorded afterwards on a stage that is already finished must not start
// another stage while a later one is in progress.
if ($decision !== 'stop' && $stage['status'] !== 'closed') { ... }decideGate() writes the stage directly, not through saveStage(); with the planned-stage refusal in step 1 it can only close the active stage (or record on a closed one), so the single_active rule holds.
Gate checklist items (3.3.0) - project_gate_items, rules in includes/projects/gatecheck.php:
| Method | Rules |
|---|---|
saveGateItem($conn, $ctx, $pid, $in): int |
projectGateItemsReady(); on create stage_id must be a stage of the project and kind one of PROJECT_GATE_ITEM_KINDS (check / document / signoff / change - fixed after create); title required, max 200; analyst_id for a sign-off only (active analyst); change_id for a change only, and it must be linked to the project on Connections (projectGateChanges()); notes 500. Renaming a sign-off's person clears done_by_id / done_datetime (the old sign-off no longer counts) and asks the new one (signoffEvent()). Audit gate_item_added on create |
deleteGateItem($conn, $ctx, $pid, $itemId): void |
audit gate_item_removed
|
tickGateItem($conn, $ctx, $pid, $itemId, $in): void |
loadForActor() only, then per kind: change - refused (done when the change is approved, in Changes); signoff - only the named analyst, and no change permission is needed (the sponsor signing off need not be on the team); document - assertCanChange(), document_id must be one of projectGateDocuments() (empty unticks); check - assertCanChange(). Audit gate_signed / gate_unsigned or gate_item_done / gate_item_reopened
|
setGateKind($conn, $ctx, $pid, $stageId, string $kind): int |
standard or golive. Going live adds every projectGoLiveStarter() item the gate does not already have by title (case-insensitive) through saveGateItem(), the sign-offs addressed to the project manager. Returns how many were added; audit gate_kind
|
Rules in includes/projects/budget.php (page 6). The writes:
| Method | Rules |
|---|---|
saveBudgetLine($conn, $ctx, $pid, $in): int |
projectBudgetReady(); title required (200); category in PROJECT_BUDGET_CATEGORIES; planned, actual, forecast through projectMoney(); notes 500. (3.3.0) planned_date, spent_date (not in the future). A contract_id needs the Contracts module and the contract linked to the project (projectBudgetContracts()); a cost_centre_id must be active and in the project's company (projectBudgetCostCentres()), or already on this line (a cost centre switched off since stays). projectStampCurrency() first. A full replace, not a merge - the REST PATCH merges the stored line before calling it. Audit budget_line_added / budget_line_changed; afterChange()
|
budgetLineWhen() (private, 3.3.0)
|
writes planned_date, spent_date, forecast_amount in their own UPDATE, so a line still saves before Verification adds them - unless one of them was actually sent, which is then not_ready
|
deleteBudgetLine($conn, $ctx, $pid, $lineId): void |
audit budget_line_removed
|
addProjectRate($conn, $ctx, $pid, $rate, ?string $from): void |
only when project_labour_mode = rate; a new dated row (scope = 'project'), never an update - the old rate still prices time logged before the new date; from defaults to today; projectLabourRatesReset(); audit labour_rate
|
deleteProjectRate($conn, $ctx, $pid, string $from): void |
removes the project's rate from that date; audit labour_rate_removed
|
setCurrency($conn, $ctx, $pid, string $code): void |
only with project_currency_per_project = 1; projectValidCurrency() (three letters); a relabel - nothing is converted; audit currency
|
Default and per-analyst rates are not here: they are api/projects/settings.php actions behind the Budget capability (Β§6).
const CR_DECISIONS = ['approved', 'rejected'];| Method | Rules |
|---|---|
takeBaseline($conn, $ctx, $pid, $label): int |
changeable(); projectControlReady(); projectTakeBaseline(..., 'manual', $label); audit baseline_taken as Baseline 4: label
|
saveChangeRequest($conn, $ctx, $pid, $in): int |
create needs changeable(); edit is allowed to whoever raised it, or anyone who may change the project, and only while proposed. Title required (200); impact_days signed, up to 3650 either way; impact_cost through projectMoney(); impact_scope 1000; description and reason 20,000. number = max + 1 per project (CR-n). Audit change_raised / change_edited; create fires project.change_raised
|
decideChangeRequest($conn, $ctx, $pid, $crId, string $decision, $notes): array |
projectCanDecideChange($conn, $actor, $project, $raisedBy) (page 6); compare-and-set WHERE id = ? AND status = 'proposed'. Approving, with project_change_apply = plan: moves target_end_date by the days (audited as an ordinary target_end_date change) and inserts a budget line for the cost (category other), recording both in applied; then always projectTakeBaseline(..., 'change', null, null, $crId) and stores baseline_id. Returns ['baseline_id', 'applied']. Audit change_approved / change_rejected; syncCalendar() if the date moved; project.change_decided; afterChange()
|
withdrawChangeRequest($conn, $ctx, $pid, $crId): void |
the raiser or the team; proposed only; audit change_withdrawn
|
| Method | Rules |
|---|---|
saveBenefit($conn, $ctx, $pid, $in): int |
projectBenefitsReady(); title required (200); baseline and target signed numbers, two decimals; direction up (default) or down; review_months 0-24, empty = project_benefit_review_months; status open / closed; owner an active analyst. A full replace like budget lines. A new benefit with no review_date gets projectBenefitNextReview(today, months). Audit benefit_added / benefit_changed
|
deleteBenefit($conn, $ctx, $pid, $benefitId): void |
measurements first (by hand), then the benefit; audit benefit_removed
|
addBenefitMeasure($conn, $ctx, $pid, $benefitId, $in): int |
a value (signed, two decimals), measured_date default today and never in the future, note 500. A measurement on an open benefit on or after review_date - PROJECT_BENEFIT_EARLY_DAYS (14) is the review: review_date moves on by review_months (NULL when 0). Audit benefit_measured
|
deleteBenefitMeasure($conn, $ctx, $pid, $benefitId, $measureId): void |
the measurement must belong to that benefit |
The plan's writes - detailed with the reads and the JS on page 4:
| Method | One line |
|---|---|
saveMilestone($conn, $ctx, $pid, $in): int (3.3.0)
|
any field left out keeps its value; done true stamps done_date (or the date given) and who, false clears both; never reached in the future; audits milestone_added / _moved / _reached / _reopened; syncCalendar(); project.milestone_reached; afterChange()
|
deleteMilestone($conn, $ctx, $pid, $milestoneId): void (3.3.0)
|
audit milestone_removed; syncCalendar(); afterChange()
|
setTaskDates($conn, $ctx, $pid, $taskId, $in): void (3.3.0)
|
{start_date?, due_date?} through TasksService::saveTask(); the task must be in the project; Tasks access is not required |
setTaskEstimate($conn, $ctx, $pid, $taskId, $hours): void (3.3.0)
|
TasksService::saveTask(['id' => $taskId, 'estimate_hours' => $hours]) |
addTaskDependency($conn, $ctx, $pid, $taskId, $dependsOnId, $lag): int (3.3.0)
|
both tasks in the project, not the same task, lag -365..365, no loop (projectDependencyMakesCycle()); upsert on the unique pair; audit dependency_added (waiter <- waited for) |
removeTaskDependency($conn, $ctx, $pid, $depId): void (3.3.0)
|
the dependency must be one of projectDependencies($pid); audit dependency_removed
|
| Method | Rules |
|---|---|
saveTarget($conn, $ctx, $pid, $in): int |
changeable() and assertAssets() (the rule names asset types and the live count would tell somebody without Assets what is in the estate); projectTargetsReady(); projectTargetNormalise(); the asset type must be one projectTargetOptions() offers the project's company. A changed rule deletes the target's snapshots - yesterday's points measured something else. Audit target_saved; afterChange()
|
deleteTarget() |
audit target_removed; afterChange()
|
assertAssets($conn, $ctx) / target($conn, $pid, $id)
|
public: api/projects/tools.php uses both for the GET target reads |
announce($conn, $ctx, $pid, $in): int |
project_disruption not off; Service Status access; a thin client of statusPlannedSave() with project_id; mode now passes the current time as the start, mode planned requires one. Audit disruption_announced. Returns the planned maintenance id |
withdrawAnnouncement($conn, $ctx, $pid, $plannedId): void |
the plan must carry this project_id (else not_found); statusPlannedCancel(); audit disruption_withdrawn
|
announcements($conn, $analystId, $pid): ?array |
NULL without Service Status or before its table exists; else {mode, list, services, impacts}
|
Targets are explained on page 5; disruption on page 8.
Both are thin over their rules files; the detail is on pages 7 and 8.
ProjectReportsService (includes/services/project_reports.php, rules and AI on page 7):
| Method | Who |
|---|---|
ready($conn), listFor($conn, $pid), latestBriefing($conn, $pid), schedule($conn, $pid), recipients($conn, $pid)
|
reads |
briefing($conn, $ctx, $pid, bool $refresh): array |
anyone who can open the project; cached BRIEFING_COOLDOWN_MINUTES (10); returns fresh
|
draftWithAi($conn, $ctx, $pid, $kind, $days = 14): int |
assertCanChange(); kind in KINDS (highlight, exception, checkpoint, closure); days clamped 1-90 |
save($conn, $ctx, $pid, $in): int |
assertCanChange(); an approved report cannot be changed; editing an AI draft sets ai_edited
|
approve($conn, $ctx, $pid, $id): void |
projectCanDelete(); an empty report cannot be approved |
delete($conn, $ctx, $pid, $id): void |
a draft: assertCanChange(); approved: projectCanDelete()
|
setSchedule($conn, $ctx, $pid, $schedule, $kind): void (3.3.0)
|
assertCanChange(); SCHEDULES off / weekly / fortnightly / monthly; kind highlight / checkpoint / exception |
send($conn, $ctx, $pid, $id, array $emails, $note = ''): array (3.3.0)
|
projectCanDelete(); approved only; at most SEND_MAX (50) addresses; ['sent' => [...], 'failed' => [...]]
|
runSchedules($conn, ?$pid), periodKey()
|
from the alert scan, as the system |
ProjectTemplatesService (includes/services/project_templates.php, format and built-ins on page 8):
| Method | Who |
|---|---|
createFromTemplate($conn, $ctx, string $key, array $in, ?int $tenantId): int |
what creating a project needs - it goes through createProject() and createTaskInProject(); no outer transaction (TasksService opens its own), cleanup() on failure |
saveFromProject($conn, $ctx, $pid, $in): int |
Cap::PROJECTS_TEMPLATES; parts filtered to plan, scope, raid, benefits, tolerances, targets; id replaces that saved template |
update($conn, $ctx, $id, $in), delete($conn, $ctx, $id)
|
Cap::PROJECTS_TEMPLATES; name required (150), description 500 |
setBuiltinHidden($conn, $ctx, string $key, bool $hidden) |
Cap::PROJECTS_TEMPLATES; the only writer of project_hidden_templates
|
Not a service class: includes/projects/assistant_chat.php holds it, and every change it makes goes through the services on this page. A proposal the person applies calls ProjectsService::updateProject(), saveStage() or createTaskInProject(), or ProjectToolsService::setTaskEstimate(), setTaskDates(), saveMilestone(), saveRaid(), saveItem(), addTaskDependency(), saveBudgetLine(), saveBenefit() or addMember() - with the person's own ActorContext, so assertCanChange(), validation, events and History are exactly as for the screens. The endpoint is api/projects/assistant_chat.php:
GET ?project_id=N -> {ready, ai_ready, can_set_up, can_change, shared, maturity, messages, remembers}
POST {action:'open'} greeting / catch-up / nothing
POST {action:'send', text} -> {messages, maturity} (or {ai_error})
POST {action:'apply'|'dismiss', message_id, items:[0,2,...]} -> {proposals, maturity}
POST {action:'clear'}
// assets/js/projects-assistant.js
const r = await P.api('assistant_chat.php', { project_id: ctx.projectId, action: 'send', text: 'What needs my attention?' });
// r.messages[r.messages.length - 1].proposals -> [{type:'task_dates', summary:'Move "Batch 3" to ...', status:'pending'}, ...]The detail - the prompt, the tools, the memory, the apply order - is in part 7. The provider loop it uses, aiProviderChatTools() in includes/ai_provider.php, gained $opts['history'] and Azure support for it (#2247).
Every file in api/projects/ starts with require_once __DIR__ . '/../../includes/projects/api_bootstrap.php';. It lives in includes/ so it cannot be requested on its own, and is kept in one place so a guard cannot be forgotten on the next endpoint (it mirrors includes/domains/api_bootstrap.php).
session_start(['read_and_close' => true]);
// config, functions, rbac, services/projects.php, projects/read.php
header('Content-Type: application/json');
if (!isset($_SESSION['analyst_id'])) { http_response_code(401); echo json_encode(['success' => false, 'error' => 'Not authenticated']); exit; }
requireModuleAccessJson('projects');
$conn = connectToDatabase();
$analystId = (int)$_SESSION['analyst_id'];
$ctx = ActorContext::fromSession($conn); // source 'ui', the session's company scope| Helper | What it does |
|---|---|
projectApiBody(): array |
the decoded JSON body, cached; never null ([] for an empty or invalid body) |
projectApiOk(array $data = []) |
echo json_encode(['success' => true] + $data); exit; |
projectApiFail(string $message, int $status = 200) |
sets the status only when not 200; {success:false, error}; exits |
projectApiRequirePost() |
405 POST required. for anything but POST |
projectApiTenantForCreate($conn, $analystId, $requested): int |
a requested company_id must exist and pass analystCanAccessTenant() (else You cannot add projects for that company.); otherwise the active company |
projectApiRun(callable $fn) |
runs the endpoint; a ServiceError becomes projectApiFail($e->getMessage()); any other Throwable is logged with file and line and answered Something went wrong: β¦
|
Because projectApiOk() and projectApiFail() both exit, the switch statements in the endpoints have no break after them - each case ends the request.
All need a session and access to the Projects module (from the bootstrap). Permissions beyond that are listed per endpoint; where it says "service", the rule is enforced in the service method named.
Query: q (name, summary or goal), status (one of projectStatuses()), mine=1 (owner = me). The page loads once and filters in the browser; the filters are there for other callers. Runs projectAlertsOpportunistic() first (the no-cron fallback, at most every 15 minutes - page 7), then projectListRows() (company scope from activeTenantReadFilter(), members-only from projectVisibleSql()).
{
"success": true,
"projects": [
{
"id": 42, "code": "PRJ-0042", "tenant_id": null, "company_name": null,
"name": "Leeds office move", "summary": "Move 60 staff to Wellington Place.", "goal": "Everyone working from the new floor by 30 November",
"methodology": "staged", "status": "active", "health": "auto", "health_note": null, "priority": "high", "visibility": "everyone",
"owner_analyst_id": 7, "owner_name": "Sam Patel", "start_date": "2026-09-01", "target_end_date": "2026-11-30", "actual_end_date": null,
"colour": "teal", "icon": "building", "approval_status": null,
"active_stage_name": "Fit-out", "stage_count": 4,
"max_risk": 12, "tol_time": 10, "tol_risk": 15, "active_stage_end": "2026-10-24",
"task_total": 38, "task_done": 21, "task_overdue": 2, "progress": 55,
"tickets_7d": 0, "milestones_missed": 0, "next_milestone": {"id": 9, "name": "Move day", "due_date": "2026-11-21"},
"raid_overdue": 0, "raid_escalated": 1, "changes_pending": 1, "benefits": 2, "benefits_due": 0, "ticket_spike": false,
"auto_health": "amber", "exceptions": [], "shown_health": "amber",
"tools": ["people", "scope", "raci", "raid", "gates", "budget", "control", "benefits"]
}
],
"multi_company": false,
"can_create": true
}can_create is projectCanCreate() - the page hides New when false.
ProjectsService::loadForActor() (not found / not visible β {success:false, error:"Project not found."}), then projectDetail() plus everything the tabs need. One call draws the whole page.
| Key | Source | Notes |
|---|---|---|
project |
projectDecorate() |
as a portfolio row, plus effort {estimate_hours, estimated_tasks, logged_hours, tasks} (3.3.0) |
stages |
project_stages |
each with task_total, task_done (top-level tasks) |
tasks |
top-level tasks | with status, priority, assignee, team, subtask_count, estimate_hours, logged_minutes, completed_datetime, created_datetime, and from projectDependencyAnalysis(): depends_on, waiting_on, clash, critical, slack (3.3.0) |
history |
project_audit |
newest 50 |
dependencies |
projectDependencies() |
[{id, task_id, depends_on_id, lag_days}] (3.3.0) |
flow |
projectFlowSnapshot() then projectFlow()
|
the status history chart; the snapshot is written on the way past (3.3.0) |
permissions |
projectCanChange(), projectCanDelete()
|
{can_change, can_delete} - the page hides what the server would refuse |
members, items, raci, raid, tolerances
|
ProjectToolsService reads |
raci is cast to an object so an empty one is {}, not []
|
targets |
projectTargetsDetail() |
upserts today's snapshot point |
milestones |
projectMilestones() |
with state and met
|
can_assets |
module access | |
budget |
projectBudgetDetail() |
NULL before Verification |
gate |
gatecheck |
{items (object keyed by stage id), documents, changes ([] without Changes), mode, me} (3.3.0) |
benefits |
projectBenefits() |
(3.3.0) |
proposal |
projectProposalDetail() |
NULL when it needed no approval and has no figures (3.3.0) |
control |
projectControlDetail() |
baselines with their variance and change requests; NULL before Verification (3.3.0) |
announcements |
ProjectToolsService::announcements() |
NULL without Service Status |
gate_changes |
projectUnapprovedChanges() |
NULL (not []) without Changes access, so the gate stays silent rather than saying "none" |
{
"success": true,
"project": { "id": 42, "code": "PRJ-0042", "name": "Leeds office move", "shown_health": "amber", "progress": 55,
"effort": {"estimate_hours": 212.5, "estimated_tasks": 30, "logged_hours": 140.25, "tasks": 38} },
"stages": [ {"id": 118, "kind": "stage", "name": "Fit-out", "status": "active", "start_date": "2026-09-28", "end_date": "2026-10-24",
"position": 2, "gate_decision": null, "gate_kind": "standard", "task_total": "9", "task_done": "4"} ],
"tasks": [ {"id": 5531, "title": "Patch panels labelled", "project_stage_id": 118, "start_date": "2026-10-19", "due_date": "2026-10-21",
"is_closed": 0, "estimate_hours": "6.00", "logged_minutes": 90,
"depends_on": [{"id": 5530, "lag": 0}], "waiting_on": [5530], "clash": true, "critical": true, "slack": 0} ],
"dependencies": [ {"id": 14, "task_id": 5531, "depends_on_id": 5530, "lag_days": 0} ],
"permissions": {"can_change": true, "can_delete": false},
"members": [], "items": [], "raci": {}, "raid": [], "tolerances": {"time": 10, "risk": 15, "cost": 10},
"milestones": [ {"id": 9, "stage_id": null, "name": "Move day", "due_date": "2026-11-21", "done_date": null, "state": "due", "met": null} ],
"budget": null, "gate": {"items": {}, "documents": [], "changes": [], "mode": "block", "me": 7},
"benefits": [], "proposal": null, "control": null, "announcements": null, "gate_changes": null,
"targets": [], "can_assets": true, "flow": {"statuses": [], "points": [], "from": "2026-09-01"}, "history": []
}Body: ProjectsService::fieldMap() keys, plus id (update), company_id (create only - projectApiTenantForCreate(), then dropped), template (create only - builtin:<key> or saved:<id>, then dropped). With template on a new project it calls ProjectTemplatesService::createFromTemplate(); otherwise ProjectsService::saveProject(). Permissions: service (create policy / change policy; visibility needs the team or a manager).
// request
{"name": "Leeds office move", "methodology": "staged", "start_date": "2026-09-01", "target_end_date": "2026-11-30", "template": "builtin:office_move"}
// response
{"success": true, "id": 42, "created": true}The edit dialog sends tailoring as {tool: bool} for the ticked boxes, except when the method was changed in the same save: then it sends null so the project takes the new method's set. The Gates tab saves business_case through this endpoint as an ordinary field.
The Toolbox (3.3.0, #2243 - assets/js/projects-toolbox.js, PrjToolbox.render()) has no endpoint of its own. The Overview lists the tools a project does not use yet, and Add writes the project's tailoring through this same save.php - exactly what the edit dialog's Tools boxes do, so a tool added there can be taken away in the dialog:
// assets/js/projects-toolbox.js
try { tail = JSON.parse(p.tailoring || '{}') || {}; } catch (e) { tail = {}; }
// tail[tool] = true, plus its NEEDS (raci adds people and scope)
await P.api('save.php', { id: ctx.projectId, tailoring: tail });So it passes the change policy and validateField()'s tailoring rule like any other edit. Whether it is shown at all is lookups.php toolbox; Hide is per browser (localStorage freeitsm.projects.toolbox.hide.<id>).
ProjectsService::deleteProject(). Permission: owner, creator or manager.
{"success": true, "id": 42, "tasks_detached": 38}Initialises I18n (the method labels and scale words are translated). Returns (lists shortened to one entry each; a template entry carries more keys than shown - see page 8):
{
"success": true,
"analysts": [{"id": 7, "full_name": "Sam Patel"}],
"companies": [], "active_company": null, "multi_company": false,
"default_method": "simple",
"roles": [{"id": 1, "name": "Sponsor", "description": "Owns the business case"}],
"teams": [{"id": 3, "name": "Infrastructure"}],
"tools": {"people": {"label_key": "projects.tools.people", "desc_key": "projects.tools.people_desc"}},
"probability_labels": ["Rare", "Unlikely", "Possible", "Likely", "Almost certain"],
"impact_labels": ["Negligible", "Minor", "Moderate", "Major", "Severe"],
"priorities": ["low", "medium", "high", "critical"], "toolbox": true,
"priority_labels": ["Low", "Medium", "High", "Critical"],
"stake_ready": true, "default_visibility": "everyone", "portfolio_sort": "target", "burnup_measure": "tasks",
"templates": [{"key": "builtin:office_move", "name": "Office move"}],
"can_manage_templates": false, "can_knowledge": true, "can_tickets": true,
"methodologies": [{"key": "staged", "label": "Staged", "description": "...", "timebox": "stage"}],
"statuses": ["proposed", "active", "on_hold", "closed", "cancelled"],
"colours": [{"key": "coral", "from": "#f43f5e", "to": "#e11d48"}],
"icons": ["rocket", "laptop", "building"],
"task_statuses": [{"id": 1, "name": "To do", "colour": "#94a3b8", "is_closed": 0}],
"task_priorities": [{"id": 2, "name": "Normal", "colour": "#64748b", "is_default": 1}]
}companies lists only the companies the analyst can reach. default_visibility is NULL before Verification, which hides the form's field; stake_ready says whether the stakeholder columns exist (the People tab draws the map only then). toolbox (3.3.0, #2243) is the project_toolbox setting (General tab, default on): whether the Overview shows the Toolbox (below). templates is what the picker offers (built-ins not hidden, saved ones active); can_manage_templates shows the Template button.
| Endpoint | Body | Service | Response |
|---|---|---|---|
stage_save.php |
{project_id, id?, name, goal?, start_date?, end_date?, status?} |
ProjectsService::saveStage() |
{success, id} |
stage_delete.php |
{project_id, id} |
ProjectsService::deleteStage() |
{success} |
stage_reorder.php |
{project_id, ids: [...]} |
ProjectsService::reorderStages() |
{success} |
stage_reorder.php exists but nothing in the UI calls it yet.
| Endpoint | Body | Service |
|---|---|---|
task_create.php |
{project_id, stage_id?, title, description?, assigned_analyst_id?, assigned_team_id?, start_date?, due_date?, priority_id?, status_id?} - only those task keys are passed on (array_intersect_key) |
ProjectsService::createTaskInProject() β {success, id}
|
task_assign.php |
{task_id, project_id | null, stage_id?} |
ProjectsService::assignTask() β {success}
|
Both on page 4 Β§3.
See Β§7.
| Method | Parameters | Returns |
|---|---|---|
| GET | ?project_id=N |
{links: {kind: [...]}, ready} - a kind the analyst cannot use is left out |
| GET | ?project_id=N&search=KIND&q= |
{results} - at most 20, already-linked rows excluded |
| GET | ?for=KIND&id=N |
{projects} - the projects a record is linked to (its own page) |
| GET | ?for=KIND&id=N&pick=1&q= |
{projects} - live projects it could be added to, that the analyst may change |
| POST | {action: "add" | "remove", project_id, kind, target_id} |
{added} / {}
|
Every rule is in includes/projects/links.php (page 8). ready is projectLinksReady($conn) with no kind: true when any link table exists (the tab's run Database Verification note). Since #2242 the function takes an optional kind - projectLinksReady($conn, 'ticket') probes that one table, cached per kind per request - so a kind whose table is missing is left out on its own instead of the whole tab failing.
GET returns settings (every key with defaults applied; the three scales as their word lists), definitions ({key: {default, tab}}), can_write per tab, roles (with in_use counts), and - only to holders of the Budget capability - rates and analysts (rates are close to pay).
| POST action | Body | Capability |
|---|---|---|
save |
{tab, settings: {key: value}} |
the tab's: general β Cap::PROJECTS_GENERAL, health β PROJECTS_HEALTH, roles β PROJECTS_ROLES, raid β PROJECTS_RAID, budget β PROJECTS_BUDGET (403 without) |
role_save |
{id?, name, description?, is_active} |
Roles; name 1-100, unique |
role_delete |
{id} |
Roles; members keep their place with no role (FK SET NULL) |
role_reorder |
{ids: [...]} |
Roles |
rate_save |
{scope: "default" | "analyst", analyst_id?, rate, from?} |
Budget; a new dated row |
rate_delete |
{id} |
Budget; default and analyst rates only |
rate_list |
- | Budget |
save refuses a key that does not belong to the tab (That setting does not belong on this tab: β¦) and validates each value with projectSettingValidate(). Saving project_calendar redraws the Calendar at once. No manifest tab declares setting_keys (except AI), on purpose - see page 1.
Everything needs Cap::PROJECTS_TEMPLATES (403 Looking after project templates needs the Templates permission). GET β {templates (hidden and inactive too), ready}.
| POST action | Body | Response |
|---|---|---|
save_from_project |
{project_id, name, description?, parts: [...], id?} |
{id, key: "saved:N"} |
update |
{id, name, description?, is_active} |
{templates} |
delete |
{id} |
{templates} |
builtin_hidden |
{key, hidden} (builtin: prefix optional) |
{templates} |
Starting from a template is save.php with template.
GET ?project_id=N β {ready, ai_ready, can_set_up, can_write, can_approve, briefing, reports, schedule, recipients} (recipients only for those who may approve). POST {action, project_id, ...}:
| Action | Body | Service |
|---|---|---|
briefing |
{refresh?} |
briefing() β {briefing}
|
draft |
{kind, days?} |
draftWithAi() β {id, reports}
|
save |
{id?, kind?, title, body} |
save() β {id, reports}
|
approve |
{id} |
approve() β {reports}
|
schedule |
{schedule, kind} |
setSchedule() β {schedule} (3.3.0) |
send |
{id, emails: [...], note?} |
send() β {sent, failed, reports} (3.3.0) |
delete |
{id} |
delete() β {reports}
|
A provider problem is not a failure: projectReportsAiFail() answers {success: true, ai_error: "not_configured"} or {success: true, ai_error: "unreachable", detail}; a PDOException is rethrown. Page 7 has the rest.
Any other value becomes 4. {capacity: projectCapacity($conn, $analystId, $weeks)} - read-only; shape on page 4 Β§8.
For the projects projectListRows() gives this analyst:
{
"success": true,
"budgets": {"42": {"planned": 85000, "actual": 51230.5, "forecast": 88400, "currency": "GBP"}},
"risks": [{"project_id": 42, "title": "Landlord delays access", "probability": 3, "impact": 4}],
"milestones": [{"project_id": 42, "name": "Move day", "due_date": "2026-11-21", "state": "due"}]
}Budgets only for projects with a budget or spend, never added across currencies; risks open with both scores; every milestone with its state.
| Query | Rows |
|---|---|
what=portfolio&ids=3,1,7&format=xlsx|csv |
the portfolio's visible projects, in the page's order. The ids are intersected with projectListRows(), so editing the URL cannot widen the list; ids present but empty exports nothing (the header row only - the view shows nothing); no ids parameter at all = every visible project |
what=raid&project_id=N&format=xlsx|csv |
one project's whole RAID log, open and closed (loadForActor() then ProjectToolsService::raid()) |
Written by includes/spreadsheet.php (every xlsx cell text, CSV formula-injection guard, UTF-8 BOM); headings are projects.export.*; file name projects-2026-10-09.xlsx or raid-PRJ-0042-2026-10-09.csv. A refusal is the usual JSON error, so the browser shows it instead of downloading.
| Query | Needs | Returns |
|---|---|---|
people=Q |
- |
{people: [{id, name, email, job_title}]} - People matching name, email or job title, in the project's company (the same rule addMember() applies, so the picker never offers somebody it would refuse), at most 20 |
target_options=1 |
Assets |
{options} from projectTargetOptions()
|
target_preview=1&<rule fields> |
Assets |
{done, total} for a rule being edited |
target_assets=ID&show=left|done |
Assets |
{assets} - at most 200, via projectTargetAssets()
|
Anything else is Unknown request.
Every action is a case in one switch; each calls exactly one service method. "Changeable" = loadForActor() + assertCanChange().
| Action | Body (beyond project_id) |
Method | Response | Permission |
|---|---|---|---|---|
member_add |
analyst_id | team_id | user_id, role_id?, notes?
|
ProjectToolsService::addMember() |
{id} |
changeable |
member_update |
member_id, role_id?, notes?, power?, interest?, stance?, keep_informed?
|
updateMember() |
{} |
changeable |
member_remove |
member_id |
removeMember() |
{} |
changeable |
item_save |
id?, title, description?, acceptance_criteria?, moscow?, stage_id?, status?
|
saveItem() |
{id} |
changeable |
item_delete |
id |
deleteItem() |
{} |
changeable |
item_move |
id, moscow, order: [ids in that column]
|
moveItem() |
{} |
changeable |
raci_set |
item_id, member_id, letter (R/A/C/I/'') |
setRaci() |
{row: {member_id: letter}} |
changeable |
raid_save |
id?, type, title, description?, probability?, impact?, response?, response_plan?, owner_analyst_id?, status?, due_date?, ticket_id?, decided_by?, decided_date?, rationale?
|
saveRaid() |
{id} |
changeable |
raid_delete |
id |
deleteRaid() |
{} |
changeable |
raid_to_knowledge |
id |
lessonToKnowledge() |
{article: {id, url}} |
changeable + Knowledge |
raid_to_ticket |
id |
issueToTicket() |
{ticket: {id, number, url}} |
changeable + Tickets + a valid email |
raid_escalate |
id, note
|
escalateRaid() |
{} |
changeable (3.3.0) |
raid_deescalate |
id |
deescalateRaid() |
{} |
changeable (3.3.0) |
raid_action_add |
id, title, assigned_analyst_id?, due_date?
|
addRaidAction() |
{task_id} |
changeable (3.3.0) |
raid_action_remove |
id, task_id
|
removeRaidAction() |
{} |
changeable (3.3.0) |
tolerances_save |
time?, risk?, cost? (blank = remove) |
saveTolerances() |
{} |
changeable |
gate_decide |
stage_id, decision, notes?
|
decideGate() |
{closed, next} |
changeable |
gate_item_save |
id?, stage_id, kind, title, analyst_id?, change_id?, notes?
|
saveGateItem() |
{id} |
changeable (3.3.0) |
gate_item_delete |
id |
deleteGateItem() |
{} |
changeable (3.3.0) |
gate_item_tick |
id, done?, document_id?, notes?
|
tickGateItem() |
{} |
per kind - a sign-off only its named analyst (3.3.0) |
gate_kind |
stage_id, kind (standard/golive) |
setGateKind() |
{added} |
changeable (3.3.0) |
target_save |
id?, name, scope, scope_type_id?, scope_field?, scope_value?, done_field, done_op, done_value, target_date?
|
saveTarget() |
{id} |
changeable + Assets |
target_delete |
id |
deleteTarget() |
{} |
changeable |
announce |
title, comment?, start?, end?, services: [{service_id, impact_level_id}]
|
announce() |
{id, announcements} |
changeable + Service Status + setting not off
|
announce_withdraw |
id |
withdrawAnnouncement() |
{announcements} |
changeable + Service Status |
budget_line_save |
id?, title, category, planned?, actual?, contract_id?, cost_centre_id?, notes?, planned_date?, spent_date?, forecast?
|
saveBudgetLine() |
{id} |
changeable (+ Contracts for a contract) |
budget_line_delete |
id |
deleteBudgetLine() |
{} |
changeable |
budget_rate_add |
rate, from?
|
addProjectRate() |
{} |
changeable + labour mode rate
|
budget_rate_delete |
from |
deleteProjectRate() |
{} |
changeable |
budget_currency |
currency |
setCurrency() |
{} |
changeable + per-project currency on |
milestone_save |
id?, name?, due_date?, stage_id?, notes?, done?, done_date?
|
saveMilestone() |
{id} |
changeable (3.3.0) |
milestone_delete |
id |
deleteMilestone() |
{} |
changeable (3.3.0) |
task_dates |
task_id, start_date?, due_date?
|
setTaskDates() |
{} |
changeable (3.3.0) |
task_estimate |
task_id, estimate_hours
|
setTaskEstimate() |
{} |
changeable (3.3.0) |
dep_add |
task_id, depends_on_id, lag_days?
|
addTaskDependency() |
{id} |
changeable (3.3.0) |
dep_remove |
id |
removeTaskDependency() |
{} |
changeable (3.3.0) |
benefit_save |
id?, title, measure?, unit?, direction, baseline_value?, target_value?, target_date?, owner_analyst_id?, review_date?, review_months?, status, notes?
|
saveBenefit() |
{id} |
changeable (3.3.0) |
benefit_delete |
id |
deleteBenefit() |
{} |
changeable (3.3.0) |
benefit_measure_add |
id (the benefit), value, measured_date?, note?
|
addBenefitMeasure() |
{id} |
changeable (3.3.0) |
benefit_measure_delete |
id (the benefit), measure_id
|
deleteBenefitMeasure() |
{} |
changeable (3.3.0) |
proposal_decide |
decision (approved/rejected), notes?
|
ProjectsService::decideProposal() |
{status} |
the named approver or Manage Projects (3.3.0) |
baseline_take |
label? |
takeBaseline() |
{id} |
changeable (3.3.0) |
change_save |
id?, title, description?, reason?, impact_days?, impact_cost?, impact_scope?
|
saveChangeRequest() |
{id} |
create changeable; edit the raiser or changeable (3.3.0) |
change_decide |
id, decision, notes?
|
decideChangeRequest() |
{baseline_id, applied} |
projectCanDecideChange() (3.3.0) |
change_withdraw |
id |
withdrawChangeRequest() |
{} |
the raiser or changeable (3.3.0) |
An unknown action is Unknown action. Example - a gate decision:
// POST api/projects/tools.php
{"action": "gate_decide", "project_id": 42, "stage_id": 118, "decision": "go_with_conditions", "notes": "Label the patch panels before move day"}
// 200
{"success": true, "closed": true, "next": "Move"}
// 200, a checklist item open and project_gate_checklist = block
{"success": false, "error": "1 checklist item(s) still open: Sponsor sign-off."}And a RACI cell that demotes an earlier A:
// POST
{"action": "raci_set", "project_id": 42, "item_id": 301, "member_id": 17, "letter": "A"}
// 200 - member 12 was A, is now R
{"success": true, "row": {"12": "R", "17": "A", "21": "C"}}assets/js/projects.js exposes window.Prj (aliased P in every Projects script). Every URL is built from window.PRJ_API, which projects/includes/header.php sets from BASE_URL, never a root-relative /api/... (that 404s on an install in a sub-directory):
<script>window.PRJ_API = <?php echo json_encode(BASE_URL . 'api/projects/'); ?>; window.PRJ_BASE = <?php echo json_encode(BASE_URL); ?>; window.PRJ_ME = <?php echo (int)($_SESSION['analyst_id'] ?? 0); ?>;</script>// assets/js/projects.js
async function api(path, body) {
const opts = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
let res;
try {
res = await fetch(window.PRJ_API + path, opts);
} catch (e) {
throw new Error('The server could not be reached.');
}
let data;
try { data = await res.json(); } catch (e) { throw new Error('The server sent back something unexpected.'); }
if (!data.success) throw new Error(data.error || 'Something went wrong.');
return data;
}No body means GET; a body means a JSON POST. The CSRF token is not mentioned anywhere in the module: assets/js/csrf.js, loaded first on every page, wraps fetch() and adds X-CSRF-Token to every same-origin non-GET request. A refusal becomes a thrown Error with the service's own sentence, which the callers show in a toast or the dialog's error line:
// assets/js/projects-timeline.js - adding a dependency from the dialog
async function depCall(body) {
try { await P.api('tools.php', Object.assign({ project_id: ctx.projectId }, body)); await ctx.refresh(); openDeps(depTask.id); }
catch (e) { const er = document.getElementById('pdpError'); er.textContent = e.message; er.hidden = false; }
}
depCall({ action: 'dep_add', task_id: depTask.id, depends_on_id: Number(pick), lag_days: document.getElementById('pdpLag').value });// a GET with a query: the People picker (assets/js/projects-tools.js)
const d = await P.api('tools.php?project_id=' + ctx.projectId + '&people=' + encodeURIComponent(q));P.lookups() caches lookups.php once per page. After any write the page calls refresh(), which reloads get.php and redraws every tab, so the ring, counts, plan and tools can never disagree. Callers outside the module (the Tasks window's Project field, assets/js/tasks.js) use plain fetch(APP_BASE + 'api/projects/...') with the same JSON shape.
api/v1/resources/projects.php, routed by api/v1/lib/routes.php. The usage guide with every field is REST API: Projects; this section is how it is built.
25 routes. A sub-resource write needs projects.update, because changing a stage, item, RAID entry or budget line is changing the project.
// api/v1/lib/routes.php
['GET', '#^/projects$#', ['projects', 'read'], 'apiProjectsList'],
['POST', '#^/projects$#', ['projects', 'create'], 'apiProjectsCreate'],
['GET', '#^/projects/(\d+)$#', ['projects', 'read'], 'apiProjectsGet'],
['PATCH', '#^/projects/(\d+)$#', ['projects', 'update'], 'apiProjectsUpdate'],
['DELETE', '#^/projects/(\d+)$#', ['projects', 'delete'], 'apiProjectsDelete'],
['GET', '#^/projects/(\d+)/stages$#', ['projects', 'read'], 'apiProjectStagesList'],
['POST', '#^/projects/(\d+)/stages$#', ['projects', 'update'], 'apiProjectStagesCreate'],
['PATCH', '#^/projects/(\d+)/stages/(\d+)$#', ['projects', 'update'], 'apiProjectStagesUpdate'],
['DELETE', '#^/projects/(\d+)/stages/(\d+)$#', ['projects', 'update'], 'apiProjectStagesDelete'],
['POST', '#^/projects/(\d+)/stages/(\d+)/gate$#', ['projects', 'update'], 'apiProjectStagesGate'],
['GET', '#^/projects/(\d+)/items$#', ['projects', 'read'], 'apiProjectItemsList'],
['POST', '#^/projects/(\d+)/items$#', ['projects', 'update'], 'apiProjectItemsCreate'],
['PATCH', '#^/projects/(\d+)/items/(\d+)$#', ['projects', 'update'], 'apiProjectItemsUpdate'],
['DELETE', '#^/projects/(\d+)/items/(\d+)$#', ['projects', 'update'], 'apiProjectItemsDelete'],
['GET', '#^/projects/(\d+)/raid$#', ['projects', 'read'], 'apiProjectRaidList'],
['POST', '#^/projects/(\d+)/raid$#', ['projects', 'update'], 'apiProjectRaidCreate'],
['PATCH', '#^/projects/(\d+)/raid/(\d+)$#', ['projects', 'update'], 'apiProjectRaidUpdate'],
['DELETE', '#^/projects/(\d+)/raid/(\d+)$#', ['projects', 'update'], 'apiProjectRaidDelete'],
['GET', '#^/projects/(\d+)/budget$#', ['projects', 'read'], 'apiProjectBudget'],
['POST', '#^/projects/(\d+)/budget-lines$#', ['projects', 'update'], 'apiProjectBudgetLinesCreate'],
['PATCH', '#^/projects/(\d+)/budget-lines/(\d+)$#', ['projects', 'update'], 'apiProjectBudgetLinesUpdate'],
['DELETE', '#^/projects/(\d+)/budget-lines/(\d+)$#', ['projects', 'update'], 'apiProjectBudgetLinesDelete'],
['GET', '#^/projects/(\d+)/tasks$#', ['projects', 'read'], 'apiProjectTasks'],
['GET', '#^/projects/(\d+)/links$#', ['projects', 'read'], 'apiProjectLinks'],
['GET', '#^/projects/(\d+)/history$#', ['projects', 'read'], 'apiProjectHistory'],-
The key acts as its analyst. Every write builds
ActorContext::fromApiKey($apiKey)(apiProjectCtx()):actorId= the key's analyst,companyScope= the key's (already narrowed to the analyst's byapiAuthenticate()),source = 'api'. So the module's create, change and delete rules apply unchanged, and a refusal is the service's own 403 with its own words. -
apiProjectTry()runs the service call; aServiceErrorbecomesapiFailFromService()β{"error": {"code": "...", "message": "..."}}withserviceErrorHttpStatus()(validation 422, bad_request 400, not_found 404, forbidden 403, conflict 409). -
apiLoadProject($conn, $apiKey, $id)is the by-id gate: the row,apiKeyCanAccessTenantRow()andprojectVisibleTo()(members-only, as the key's analyst) - any failure is 404, never 403. It then decorates the row the way the portfolio does (apiProjectDecorate():projectTaskStats()+projectHealthConfig()+projectDecorate()), sohealthis the health the portfolio shows. Sub-resources are loaded through their project (apiProjectStage(),apiProjectItem(),apiProjectRaidRow(),apiProjectBudgetLine()look the child up among the project's own), so they share its scope. -
apiProjectFields($body)keeps onlyfieldMap()keys plusproject_manager_id, which is renamed toowner_analyst_id. Sub-resources have their own allow-lists (API_PROJECT_ITEM_FIELDS,API_PROJECT_RAID_FIELDS, the stage and budget-line lists). -
The list filters
status(comma list),methodology,project_manager_id,company_id(must be in the key's scope - 403 otherwise),q; sorts byname,target_end_date(default),start_date,created_at,updated_at,id(prefix-for descending, NULLs last); scopes withapiKeyTenantFilter()plusprojectVisibleSql()as the key's analyst.?health=is special: health is not a column, so the handler fetches up to 2000 rows, decorates, filters, then pages. -
PATCH. Projects, stages, items and RAID entries are PATCH-friendly in the services (a key left out keeps its value). A budget line is a full replace in
saveBudgetLine(), soapiProjectBudgetLinesUpdate()merges the stored line first (actual_typed,forecast_typed, the contract and cost centre ids). -
Templates:
POST /projectswithtemplategoes throughProjectTemplatesService::createFromTemplate(). -
The budget route returns 409
not_readybefore Database Verification, and never one person's hourly rate. - Catalogue: the
Projectssection ofapi/v1/spec.json; schemasProject,ProjectStage,ProjectGateResult,ProjectItem,ProjectRaidEntry,ProjectBudget,ProjectBudgetLine,ProjectTaskSummary,ProjectLinks,ProjectHistoryEntry,ProjectDeleteResultinopenapi_schemas.php, spliced as text. TRAP: never runapi/v1/dev/openapi_fix.phpin place - it rewrites that whole file withvar_exportand strips every comment. Run it on a copy and diff, or add fields by hand, thenopenapi_verify.phpandopenapi_check.php.
In: projects (with priority, visibility, approval, estimated_cost, estimated_benefit), stages and gate decisions, scope items, the RAID log (3.3.0: dependency type, the decision log on write; escalation and actions read-only), the budget and its lines (3.3.0: planned_date, spent_date, forecast), tasks (read), links (read), history (read).
Not in REST yet: members and roles, RACI, tolerances, asset targets, templates as a resource, announcing disruption, milestones, dependencies, estimates (except through /tasks), capacity, escalating and RAID actions, gate checklists, benefits, change requests and baselines, deciding a proposal, reports and briefings.
B="https://your-server/api/v1"; K="Authorization: Bearer fitsm_β¦"
# Live projects that are red, newest target first
curl -s "$B/projects?status=proposed,active&health=red&sort=-target_end_date" -H "$K"
# Create one, already planned from a built-in template, in a named company
curl -s -X POST "$B/projects" -H "$K" -H "Content-Type: application/json" -d '{
"name": "Leeds office move", "template": "builtin:office_move", "company_id": 3,
"start_date": "2026-11-02", "priority": "high", "project_manager_id": 7
}'
# Change only the target finish (history row carries source "api")
curl -s -X PATCH "$B/projects/42" -H "$K" -H "Content-Type: application/json" -d '{"target_end_date": "2026-12-11"}'
# Log a decision with its rationale (3.3.0)
curl -s -X POST "$B/projects/42/raid" -H "$K" -H "Content-Type: application/json" -d '{
"type": "decision", "title": "Keep the old office until January", "status": "closed",
"decided_by": "Finance Director", "decided_date": "2026-10-08", "rationale": "Lease break penalty is lower than double rent"
}'
# A gate decision
curl -s -X POST "$B/projects/42/stages/118/gate" -H "$K" -H "Content-Type: application/json" \
-d '{"decision": "go_with_conditions", "notes": "Label the patch panels before move day"}'
# Correct a budget line's spend date only - the rest of the line is merged in
curl -s -X PATCH "$B/projects/42/budget-lines/77" -H "$K" -H "Content-Type: application/json" -d '{"spent_date": "2026-10-02"}'A refusal from the module's own rules:
HTTP/1.1 403
{"error": {"code": "forbidden", "message": "Only this project's team, or someone who manages Projects, can change it."}}A gate on a planned stage:
HTTP/1.1 422
{"error": {"code": "invalid_field", "message": "That stage has not started yet."}}GET /projects/42 (shortened):
{
"data": {
"id": 42, "code": "PRJ-0042", "name": "Leeds office move", "company": null,
"methodology": "staged", "status": "active", "health": "amber", "health_mode": "auto", "priority": "high",
"visibility": "everyone", "approval": null,
"project_manager": {"id": 7, "name": "Sam Patel"},
"start_date": "2026-09-01", "target_end_date": "2026-11-30", "actual_end_date": null,
"tools": ["people", "scope", "raci", "raid", "gates", "budget", "control", "benefits"],
"progress": {"percent": 55, "tasks_total": 38, "tasks_done": 21, "tasks_overdue": 2},
"active_stage": {"id": 118, "name": "Fit-out"},
"exceptions": [],
"budget": {"currency": "GBP", "planned": 85000.0, "actual": 51230.5, "forecast": 88400.0},
"created_at": "2026-08-14T09:12:00Z", "updated_at": "2026-10-09T08:40:11Z", "closed_at": null
}
}Both assistants answer questions about projects with the same text, from includes/projects/assistant.php (3.3.0). It was moved out of includes/mcp/tools.php when Warbot gained the same tools, so the two can never describe a project differently. Health, progress and exceptions come from the same projectDecorate() the screens use, so an assistant can never call a project green that its page calls red. Everything is read-only.
The caller passes a scope: [' AND p.tenant_id ...', [args]] for alias p, NULL company read as Default, already including the members-only clause.
| Function | Answers |
|---|---|
projectAssistRows($conn, $scope, $where, $args, $limit) |
decorated rows (+ _budget), ordered active, proposed, on hold, closed, cancelled, then target date; limit capped at 500 |
projectAssistFind($conn, $scope, $ref) |
one project by PRJ-0042 / prj42 / 42, or a unique part of its name (an exact name wins among several); ServiceError missing_field, ambiguous (Several projects match "move": PRJ-0042 β¦, PRJ-0051 β¦. Use the code.) or not_found (No project "x" that you can see.) |
projectAssistWhy($p) |
why it is the colour it is: set by hand, each exception, overdue tasks, missed milestones, late dependencies or decisions, escalations, change requests waiting, benefit reviews due, a ticket spike |
projectAssistLine($p) |
PRJ-0042 Leeds office move [active, amber, high priority, 55% of 38 task(s) done, target 2026-11-30, led by Sam Patel] - 2 overdue task(s); 1 escalated |
projectAssistList($conn, $scope, $args) |
status (live = proposed + active, the default; all; or one status), health, q, limit (1-100, default 25) |
projectAssistOverview($conn, $scope, $analystId, $args, bool $withBudget) |
the line, goal, summary (600), method and dates, stages with gates, milestones (reached on time / late / MISSED / not reached yet), up to 10 open risks, issues, dependencies and decisions (LATE, ESCALATED), the budget only when $withBudget, unapproved linked changes only with Changes access, and the last 8 history rows filtered by module (below) |
projectAssistRaid($conn, $scope, $args) |
type, status (open default, closed, all) - each entry with score, owner, due / needed by, plan, decision, escalation and "n of m follow-up actions done" |
projectAssistTasks($conn, $scope, $args) |
top-level tasks, open_only default true, overdue first then by due date, at most 100 |
projectAssistDates($conn, $scope, $args) |
across every project in scope: open stage ends, target finishes and milestones in the next days (1-90, default 14), plus milestones already missed |
projectAssistMoney($v, $cur) |
GBP 85,000.00 |
The history filter in projectAssistOverview() - the MCP security review's finding 6 - drops rows that name a record in a module the analyst cannot open, and the money rows when the budget is left out:
if (in_array($r['field_name'], ['link_added', 'link_removed'], true)) {
$kind = strtok((string)$r['new_value'] ?: (string)$r['old_value'], ':');
return projectLinkKindAllowed($conn, $analystId, (string)$kind);
}
if ($r['field_name'] === 'raid_ticket_raised') return analystCanAccessModule($conn, $analystId, 'tickets');
if ($r['field_name'] === 'raid_to_knowledge') return analystCanAccessModule($conn, $analystId, 'knowledge');
if ($r['field_name'] === 'disruption_announced' || $r['field_name'] === 'disruption_withdrawn') return analystCanAccessModule($conn, $analystId, 'service-status');
if (!$withBudget && (strpos($r['field_name'], 'budget_') === 0 || strpos($r['field_name'], 'labour_') === 0 || $r['field_name'] === 'currency')) return false;Plain text out, - lists, no markdown: Warbot's chat shows text as typed, and an MCP client reads it either way.
api/mcp/index.php is the protocol; a key needs the mcp.read permission, and $apiKey['company_scope'] is replaced straight after authenticating with mcpEffectiveScope() - the key's companies intersected with its analyst's, so a key left on "all companies" still sees only what its analyst may. Every tool is declared with module (the analyst must be able to open it) and company_safe; the Projects tools are all company_safe because they scope themselves.
| MCP tool | Handler | Shared function |
|---|---|---|
list_projects |
mcpToolListProjects() |
projectAssistList() |
project_overview |
mcpToolProjectOverview() |
projectAssistOverview(..., true) - with the budget |
project_raid |
mcpToolProjectRaid() |
projectAssistRaid() |
project_tasks |
mcpToolProjectTasks() |
projectAssistTasks() |
project_dates |
mcpToolProjectDates() |
projectAssistDates() |
project_budget |
mcpToolProjectBudget() |
MCP only: projectBudgetDetail() as text - totals, labour hours and cost (never a rate), each line, a contract in another currency marked not counted
|
The scope:
function mcpProjectScopeSql(PDO $conn, array $apiKey): array
{
// Members-only projects (3.3.0): as the key's analyst.
require_once __DIR__ . '/../projects/visibility.php';
[$vSql, $vArgs] = projectVisibleSql($conn, (int)($apiKey['analyst_id'] ?? 0), 'p');
[$sql, $args] = mcpProjectCompanySql($conn, $apiKey); // ' AND (p.tenant_id IN (...) OR p.tenant_id IS NULL)' when Default is in scope
return [$sql . $vSql, array_merge($args, $vArgs)];
}A tool the key may not run is not listed (mcpToolsFor()), rather than listed and refused. mcpRunTool() returns a ServiceError's message as the tool's error text (isError: true) and anything else as That lookup failed. - never an SQL message, which would hand table and column names to the client.
Example exchange:
// tools/call
{"name": "project_overview", "arguments": {"project": "PRJ-0042"}}
// result content (text)
"PRJ-0042 Leeds office move [active, amber, high priority, 55% of 38 task(s) done, target 2026-11-30, led by Sam Patel] - 2 overdue task(s); 1 escalated\nGoal: Everyone working from the new floor by 30 November\nRuns as: staged. Start 2026-09-01, target finish 2026-11-30.\nStages:\n- Initiation (stage) closed, ends 2026-09-12, gate: go\n- Fit-out (stage) active, ends 2026-10-24\nMilestones:\n- Move day, due 2026-11-21: not reached yet\nBudget: GBP 85,000.00 planned, GBP 51,230.50 spent, GBP 88,400.00 forecast.\nRecent history:\n- 2026-10-08 Sam Patel raid escalated: risk: Landlord delays access"The same five tools (no budget), each declared with 'module' => 'projects', so warbotToolAllowed() offers them only to somebody who can open Projects.
/** The asking analyst's projects: their active company, or every company they can see under "All". */
function warbotProjectScope(PDO $conn, int $analystId): array
{
require_once __DIR__ . '/../tenancy.php';
require_once __DIR__ . '/../projects/assistant.php';
require_once __DIR__ . '/../projects/visibility.php';
[$sql, $args] = activeTenantReadFilter($conn, $analystId, 'p');
[$vSql, $vArgs] = projectVisibleSql($conn, $analystId, 'p'); // members-only (3.3.0)
return [$sql . $vSql, array_merge($args, $vArgs)];
}| Warbot tool | Handler | Differs from MCP |
|---|---|---|
list_projects |
warbotToolListProjects() |
default limit 10 (a channel answer) |
project_overview |
warbotToolProjectOverview() |
$withBudget = false - a channel is read by everyone in it, and money is not an operational fact; budget history rows are dropped too |
project_raid, project_tasks, project_dates
|
warbotToolProjectRaid(), ...Tasks(), ...Dates()
|
none |
warbotProjectAnswer() turns a ServiceError (no such project, several match) into the sentence the room sees - it is an answer, not a failure. Re-run the MCP test after touching assistant.php: it covers the scope rules.
-
The rule goes in a service. A new write is a method on
ProjectsServiceorProjectToolsServicethat starts withchangeable()(orloadForActor()plus its own check), looks children upWHERE id = ? AND project_id = ?, throwsServiceError, and ends withProjectsService::audit(),touchProject()and - if it can move health, a date or a tolerance -afterChange()andsyncCalendar(). A new read catches a missing table and returns empty. -
The door only routes. A new
caseinapi/projects/tools.php(or a new file that requiresincludes/projects/api_bootstrap.php, callsprojectApiRequirePost()for a write and wraps everything inprojectApiRun()). Update the doc comment at the top oftools.php. -
The page calls it through
P.api(); hide whatpermissionssays the server would refuse, but never rely on that. -
REST, if the thing belongs there: a route in
api/v1/lib/routes.php, a handler inapi/v1/resources/projects.phpthat loads throughapiLoadProject(), calls the same service withapiProjectCtx()insideapiProjectTry(), and a serialiser; the spec and schemas by hand (neveropenapi_fix.phpin place); REST API: Projects. -
Assistants, if it is something an assistant should be able to tell: a function in
includes/projects/assistant.phptaking a scope, then a tool in bothincludes/mcp/tools.phpandincludes/warbot/tools.php. Decide on purpose whether the answer may be read by a whole channel. - History words under
history.*inlang/en/projects.php, a Feature Bingo card, and the wiki.
See also: Projects Β· REST API: Projects Β· Service Layer Β· CSRF protection
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