Repository navigation
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.
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.
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, andprojectApiRun()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 byincludes/request_guard.php- nothing in the module opts in. See CSRF protection. -
ProjectToolsServiceis 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 privatechangeable()isloadForActor()thenassertCanChange(), the same pairProjectsServiceuses. - The services are written so the REST API and an MCP server can call them later with an API-key
ActorContext;source()/src()already recordapifor those.
| 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 |
| 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.
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 byupdateProject(),saveStage(),deleteStage(),reorderStages(),createTaskInProject(),assignTask()(for the project a task is leaving as well as the one it joins),projectLinkAdd()/projectLinkRemove(), and everyProjectToolsServicewrite. - A system actor (
actorId0 - 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.
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.
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.
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
timeboxas itskind(saveStage()). -
single_activeis enforced insaveStage(): setting a stage toactivewhile another is active throws "X" is still active. Close it before starting another. -
Switching method (
updateProject()seesmethodologychange) callsapplyMethodology()inside the same transaction as the update:- every time box whose status is not
closedgets the newkind. Closed ones keep the name they were run under - they are history; - if the new preset is
single_activeand more than one box is active, the earliest byposition, then id stays active and the rest go back toplanned, so the switch never leaves the project in a state its new method forbids.
- every time box whose status is not
- The switch is audited as an ordinary
methodologyfield change.
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
tailoringfield type inProjectsService::validateField()keeps only known tool keys, casts each to bool, and stores NULL when nothing is left. NULL means "the method's defaults". -
projectDecorate()addstoolsto every project row, and the page shows a tab only when its tool is in that list ([data-tool]buttons inprojects/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 sendsnull, 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.ProjectToolsServicedoes 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 unlessgatesis 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.
ProjectsService::createTaskInProject():
- loads the project through
loadForActor(), checksassertCanChange(), and checks the stage belongs to it; - strips
id,ticket_idandparent_task_idfrom the input and overwritestenant_idwith the project's company - a project task's company and links are the project's to decide; -
creates the task with
TasksService::saveTask()- the one create path - so the assigned email, the bell,task.created/task.assignedworkflow 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); - only then sets
tenant_id(as stored, NULL for Default),project_idandproject_stage_idwith anUPDATE.
🔑 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.
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_idnull 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".
-
api/tasks/list.phpaddsproject_id,project_name,project_colour(resolved to a hex byprojectColourHex()) andproject_stage_namein a separate query after the main one, not a join in it. -
api/tasks/get.phpdoes 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), andprojectFieldHtml()/setTaskProject()in the task window. -
tasks/index.phpsetswindow.TASK_CAN_PROJECTS. Only analysts who can open Projects get the picker (which readsapi/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.
🔑 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).
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.phprelies on it; the page shows "does not exist, or it belongs to a company you cannot see". -
Create:
projectApiTenantForCreate()takescompany_idfrom the body (checked withanalystCanAccessTenant()), else the active company;createProject()checks it againstcompanyScopetoo. -
No move.
tenant_idis not infieldMap(), 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 inapi/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 inapi/projects/tools.phpapplies the same filter so it never offers somebodyaddMember()would refuse). A RAID entry's linked ticket goes throughprojectLinkTargetOk($conn, $actor, 'ticket', ...), the same rule as a Connections ticket link. Analysts, teams and roles are install-wide.
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.
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.
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).
// 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... }-
goandgo_with_conditions(notes required) close the stage and start the next planned stage byposition, in one transaction - the decision is the hand-over. - A go on a stage that is already
closedonly records the decision. -
stoponly 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 toproject_stages. It does not go throughsaveStage(), so thesingle_activecheck 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: agosent 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.
- A key in
projectToolDefinitions()and, if a method should switch it on, in that preset'stools(includes/projects/methodologies.php).tools.<key>andtools.<key>_descinlang/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'shelp.tools.p1names the tools in words and needs the new one added. - Its tables in
database/freeitsm.sqlandincludes/db_verify_schema.php, FKs in$projectFks, and regenerateincludes/db_verify_indexes.php. Add the table to the by-hand delete list inProjectsService::deleteProject(). - Writes in
ProjectToolsService, each starting withchangeable()and ending withaudit()/touchProject(); a read that catches a missing table; anactioninapi/projects/tools.php; the read inapi/projects/get.php. - A tab button with
data-tool="<key>"and a panel inprojects/view.php, arender<Tool>()inassets/js/projects-tools.jscalled fromPrjTools.render(), and acanChange()check before anything that writes. - History strings under
history.*, a help section, a Feature Bingo card, and both wiki pages.
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 toredwhen there are exceptions (§11); -
exceptions=projectExceptions(); -
shown_health= null for closed/cancelled, else the manualhealthif it is notauto, elseauto_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.
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'(joinproject_assets) or'filter'(scope_type_idand/orscope_field LIKE %scope_value%, at least one required). AlwaysCOALESCE(a.tenant_id, default) = project's companyon a multi-company install. -
Done:
done_field+done_op+done_value, fromprojectTargetDoneFields()- 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 orders23H2 < 24H2and1.2 < 2.0). - 🔑 The SQL is built from whitelists only.
projectTargetSql()runsprojectTargetNormalise()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(*)andSUM(CASE WHEN done ...).projectTargetsFor($conn, $ids)returns every target withdone,total,pct,due(own date or the project's) andhealth;projectTaskStats()calls it and addstargets_health=projectTargetsWorst(), so the portfolio, the project page, People and the other-side panels all agree. -
Health:
projectTargetHealth()-redpastdueand unfinished;amberwhendone%is more than 25 below a straight line from the target'screated_datetimetodue; null with nothing in scope or on a finished project.projectAutoHealth()returns red first ontargets_health = red, and turns its final green amber ontargets_health = amber. -
Snapshots:
projectTargetsDetail()(only fromapi/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()andassertAssets()(Assets module access), the type must be oneprojectTargetOptions()offers the project's company; audittarget_saved/target_removed. -
Endpoint:
api/projects/tools.php- GETtarget_options=1,target_preview=1&<rule>({done,total}),target_assets=ID&show=left|done(at most 200, viaprojectTargetAssets()), all needing Assets; POSTtarget_save,target_delete.get.phpaddstargetsandcan_assets. -
Screen:
assets/js/projects-targets.js(PrjTargets.render) draws into#pvTargets, whichrenderOverview()leaves empty; the two dialogs are#prjTargetModaland#prjTargetListModalinprojects/view.php. Phone: mobile.css LAYER 44q. -
projectTargetsReady()probes both tables; before Verification every target read is empty.
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.
A tolerance breach is computed on every read, like health:
-
projectExceptionColumns($conn)adds four columns to the portfolio and project queries:max_risk(the highestprobability * impactamong the project's open risks),tol_time,tol_risk(project-level tolerances) andactive_stage_end(the end date of the first active stage). -
projectExceptions($p, $stats)returns a list of breaches - only for a live project with thegatestool on:-
time/stage_time:target_end_dateoractive_stage_endis more thantol_timedays in the past and work is still open; -
risk:max_risk > tol_risk.
-
-
projectDecorate()turnsauto_healthred when the list is not empty. A manual health still wins inshown_health. - The page shows
exceptionsBanner()(inassets/js/projects-tools.js, shared by the Overview and Gates tabs) and the portfolio card an Exception chip.
🔑 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.
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 asprojectLinkAdd(), 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_removedtoproject_auditaskind: 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 throughwindow.tf()with English fallbacks (and anENmap for status and health), so a host page need not export theprojectsnamespace. Links arebase + 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 viewhideEmpty, editor editable;KB_SHOW_PROJECTS). Each flag isanalystCanAccessModule(..., 'projects'). - Tickets do not use the widget:
assets/js/inbox.jsdraws pills in#stripProjectPillsand a Project item in the Link to... menu (openLinkProjectPicker(),removeTicketProject()), the same shape as the Domains pills. FlagTICKETS_SHOW_PROJECTS. - People:
peopleProjects()inincludes/people.php(a person:project_members.user_id, roles viaGROUP_CONCAT; a company:projects.tenant_id), rendered bypplSectionProjects()inpeople/includes/render.php, gated by$can('projects')like every People section.
- A join table in
database/freeitsm.sqlandincludes/db_verify_schema.php(copyproject_assets: unique pair, target index, three FKs), the FKs in$projectFksinapi/system/db_verify.php, and regenerateincludes/db_verify_indexes.php. - A row in
projectLinkKinds().scopednames the target's table when it carriestenant_id, ornullwhen it is not a company record. - A
caseinprojectLinkTargetOk()(the access check),projectLinkDescribe()(label, sub-line, status,entityLink()) andprojectLinkSearch(). -
links.kind.*andlinks.hint.*inlang/en/projects.php, an icon inLINK_ICONSinassets/js/projects-view.js, a check in theprojects.connectionsBingo card, and a line in the help page.
projectLinksDeleteAll() and projectLinksReady() loop over projectLinkKinds(), so they pick a new kind up by themselves.
-
One call draws a page.
assets/js/projects-view.jsloadsapi/projects/get.php,api/projects/lookups.phpandapi/projects/links.phptogether, and redraws every tab after each change, so the ring, the counts, the plan and the tools can never disagree. It hands the data towindow.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.phpacceptsq,statusandminebut the page does not use them. The chosen view is kept inlocalStorage(freeitsm.projects.view) as a convenience only. -
Asset targets draw after the tools:
renderOverview()leaves an empty#pvTargets, andwindow.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 withhistory.replaceState);?new=1opens a new project on Plan with the add box open. -
Read-only:
.prj-readonlyinassets/css/projects.csshides the Plan's write controls; the tool tabs checkcanChange()as they draw (§4). -
celebrate()inassets/js/projects.jsreturns immediately underprefers-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 fromapi/projects/settings.phpwith 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.
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.
| 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 |
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_idNULL; - 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
TasksServiceraises for any tasks the test creates.
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(), andprojectTemplateCapture()(a project as a template: risks and assumptions only, a target's status turned into its name). -
includes/services/project_templates.php-ProjectTemplatesService:createFromTemplate()(throughcreateProject()andcreateTaskInProject(), 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.
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 — 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)
-
🐞 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