Projects Internals 8 Connections and Templates - edmozley/freeitsm GitHub Wiki

๐Ÿ› ๏ธ Projects internals 8 - Connections, templates and people

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. The files

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

2. Connections - includes/projects/links.php

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.

2.1 The seven kinds - projectLinkKinds()

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;

2.2 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()). 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.

2.3 Reading, adding, removing, searching

  • projectLinks($conn, $ctx, $projectId) - for each kind: skip it entirely when projectLinkKindAllowed() is false; read the ids; drop each one projectLinkTargetOk() 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_colour and closed (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_number or PRB-0042; the article title. url is entityLink().
  • 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 returns added: false. Audited link_added as kind: label.
  • projectLinkRemove() - the same right as adding: change the project and see both ends. A link you cannot see is not yours to delete. Audited link_removed.
  • projectLinkSearch($conn, $ctx, $projectId, $kind, $q) - narrows in SQL (the LIKE columns per kind, not already linked, COALESCE(x.tenant_id, default) = project company for company records on a multi-company install, knowledgeVisibilitySql() for articles), takes up to 40, then runs the same per-record check as projectLinkAdd() 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 (no project_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's ready use. 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 own try, called by deleteProject() because an upgraded install whose FKs failed has no cascade to rely on.

2.4 The endpoint - api/projects/links.php

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.


3. The other direction

A record's own page shows the projects it is part of, with the same rules read from the other end.

3.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 reach, so a page can call it unconditionally:

  1. the kind exists, its table is ready (projectLinksReady($conn, $kind)), projectLinkKindAllowed();
  2. projectLinkTargetOk(..., null) - the record itself (no company to compare with yet);
  3. read the project ids from the join table;
  4. keep each project only if ProjectsService::loadForActor() succeeds - company scope and members-only visibility;
  5. projectLinkProjectRows().

3.2 projectsPickableFor($conn, $ctx, $kind, $targetId, $q)

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.

3.3 projectLinkProjectRows($conn, $ids)

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.

3.4 The shared widget - assets/js/project-links.js

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 POSTs add, the ร— POSTs remove, and the list reloads.
  • Words go through window.tf() with English fallbacks, and an EN map for status and health, so a host page need not export the whole projects translation namespace to show one panel. Links are base + 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.

3.5 Adding a link kind

A walkthrough, with problem (3.3.0) as the worked example:

  1. Schema. A join table in database/freeitsm.sql and includes/db_verify_schema.php - copy project_assets / project_problems: unique pair, target index, three FKs (project CASCADE, target CASCADE, analyst SET NULL). The FKs go in $projectFks in api/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).

  2. The kind. A row in projectLinkKinds(): table, col, module (the key analystCanAccessModule() knows) and scoped (the target table when it carries tenant_id, else null).

  3. Three cases in links.php: projectLinkTargetOk() (the module's own record check - analystCanAccessProblem()), projectLinkDescribe() (label, sub-line, status, entityLink()), and projectLinkSearch() (what q matches; keep $taken and $company).

  4. Words and icon. links.kind.<kind> and links.hint.<kind> in lang/en/projects.php; an icon in LINK_ICONS in assets/js/projects-view.js (problem: 'shield').

  5. The other side. On the record's page: a flag (window.PM_SHOW_PROJECTS = analystCanAccessModule(..., 'projects')), a host element, assets/js/project-links.js and assets/css/project-links.css loaded, and ProjectLinks.mount(host, {kind, id, base, ...}). Add the kind to the comment at the top of project-links.js.

  6. Bingo and help. A ['rows', '<table>'] check in the projects.connections Feature 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.


4. Documents on projects (3.3.0)

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 in includes/tenancy.php) - members-only first (projectIdVisibleTo(), on every install, inside a try so a missing column allows), then single-company yes, otherwise the project's company (NULL = Default) through analystCanAccessTenant(), with tenancyDegradeAllowed() on a part-migrated table - the same rules as the change and problem checks.
  • documentCanViewParent() asks the module gate, then can, then that the project still exists. Attaching is gated by seeing the project, as for every Documents parent; the panel's canEdit is data.permissions.can_change, so the page offers attaching only to people who may change the project.
  • The tab. projects-view.js mounts the shared FreeITSMDocuments panel into #pvDocumentsPanel once per page load (docsMounted), with parentType: 'project', apiBase: '../api/documents/'. documentsPanelAssets('../') in projects/view.php loads its script and style. Opening the Documents tab sets docsTouched, 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.

5. Templates

5.1 What a template is

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 task n. task waits for on to finish, plus lag days.
  • 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.

5.2 projectTemplateNormalise() - the one gate

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).

5.3 Two kinds, one key space

  • Built-ins are written in code (projectBuiltinTemplates()), keyed builtin:<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_templates rows (name VARCHAR(150), description VARCHAR(500), content MEDIUMTEXT JSON, is_active, created_by_analyst_id, is_demo), keyed saved:<id>.
  • Hiding a built-in writes its key to the project_hidden_templates setting (comma list). That setting is written only by ProjectTemplatesService::setBuiltinHidden(): its validator rule builtin_keys makes projectSettingValidate() refuse it (Hide or show templates on the Templates tab.), so the Settings save can never store an unchecked list.
  • projectTemplateList($conn, $all) - $all = false for the picker (built-ins not hidden, saved ones active); true for the Settings tab (everything, with active). projectTemplateLoad($conn, $key, $evenHidden = false) returns [name, normalised content] or null.
  • projectTemplatesReady() - built-ins work before Database Verification; saved ones need the table.

5.4 Starting a project from one - createFromTemplate()

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:

  1. projectTemplateLoad() - an unknown or hidden template is That template is not available.
  2. 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's tailoring is applied.
  3. ProjectsService::createProject() - the create policy, company scope, currency stamp, calendar, project.created.
  4. Stages inserted directly, with the method's timebox as kind, status planned, positions 1..n; an end before its start is moved to the start.
  5. Each stage's tasks through ProjectsService::createTaskInProject() - real tasks with their events and company, estimate_hours included.
  6. Each stage's gate (3.3.0): gate_kind = 'golive' and the checklist items (nobody named, nothing chosen yet) - when projectGateItemsReady().
  7. Milestones (3.3.0), their stage index mapped back to the new stage id.
  8. The loose tasks.
  9. Dependencies (3.3.0) - $taskIds is in plan order, INSERT IGNORE into task_dependencies; a pair whose task was not made is skipped.
  10. Benefits (3.3.0) - first reviewed on their target date (review_date = target_date), review_months from the template or the project_benefit_review_months setting.
  11. Scope, RAID, tolerances when projectsPhase2Ready() - inserted directly, RAID raised by the actor.
  12. Targets - status and location names looked up in projectTargetOptions() for the project's company; an unknown name or a rule projectTargetNormalise() refuses is skipped ($skipped is counted but not returned).
  13. One template_used history row instead of forty.
  14. 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 with gmdate() 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. Both createFromTemplate() and projectTemplateCapture() 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);

5.5 Saving a project as a template - projectTemplateCapture() and saveFromProject()

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 through loadForActor().
  • parts is 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 and is_active, and writes template_replaced to the project's history; without id it inserts and writes template_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.

5.6 Permissions and the Templates capability

  • 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 of projects/settings/manifest.php), because templates are shared by every company on the install. api/projects/templates.php checks it at the top (403), and the service checks it again.
  • api/projects/lookups.php returns templates (what the picker offers) and can_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.

5.7 The screens

  • Start from cards - openProjectForm() in assets/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 sends template with the form.
  • Template button on the project banner (#pvTemplate, hidden unless can_manage_templates) - assets/js/projects-templates.js runs #prjTemplateModal in projects/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 are tps*: pt* belongs to the asset target dialog and pm* 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).

6. The spreadsheet export (3.3.0)

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() in assets/js/projects-portfolio.js). The endpoint intersects them with projectListRows($conn, $analystId) - company scope and members-only - so the URL cannot widen the list. The difference between no ids and 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() then ProjectToolsService::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 like 0010 keeps its zeros), with a bold, frozen header; it is written with zlib, no ZipArchive. 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 names projects-YYYY-MM-DD.xlsx and raid-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's data-raid-export); projects.js closes an open one on an outside click.


7. People

7.1 Members - project_members

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() (tools member_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.). Audited member_added.
  • updateMember() (tools member_update {member_id, role_id?, notes?, power?, interest?, stance?, keep_informed?}) - only the keys sent are changed. A role change is audited member_role; any stakeholder field is audited stakeholder_saved (3.3.0). The stakeholder keys are ignored until stakeReady().
  • removeMember() - deletes the member's project_raci rows first (by hand as well as the FK), then the member. Audited member_removed.
  • members() - each with name (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.

7.2 Roles - Settings -> Roles

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: [...]} - rewrites display_order.

Lookups send only active roles (roles), ordered by display_order.

7.3 The RACI matrix

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() (tools raci_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 out dropped items, 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 without canChange().
  • ProjectToolsService::raci() returns {item_id: {member_id: letter}} and an empty map before Verification.

7.4 The stakeholder map (3.3.0)

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 _d descriptions). 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.

7.5 A person's projects in People - peopleProjects()

peopleProjects($conn, $analystId, $who) in includes/people.php:

  • A person ($who['user']): the projects they are a member of through project_members.user_id, with their roles (GROUP_CONCAT(DISTINCT r.name ORDER BY r.display_order)), needs projectsPhase2Ready().
  • A company ($who['company']): its projects, through peopleCompanyMatch() on p.tenant_id.
  • Scoped by peopleScope() (the companies the analyst can access) and projectVisibleSql() (members-only, 3.3.0); projectsSchemaReady() first. Live first, then name, at most PEOPLE_SECTION_LIMIT (50).
  • Decorated with projectTaskStats() / projectDecorate(), so health and progress match the project page.
  • RACI duties. For a person, duties per 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").

7.6 Members-only visibility, from this side (3.3.0)

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() and projectsPickableFor() both run loadForActor() 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() adds projectVisibleSql(). 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 visibility itself needs projectIsTeam() || 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 projects must use projectVisibleSql(), projectVisibleTo() or projectIdVisibleTo().

8. Intake via Forms - briefly

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.


9. Not built yet

  • A dependency cannot point at another project, and Connections has no Service Status kind (disruption is linked through status_planned.project_id instead - see 6 Governance).
  • No link from a Calendar entry back to its project (calendar_events has 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.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ