Projects Internals 8 Connections and Templates - edmozley/freeitsm GitHub Wiki
How a project is joined to the rest of FreeITSM and to the people around it. Connections (includes/projects/links.php) are seven plain join tables guarded by one rule - both ends visible, same company - and read from both directions, so an asset, change, problem, CI, contract, article or ticket shows the projects it is part of through one shared widget (assets/js/project-links.js). Documents hang off a project through the shared Documents registry. Templates (includes/projects/templates.php and ProjectTemplatesService) are plans with no dates and no people, built through the same doors as a project made by hand. Then the people: members, roles, the RACI matrix, the 3.3.0 stakeholder map, the People module's view of a person's projects, members-only visibility seen from the connections and people side, the spreadsheet export, and how a form proposes a project.
Pages in this series
- 1 Architecture
- 2 Schema
- 3 Services and API
- 4 Plan, Timeline and dependencies
- 5 Health and charts
- 6 Governance
- 7 Alerts, reports and AI
- 8 Connections, templates, people
- 9 Demo data, testing and traps
- 10 Contractors
| File | What it holds |
|---|---|
includes/projects/links.php |
every connection rule: projectLinkKinds(), projectLinksReady(), projectLinkKind(), projectLinkKindAllowed(), projectLinkTenantOf(), projectLinkProjectTenant(), projectLinkTargetOk(), projectLinkDescribe(), projectLinks(), projectLinkAdd(), projectLinkRemove(), projectLinkSearch(); the other side projectLinkProjectRows(), projectsLinkedTo(), projectsPickableFor(); projectLinksDeleteAll(); and projectUnapprovedChanges() (see 6 Governance) |
api/projects/links.php |
the one Connections endpoint, both directions |
assets/js/project-links.js, assets/css/project-links.css
|
window.ProjectLinks.mount() - the Projects panel other modules show |
assets/js/projects-view.js |
the project's Connections tab and the Overview link chips (LINK_ICONS) |
assets/js/inbox.js, tickets/index.php
|
the ticket side: pills and the Project item in Link to... |
includes/documents.php, includes/tenancy.php
|
the project parent in documentEntityRegistry(); analystCanAccessProject()
|
includes/projects/templates.php |
the template format, the five built-ins, projectTemplateNormalise(), projectTemplateSummary(), projectTemplateList(), projectTemplateLoad(), projectTemplateCapture(), projectGateKindReady(), projectTemplatesReady()
|
includes/services/project_templates.php |
ProjectTemplatesService - createFromTemplate(), cleanup(), saveFromProject(), update(), delete(), setBuiltinHidden()
|
api/projects/templates.php, api/projects/save.php, api/projects/lookups.php
|
template management; starting from a template; the picker's list |
assets/js/projects.js, assets/js/projects-templates.js, assets/js/projects-settings.js
|
the Start from cards; the Template button and #prjTemplateModal; Settings -> Templates |
api/projects/export.php, includes/spreadsheet.php
|
the spreadsheet export (3.3.0) |
includes/services/project_tools.php |
members (addMember(), updateMember(), removeMember(), members()), stakeholder fields (STANCES, stakeReady()), RACI (setRaci(), raci()) |
api/projects/settings.php |
the Roles tab (role_save, role_delete, role_reorder) |
assets/js/projects-tools.js |
renderPeople(), stakeMap(), openStake() / saveStake(), renderRaci(), cycleRaci()
|
includes/people.php, people/includes/render.php
|
peopleProjects(); pplSectionProjects(), pplRaciDuties()
|
includes/projects/visibility.php |
members-only projects |
includes/projects/intake.php |
projectCreateFromAction() - a form proposing a project |
Modelled on includes/domains/links.php (see Domains developer guide ยง11). One join table per kind - the Domains pattern: every link is a real foreign key that cascades with either side, and permission checks stay per module. There is no polymorphic link table.
function projectLinkKinds(): array
{
return [
'asset' => ['table' => 'project_assets', 'col' => 'asset_id', 'module' => 'assets', 'scoped' => 'assets'],
'change' => ['table' => 'project_changes', 'col' => 'change_id', 'module' => 'changes', 'scoped' => 'changes'],
'ticket' => ['table' => 'project_tickets', 'col' => 'ticket_id', 'module' => 'tickets', 'scoped' => 'tickets'],
'contract' => ['table' => 'project_contracts', 'col' => 'contract_id', 'module' => 'contracts', 'scoped' => null],
'cmdb' => ['table' => 'project_cmdb_objects', 'col' => 'cmdb_object_id', 'module' => 'cmdb', 'scoped' => 'cmdb_objects'],
'article' => ['table' => 'project_knowledge_articles', 'col' => 'article_id', 'module' => 'knowledge', 'scoped' => null],
'problem' => ['table' => 'project_problems', 'col' => 'problem_id', 'module' => 'problems', 'scoped' => 'problems'],
];
}| Kind | What it is for | Record check in projectLinkTargetOk()
|
Same company? |
|---|---|---|---|
asset |
the equipment the project works on | analystCanAccessAsset() |
yes |
change |
changes the project raises | analystCanAccessChange() |
yes |
ticket |
tickets about it, including the ones it caused |
analystCanAccessTicket() and deleted_datetime IS NULL
|
yes |
contract |
supplier contracts it relies on | exists | no - contracts are install-wide |
cmdb |
configuration items it touches | analystCanAccessCmdbObject() |
yes |
article |
runbooks and write-ups | knowledgeCanRead(..., ['lifecycle' => 'unarchived']) |
no - articles have their own audience model |
problem (3.3.0)
|
problems it exists to fix, or that it caused | analystCanAccessProblem() |
yes |
scoped names the target's table when it carries tenant_id, or null when it is not a company record.
Each join table has the same shape - project_problems as the newest example:
CREATE TABLE IF NOT EXISTS `project_problems` (
`id` INT NOT NULL AUTO_INCREMENT,
`project_id` INT NOT NULL,
`problem_id` INT NOT NULL,
`created_by_analyst_id` INT NULL,
`created_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`is_demo` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_ppr_pair` (`project_id`, `problem_id`),
KEY `ix_ppr_target` (`problem_id`),
CONSTRAINT `fk_ppr_project` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ppr_target` FOREIGN KEY (`problem_id`) REFERENCES `problems` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ppr_analyst` FOREIGN KEY (`created_by_analyst_id`) REFERENCES `analysts` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;๐ 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()). A link the reader cannot see both ends of does not exist for them: it is left out, never shown as "hidden".
๐ And a link never crosses companies. An asset, change, ticket, CI or problem must be in the project's own company. Contracts and articles are not company records, so they are judged by their own permissions only.
function projectLinkKindAllowed(PDO $conn, int $analystId, string $kind): bool
{
$k = projectLinkKinds()[$kind] ?? null;
return $k !== null
&& analystCanAccessModule($conn, $analystId, 'projects')
&& analystCanAccessModule($conn, $analystId, $k['module']);
}projectLinkTargetOk($conn, $analystId, $kind, $targetId, ?$projectTenant) runs the per-kind access check; contracts and articles return there. For a company record it then requires the record to exist (single-company install, or $projectTenant null) or to sit in the project's company:
// A company record: must exist and sit in the project's company.
if (!$multi || $projectTenant === null) {
$st = $conn->prepare("SELECT 1 FROM {$k['scoped']} WHERE id = ?");
$st->execute([$targetId]);
return (bool)$st->fetchColumn();
}
return projectLinkTenantOf($conn, $k['scoped'], $targetId) === $projectTenant;projectLinkTenantOf() reads the record's tenant_id with NULL as the Default company; projectLinkProjectTenant() loads the project through ProjectsService::loadForActor() (so a project out of reach - another company, or members-only - is not found) and returns its company, Default for NULL.
The article case reads with lifecycle => 'unarchived', as the analyst article list does, so a draft can be linked: a lesson turned into an article from the RAID log is saved as a draft and linked at once, and the default 'live' refused it. Permissions are unchanged - only the publish state is no longer a bar.
-
projectLinks($conn, $ctx, $projectId)- for each kind: skip it entirely whenprojectLinkKindAllowed()is false; read the ids; drop each oneprojectLinkTargetOk()refuses; describe the rest. Returns{kind: [rows]}with only the kinds the analyst may use. -
projectLinkDescribe($conn, $kind, $ids)- one query per kind, shaped{id, label, sub, status, url}plus kind extras:status_colourandclosed(change, ticket, problem),end(contract),known_error(problem). Labels: hostname or asset tag;CHG-0042; the ticket number; contract title; CI name with its class as the sub-line;problem_numberorPRB-0042; the article title.urlisentityLink(). -
projectLinkAdd()-projectLinksReady($conn, $kind)(else Run System โ Database Verification first.), the project's company,ProjectsService::assertCanChange()on the project, then the kind and record checks (a refusal is not found, That record cannot be linked to this project.).INSERT IGNORE, so linking twice is harmless and returnsadded: false. Auditedlink_addedaskind: label. -
projectLinkRemove()- the same right as adding: change the project and see both ends. A link you cannot see is not yours to delete. Auditedlink_removed. -
projectLinkSearch($conn, $ctx, $projectId, $kind, $q)- narrows in SQL (theLIKEcolumns per kind, not already linked,COALESCE(x.tenant_id, default) = project companyfor company records on a multi-company install,knowledgeVisibilitySql()for articles), takes up to 40, then runs the same per-record check asprojectLinkAdd()and keeps at most 20. So the picker never offers something the add would refuse. Change and problem searches also match the number typed (x.id = <digits of q>). -
projectLinksReady($conn, ?$kind)probes per kind, caching each answer for the request. With a kind it says whether that table exists -projectLinks(), add, remove, search and both other-side functions all ask per kind, so an install that has pulled 3.3.0 but not yet verified (noproject_problems) still shows and edits its other six kinds; only problem links wait. With no kind it says whether any kind is usable - what the Connections tab's "run Database Verification" note and the endpoint'sreadyuse. Nothing errors before Verification. (Until #2242 it needed all seven tables, so one missing table hid every link.)
function projectLinksReady(PDO $conn, ?string $kind = null): bool
{
static $ready = [];
$kinds = $kind !== null ? [$kind => projectLinkKinds()[$kind] ?? null] : projectLinkKinds();
foreach ($kinds as $name => $k) {
if ($k === null) return false;
if (!isset($ready[$name])) {
try { $conn->query("SELECT 1 FROM {$k['table']} LIMIT 0"); $ready[$name] = true; }
catch (Throwable $e) { $ready[$name] = false; }
}
if ($kind !== null) return $ready[$name];
}
// No kind named: is ANY kind usable (the Connections tab's "run Verification" note)?
return in_array(true, $ready, true);
}-
projectLinksDeleteAll()- every table in its owntry, called bydeleteProject()because an upgraded install whose FKs failed has no cascade to rely on.
Every rule lives in links.php; the endpoint only routes:
GET ?project_id=N {links: {kind: [rows]}, ready}
GET ?project_id=N&search=KIND&q= {results: [rows]} records that could be linked
GET ?for=KIND&id=N {projects: [rows]} the projects a record is linked to
GET ?for=KIND&id=N&pick=1&q= {projects: [rows]} live projects it could be added to
POST {action: 'add'|'remove', project_id, kind, target_id}
Example - linking problem 17 to project 42 from either side:
POST api/projects/links.php
{ "action": "add", "project_id": 42, "kind": "problem", "target_id": 17 }
-> { "success": true, "added": true }The history then reads link_added: problem: PRB-0017.
A record's own page shows the projects it is part of, with the same rules read from the other end.
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 reach, so a page can call it unconditionally:
- the kind exists, its table is ready (
projectLinksReady($conn, $kind)),projectLinkKindAllowed(); -
projectLinkTargetOk(..., null)- the record itself (no company to compare with yet); - read the project ids from the join table;
- keep each project only if
ProjectsService::loadForActor()succeeds - company scope and members-only visibility; -
projectLinkProjectRows().
The picker on the record's page: live projects (status NOT IN ('closed', 'cancelled')) matching the name or the id typed, not already linked, in the record's company for company records on a multi-company install, reachable through loadForActor(), and passing projectCanChange() - you may only add a record to a project you may change. Up to 60 rows are read, at most 20 returned.
Shapes the list for another module's page, decorated with projectTaskStats() and projectDecorate() so health and progress match the project page:
['id', 'code', 'name', 'status', 'colour' => projectColourHex(...), 'icon', 'health' => shown_health,
'progress', 'owner_name', 'target_end_date', 'url' => entityLink('project', $id)]Ordered active, proposed, on hold, closed, cancelled, then name.
ProjectLinks.mount(hostEl, {
kind: 'asset' | 'change' | 'ticket' | 'contract' | 'cmdb' | 'article' | 'problem',
id: the record's id,
base: BASE_URL,
cardClass / headClass / titleClass: the host page's own card classes,
bare: true when the host draws its own heading - just the body,
editable: false to list only,
hideEmpty: true to draw nothing at all when there is nothing linked,
});- Each row: the project's colour dot, name,
code ยท status ยท owner, a health dot, a progress bar in the project's colour, and (editable) an ร to unlink. - The add box searches
?for=KIND&id=N&pick=1&q=180 ms after typing (and on focus, so the short list of live projects shows at once); clicking a result POSTsadd, the ร POSTsremove, and the list reloads. - Words go through
window.tf()with English fallbacks, and anENmap for status and health, so a host page need not export the wholeprojectstranslation namespace to show one panel. Links arebase + url. - A host page only mounts it for an analyst who can open Projects (each flag is
analystCanAccessModule(..., 'projects')), so the widget never has to ask. The server applies every rule either way.
Where it is mounted (search Projects (3.2.0) in each):
| Page | Kind | Flag | How |
|---|---|---|---|
asset-management/index.php |
asset |
ASSET_SHOW_PROJECTS |
a Projects detail tab, bare: true into #assetProjects
|
change-management/index.php + assets/js/change-management.js
|
change |
CHG_SHOW_PROJECTS |
#changeProjects |
problem-management/index.php + assets/js/problem-management.js (3.3.0)
|
problem |
PM_SHOW_PROJECTS |
a Projects section, bare: true into #pmProjects
|
cmdb/object.php + cmdb/object.js
|
cmdb |
SHOW_PROJECTS |
#cmdbProjects, with the object page's o2-card classes |
contracts/view.php |
contract |
CT_SHOW_PROJECTS |
the related section, bare: true
|
knowledge/index.php + assets/js/knowledge.js
|
article |
KB_SHOW_PROJECTS |
the read view with editable: false, hideEmpty: true; the editor editable |
Tickets do not use the widget. assets/js/inbox.js draws pills in #stripProjectPills (loadTicketProjects(), from ?for=ticket&id=N) and a Project item in the Link to... menu (openLinkProjectPicker(), which shows the live projects straight away, with arrow-key navigation; removeTicketProject() confirms then POSTs remove) - the same shape as the Domains pills. Flag TICKETS_SHOW_PROJECTS in tickets/index.php.
The REST API reads the same function: GET /projects/{id}/links (apiProjectLinks()) calls projectLinks() as the key's analyst, so it picks up a new kind by itself; the ProjectLinks schema description in api/v1/lib/openapi_schemas.php names the seven kinds.
A walkthrough, with problem (3.3.0) as the worked example:
-
Schema. A join table in
database/freeitsm.sqlandincludes/db_verify_schema.php- copyproject_assets/project_problems: unique pair, target index, three FKs (project CASCADE, target CASCADE, analyst SET NULL). The FKs go in$projectFksinapi/system/db_verify.php:['project_problems', 'fk_ppr_project', "ALTER TABLE project_problems ADD CONSTRAINT fk_ppr_project FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE"], ['project_problems', 'fk_ppr_target', "ALTER TABLE project_problems ADD CONSTRAINT fk_ppr_target FOREIGN KEY (problem_id) REFERENCES problems (id) ON DELETE CASCADE"], ['project_problems', 'fk_ppr_analyst', "ALTER TABLE project_problems ADD CONSTRAINT fk_ppr_analyst FOREIGN KEY (created_by_analyst_id) REFERENCES analysts (id) ON DELETE SET NULL"],
Regenerate
includes/db_verify_indexes.php(uq_ppr_pair,ix_ppr_target). -
The kind. A row in
projectLinkKinds():table,col,module(the keyanalystCanAccessModule()knows) andscoped(the target table when it carriestenant_id, elsenull). -
Three
cases inlinks.php:projectLinkTargetOk()(the module's own record check -analystCanAccessProblem()),projectLinkDescribe()(label, sub-line, status,entityLink()), andprojectLinkSearch()(whatqmatches; keep$takenand$company). -
Words and icon.
links.kind.<kind>andlinks.hint.<kind>inlang/en/projects.php; an icon inLINK_ICONSinassets/js/projects-view.js(problem: 'shield'). -
The other side. On the record's page: a flag (
window.PM_SHOW_PROJECTS = analystCanAccessModule(..., 'projects')), a host element,assets/js/project-links.jsandassets/css/project-links.cssloaded, andProjectLinks.mount(host, {kind, id, base, ...}). Add the kind to the comment at the top ofproject-links.js. -
Bingo and help. A
['rows', '<table>']check in theprojects.connectionsFeature Bingo card (includes/feature_bingo/cards/projects.php- it lists all seven tables since #2242) and a line in the help page.
projectLinksDeleteAll(), projectLinksReady() (both with and without a kind), projectLinks() and the REST read loop over or look up projectLinkKinds(), so they pick a new kind up by themselves - and until Database Verification makes the new table, only the new kind is missing.
A project is a parent in the shared Documents module: plans, designs, sign-off letters, backout plans - and what a gate checklist's document items point at (6 Governance).
'project' => [
'module' => 'projects',
'table' => 'projects',
'label' => 'Project',
'url' => 'projects/view.php?id=%d',
'title' => 'name',
'alive' => null,
'can' => function (PDO $c, int $a, int $id) { return analystCanAccessProject($c, $a, $id); },
'filter' => function (PDO $c, int $a, string $alias) { return activeTenantFilter($c, $a, $alias); },
],-
analystCanAccessProject()(new inincludes/tenancy.php) - members-only first (projectIdVisibleTo(), on every install, inside atryso a missing column allows), then single-company yes, otherwise the project's company (NULL = Default) throughanalystCanAccessTenant(), withtenancyDegradeAllowed()on a part-migrated table - the same rules as the change and problem checks. -
documentCanViewParent()asks the module gate, thencan, then that the project still exists. Attaching is gated by seeing the project, as for every Documents parent; the panel'scanEditisdata.permissions.can_change, so the page offers attaching only to people who may change the project. -
The tab.
projects-view.jsmounts the sharedFreeITSMDocumentspanel into#pvDocumentsPanelonce per page load (docsMounted), withparentType: 'project',apiBase: '../api/documents/'.documentsPanelAssets('../')inprojects/view.phploads its script and style. Opening the Documents tab setsdocsTouched, and going to Gates afterwards refreshes the project, so a document attached since the page loaded can be picked by a gate item. -
Orphans are swept by
documentsCollectOrphans()like every other parent; deleting a project does not delete its documents by hand.
A plan with no dates and no people, as JSON. The format is documented at the top of includes/projects/templates.php:
methodology, colour, icon, goal, summary, duration_days,
stages [{name, goal, start_day, end_day,
tasks: [{title, description?, due_day?, estimate_hours?}],
gate_kind? (standard | golive), gate_items? [{kind: check|document|signoff|change, title}]}] (gate: 3.3.0)
tasks [{title, description?, due_day?, estimate_hours?}] - not in any stage
milestones [{name, day, stage?}] - stage = index into stages, or null (3.3.0)
items [{title, description?, moscow?}] - scope, MoSCoW
raid [{type, title, description?, probability?, impact?, response?, response_plan?}] type incl. dependency (3.3.0)
tolerances {time?, risk?, cost?} - cost carried since #2242
targets [{name, scope, scope_field?, scope_value?, done_field, done_op, done_value}]
tailoring {tool: bool}
dependencies [{task, on, lag?}] (3.3.0)
benefits [{title, measure?, unit?, direction (up|down), target_value?, target_day?, review_months?}] (3.3.0)
- Every date is a day from the start (day 0), so the same template plans a project starting next week or next spring.
- ๐ Nothing in a template belongs to a company or a person - no analysts, no tickets, no asset ids. One template serves every company on an MSP install. A target's list value (a status, a location) is kept as its name (
projectTargetValueNames()on capture) and looked up when the template is used; a name that does not exist there drops that one target rather than guessing. -
Dependencies name tasks by place in the plan (3.3.0): every stage's tasks in order, then the loose tasks, counting from 0. That is the order
createFromTemplate()makes them, so$taskIds[$n]is taskn.taskwaits foronto finish, pluslagdays. - Benefits (3.3.0) never carry a baseline (that is measured on the project) or an owner.
- Gate checklists (3.3.0) keep the kind of gate and each item's kind and title - never who signs, which change or which document.
Every template - a saved row, the screen, a built-in - passes through it. Unknown keys are dropped, every list is capped, days are clamped to 0-3650, enums are checked. It never fails; bad entries are left out.
| Part | Rule |
|---|---|
methodology, colour, icon
|
a known key, else simple / coral / rocket
|
goal, summary
|
trimmed, up to 500 / 20,000 |
stages |
at most 40; name required (150); gate_kind is golive or standard; at most 30 gate_items, each a known kind with a title (200) |
| tasks (per stage and loose) | at most 200 per list; title required (255); description 5,000; estimate_hours > 0 and <= 9999, 2 dp |
milestones |
at most 100; name and day required; stage kept only when it is a valid index into the stages kept |
items |
at most 200; MoSCoW must / should / could / wont or null |
raid |
at most 100; type risk, assumption, issue, dependency, decision or lesson; probability for risks only, impact for risks and issues (1-5); response avoid / reduce / transfer / accept / share |
tolerances |
time 0-365, risk 1-25, cost 0-500 (%) - cost was dropped until #2242 |
targets |
at most 20; done_field and done_op from projectTargetDoneFields()
|
dependencies |
at most 500; both ends numeric and in range, not the same task, no repeat; lag 0-365 |
benefits |
at most 30; title required (200); direction down or up; review_months 0-24 |
tailoring |
only known tool keys, cast to bool; empty = null |
projectTemplateSummary() is what the picker sees: name, description, method, colour, icon, goal, duration_days and counts - stages, tasks, items, risks, targets, milestones, and since 3.3.0 dependencies, benefits, gate_items. The Start from card shows templateMeta() (4 stages ยท 16 tasks ยท 12 weeks).
-
Built-ins are written in code (
projectBuiltinTemplates()), keyedbuiltin:<key>, and are never in the database - so deleting every template can never bring them back unasked - and they ship in English, like the seeded roles. Five:office_move(Staged, 84 days),laptop_refresh(Staged, 112 days, with an asset target Old laptops retired on linked assets),mail_migration(Staged, 70 days),windows11(Simple, 90 days, with a filter target On Windows 11),service_desk(Agile, 56 days). Since #2239 they come with dependencies, benefits, and gate checklists - the office move's Move weekend and the mail migration's Cut over are go-live gates. -
Saved ones are
project_templatesrows (nameVARCHAR(150),descriptionVARCHAR(500),contentMEDIUMTEXT JSON,is_active,created_by_analyst_id,is_demo), keyedsaved:<id>. -
Hiding a built-in writes its key to the
project_hidden_templatessetting (comma list). That setting is written only byProjectTemplatesService::setBuiltinHidden(): its validator rulebuiltin_keysmakesprojectSettingValidate()refuse it (Hide or show templates on the Templates tab.), so the Settings save can never store an unchecked list. -
projectTemplateList($conn, $all)-$all = falsefor the picker (built-ins not hidden, saved ones active);truefor the Settings tab (everything, withactive).projectTemplateLoad($conn, $key, $evenHidden = false)returns[name, normalised content]or null. -
projectTemplatesReady()- built-ins work before Database Verification; saved ones need the table.
Starting from a template is api/projects/save.php with template ("builtin:office_move", "saved:12") on a new project, so it passes the same doors as a project made by hand:
$template = trim((string)($in['template'] ?? ''));
unset($in['template']);
if ($isNew && $template !== '') {
require_once __DIR__ . '/../../includes/services/project_templates.php';
projectApiOk(['id' => ProjectTemplatesService::createFromTemplate($conn, $ctx, $template, $in, $tenant), 'created' => true]);
}What createFromTemplate() does, in order:
-
projectTemplateLoad()- an unknown or hidden template is That template is not available. -
Form values win over the template's for
methodology,colour,icon,goal,summary. An empty start date means today; an empty target finish means start +duration_days; the template'stailoringis applied. -
ProjectsService::createProject()- the create policy, company scope, currency stamp, calendar,project.created. -
Stages inserted directly, with the method's
timeboxaskind, statusplanned, positions 1..n; an end before its start is moved to the start. - Each stage's tasks through
ProjectsService::createTaskInProject()- real tasks with their events and company,estimate_hoursincluded. - Each stage's gate (3.3.0):
gate_kind = 'golive'and the checklist items (nobody named, nothing chosen yet) - whenprojectGateItemsReady(). - Milestones (3.3.0), their stage index mapped back to the new stage id.
- The loose tasks.
-
Dependencies (3.3.0) -
$taskIdsis in plan order,INSERT IGNOREintotask_dependencies; a pair whose task was not made is skipped. -
Benefits (3.3.0) - first reviewed on their target date (
review_date = target_date),review_monthsfrom the template or theproject_benefit_review_monthssetting. -
Scope, RAID, tolerances when
projectsPhase2Ready()- inserted directly, RAID raised by the actor. -
Targets - status and location names looked up in
projectTargetOptions()for the project's company; an unknown name or a ruleprojectTargetNormalise()refuses is skipped ($skippedis counted but not returned). -
One
template_usedhistory row instead of forty. -
ProjectsService::syncCalendar()-createProject()drew the end date; the stages came after it.
๐ No outer transaction. TasksService opens its own and PDO cannot nest them. If anything fails after the project exists, cleanup() deletes the tasks made for it, the RAID action joins, project_gate_items, project_benefits, project_milestones, project_asset_targets, project_tolerances, project_raid, project_items, project_stages, project_audit and the project, each in its own try, then resyncs the calendar, and rethrows - a failure never leaves a stray project.
TRAP: dates are UTC midnight.
strtotime('2026-10-07')is local midnight; turned back into a date withgmdate()it is the day before whenever the server is ahead of UTC. Every stage and task came out a day early during British Summer Time - and a test with November dates passed. BothcreateFromTemplate()andprojectTemplateCapture()parse dates as... 00:00:00 UTC:$d0 = strtotime($in['start_date'] . ' 00:00:00 UTC'); $at = fn($day) => $day === null ? null : gmdate('Y-m-d', $d0 + (int)$day * 86400);
projectTemplateCapture($conn, $project, $parts) turns a project into a template. Day 0 is the project's start, else its earliest stage start, else the day it was made. $parts picks what to keep:
| Part | What is kept |
|---|---|
| (always) | method, colour, icon, goal, summary, duration_days (target finish as a day), tailoring |
plan |
stages with goals and days, their top-level tasks (title, description, due day, estimate) in board_position order, gate kind and gate items (kind + title) (3.3.0), loose tasks, dependencies between tasks in the plan (3.3.0), milestones with their stage index (3.3.0)
|
scope |
items not dropped: title, description, MoSCoW |
raid |
risks, assumptions and dependencies (dependency 3.3.0) - "the landlord's fit-out must be finished" recurs from project to project; issues, decisions and lessons were about this project |
benefits (3.3.0)
|
title, measure, unit, direction, target value, target day, review months - never the baseline or the owner |
tolerances |
ProjectToolsService::tolerances() - time, risk and (since #2242) cost; createFromTemplate() inserts each as a project-level row |
targets |
each rule, with a status or location value turned into its name; a rule whose value has no name is left out |
The capture builds the dependency order the same way the build does: each stage's task ids in stage order, then the loose ones, and records each task_dependencies row whose both ends are in that list. A task whose project_stage_id names a stage that no longer exists is not captured at all (in practice deleteStage() clears project_stage_id first, so this does not arise). The result goes through projectTemplateNormalise().
ProjectTemplatesService::saveFromProject($conn, $ctx, $projectId, $in):
-
assertManage()-Cap::PROJECTS_TEMPLATES(analyst 0 skips it); the table must exist; the project throughloadForActor(). -
partsis intersected with['plan', 'scope', 'raid', 'benefits', 'tolerances', 'targets']. -
Editing is replacing. With
id, it overwrites that saved template's name, description and content, keeping its id andis_active, and writestemplate_replacedto the project's history; withoutidit inserts and writestemplate_saved. Built-ins have no id, so they can be copied but never overwritten. There is no template editor - a project already is one; a second editor for stages, tasks, scope and RAID would only drift from the real screens. Projects already started from a template are untouched.
- Picking a template needs only what creating a project needs.
-
Saving one, and everything on Settings -> Templates, needs
Cap::PROJECTS_TEMPLATES(projects.templates; the Templates tab ofprojects/settings/manifest.php), because templates are shared by every company on the install.api/projects/templates.phpchecks it at the top (403), and the service checks it again. -
api/projects/lookups.phpreturnstemplates(what the picker offers) andcan_manage_templates(whether to show the Template button).
api/projects/templates.php:
GET {templates (all, with inactive and hidden), ready}
POST {action: 'save_from_project', project_id, name, description?, parts: [...], id?} -> {id, key: 'saved:<id>'}
POST {action: 'update', id, name, description?, is_active} -> {templates}
POST {action: 'delete', id} -> {templates}
POST {action: 'builtin_hidden', key, hidden} key with or without 'builtin:' -> {templates}
Name required, up to 150 characters; description up to 500. Deleting a saved template leaves the projects made from it alone.
-
Start from cards -
openProjectForm()inassets/js/projects.js, new projects only, a Blank card first.chooseTemplate()fills method, colour and icon, and the goal only while it is empty or still the previous template's (formState.tplGoal), and shows the template's description with a dates hint. The save sendstemplatewith the form. -
Template button on the project banner (
#pvTemplate, hidden unlesscan_manage_templates) -assets/js/projects-templates.jsruns#prjTemplateModalinprojects/view.php: name (the project's by default), description, the six part tick-boxes (plan,scope,raid,benefits,tolerances,targets, all ticked) and Replace one of your templates when there are saved ones (it fills that template's name and description, and confirms before overwriting). Its element ids aretps*:pt*belongs to the asset target dialog andpm*to the member dialog. -
Settings -> Templates -
assets/js/projects-settings.js(renderTemplates(),tplCall(),wireTemplates()): built-ins with an Offered tick-box (builtin_hidden), saved ones with an edit button opening#prjTplModal(name, description, active, delete).
api/projects/export.php, a GET, writes through includes/spreadsheet.php:
?what=portfolio&ids=3,1,7&format=xlsx|csv the projects in the portfolio's view, in its order
?what=raid&project_id=N&format=xlsx|csv one project's whole RAID log, open and closed
-
Portfolio. The page filters and sorts in the browser, so it sends the ids it shows, in order (
exportView()inassets/js/projects-portfolio.js). The endpoint intersects them withprojectListRows($conn, $analystId)- company scope and members-only - so the URL cannot widen the list. The difference between noidsand an empty one matters (#2242):// ids given but empty = the view shows nothing; no ids at all = every project you can see. $all = !isset($_GET['ids']); foreach ($all ? array_keys($byId) : $want as $id) if (isset($byId[$id])) $rows[$id] = $byId[$id];
So exporting an empty filtered view gives an empty sheet with headings, not the whole portfolio. Columns: code, name, company (multi-company only), status, priority (its label), health, progress, owner, current stage, start, target, finished, tasks done, tasks, overdue, next milestone, missed milestones, escalated RAID entries, currency, planned, spent, forecast (money only when the project has a budget or spend, two decimals, no thousands separator).
-
RAID.
ProjectsService::loadForActor()thenProjectToolsService::raid(). Columns: type, title, description, status, owner, probability, impact, score, response, plan, due, raised, closed, escalated, decided by, rationale, ticket, actions (titles with (done)). -
Writing.
spreadsheetWriteXlsx()makes every cell an inline text cell (so a code like0010keeps its zeros), with a bold, frozen header; it is written with zlib, noZipArchive.spreadsheetWriteCsv()writes UTF-8 with a BOM and CRLF, and prefixes a cell starting=,+,-or@with'(a plain negative number is left alone) - the formula-injection guard. -
Headings are
projects.export.*; file namesprojects-YYYY-MM-DD.xlsxandraid-PRJ-0042-YYYY-MM-DD.csv. -
A refusal is the usual JSON error, so the browser shows it instead of downloading.
-
Menus are
details.prj-export(the portfolio's#prjExport; the RAID log'sdata-raid-export);projects.jscloses an open one on an outside click.
| Column | Notes |
|---|---|
project_id |
|
analyst_id / team_id / user_id
|
exactly one - an analyst, a team, or a person from People. A person lets a sponsor with no analyst account hold a role. The rule is in addMember(), not a CHECK |
role_id |
FK SET NULL - deleting a role never removes a person |
notes, position
|
|
power, interest (3.3.0)
|
TINYINT 1-5, NULL = not placed |
stance (3.3.0)
|
VARCHAR(10): champion / supporter / neutral / sceptic / blocker |
keep_informed (3.3.0)
|
VARCHAR(255) - how they are kept informed |
ProjectToolsService (each write starts with changeable() - loadForActor() + assertCanChange()):
-
addMember()(toolsmember_add {analyst_id | team_id | user_id, role_id?, notes?}) - exactly one id (Choose one analyst, team or person.); an analyst must be active; a team must exist; a person must be in the project's company on a multi-company install (That person belongs to a different company from the project.); no duplicates (conflict, They are already on this project.). Auditedmember_added. -
updateMember()(toolsmember_update {member_id, role_id?, notes?, power?, interest?, stance?, keep_informed?}) - only the keys sent are changed. A role change is auditedmember_role; any stakeholder field is auditedstakeholder_saved(3.3.0). The stakeholder keys are ignored untilstakeReady(). -
removeMember()- deletes the member'sproject_racirows first (by hand as well as the FK), then the member. Auditedmember_removed. -
members()- each withname(analyst name, team name, or the person's display name or email),kind(analyst/team/person),role_name,email,job_title, and the stakeholder columns (NULL AS ...before Verification). Catches a missing table and returns[].
The People picker - api/projects/tools.php GET ?project_id=N&people=Q searches users by display name, email or job title, in the project's company - the same rule addMember() applies, so the picker never offers somebody it would then refuse. At most 20.
project_roles (name unique, description, display_order, is_active, is_demo) is install-wide, a list of words like task statuses. Seeded with nine roles - Executive, Senior User, Senior Supplier, Project Manager, Team Manager, Project Assurance, Project Support, Team member, Stakeholder - in database/freeitsm.sql and by DB Verify, only into an empty table, so an edited list is never put back. They describe a PRINCE2-style approach in FreeITSM's own words.
api/projects/settings.php, behind the Roles tab's capability (Cap::PROJECTS_ROLES):
-
role_save {id?, name, description, is_active}- name required, up to 100 characters, unique (There is already a role with that name.); description up to 255; a new role goes last. -
role_delete {id}- members who held it keep their place with no role. -
role_reorder {ids: [...]}- rewritesdisplay_order.
Lookups send only active roles (roles), ordered by display_order.
project_raci (project_id, item_id, member_id, letter R / A / C / I) has unique (item_id, member_id) - one letter per cell - and cascades with the item and with the member.
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()(toolsraci_set {item_id, member_id, letter}) checks the item and the member belong to the project, deletes the cell for'', upserts otherwise, touches the project, and returns the whole row{member_id: letter}, so the screen redraws a demoted cell without a reload. - The screen (
renderRaci()) leaves outdroppeditems, columns are members with their role, and a row with no A (or no R) is flagged - allowed, as a draft.cycleRaci()cycles'' โ R โ A โ C โ I โ ''optimistically, then takes the server's row and shows a toast when an A was demoted. Cells are disabled withoutcanChange(). -
ProjectToolsService::raci()returns{item_id: {member_id: letter}}and an empty map before Verification.
Power (can they affect it?) against interest (does it affect them?), 1-5 each, on the People tab, drawn by stakeMap() in assets/js/projects-tools.js once lookups say stake_ready.
const STANCES = ['champion', 'supporter', 'neutral', 'sceptic', 'blocker'];
function quadrant(m) { return (m.power >= 3 ? 'h' : 'l') + (m.interest >= 3 ? 'h' : 'l'); }-
3 and up is "high": the four classic quadrants
hh,hl,lh,ll(stake.q_*and their_ddescriptions). The grid draws high power on top, low interest on the left, each name sorted by power + interest, with its stance chip. -
The communications plan below it lists everybody placed, most important first (power ร interest), with where they sit, their scores, their stance and
keep_informed(or not planned). - Only members with both power and interest are placed; the person card shows the stance and quadrant and a Place / Edit link (
openStake()โ#prjStakeModal,saveStake()โmember_update). - The stance is always shown in words (
stake.stance_*), never by colour alone.
peopleProjects($conn, $analystId, $who) in includes/people.php:
-
A person (
$who['user']): the projects they are a member of throughproject_members.user_id, with their roles (GROUP_CONCAT(DISTINCT r.name ORDER BY r.display_order)), needsprojectsPhase2Ready(). -
A company (
$who['company']): its projects, throughpeopleCompanyMatch()onp.tenant_id. - Scoped by
peopleScope()(the companies the analyst can access) andprojectVisibleSql()(members-only, 3.3.0);projectsSchemaReady()first. Live first, then name, at mostPEOPLE_SECTION_LIMIT(50). - Decorated with
projectTaskStats()/projectDecorate(), so health and progress match the project page. -
RACI duties. For a person,
dutiesper project - letter => deliverable titles, A then R, C, I, in scope order, dropped deliverables left out.
// rows: {id, code, name, status, colour, health, progress, owner, role, duties: {A: [...], R: [...]}, target_end_date, url}pplSectionProjects() (people/includes/render.php) draws the card, gated by $can('projects') like every People section; pplRaciDuties() prints one line per letter under the role - two titles and "and N more", the full list as the tooltip ("Accountable for Phones working on day one, Every desk patched and 2 more").
projects.visibility is everyone (default) or members (includes/projects/visibility.php; the full rule is in 3 Services and API). A members-only project is seen only by its team - the project manager, the creator, analyst members and analysts in a member team (projectIsTeam()'s rule) - and by people who manage Projects (admins always). Anyone else gets not found, never "hidden".
[" AND ($alias.visibility <> 'members' OR $alias.owner_analyst_id = ? OR $alias.created_by_id = ?
OR EXISTS (SELECT 1 FROM project_members vm
LEFT JOIN analyst_teams vat ON vat.team_id = vm.team_id AND vat.analyst_id = ?
WHERE vm.project_id = $alias.id AND (vm.analyst_id = ? OR vat.analyst_id IS NOT NULL)))", [...]]What that means for this page's features:
-
Connections: the project's own tab is behind
loadForActor(). From the other side,projectsLinkedTo()andprojectsPickableFor()both runloadForActor()per project, so a record's page never shows or offers a members-only project to an outsider - the link exists, but not for them. -
Documents:
analystCanAccessProject()checks visibility first. -
People:
peopleProjects()addsprojectVisibleSql(). A person member (user_id) is not an analyst, so being on the People tab does not let anybody extra see the project. -
Membership is the key. Adding an analyst or a team as a member widens who may see a members-only project; removing them narrows it. Changing
visibilityitself needsprojectIsTeam() || projectIsManager()on top of the change policy - under "anyone may change", an outsider must not be able to shut the door. - What it does not hide: tasks stay Tasks records, seen by whoever Tasks lets see them.
- A new reader of
projectsmust useprojectVisibleSql(),projectVisibleTo()orprojectIdVisibleTo().
A form can propose a project: the workflow action create_project (engine availableActions(), action_create_project() โ projectCreateFromAction() in includes/projects/intake.php). It runs as actor 0 (a form is not somebody pressing New, so the create policy does not apply); the company and proposer come from the portal requester (submitted_by_user_id), or the analyst (submitted_by โ created_by_id), or submission.email; a blank name is the form title and the requester, a blank summary is catalogueSubmissionAnswers() as label: value lines; a typed cost keeps only digits, . and -; methodology and priority are select args (the form designer's faArgControl() in forms/edit/index.php learned select args for it). It stamps form_submission_id, proposed_by_name, proposed_by_email, then fires project.proposal_submitted once the proposer is known. Approval and its settings are in 6 Governance; the event is in 7 Alerts, reports and AI.
- A dependency cannot point at another project, and Connections has no Service Status kind (disruption is linked through
status_planned.project_idinstead - see 6 Governance). - No link from a Calendar entry back to its project (
calendar_eventshas no reference column). - Templates do not carry budget lines, members or connections, and targets skipped when a template is used (an unknown status or location name) are counted but not reported to the person; there is no template editor beyond "start from it, change the project, save it back".
- A benefit's owner, a sign-off and a RACI letter belong to members or analysts only; the stakeholder map has no history.
- Members-only projects do not hide their tasks from Tasks.