Skip to content

Projects Developer Guide

Ed Mozley edited this page Oct 6, 2026 · 4 revisions

🛠️ Projects - Developer Guide

How the Projects module (3.2.0) is put together: the files, the two services every write goes through, why the schema is shaped the way it is, the permission model, how a methodology works as a lens over one data model and switches tools on, how project tasks stay ordinary tasks, the project-management tools (people, scope, RACI, RAID, gates), exceptions, the link rules, and the traps found while building it. For the plain-language version see Projects.


1. 📁 The files

Colour key: 🗄️ schema · ⚙️ rules · ✏️ write · 📖 read · 🔗 cross-module · 🖥️ UI

🎨 File What it does
🗄️ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php projects, project_stages, project_audit, the six link tables, the six phase 2 tables (§3), project_asset_targets + project_asset_target_snapshots (§10), and tasks.project_id / tasks.project_stage_id
🗄️ api/system/db_verify.php the foreign keys ($projectFks) - names and rules match database/freeitsm.sql - and the project_roles seed (only into an empty table)
⚙️ includes/projects/methodologies.php the presets (projectMethodologies(), including each method's tools), projectToolDefinitions(), projectEnabledTools(), statuses, health values, palette, icon keys, and projectsSchemaReady()
⚙️ includes/projects/settings.php every setting's key, default, validator and tab (projectSettingDefinitions()), the one reader (projectSettings()), and the permission rules projectCanCreate() / projectCanChange() / projectCanDelete() (§4)
⚙️ projects/settings/manifest.php, includes/capabilities.php the four Settings tabs and their capabilities (Cap::PROJECTS_MANAGE umbrella, PROJECTS_GENERAL / HEALTH / ROLES / RAID)
⚙️ includes/projects/links.php every connection rule (§12), and the other side: projectsLinkedTo() / projectsPickableFor()
⚙️ includes/projects/targets.php asset targets - the whitelisted rule fields, the SQL builder, counts, health, snapshots, the "still to do" list (§10)
⚙️ includes/projects/templates.php, includes/services/project_templates.php 🚧 templates - in progress, not wired in (§17). Nothing includes them yet
✏️ includes/services/projects.php ProjectsService - projects (including business_case and tailoring), stages, putting tasks in and out, creating a task in a project, assertCanChange(), audit(), touchProject()
✏️ includes/services/project_tools.php ProjectToolsService - members, scope items, RACI, RAID, tolerances, gate decisions (§8), asset targets (saveTarget() / deleteTarget(), §10)
📖 includes/projects/read.php the portfolio and one project for the screens; progress, health (projectAutoHealth(), projectHealthConfig()), exceptions (projectExceptions()), projectsPhase2Ready()
🖥️ includes/projects/api_bootstrap.php the start of every endpoint: session, module access, JSON, $conn, $ctx, $analystId, helpers
🖥️ api/projects/list.php, api/projects/get.php, api/projects/save.php, api/projects/delete.php, api/projects/lookups.php the portfolio (+ can_create), one project (+ permissions, members, items, RACI, RAID, tolerances), create/update, delete, form lookups (+ roles, teams, tools, RAID labels)
🖥️ api/projects/stage_save.php, api/projects/stage_delete.php, api/projects/stage_reorder.php time boxes
🖥️ api/projects/task_create.php, api/projects/task_assign.php a new task straight into a project; put an existing task in, move it between stages, or take it out
🖥️ api/projects/tools.php the tools: one POST endpoint routing an action to ProjectToolsService, plus the People picker (GET ?people=) and the asset target reads (GET target_options / target_preview / target_assets)
🖥️ api/projects/settings.php Projects → Settings: read every setting, save a tab, and the Roles list - each behind the tab's capability
🖥️ api/projects/links.php Connections: load, search, add, remove; and the other side (GET ?for=KIND&id=N, &pick=1&q=)
🖥️ projects/index.php, projects/view.php, projects/settings/index.php, projects/help.php, projects/includes/header.php, projects/includes/project_form.php the portfolio, the project page (tabs and the tool dialogs), Settings, the help page, the module header, the create/edit dialog (with the Tools section)
🖥️ assets/js/projects.js window.Prj: api(), lookups, the ring, pills, icons (the SVG lives here), the form (including tailoring), celebrate()
🖥️ assets/js/projects-portfolio.js, assets/js/projects-view.js, assets/js/projects-tools.js, assets/js/projects-targets.js, assets/js/projects-settings.js, assets/css/projects.css the portfolio, the project page, the five tool tabs (window.PrjTools), the asset targets on the Overview (window.PrjTargets), the Settings page
🔗 assets/js/project-links.js, assets/css/project-links.css the shared Projects panel other modules mount (§12)
🔗 cmdb/object.php + cmdb/object.js, contracts/view.php, asset-management/index.php, change-management/index.php + assets/js/change-management.js, knowledge/index.php + assets/js/knowledge.js, assets/js/inbox.js + tickets/index.php where that panel (or, on tickets, the pills) appears - search Projects (3.2.0)
🔗 includes/people.php, people/includes/render.php, people/help.php, lang/en/people.php the Projects section on People person and company pages (peopleProjects(), pplSectionProjects())
🖥️ lang/en/projects.php every string, including the help page under help.*
🔗 api/tasks/list.php, api/tasks/get.php, assets/js/tasks.js, tasks/index.php, assets/css/tasks.css, lang/en/tasks.php the Tasks board side (§6) - search Projects (3.2.0)
🔗 includes/entity_links.php, includes/recent_trail.php, api/system/global_search.php, assets/js/command-palette.js entityLink('project'), the recent trail, search and Cmd/Ctrl+K
🔗 includes/module-colors.php, includes/waffle-menu.php, assets/css/theme.css coral to violet (#f43f5e to #7c3aed), the tile, the --prj-* tokens
🔗 includes/feature_bingo/cards/projects.php eight cards
🔗 database/demo-data/tasks.json, system/demo-data/index.php demo data, inside the Tasks demo module (§14)

What you do not touch: the Tasks code that creates, edits or deletes a task. Projects never owns a task row; it only sets project_id / project_stage_id.


2. One path in

projects/*.php + assets/js/projects*.js
        │  fetch, JSON (CSRF token added by assets/js/csrf.js)
        ▼
api/projects/*.php  ── includes/projects/api_bootstrap.php (session, requireModuleAccessJson('projects'), $ctx)
        │
        ├─ writes ─► ProjectsService ─────► projects / project_stages / project_audit / tasks.project_id
        │                    └─ createTaskInProject() ─► TasksService::saveTask()
        ├─ tools ──► ProjectToolsService ─► project_members / _items / _raci / _raid / _tolerances, stage gate columns
        │                    └─ every method: ProjectsService::loadForActor() + assertCanChange()
        ├─ settings ► includes/projects/settings.php ─► system_settings, project_roles
        ├─ reads ──► includes/projects/read.php (+ ProjectToolsService::members/items/raci/raid/tolerances)
        └─ links ──► includes/projects/links.php ─► project_* join tables
  • Writes are unified, reads stay per surface - the Service Layer rule. The services never emit HTTP; they return ids or throw ServiceError, and projectApiRun() turns that into {success:false, error}.
  • Writes are POST only (projectApiRequirePost()), so a link or an image tag can never change a project. CSRF is enforced centrally by includes/request_guard.php - nothing in the module opts in. See CSRF protection.
  • ProjectToolsService is a separate class so neither service grows into a thousand-line file, but it has no rules of its own about who may do what: its private changeable() is loadForActor() then assertCanChange(), the same pair ProjectsService uses.
  • The services are written so the REST API and an MCP server can call them later with an API-key ActorContext; source() / src() already record api for those.

3. The schema, and why

Phase 1

Table Holds Why this shape
projects name, summary, goal, methodology, status, health + health_note, owner, dates, colour, icon, business_case, tailoring, created_by_id, tenant_id, is_demo scoped data like tickets: tenant_id NULL = the Default company
project_stages kind (phase / stage / sprint), name, goal, dates, position, status (planned / active / closed), and the gate: gate_decision, gate_notes, gate_decided_by, gate_decided_datetime one table for phases, stages and sprints, because they are the same thing seen through different methods. That is what makes switching method safe: nothing has to be converted
project_audit field_name, old, new, source, who, when the History tab. Ids are stored as names (auditDisplay()), so it reads as English later
tasks.project_id, tasks.project_stage_id which project and stage a task is part of a project's work items are tasks - there is no second task system
project_assets, project_changes, project_tickets, project_contracts, project_cmdb_objects, project_knowledge_articles one link per row, unique pair, created_by_analyst_id one join table per kind - the Domains pattern. Every link is a real FK that cascades with either side, and permission checks stay per module. No polymorphic link table

Phase 2 (new columns and six tables)

Column / table Holds Notes
projects.business_case TEXT saved through ProjectsService like any other field (fieldMap(), max 50,000). History shows changed the business case without the text
projects.tailoring VARCHAR(500), JSON {"raci": true, "raid": false} NULL = the method's defaults (§5)
project_stages.gate_* the decision at the end of a stage gate_decided_by is an analyst id with no FK
project_roles name (unique), description, display_order, is_active, is_demo install-wide, a list of words like task statuses. Seeded with nine roles in database/freeitsm.sql and by DB Verify, only into an empty table, so an edited list is never put back
project_members project_id + exactly one of analyst_id / team_id / user_id, role_id, notes, position a person from People (user_id) lets a sponsor with no analyst account hold a role. The "exactly one" rule is in addMember(), not a CHECK. Role FK is SET NULL - deleting a role never removes a person
project_items title, description, acceptance_criteria, moscow (must / should / could / wont / NULL), stage_id, status (proposed / agreed / in_progress / accepted / dropped), position, parent_id deliverables and requirements in one table. MoSCoW sorts the rows, RACI says who does what. parent_id is in the schema for a tree; the UI does not use it yet
project_raci project_id, item_id, member_id, letter (R / A / C / I) unique (item_id, member_id) - one letter per cell. Cascades with the item and with the member
project_raid type (risk / assumption / issue / decision / lesson), title, description, probability (1-5, risks), impact (1-5, risks and issues), response, response_plan, owner_analyst_id, status (open / closed), due_date, ticket_id, raised_by_id, closed_datetime the score is not stored: probability * impact is computed in SQL by raid() and by projectExceptionColumns(). The heat map is drawn from the rows, never kept separately
project_tolerances project_id, stage_id, dimension (time / risk), value unique (project_id, stage_id, dimension). Only project-level rows (stage_id NULL) are used today; the column is there for stage tolerances later

The phase 2 tables add 18 foreign keys, all in $projectFks.

🔑 tasks.project_id and project_stage_id are ON DELETE SET NULL, never CASCADE. Deleting a project must not delete the work people did. ProjectsService::deleteProject() also detaches tasks by hand, and deletes stages, history, links and the phase 2 rows (project_raci, project_members, project_items, project_raid, project_tolerances) by hand, because an upgraded install whose foreign keys failed to add has no cascade to rely on. Each phase 2 table and each link table is deleted in its own try, so a table not created yet never stops a delete. removeMember() and deleteItem() likewise delete their RACI rows by hand before the row itself.

🔑 Progress, health and exceptions are never stored (§10, §11). There is no progress or exception column on purpose.

The reference PRJ-0042 is not stored either: projectCode() pads the id. Global search parses PRJ-0042, prj42 and 42 back to an id.

Colour and icon are keys, never CSS or SVG. projectColours() and projectIcons() are fixed lists the service validates against, and the SVG for each icon key lives in assets/js/projects.js. A stored value can never be markup.


4. Permissions

Module access (requireModuleAccessJson('projects')) lets someone into the module; company scope (loadForActor(), §7) decides which projects exist for them. Inside that, three rules in includes/projects/settings.php decide what they may do. All three short-circuit for administrators, because analystHasCapability() does.

Function True when Setting
projectIsManager($conn, $id) holds Cap::PROJECTS_MANAGE (or is an admin) -
projectCanCreate($conn, $id) project_create_policy is anyone, or a manager General → Who may create projects (anyone / managers, default anyone)
projectIsTeam($conn, $id, $project) owner, creator, an analyst member, or in a team that is a member (analyst_teams) -
projectCanChange($conn, $id, $project) project_change_policy is anyone, or on the team, or a manager General → Who may change a project (team / anyone, default team)
projectCanDelete($conn, $id, $project) owner, creator, or a manager - never affected by a setting -

🔑 The default for changing is team, not anyone. Every other default is what phase 1 shipped with; this one was a deliberate change, because a project page is somebody's plan and the delete button sat on it.

Where they are enforced - always in the service, never only in the page:

  • createProject() → projectCanCreate(); deleteProject() → projectCanDelete().
  • ProjectsService::assertCanChange() → projectCanChange(), called by updateProject(), saveStage(), deleteStage(), reorderStages(), createTaskInProject(), assignTask() (for the project a task is leaving as well as the one it joins), projectLinkAdd() / projectLinkRemove(), and every ProjectToolsService write.
  • A system actor (actorId 0 - the demo importer, scheduled work) is not a person and is not asked.

How the page knows: api/projects/list.php returns can_create (hides New), and api/projects/get.php returns permissions: {can_change, can_delete}. assets/js/projects-view.js sets .prj-readonly on the page, hides Edit / Delete, and stops drag; assets/js/projects-tools.js checks canChange() before drawing an add box, a Save button or an enabled RACI cell. That is courtesy only - the server refuses either way.

The Settings page

projects/settings/manifest.php declares four tabs, each with its own capability; the tab bar, the tick-boxes on System → Roles and their descriptions all come from it. Cap::PROJECTS_MANAGE is the umbrella: holding it satisfies every tab's capability, and it is also what projectIsManager() asks.

⚠️ No tab declares setting_keys, on purpose. Every setting is saved through api/projects/settings.php, which validates each value with projectSettingValidate(), refuses a key that does not belong to the tab being saved, and checks that tab's capability (the Domains pattern). Declaring the keys in the manifest would let the generic settings writer store them unvalidated.

Tab Keys (default) Validator
General project_default_method (simple), project_create_policy (anyone), project_change_policy (team) a known method; anyone/managers; anyone/team
Health project_amber_days (14), project_amber_progress (75), project_red_overdue_pct (25) whole numbers 1-120, 1-100, 1-100
Roles the project_roles table - role_save, role_delete, role_reorder name required, up to 100 characters, unique
RAID project_probability_labels, project_impact_labels exactly five comma-separated labels, each up to 40 characters

projectSettings() reads all keys in one query, applies the defaults, and caches per request ($fresh after a save). Before Database Verification it returns the defaults.


5. Methodology is a lens - and it switches tools on

projectMethodologies() is defined in code, not in the database - the behaviour behind each key is code, so the key list must be (the same reason as the Warbot tool registry):

Key timebox single_active tools
simple phase false people
staged stage true people, scope, raci, raid, gates
agile sprint true people, scope, raid
  • A new time box takes the project's preset timebox as its kind (saveStage()).
  • single_active is enforced in saveStage(): setting a stage to active while another is active throws "X" is still active. Close it before starting another.
  • Switching method (updateProject() sees methodology change) calls applyMethodology() inside the same transaction as the update:
    1. every time box whose status is not closed gets the new kind. Closed ones keep the name they were run under - they are history;
    2. if the new preset is single_active and more than one box is active, the earliest by position, then id stays active and the rest go back to planned, so the switch never leaves the project in a state its new method forbids.
  • The switch is audited as an ordinary methodology field change.

Tailoring

projectToolDefinitions() lists the five tools. projectEnabledTools($project) starts from the preset's tools, then applies projects.tailoring:

$on = array_fill_keys($preset['tools'], true);
$tail = json_decode((string)($project['tailoring'] ?? ''), true);
if (is_array($tail)) {
    foreach ($tail as $k => $v) if (isset(projectToolDefinitions()[$k])) $on[$k] = (bool)$v;
}
return array_keys(array_filter($on));
  • The tailoring field type in ProjectsService::validateField() keeps only known tool keys, casts each to bool, and stores NULL when nothing is left. NULL means "the method's defaults".
  • projectDecorate() adds tools to every project row, and the page shows a tab only when its tool is in that list ([data-tool] buttons in projects/view.php; a hidden tool's tab is never left open).
  • The edit dialog sends the ticked boxes as tailoring, except when the method was changed in the same save: then it sends null, so the project takes the new method's own set (assets/js/projects.js).
  • 🔑 Switching a tool off hides it; it never deletes. The rows stay and come back when the tool is switched on again.
  • ⚠️ Tailoring is presentation, not permission. ProjectToolsService does not refuse a write to a tool that is switched off; the API will still accept it from someone allowed to change the project. The one place tailoring changes behaviour is exceptions: projectExceptions() returns nothing unless gates is on (§11).

Note

PRINCE2® is a registered trademark. "Staged", the Gates tool, tolerances and the seeded roles are described in FreeITSM's own words as a PRINCE2-style approach (see the header of includes/projects/methodologies.php). Never copy text from the manual into this module, and never describe it as certified or official.


6. Project tasks are ordinary tasks

Creating a task in a project

ProjectsService::createTaskInProject():

  1. loads the project through loadForActor(), checks assertCanChange(), and checks the stage belongs to it;
  2. strips id, ticket_id and parent_task_id from the input and overwrites tenant_id with the project's company - a project task's company and links are the project's to decide;
  3. creates the task with TasksService::saveTask() - the one create path - so the assigned email, the bell, task.created / task.assigned workflow events and everything else that happens for a new task happens here too, naming the project's company (passing it explicitly fixed events that named the analyst's active company when the two differed);
  4. only then sets tenant_id (as stored, NULL for Default), project_id and project_stage_id with an UPDATE.

🔑 Do not insert task rows directly from Projects. A hand-written INSERT would silently skip notifications and workflows, and the next change to task creation would have to be made twice.

Putting a task in, moving it, taking it out

ProjectsService::assignTask() (endpoint api/projects/task_assign.php, used by drag between lanes, the × on a task row, and the Project field in the task window):

  • the task must be in the caller's company scope (not found otherwise);
  • the caller must be allowed to change the project the task is leaving, and the one it is joining;
  • project_id null takes it out of the project and its stage;
  • otherwise the project must be reachable and in the same company as the task (sameTenant(), NULL read as Default), and a stage must belong to that project.

The task window always sends stage_id: null, so choosing a project there files the task under "not in a phase yet".

The Tasks board side

  • api/tasks/list.php adds project_id, project_name, project_colour (resolved to a hex by projectColourHex()) and project_stage_name in a separate query after the main one, not a join in it.
  • api/tasks/get.php does the same for one task.
  • assets/js/tasks.js: the card chip, the sidebar Project filter (refreshProjectFilter() builds it from the loaded tasks - nothing to fetch, hidden when none belong to a project), and projectFieldHtml() / setTaskProject() in the task window.
  • tasks/index.php sets window.TASK_CAN_PROJECTS. Only analysts who can open Projects get the picker (which reads api/projects/list.php, refused to everyone else); others see the project as plain text. The project name is shown to analysts without Projects on purpose: it is a fact about a task they can already see.
  • The Project field is offered on top-level tasks only. Progress counts top-level tasks only; subtasks belong to their task and never carry project_id.

projectsSchemaReady(), and why api/tasks needs it

🔑 An install that has pulled 3.2.0 but not yet run Database Verification has no tasks.project_id. A Tasks query naming that column would take the whole Tasks board down - a module the person never chose to upgrade. So every caller outside the Projects module asks projectsSchemaReady() first (information_schema check for the column and the projects table, cached per request) and simply leaves projects out. That is also why the project data is a second query in api/tasks/list.php rather than a join: the main query never names the new column.

Inside the module, phase 2 has its own guard, projectsPhase2Ready() (§11), and Connections has projectLinksReady() (§12).


7. Companies

A project is scoped data: exactly one company, tenant_id NULL = Default (storeTenant() writes the Default company as NULL, the way every scoped table does).

  • loadForActor() starts every by-id method in both services. A project in a company the caller cannot reach throws not found, never forbidden, so ids cannot be probed. api/projects/get.php relies on it; the page shows "does not exist, or it belongs to a company you cannot see".
  • Create: projectApiTenantForCreate() takes company_id from the body (checked with analystCanAccessTenant()), else the active company; createProject() checks it against companyScope too.
  • No move. tenant_id is not in fieldMap(), and the dialog only shows Company when creating.
  • The portfolio filters in SQL with activeTenantReadFilter() - one company, or every company the analyst can see in the All companies view. Nothing in the JS decides who sees what.
  • Global search uses activeTenantFilter() (the active company only), like the other modules in api/system/global_search.php. The recent trail gates a project's label by its company.
  • Tasks created in a project take the project's company; assignTask() refuses a task from another company.
  • Phase 2: a member from People (user_id) must be in the project's company (addMember(), and the People picker in api/projects/tools.php applies the same filter so it never offers somebody addMember() would refuse). A RAID entry's linked ticket goes through projectLinkTargetOk($conn, $actor, 'ticket', ...), the same rule as a Connections ticket link. Analysts, teams and roles are install-wide.

8. ProjectToolsService - the tools

Every public write starts with changeable() (loadForActor() + assertCanChange()), writes, then calls ProjectsService::audit() and/or touchProject(). One endpoint, api/projects/tools.php, routes the action:

Action Method Rule worth knowing
member_add addMember() exactly one of analyst_id / team_id / user_id; analyst must be active; no duplicates (conflict); audited member_added
member_update updateMember() role and notes; a role change is audited member_role
member_remove removeMember() deletes the member's project_raci rows first; audited member_removed
item_save saveItem() create or update; validates moscow, status, and that stage_id belongs to the project; a MoSCoW change is audited item_moscow
item_delete deleteItem() deletes its RACI rows, re-parents children to NULL, then the item; audited item_removed
item_move moveItem() the board's drag: sets moscow and rewrites position for the given column order
raci_set setRaci() see below; returns the row's letters
raid_save / raid_delete saveRaid() / deleteRaid() see below
tolerances_save saveTolerances() time 0-365, risk 1-25, blank deletes the row; audited tolerances
gate_decide decideGate() see below; returns {closed, next}

The reads the page needs - members(), items(), raci(), raid(), tolerances() - are on the same class and each catches a missing table and returns empty, so api/projects/get.php works before Verification.

RACI: one A per row, by demotion

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]);
}

🔑 Setting 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. setRaci() returns the whole row afterwards, so the screen redraws a demoted cell without a reload and shows a toast. An empty letter deletes the cell. A row with no A or no R is allowed (a draft); renderRaci() flags it. The screen cycles '' → R → A → C → I → '' and leaves out dropped items.

RAID: the score

saveRaid() keeps probability for risks only, impact for risks and issues, and response for risks only - other types store NULL whatever is sent. Scales are 1-5. raid() computes score = probability * impact in SQL when both are set and sorts open first, then by score. The heat map (heatMap() in assets/js/projects-tools.js) counts open risks with both values; shading is < 8 low, 8-14 mid, >= 15 high. A status change is audited raid_open / raid_closed and stamps or clears closed_datetime. The scale words come from api/projects/lookups.php (probability_labels, impact_labels).

Gates: what a decision changes

// 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') { ...close it, start the next planned one by position... }
  • go and go_with_conditions (notes required) close the stage and start the next planned stage by position, in one transaction - the decision is the hand-over.
  • A go on a stage that is already closed only records the decision.
  • stop only records. What happens next is the board's call, not the software's.
  • The screen offers Decide only on active or closed stages.
  • decideGate() writes the gate straight to project_stages. It does not go through saveStage(), so the single_active check there is not involved. That is safe from the screen, where the stage being closed is the active one. ⚠️ The service itself does not check the stage's status: a go sent through the API for a planned stage would close it and start the next planned one even while another stage is active.

The business case is not a tool method: the Gates tab saves it through api/projects/save.php as an ordinary project field.


9. Adding a tool

  1. A key in projectToolDefinitions() and, if a method should switch it on, in that preset's tools (includes/projects/methodologies.php). tools.<key> and tools.<key>_desc in lang/en/projects.php. The Tools section in the edit dialog and the help page's per-method list pick it up by themselves; the help's help.tools.p1 names the tools in words and needs the new one added.
  2. Its tables in database/freeitsm.sql and includes/db_verify_schema.php, FKs in $projectFks, and regenerate includes/db_verify_indexes.php. Add the table to the by-hand delete list in ProjectsService::deleteProject().
  3. Writes in ProjectToolsService, each starting with changeable() and ending with audit() / touchProject(); a read that catches a missing table; an action in api/projects/tools.php; the read in api/projects/get.php.
  4. A tab button with data-tool="<key>" and a panel in projects/view.php, a render<Tool>() in assets/js/projects-tools.js called from PrjTools.render(), and a canChange() check before anything that writes.
  5. History strings under history.*, a help section, a Feature Bingo card, and both wiki pages.

10. Progress and health - worked out, never stored

projectTaskStats() counts a project's top-level tasks in one grouped query: total, done (status is_closed = 1), overdue (not closed, due_date < UTC_DATE()). projectDecorate() adds:

  • progress = round(done × 100 / total), 0 with no tasks;
  • auto_health = projectAutoHealth(), then forced to red when there are exceptions (§11);
  • exceptions = projectExceptions();
  • shown_health = null for closed/cancelled, else the manual health if it is not auto, else auto_health;
  • tools = projectEnabledTools().

projectAutoHealth($p, $stats, $cfg), in order (open = total - done; $cfg from projectHealthConfig(), i.e. Projects → Settings → Health, defaults 14 / 75 / 25):

Returns When
null status is closed or cancelled
red target_end_date < today and open > 0
red open > 0, overdue > 0 and overdue × 100 >= open × red_overdue_pct
amber overdue > 0
amber a target date, open > 0, the target amber_days away or less, total > 0, and done × 100 / total < amber_progress
green otherwise

Storing either value would let it go stale the moment somebody ticked a task on the Tasks board, which does not know projects exist.

Asset targets - includes/projects/targets.php

Live progress measured from Assets. project_asset_targets holds the rule; project_asset_target_snapshots one point a day (UNIQUE (target_id, snap_date)).

  • Scope: scope = 'linked' (join project_assets) or 'filter' (scope_type_id and/or scope_field LIKE %scope_value%, at least one required). Always COALESCE(a.tenant_id, default) = project's company on a multi-company install.
  • Done: done_field + done_op + done_value, from projectTargetDoneFields() - list fields (status, location: is/is_not, value an id) and text fields (contains, not_contains, is, is_not, at_least - a string compare, which orders 23H2 < 24H2 and 1.2 < 2.0).
  • 🔑 The SQL is built from whitelists only. projectTargetSql() runs projectTargetNormalise() again on every row it is given, stored or not; column names come from the two field maps and every value is a bound parameter.
  • Counts on read: projectTargetCount() = COUNT(*) and SUM(CASE WHEN done ...). projectTargetsFor($conn, $ids) returns every target with done, total, pct, due (own date or the project's) and health; projectTaskStats() calls it and adds targets_health = projectTargetsWorst(), so the portfolio, the project page, People and the other-side panels all agree.
  • Health: projectTargetHealth() - red past due and unfinished; amber when done% is more than 25 below a straight line from the target's created_datetime to due; null with nothing in scope or on a finished project. projectAutoHealth() returns red first on targets_health = red, and turns its final green amber on targets_health = amber.
  • Snapshots: projectTargetsDetail() (only from api/projects/get.php) upserts today's point and returns the last 120 days. Saving a changed rule deletes the target's points.
  • Writes: ProjectToolsService::saveTarget() / deleteTarget() - changeable() and assertAssets() (Assets module access), the type must be one projectTargetOptions() offers the project's company; audit target_saved / target_removed.
  • Endpoint: api/projects/tools.php - GET target_options=1, target_preview=1&<rule> ({done,total}), target_assets=ID&show=left|done (at most 200, via projectTargetAssets()), all needing Assets; POST target_save, target_delete. get.php adds targets and can_assets.
  • Screen: assets/js/projects-targets.js (PrjTargets.render) draws into #pvTargets, which renderOverview() leaves empty; the two dialogs are #prjTargetModal and #prjTargetListModal in projects/view.php. Phone: mobile.css LAYER 44q.
  • projectTargetsReady() probes both tables; before Verification every target read is empty.

⚠️ The rules are written out three times: in projectAutoHealth(), in the help page (help.health.* in lang/en/projects.php, rendered by projects/help.php, whose header says so), and on the user page. Change one, change the others.


11. Exceptions - worked out, never stored

A tolerance breach is computed on every read, like health:

  • projectExceptionColumns($conn) adds four columns to the portfolio and project queries: max_risk (the highest probability * impact among the project's open risks), tol_time, tol_risk (project-level tolerances) and active_stage_end (the end date of the first active stage).
  • projectExceptions($p, $stats) returns a list of breaches - only for a live project with the gates tool on:
    • time / stage_time: target_end_date or active_stage_end is more than tol_time days in the past and work is still open;
    • risk: max_risk > tol_risk.
  • projectDecorate() turns auto_health red when the list is not empty. A manual health still wins in shown_health.
  • The page shows exceptionsBanner() (in assets/js/projects-tools.js, shared by the Overview and Gates tabs) and the portfolio card an Exception chip.

projectsPhase2Ready()

🔑 The portfolio query names project_raid and project_tolerances in subqueries. Before Database Verification those tables do not exist, and the whole portfolio would fail - not just the new tabs. projectsPhase2Ready() probes both tables once per request (SELECT 1 ... LIMIT 0), and when they are missing projectExceptionColumns() returns NULL AS max_risk, NULL AS tol_time, NULL AS tol_risk, NULL AS active_stage_end instead. Every exception is then empty and the module works as phase 1 did. Any new subquery against a phase 2 table in a shared read needs the same guard.


12. Connections - includes/projects/links.php

Modelled on includes/domains/links.php (see Domains developer guide §11). projectLinkKinds():

Kind Table / column Module Same company?
asset project_assets.asset_id assets - analystCanAccessAsset() yes
change project_changes.change_id changes - analystCanAccessChange() yes
ticket project_tickets.ticket_id tickets - analystCanAccessTicket(), not deleted yes
contract project_contracts.contract_id contracts - exists no (install-wide)
cmdb project_cmdb_objects.cmdb_object_id cmdb - analystCanAccessCmdbObject() yes
article project_knowledge_articles.article_id knowledge - knowledgeCanRead() no (own audience model)

🔑 The rule, for every kind: a link is shown, made or removed only between two records the analyst can already open - Projects and the other module (projectLinkKindAllowed()) and the record itself (projectLinkTargetOk()). Adding and removing also need the right to change the project (§4).

  • projectLinks() leaves out a kind the analyst cannot use entirely, and drops rows they cannot see, rather than marking them hidden.
  • projectLinkSearch() narrows by company in SQL, then runs the same per-record check as projectLinkAdd(), so a picker never offers something the add would refuse. At most 20 results, already-linked rows excluded.
  • projectLinkRemove() needs the same right as adding. A link you cannot see is not yours to delete.
  • Add and remove write link_added / link_removed to project_audit as kind: label.
  • projectLinksReady() probes all six tables once per request; before Verification the tab says so and nothing errors.

One endpoint: api/projects/links.php - GET ?project_id= (links per kind, plus ready), GET ?project_id=&search=KIND&q=, POST {action: add|remove, project_id, kind, target_id}.

The other side (3.2.0 group 1). projectsLinkedTo($conn, $ctx, $kind, $targetId) returns the projects a record is linked to - empty, never an error, when the analyst cannot use the kind, cannot open the record, or a project is out of their company scope (loadForActor() per project). projectsPickableFor() is the picker: live projects (status NOT IN closed, cancelled) not already linked, in the record's company for scoped kinds, that pass projectCanChange(), at most 20. Both go through projectLinkProjectRows(), which decorates with projectTaskStats() so health and progress match the project page.

  • Endpoint: GET api/projects/links.php?for=KIND&id=N → {projects}; add &pick=1&q= for the picker. Linking and unlinking use the same POST as the Connections tab.
  • One shared widget, assets/js/project-links.js + assets/css/project-links.css: ProjectLinks.mount(host, {kind, id, base, cardClass, headClass, titleClass, bare, editable, hideEmpty}). Words go through window.tf() with English fallbacks (and an EN map for status and health), so a host page need not export the projects namespace. Links are base + url.
  • Mounted on: cmdb/object.php (SHOW_PROJECTS), contracts/view.php (CT_SHOW_PROJECTS), asset-management/index.php (a Projects detail tab, ASSET_SHOW_PROJECTS), change-management (CHG_SHOW_PROJECTS), knowledge (read view hideEmpty, editor editable; KB_SHOW_PROJECTS). Each flag is analystCanAccessModule(..., 'projects').
  • Tickets do not use the widget: assets/js/inbox.js draws pills in #stripProjectPills and a Project item in the Link to... menu (openLinkProjectPicker(), removeTicketProject()), the same shape as the Domains pills. Flag TICKETS_SHOW_PROJECTS.
  • People: peopleProjects() in includes/people.php (a person: project_members.user_id, roles via GROUP_CONCAT; a company: projects.tenant_id), rendered by pplSectionProjects() in people/includes/render.php, gated by $can('projects') like every People section.

Adding a link kind

  1. A join table in database/freeitsm.sql and includes/db_verify_schema.php (copy project_assets: unique pair, target index, three FKs), the FKs in $projectFks in api/system/db_verify.php, and regenerate includes/db_verify_indexes.php.
  2. A row in projectLinkKinds(). scoped names the target's table when it carries tenant_id, or null when it is not a company record.
  3. A case in projectLinkTargetOk() (the access check), projectLinkDescribe() (label, sub-line, status, entityLink()) and projectLinkSearch().
  4. links.kind.* and links.hint.* in lang/en/projects.php, an icon in LINK_ICONS in assets/js/projects-view.js, a check in the projects.connections Bingo card, and a line in the help page.

projectLinksDeleteAll() and projectLinksReady() loop over projectLinkKinds(), so they pick a new kind up by themselves.


13. The screens

  • One call draws a page. assets/js/projects-view.js loads api/projects/get.php, api/projects/lookups.php and api/projects/links.php together, and redraws every tab after each change, so the ring, the counts, the plan and the tools can never disagree. It hands the data to window.PrjTools.render({data, L, projectId, refresh, page}), which draws only the tabs whose tool is on.
  • The task window is the one place a task is edited. Task rows link to tasks/?task=N; the project page never grows a second editor.
  • The portfolio loads once and filters in the browser. Views and search are client side; api/projects/list.php accepts q, status and mine but the page does not use them. The chosen view is kept in localStorage (freeitsm.projects.view) as a convenience only.
  • Asset targets draw after the tools: renderOverview() leaves an empty #pvTargets, and window.PrjTargets.render({data, projectId, refresh}) (assets/js/projects-targets.js) fills it and owns #prjTargetModal / #prjTargetListModal.
  • Tabs are #overview, #plan, #people, #scope, #raci, #raid, #gates, #connections, #history (kept with history.replaceState); ?new=1 opens a new project on Plan with the add box open.
  • Read-only: .prj-readonly in assets/css/projects.css hides the Plan's write controls; the tool tabs check canChange() as they draw (§4).
  • celebrate() in assets/js/projects.js returns immediately under prefers-reduced-motion: reduce, leaving only the toast. It fires on finishing a stage, closing a project, and a go at a gate.
  • Settings (projects/settings/index.php + assets/js/projects-settings.js) shows only the tabs the analyst's capabilities allow (settingsVisibleTabs()), fills each control from api/projects/settings.php with its default shown underneath, and keeps the tab in ?tab=.
  • api/projects/stage_reorder.php / ProjectsService::reorderStages() exist, but nothing in the UI calls them yet.

14. Demo data

The three demo projects live in database/demo-data/tasks.json - projects and project_stages ahead of tasks - not in a module of their own. A separate Projects module inserting tasks would clear the Tasks demo's tasks on re-import, because both write to tasks. The demo card is called Tasks and Projects. Phase 2 content (members with roles, MoSCoW scope, RACI, RAID, tolerances, a business case and a recorded gate) sits on the office move. The demo has no asset targets yet; one would go after projects, with a filter rule, because demo assets differ per install.


15. Traps worth remembering

Trap What happened
A class that sets display beats [hidden] .prj-chip (display:inline-flex) still showed when hidden. assets/css/projects.css carries a TRAP: rule re-asserting [hidden]{display:none!important} inside the module
$icon in projects/help.php includes/header.php loops its nav as [$href, $label, $icon] and overwrote it. The help page's helper is $prjHelpIcon
Naming tasks.project_id in a Tasks query breaks the whole board before Verification. Guard with projectsSchemaReady() and keep it a separate query (§6)
Naming a phase 2 table in the portfolio query breaks the whole portfolio before Verification. Guard with projectsPhase2Ready() (§11)
A go recorded on a stage that was already closed closing-and-advancing on every go would start another stage while a later one is in progress. decideGate() hands over only when the decision actually closes the stage (§8)
Seeding project tasks through TasksService correct, but each one raises a "task assigned" bell notification. Test clean-up must delete those notifications as well as the tasks
Headless Chrome and modals modal transitions never finish headless, so a dialog looked as if it had lost its icons and Cancel button. Inject *{transition:none!important} before screenshotting
FKs that never got added an upgraded install may have no cascade. Every delete in both services removes children by hand (§3)
Settings keys in the manifest would let the generic settings writer store them unvalidated. Keep them out (§4)
NOT (done) for "still to do" an asset with no status makes status = 6 NULL, and NOT NULL is NULL - the asset vanished from the list while the count (a CASE WHEN) still counted it. projectTargetAssets() uses NOT COALESCE((done), 0) - a TRAP: comment marks it
Test scripts against a real install a test that seeds and then "cleans up" by field name can delete somebody's own history. Use a temporary project and delete it, or delete only rows the test can prove it wrote

16. Testing

There is no test under tests/ for Projects yet. Asset targets and the other side were checked the same way (prj_targets_e2e.php: counts against direct SQL, every refusal, health in the portfolio and on the page, a temporary project deleted at the end; prj_other_e2e.php: ?for / ?pick / unlink for all six kinds, every host page, the People sections). During the build, end-to-end scripts ran through the real endpoints with a forged session carrying a csrf_token, all passing: 43 checks for the foundation; links including cross-company refusal; Settings save / validate / roles and every permission rule as a non-admin, with the settings restored afterwards; 24 checks for People, Scope and RACI including a cross-company person refusal; RAID including scale and ticket refusals; and gates including restoring the stages afterwards. When writing a permanent one:

  • go through api/projects/*.php, not the services, so module access, CSRF and the permission rules are exercised;
  • include a positive control - a write that should succeed - next to each refusal (cross-company asset or person, a ticket in a company the analyst cannot see, an article they cannot read, a non-team analyst changing a project);
  • run as a non-admin: admins short-circuit every capability, so a test run as one proves nothing about permissions;
  • check a task survives deleting its project and its stage, with project_id / project_stage_id NULL;
  • check a method switch relabels open stages, leaves closed ones, and leaves at most one active;
  • check a second A demotes the first, and a go on a closed stage starts nothing;
  • restore any Settings you change, and delete the bell notifications TasksService raises for any tasks the test creates.

17. Not built yet

🚧 Templates - in progress (2026-10-07)

Two files exist and are committed but nothing includes them, so they cannot run:

  • includes/projects/templates.php - the format (days from the start, no people, no company records; list values in a target rule kept as names), five built-ins in code (Office move, Laptop refresh, Mail migration to the cloud, Windows 11 rollout, Service desk improvement - never in the database, so deleting every template cannot bring them back), projectTemplateNormalise(), projectTemplateList() / Load(), and projectTemplateCapture() (a project as a template: risks and assumptions only, a target's status turned into its name).
  • includes/services/project_templates.php - ProjectTemplatesService: createFromTemplate() (through createProject() and createTaskInProject(), no transaction because TasksService opens its own, cleanup() on failure), saveFromProject(), update(), delete(), setBuiltinHidden().

Still to build: the project_templates table (id, name, description, content JSON, is_active, created_by_analyst_id, dates, is_demo) in all three schema places; Cap::PROJECTS_TEMPLATES and a Templates tab in the manifest; the project_hidden_templates setting (validator for built-in keys); template handling in api/projects/save.php and templates in lookups.php; the Start from picker in the new-project dialog; Template (save as) on a project; the Settings tab; template_used / template_saved history words; tests, help, Bingo card.

Otherwise

Against docs/design/projects.md (pre-build intent): no project templates; no budget, cost tracking or reports; no per-stage tolerances (the column exists); no deliverable tree in the UI (project_items.parent_id exists); no project board, timeline or calendar source; no members-only visibility (anyone with module access and the company can see a project); no project workflow events, Watchtower card or REST resource; no demo asset targets; Connections has no Service Status kind; asset targets cannot be re-ordered (position exists); and no MCP server or AI project manager yet.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally