Repository navigation
Projects Internals 1 Architecture
This is the front door for anybody changing the Projects module. It says what the module is, lists every file it owns and every file elsewhere that knows about it, follows one request from the page to the database and back, and explains the pieces every other part of the series leans on: ActorContext and ServiceError, the permission rules and Projects -> Settings, members-only visibility, companies, methodologies and tool tailoring, and how the JavaScript is organised. It ends with a walkthrough for adding a new tab or tool. Read it before any of the later parts - they assume the vocabulary set out here. For the plain-language version of the module see Projects.
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
- What the module is
- The file map
- One path in: the request path
- ActorContext and ServiceError
- The services
- Permissions
- Projects -> Settings
- Members-only projects
- Companies
- Methodologies, tools and tailoring
- Before Database Verification
- How the JavaScript is organised
- Walkthrough: adding a tool tab
- Architectural traps
Projects (3.2.0, much extended in 3.3.0) plans and tracks pieces of IT work bigger than a ticket: an office move, a Windows 11 refresh, a firewall replacement. Five ideas shape every file:
| Idea | What it means in the code |
|---|---|
| A project is a container over ordinary tasks | A project's work items are rows in tasks carrying tasks.project_id (and optionally tasks.project_stage_id). There is no second task system. Projects never owns a task row: it only sets those two columns, and creates tasks through TasksService::saveTask() so every notification and workflow fires as it would on the Tasks board. |
| A methodology is a lens, not a schema |
simple, staged and agile are presets in code (projectMethodologies()). Phases, stages and sprints are one table (project_stages) with a kind, so switching method converts and deletes nothing (§10). |
| Worked out, never stored | Progress, automatic health, tolerance exceptions, milestone state, benefit state, the critical path and RAID scores are computed on every read. There is no progress, exception or score column on purpose: storing them would let them go stale the moment somebody ticked a task on the Tasks board, which does not know projects exist. |
| Writes are unified, reads stay per surface | The Service Layer rule. Every write goes through ProjectsService or ProjectToolsService (plus ProjectTemplatesService and ProjectReportsService); the screens, the REST API and the scheduled jobs all call them. Reads live in includes/projects/*.php, the REST resource has its own serialisers. |
| Scoped data | A project belongs to exactly one company (projects.tenant_id, NULL = the Default company), like a ticket. Out-of-scope reads as not found, never forbidden (§9). |
Three smaller conventions run through everything:
-
The reference
PRJ-0042is not stored.projectCode()inincludes/projects/read.phppads the id; global search parsesPRJ-0042,prj42and42back to an id. -
Colour and icon are keys, never CSS or SVG.
projectColours()andprojectIcons()(includes/projects/methodologies.php) are fixed lists the service validates against (fieldMap()enum types), and the SVG for each icon key lives only inassets/js/projects.js(ICONS). A stored value can never be markup. -
Every date the module reasons about is UTC. Task counts use
UTC_DATE(), PHP usesgmdate(), and template dates are parsed as... 00:00:00 UTC(see part 8 for the trap that taught this).
Colour key: 🗄️ schema · ⚙️ rules · ✏️ write · 📖 read · 🖥️ UI · 🔗 cross-module
| File | What it does | |
|---|---|---|
| 🖥️ | projects/index.php |
The portfolio: the card wall, Roadmap and Charts layouts, the views and the search. Behaviour in projects-portfolio.js. data-mobile-page="projects-portfolio"
|
| 🖥️ | projects/view.php |
One project: the banner, every tab panel (<section class="prj-tab-panel" data-panel="...">) and every dialog the tabs open (prjStageModal, prjMemberModal, prjRaidModal, prjGateModal, prjTargetModal, prjBudgetModal, prjChangeModal, prjBenefitModal, prjReportModal, ... 25 in all). Loads includes/documents_panel.php for the Documents tab |
| 🖥️ | projects/capacity.php |
Capacity (3.3.0): each person's load in the weeks ahead. Nav key capacity, data-mobile-page="projects-capacity"
|
| 🖥️ | projects/help.php |
The help page: numbered sections with a scroll-spy sidebar, words from help.* in lang/en/projects.php. Its header warns that the health section must match projectAutoHealth(). Section map (3.3.0) mounts the interactive map into #prjTour and sets window.PRJ_TOUR from projectMethodologies()
|
| 🖥️ | projects/includes/header.php |
The module header (waffle, title, nav: portfolio / capacity / settings / help) and the three page globals window.PRJ_API, window.PRJ_BASE, window.PRJ_ME
|
| 🖥️ | projects/includes/project_form.php |
The create / edit project dialog (#prjFormModal), shared by the portfolio and the project page, filled by Prj.openProjectForm()
|
| ⚙️ | projects/settings/manifest.php |
The seven Settings tabs and their capabilities - the single declaration (§7) |
| 🖥️ | projects/settings/index.php |
Projects -> Settings: only the tabs settingsVisibleTabs() allows, the active tab in ?tab=; the AI tab is the shared ai_settings_panel.php
|
Every one starts with require_once __DIR__ . '/../../includes/projects/api_bootstrap.php' (§3).
| File | Method and shape | |
|---|---|---|
| 🖥️ |
assistant_chat.php (3.3.0)
|
GET ?project_id= the Ask AI conversation and state; POST open / send / apply / dismiss / clear - see part 7
|
| 📖 | list.php |
GET ?q=&status=&mine=1 - the portfolio (projectListRows()) plus multi_company and can_create. Also the no-cron fallback for alerts (projectAlertsOpportunistic(), at most every 15 minutes) |
| 📖 | get.php |
GET ?id= - one project for its page: projectDetail() plus dependencies, flow, permissions, members, items, raci, raid, tolerances, targets, milestones, can_assets, budget, gate, benefits, proposal, control, announcements, gate_changes
|
| ✏️ | save.php |
POST - create (no id) or update; a new project with template goes to ProjectTemplatesService::createFromTemplate()
|
| ✏️ | delete.php |
POST {id} - ProjectsService::deleteProject()
|
| 📖 | lookups.php |
GET - what the forms draw from: analysts, companies, methods, roles, teams, tools, scale words, priorities, templates, task statuses and priorities, and capability flags (can_manage_templates, can_knowledge, can_tickets, stake_ready, default_visibility, toolbox) |
| ✏️ |
stage_save.php / stage_delete.php / stage_reorder.php
|
POST - a phase, stage or sprint. stage_reorder.php exists but nothing in the UI calls it yet |
| ✏️ | task_create.php |
POST {project_id, stage_id?, title, ...} - a new task in the project through createTaskInProject(); only title, description, assigned_analyst_id, assigned_team_id, start_date, due_date, priority_id, status_id are passed on |
| ✏️ | task_assign.php |
POST `{task_id, project_id |
| ✏️📖 | tools.php |
One POST endpoint routing an action (about 45 of them) to ProjectToolsService / ProjectsService::decideProposal(); GET for the People picker (?people=) and the asset target reads (target_options, target_preview, target_assets) |
| ✏️📖 | links.php |
Connections: GET ?project_id=, &search=KIND&q=, the other side ?for=KIND&id=N (&pick=1&q=); POST `{action: add |
| ✏️📖 | settings.php |
Projects -> Settings: GET everything, POST save per tab, role_*, rate_* - each behind its tab's capability |
| ✏️📖 | templates.php |
Settings -> Templates: GET every template; POST save_from_project, update, delete, builtin_hidden - all behind Cap::PROJECTS_TEMPLATES
|
| ✏️📖 | reports.php |
The briefing and reports (AI project manager): GET state; POST briefing, draft, save, approve, schedule, send, delete
|
| 📖 | capacity.php |
GET `?weeks=4 |
| 📖 | portfolio_charts.php |
GET - budgets, open risks and milestones for the portfolio's Charts layout (3.3.0), scoped with projectListRows()
|
| 📖 | export.php |
GET what=portfolio&ids= or what=raid&project_id=, `format=xlsx |
| File | What it holds | |
|---|---|---|
| 🤖 |
assistant_chat.php (3.3.0)
|
The Ask AI project assistant: conversations and their memory (project_ai_threads / project_ai_messages), the set-up checklist, the prompt, read and propose tools, applying proposals through the services as the person - part 7
|
| 🖥️ | api_bootstrap.php |
The start of every endpoint: session, module access, JSON, $conn, $ctx, $analystId, projectApiBody/Ok/Fail/RequirePost/TenantForCreate/Run()
|
| ⚙️ | methodologies.php |
projectsSchemaReady(), projectColourHex(), projectMethodologies(), projectToolDefinitions(), projectEnabledTools(), the status / priority / health / stage-kind lists, projectColours(), projectIcons()
|
| ⚙️ | settings.php |
projectSettingDefinitions() (every key, default, validator, tab), projectSettings() / projectSetting() / projectSettingWrite() / projectSettingValidate(), the scale words, and the permission rules projectIsManager() / projectCanCreate() / projectIsTeam() / projectCanChange() / projectCanDelete()
|
| ⚙️ | visibility.php |
Members-only projects (3.3.0): projectVisibleSql(), projectVisibleTo(), projectIdVisibleTo() (§8) |
| 📖 | read.php |
The portfolio and one project: projectTaskStats(), projectAutoHealth(), projectHealthConfig(), projectExceptions(), projectDecorate(), projectCode(), projectListRows(), projectDetail() and the guards projectsPhase2Ready(), projectPriorityColumn(), projectEstimatesReady() (part 5) |
| ⚙️ | links.php |
Every Connections rule, seven link kinds, the other side (projectsLinkedTo(), projectsPickableFor()) and projectUnapprovedChanges() (part 8) |
| ⚙️ | targets.php |
Asset targets: the whitelisted fields, the SQL builder, counts, health, snapshots, the "still to do" list |
| ⚙️ | budget.php |
The budget: currency rules, dated labour rates, projectLabour(), projectBudgetTotals(), the forecast, projectBudgetTimeline(), projectEarnedValue(), projectBudgetDetail()
|
| ⚙️ | milestones.php |
Milestones (3.3.0): projectMilestonesReady(), projectMilestoneState(), projectMilestones(), projectMilestoneStats()
|
| ⚙️ | dependencies.php |
Task dependencies and the critical path (3.3.0) |
| ⚙️ | flow.php |
The daily task-status snapshot behind the cumulative flow chart (3.3.0) |
| ⚙️ | capacity.php |
Capacity (3.3.0): projectCapacity(), projectCapacityWorkdays(), projectCapacityShiftHours()
|
| ⚙️ | control.php |
Change control (3.3.0): baselines, change requests, variance, projectCanDecideChange(), projectBaselineAuto()
|
| ⚙️ | intake.php |
Intake and approval (3.3.0): proposals, projectCanDecideProposal(), the workflow action projectCreateFromAction()
|
| ⚙️ | gatecheck.php |
Stage gate checklists (3.3.0) |
| ⚙️ | benefits.php |
Benefits realisation (3.3.0) and its review reminders |
| ⚙️ | alerts.php |
Events and the alert scan (projectAlertsScan(), projectAlertsAsSystem()), stages and milestones due |
| ⚙️ | nudges.php |
The overdue digest and stalled-approval nudges (3.3.0) |
| ⚙️ | calendar.php |
Project dates on the shared Calendar: projectSyncCalendar()
|
| ⚙️ | templates.php |
The template format, the built-ins (in code), normalise, list, load, capture |
| 📖 | ai.php |
The AI project manager's prompt and facts: projectAiFacts(), projectAiSystemPrompt(), projectAiAsk()
|
| 📖 | assistant.php |
The read-only answers MCP and Warbot share (3.3.0): projectAssistList(), projectAssistOverview(), projectAssistRaid(), projectAssistTasks(), projectAssistDates()
|
| File | Class and job | |
|---|---|---|
| ✏️ | projects.php |
ProjectsService - projects (fieldMap()), stages, putting tasks in and out, creating a task in a project, decideProposal(), the event helpers, loadForActor(), assertCanChange(), audit(), touchProject(), syncCalendar(), afterChange()
|
| ✏️ | project_tools.php |
ProjectToolsService - every tool's writes (members, scope, RACI, RAID, tolerances, gates and gate items, targets, milestones, task dates and estimates, dependencies, budget, benefits, baselines and change requests, announcements) and the reads members(), items(), raci(), raid(), tolerances(), announcements()
|
| ✏️ | project_templates.php |
ProjectTemplatesService - start a project from a template, save one from a project, rename / switch off / delete, hide a built-in |
| ✏️ | project_reports.php |
ProjectReportsService - briefing, reports, approve, schedule, send, runSchedules()
|
| File | Global | What it draws | |
|---|---|---|---|
| 🖥️ | assets/js/projects.js |
window.Prj |
Shared helpers on every Projects page: api(), lookups(), icons, ring, pills, dates, modals, the project dialog, celebrate() (§12) |
| 🖥️ | assets/js/projects-portfolio.js |
- | The portfolio: views, sort, layouts (cards / roadmap / charts), health strip, export |
| 🖥️ | assets/js/projects-view.js |
- | The project page: loads, owns data, draws banner / Overview / Plan / Connections / History, calls every tool module, showTab()
|
| 🖥️ | assets/js/projects-tools.js |
window.PrjTools |
People (with the stakeholder map), Scope (MoSCoW), RACI, RAID, Gates; exceptionsBanner()
|
| 🖥️ | assets/js/projects-timeline.js |
window.PrjTimeline |
The Timeline tab (Gantt, drag, dependency arrows, critical path) |
| 🖥️ | assets/js/projects-milestones.js |
window.PrjMilestones |
Milestones on the Overview, the Plan's chips and strip, #prjMilestoneModal
|
| 🖥️ | assets/js/projects-targets.js |
window.PrjTargets |
Asset targets in #pvTargets, #prjTargetModal, #prjTargetListModal
|
| 🖥️ | assets/js/projects-budget.js |
window.PrjBudget |
The Budget tab |
| 🖥️ | assets/js/projects-control.js |
window.PrjControl |
The Change control tab |
| 🖥️ | assets/js/projects-intake.js |
window.PrjIntake |
A proposal and its approval on the Overview (#pvProposal) |
| 🖥️ | assets/js/projects-benefits.js |
window.PrjBenefits |
The Benefits tab |
| 🖥️ | assets/js/projects-gatecheck.js |
window.PrjGateCheck |
Gate checklists inside the Gates tab (fill(), openBox()) |
| 🖥️ | assets/js/projects-insights.js |
window.PrjInsights |
The Overview's status, progress-by-stage and flow charts |
| 🖥️ | assets/js/projects-toolbox.js |
window.PrjToolbox |
The Toolbox on the Overview (#pvToolbox, 3.3.0): tools not in use yet, with Add (§10) |
| 🖥️ | assets/js/projects-assistant.js |
window.PrjAssistant |
Ask AI (3.3.0): the slide-in panel (#paPanel, Knowledge's .ai-chat-* styles), opened by the header button projects/includes/header.php draws when projects/view.php sets $prjAskAi; bind({projectId, refresh}) from renderAll()
|
| 🖥️ | projects/tutorial.php |
- | "How it fits together" on a page of its own, full width (3.3.0, #2253); ?walk=1 sets data-start="walk". Linked from Help (which now has only a card with two buttons), the empty portfolio and the Toolbox. Sets window.PRJ_TOUR
|
| 🖥️ | assets/js/projects-tour.js |
- | "How it fits together" (3.3.0, #2244), mounted into #prjTour by projects/tutorial.php; Left / Right step the walk, and the root gets .is-walk during it: an interactive map of the parts of a project and what feeds what, a 12-step walk through an office move, and a filter by method from window.PRJ_TOUR ({methods: {simple: [...], ...}, labels: {...}}), so the map can never disagree with the presets. Words projects.tour.*; every box is a button, the lines are decoration |
| 🖥️ | assets/js/projects-charts.js |
window.PrjCharts |
Plain SVG charts: burnup, burndown, bars, spend, stack, flow, milestones, lines, slot
|
| 🖥️ | assets/js/projects-reports.js |
window.PrjReports |
The briefing (#pvBriefing) and the Reports tab |
| 🖥️ | assets/js/projects-templates.js |
- | "Save as template" (#prjTemplateModal, ids tps*) |
| 🖥️ | assets/js/projects-settings.js |
- | Projects -> Settings, Roles and Templates lists |
| 🖥️ | assets/js/projects-capacity.js |
- | The Capacity page |
| 🔗 | assets/js/project-links.js |
window.ProjectLinks |
The Projects panel other modules mount (ProjectLinks.mount()) |
| 🖥️ | assets/css/projects.css |
- | Everything Projects, the --prj-* and --prj-viz-* tokens' uses, the .prj-readonly rules and the [hidden] TRAP rule (§14) |
| 🔗 | assets/css/project-links.css |
- | The shared panel's styles |
| File | What it holds | |
|---|---|---|
| 🖥️ | lang/en/projects.php |
Every string (about 1,900 lines), grouped nav, status, health, method, portfolio, form, view, ... settings, templates, scale, and the help page under help.*
|
| 🔗 | api/v1/resources/projects.php |
The REST API resource: 25 routes under /projects (registered in api/v1/lib/routes.php), reads and serialisers here, every write through the services with ActorContext::fromApiKey() (part 3) |
| 🔗 |
includes/mcp/tools.php (project part) |
Six MCP tools - list_projects, project_overview, project_raid, project_budget, project_tasks, project_dates - with handlers mcpToolListProjects() ... mcpToolProjectBudget(), scoped by mcpProjectScopeSql() (the key's companies plus members-only visibility, via mcpProjectCompanySql()). project_budget is MCP-only |
| 🔗 |
includes/warbot/tools.php (project part) |
Five Warbot tools - list_projects, project_overview, project_raid, project_tasks, project_dates - handlers warbotToolListProjects() ... warbotToolProjectDates(), scoped by warbotProjectScope() (the asker's active company plus visibility); warbotProjectAnswer() turns a ServiceError into an answer for the room. List is capped at 10 |
| File | Role | |
|---|---|---|
| 🗄️ | database/freeitsm.sql |
Every project_* table, task_dependencies, and the project columns on tasks - the fresh-install source |
| 🗄️ | includes/db_verify_schema.php |
The same columns, for Database Verification on an existing install |
| 🗄️ | includes/db_verify_indexes.php |
Generated by php scripts/gen_db_verify_indexes.php from freeitsm.sql
|
| 🗄️ | api/system/db_verify.php |
$projectFks (foreign keys), $primaryKeys (project_task_flow's composite key), the project_roles seed |
| 🗄️ | includes/db_verify_modules.php |
Files the project* tables under the Projects card on the DB Verify page ('project' => 'projects') |
Part 2 (Schema) covers every table.
Search the codebase for Projects (3.2.0) to find most of them.
| Files | What they do | |
|---|---|---|
| 🔗 |
api/tasks/list.php, api/tasks/get.php, assets/js/tasks.js, tasks/index.php, assets/css/tasks.css, lang/en/tasks.php
|
The Tasks board side: the project chip, the Project filter, the Project field in the task window (window.TASK_CAN_PROJECTS). Guarded by projectsSchemaReady() (part 4) |
| 🔗 |
cmdb/object.php + cmdb/object.js, contracts/view.php, asset-management/index.php, change-management/index.php + assets/js/change-management.js, knowledge/index.php + assets/js/knowledge.js, assets/js/inbox.js + tickets/index.php
|
Where the Projects panel (or, on tickets, the pills) appears (part 8) |
| 🔗 |
includes/people.php, people/includes/render.php, people/help.php, lang/en/people.php
|
The Projects section on People person and company pages (peopleProjects(), pplSectionProjects(), pplRaciDuties()) |
| 🔗 |
includes/entity_links.php, includes/recent_trail.php, api/system/global_search.php, assets/js/command-palette.js
|
entityLink('project') (projects/view.php?id=N), the recent trail, global search and Cmd/Ctrl+K |
| 🔗 | includes/tenancy.php |
analystCanAccessProject() - used by Documents (3.3.0) |
| 🔗 | includes/documents.php |
project as a Documents parent (documentEntityRegistry()) |
| 🔗 |
includes/module-colors.php, includes/waffle-menu.php, assets/css/theme.css
|
Coral to violet (#f43f5e to #7c3aed), the waffle tile, the --prj-* tokens |
| 🔗 | includes/report_packs/blocks_projects.php |
Report Packs blocks (rpProjectClause()) |
| 🔗 |
includes/watchtower_queries.php, watchtower/index.php
|
The Watchtower projects block |
| 🔗 |
workflow/includes/engine.php, includes/notifications_router.php, includes/services/notifications.php
|
project.* triggers, the create_project action, the bell (part 7) |
| 🔗 | includes/feature_bingo/cards/projects.php |
Feature Bingo cards (25 at the time of writing) |
| 🔗 |
includes/service_status_planned.php / status_planned.project_id
|
Announced disruption (part 6) |
| 🔗 |
database/demo-data/tasks.json, system/demo-data/index.php
|
Demo data (part 9) |
What you do not touch: the Tasks code that creates, edits or deletes a task. Projects never owns a task row; it only sets project_id / project_stage_id (and estimate_hours, through TasksService).
projects/*.php (window.PRJ_API = BASE_URL + 'api/projects/')
+ assets/js/projects*.js
│ fetch(), JSON body; the CSRF token is added by assets/js/csrf.js,
│ which the server injects into every page
▼
api/projects/<endpoint>.php
│ require includes/projects/api_bootstrap.php
│ session_start(read_and_close) · config · functions (→ request_guard → csrfEnforce())
│ 401 if not signed in · requireModuleAccessJson('projects') · $conn · $analystId · $ctx
│ projectApiRequirePost() for writes
│ projectApiRun(function () { ... })
▼
ProjectsService / ProjectToolsService / ProjectTemplatesService / ProjectReportsService
│ loadForActor() company scope + members-only → "not found"
│ assertCanChange() / projectCanCreate() / projectCanDelete()
│ validate, write, audit(), touchProject()
│ syncCalendar(), dispatch('project.*'), afterChange() (outside the transaction)
▼
includes/projects/*.php (rules, reads, guards) ──► MySQL
▲
└── reads: get.php / list.php call projectDetail() / projectListRows() directly
The services never emit HTTP; they return ids (or arrays) or throw ServiceError. projectApiRun() turns that into the UI's {success:false, error}.
The whole file is the gate every endpoint passes. Kept in includes/ so it cannot be requested on its own, and shared so a guard cannot be forgotten on the next endpoint (it mirrors includes/domains/api_bootstrap.php):
session_start(['read_and_close' => true]);
require_once __DIR__ . '/../../config.php';
require_once __DIR__ . '/../functions.php';
require_once __DIR__ . '/../rbac.php';
require_once __DIR__ . '/../services/projects.php';
require_once __DIR__ . '/read.php';
header('Content-Type: application/json');
if (!isset($_SESSION['analyst_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
requireModuleAccessJson('projects');
$conn = connectToDatabase();
$analystId = (int)$_SESSION['analyst_id'];
$ctx = ActorContext::fromSession($conn);The helpers it defines:
| Function | What it does |
|---|---|
projectApiBody(): array |
The decoded JSON body, never null (cached in a static) |
projectApiOk(array $data = []) |
Echoes ['success' => true] + $data and exits |
projectApiFail(string $message, int $status = 200) |
Echoes {success:false, error} and exits; sets the HTTP status only when it is not 200 |
projectApiRequirePost() |
Refuses anything but POST with 405 - "a link or an image tag can never change a project" |
projectApiTenantForCreate($conn, $analystId, $requested) |
The company a new project goes in: the body's company_id (checked with getTenantById() and analystCanAccessTenant()) or the analyst's active company |
projectApiRun(callable $fn) |
Runs the body; a ServiceError becomes projectApiFail($e->getMessage()); any other Throwable is logged with file and line and becomes Something went wrong: ...
|
function projectApiRun(callable $fn): void
{
try {
$fn();
} catch (ServiceError $e) {
projectApiFail($e->getMessage());
} catch (Throwable $e) {
error_log('projects api: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
projectApiFail('Something went wrong: ' . $e->getMessage());
}
}Note: a
ServiceErrorreaches the page as HTTP 200 withsuccess:false, whatever itskind- the UI only readssuccessanderror. The REST API maps the same error to 404 / 403 / 409 / 422 (serviceErrorHttpStatus()). Only the bootstrap's own refusals (401, the module-access 403),projectApiRequirePost()'s 405 and a few explicitprojectApiFail(..., 403)calls insettings.phpandtemplates.phpset another status.
CSRF is enforced centrally: includes/functions.php requires includes/request_guard.php, which calls csrfEnforce(). Nothing in the module opts in. See CSRF protection.
api/projects/save.php, complete:
require_once __DIR__ . '/../../includes/projects/api_bootstrap.php';
projectApiRequirePost();
projectApiRun(function () use ($conn, $ctx, $analystId) {
$in = projectApiBody();
$isNew = empty($in['id']);
$tenant = $isNew ? projectApiTenantForCreate($conn, $analystId, $in['company_id'] ?? null) : null;
unset($in['company_id']);
$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]);
}
$res = ProjectsService::saveProject($conn, $ctx, $in, $tenant);
projectApiOk(['id' => $res['id'], 'created' => $res['created']]);
});Request and response:
POST api/projects/save.php
Content-Type: application/json
{"name": "Office move", "methodology": "staged", "priority": "high",
"owner_analyst_id": 7, "start_date": "2026-11-02", "target_end_date": "2027-02-26",
"colour": "teal", "icon": "building"}{"success": true, "id": 42, "created": true}A refusal:
{"success": false, "error": "Only this project's team, or someone who manages Projects, can change it."}api/projects/tools.php is one endpoint for every tool. The non-POST branch serves reads only (the People picker and the asset-target reads, each after ProjectsService::loadForActor()); everything else is a switch on action:
$in = projectApiBody();
$pid = (int)($in['project_id'] ?? 0);
switch ($in['action'] ?? '') {
case 'member_add':
projectApiOk(['id' => ProjectToolsService::addMember($conn, $ctx, $pid, $in)]);
case 'member_update':
ProjectToolsService::updateMember($conn, $ctx, $pid, (int)($in['member_id'] ?? 0), $in);
projectApiOk();
// ... about forty more ...
}
projectApiFail('Unknown action.');There is no break after each case on purpose: projectApiOk() exits. The header comment of tools.php lists every action with its body; part 3 documents each.
Both live in includes/service_context.php and are shared by every module that has a service layer.
final class ActorContext
{
/** @param ?array<int> $companyScope null = all companies, else the allowed tenant ids */
public function __construct(
public int $actorId,
public ?array $companyScope = null,
public string $source = 'api', // 'ui' | 'api'
public string $locale = 'en',
public string $actorName = '' // the acting analyst's display name (for *_by attribution columns)
) {}| Factory | Used by | actorId |
companyScope |
source |
|---|---|---|---|---|
ActorContext::fromSession($conn) |
every api/projects/*.php (via the bootstrap) |
the signed-in analyst | null on a single-company install or for an all-companies analyst, else getAccessibleTenantIds()
|
ui |
ActorContext::fromApiKey($apiKey) |
api/v1/resources/projects.php |
the key's analyst | the key's company_scope
|
api |
ActorContext::system($name) |
scheduled work | 0 | null | system |
new ActorContext(0, null, 'ui', 'en', 'Workflow') |
projectCreateFromAction() in includes/projects/intake.php (a form is not somebody pressing New, so the create policy does not apply) |
0 | null | ui |
How the Projects services read it:
-
actorId <= 0is the system - not a person, and not asked about permissions:assertCanChange()returns at once,createProject()skipsprojectCanCreate(),deleteProject()skipsprojectCanDelete(), andprojectVisibleSql()hides nothing (unless the caller passes$systemSeesAll = false). History rows store NULL as the analyst. -
companyScopedrivesassertScope()(§9). -
sourcebecomes the history row'ssource:ProjectsService::source()andProjectToolsService::src()both return'api'for an API key and'app'for everything else. (The column comment also allowsdemo; the demo importer inserts its rows straight from JSON rather than through the services, with whateversourcethe JSON carries.)
class ServiceError extends Exception
{
/** @var string one of: validation | not_found | forbidden | conflict */
public string $kind;
/** @var string machine slug, e.g. 'missing_field', 'invalid_field', 'not_found' (named errorCode to avoid Exception::$code) */
public string $errorCode;The kinds the Projects services throw, and what the REST API turns them into (serviceErrorHttpStatus()):
kind |
REST status | Typical Projects use |
|---|---|---|
validation |
422 |
missing_field (no name), invalid_field (a stage from another project, an end before the start, a second active stage) |
bad_request |
400 | an unparseable date in ProjectsService::date()
|
not_found |
404 | out of company scope, members-only, a row not in this project |
forbidden |
403 | create / change / delete policy |
conflict |
409 | already a member, a lesson already turned into an article |
The messages are written to be shown to a person as they are.
Part 3 documents every method. The shape is what matters here.
ProjectsService (includes/services/projects.php) owns projects, stages and putting tasks in and out. Its by-id methods all start the same way:
/** Load a project the caller may touch - out of scope reads as not found. */
public static function loadForActor(PDO $conn, ActorContext $ctx, int $id): array
{
$row = self::loadRow($conn, $id);
self::assertScope($conn, $ctx, $row, 'Project not found.');
// Members-only (3.3.0): not found, never "hidden".
require_once __DIR__ . '/../projects/visibility.php';
if (!projectVisibleTo($conn, $ctx->actorId, $row)) throw new ServiceError('not_found', 'not_found', 'Project not found.');
return $row;
}ProjectToolsService (includes/services/project_tools.php) is a separate class so neither service grows into one enormous file, but it has no rules of its own about who may do what. Every public write starts with the private changeable(), which is exactly the pair ProjectsService uses:
private static function changeable(PDO $conn, ActorContext $ctx, int $projectId): array
{
$p = ProjectsService::loadForActor($conn, $ctx, $projectId);
ProjectsService::assertCanChange($conn, $ctx, $p);
return $p;
}The tail of a write. After the row is written, a write does some of these, in this order and outside any transaction:
| Call | What it does | Never throws? |
|---|---|---|
ProjectsService::audit($conn, $pid, $actorId, $field, $old, $new, $source) |
One project_audit row (old / new cut to 1,000 characters) - the History tab |
yes (logs) |
ProjectsService::touchProject($conn, $pid) |
updated_datetime = UTC_TIMESTAMP() |
- |
ProjectsService::syncCalendar($conn) |
Redraws the project entries on the shared Calendar | yes (logs) |
self::dispatch($conn, 'project.updated', $id, [...]) (private, ProjectsService) |
One WorkflowEngine dispatch: workflows, webhooks, the bell |
- |
ProjectsService::afterChange($conn, $pid) |
Runs the alert scan for that one project, as the person who made the change | yes (logs) |
A small, complete example - deleting a milestone:
public static function deleteMilestone(PDO $conn, ActorContext $ctx, int $projectId, int $milestoneId): void
{
require_once __DIR__ . '/../projects/milestones.php';
self::changeable($conn, $ctx, $projectId);
$m = self::milestone($conn, $projectId, $milestoneId);
$conn->prepare("DELETE FROM project_milestones WHERE id = ?")->execute([$milestoneId]);
ProjectsService::audit($conn, $projectId, $ctx->actorId, 'milestone_removed', $m['name'], null, self::src($ctx));
ProjectsService::touchProject($conn, $projectId);
ProjectsService::syncCalendar($conn);
ProjectsService::afterChange($conn, $projectId);
}Note the private milestone() loader: it selects WHERE id = ? AND project_id = ?, so an id from another project is "not part of this project" (not_found). Every child-row loader in ProjectToolsService (member(), item(), raidRow(), gateItem(), benefit(), changeRequest(), target()) works the same way.
ProjectTemplatesService and ProjectReportsService follow the same rules: they call ProjectsService::createProject(), createTaskInProject(), loadForActor() and assertCanChange() rather than having their own. The services are written so the REST API and the MCP server can call them with an API-key ActorContext, which the REST API now does.
Three layers:
-
Module access -
requireModuleAccessJson('projects')in the bootstrap (andrequireModuleAccess('projects')on each page) lets someone into the module at all. -
Company scope and visibility -
loadForActor()decides which projects exist for them (§8, §9). -
What they may do to a project - five functions in
includes/projects/settings.php. All of them reachanalystHasCapability()(includes/rbac.php), which returns true at once for an administrator, so admins short-circuit every rule that asksprojectIsManager().
| Function | True when | Setting |
|---|---|---|
projectIsManager($conn, $id) |
holds Cap::PROJECTS_MANAGE (or is an admin); false for id 0 |
- |
projectCanCreate($conn, $id) |
project_create_policy is anyone, or a manager |
General -> Who may create projects (anyone / managers, default anyone) |
projectIsTeam($conn, $id, $project) |
owner, creator, an analyst member, or in a team that is a member (analyst_teams) |
- |
projectCanChange($conn, $id, $project) |
project_change_policy is anyone, or on the team, or a manager |
General -> Who may change a project (team / anyone, default team) |
projectCanDelete($conn, $id, $project) |
owner, creator, or a manager - never affected by a setting | - |
function projectIsTeam(PDO $conn, int $analystId, array $project): bool
{
if ($analystId <= 0) return false;
if ((int)($project['owner_analyst_id'] ?? 0) === $analystId || (int)($project['created_by_id'] ?? 0) === $analystId) return true;
try {
$st = $conn->prepare("SELECT 1 FROM project_members m
LEFT JOIN analyst_teams at ON at.team_id = m.team_id AND at.analyst_id = ?
WHERE m.project_id = ? AND (m.analyst_id = ? OR at.analyst_id IS NOT NULL) LIMIT 1");
$st->execute([$analystId, (int)$project['id'], $analystId]);
return (bool)$st->fetchColumn();
} catch (Throwable $e) {
return false;
}
}
function projectCanChange(PDO $conn, int $analystId, array $project): bool
{
if (projectSetting($conn, 'project_change_policy') === 'anyone') return true;
return projectIsTeam($conn, $analystId, $project) || projectIsManager($conn, $analystId);
}🔑 The default for changing is team, not anyone. Every other default is what the first 3.2.0 build shipped with; this one was a deliberate change (Ed's call, 2026-10-06), because a project page is somebody's plan and the delete button sat on it.
Where they are enforced - always in the service, never only in the page:
-
createProject()->projectCanCreate();deleteProject()->projectCanDelete(). -
ProjectsService::assertCanChange()->projectCanChange(), called byupdateProject(),saveStage(),deleteStage(),reorderStages(),createTaskInProject(),assignTask()(for the project a task is leaving as well as the one it joins),projectLinkAdd()/projectLinkRemove(), and everyProjectToolsServicewrite throughchangeable(). -
Changing visibility needs more:
updateProject()refuses avisibilitychange unless the actor is on the team or a manager, even under theanyonechange policy - "an outsider must not be able to shut the door". - A system actor (
actorId0 - scheduled work, a form'screate_projectaction) is not a person and is not asked. - Some 3.3.0 decisions have their own rules on top:
projectCanDecideChange()(change requests),projectCanDecideProposal()(proposals), approving and deleting an approved report usesprojectCanDelete(), and a gate sign-off can be ticked only by its named analyst. See part 6 and part 7.
How the page knows. api/projects/list.php returns can_create (hides New and the empty-state button); api/projects/get.php returns permissions: {can_change, can_delete}. assets/js/projects-view.js renderAll() toggles .prj-readonly on #prjPage, hides Edit / Delete and stops drag; every tool module checks its own canChange() before drawing an add box, a Save button or an enabled RACI cell. That is courtesy only - the server refuses either way.
// assets/js/projects-view.js - renderAll()
const perms = data.permissions || { can_change: true, can_delete: true };
page.classList.toggle('prj-readonly', !perms.can_change);
document.getElementById('pvEdit').hidden = !perms.can_change;
document.getElementById('pvDelete').hidden = !perms.can_delete;projects/settings/manifest.php is the single declaration of the module's settings tabs, and therefore of its capabilities: the tab bar, the tick-boxes on System -> Roles and their descriptions all come from it. The constants are in includes/capabilities.php; analystHasCapability() is in includes/rbac.php.
| Tab id | Capability | Sensitive | Saved by |
|---|---|---|---|
| (umbrella) |
Cap::PROJECTS_MANAGE = projects.manage
|
yes | - |
general |
Cap::PROJECTS_GENERAL |
yes ("these ARE the module's permission rules") | api/projects/settings.php |
health |
Cap::PROJECTS_HEALTH |
api/projects/settings.php |
|
roles |
Cap::PROJECTS_ROLES |
api/projects/settings.php (role_*) |
|
raid |
Cap::PROJECTS_RAID |
api/projects/settings.php |
|
templates |
Cap::PROJECTS_TEMPLATES |
api/projects/templates.php |
|
budget |
Cap::PROJECTS_BUDGET |
yes (per-analyst rates are close to pay) |
api/projects/settings.php (save, rate_*) |
ai |
Cap::PROJECTS_AI |
yes (a key that spends money) | the shared api/system/ai/* endpoints |
Cap::PROJECTS_MANAGE is the umbrella: capExpandUmbrellas() makes holding it satisfy every tab's capability, and it is also what projectIsManager() asks - so it means "change and delete any project" as well as "every setting".
setting_keys, on purpose - except ai. Every Projects setting is saved through api/projects/settings.php, which validates each value with projectSettingValidate(), refuses a key that does not belong to the tab being saved, and checks that tab's capability (the Domains pattern). Declaring the keys in the manifest would let the generic settings writer store them unvalidated. The AI tab is the one exception: the shared AI endpoints authorise a namespace by the tab that owns its keys, so it lists projects_ai_provider, projects_ai_model, projects_ai_api_key, projects_ai_verify_ssl, and those endpoints validate them.
projects/settings/index.php renders only settingsVisibleTabs() and keeps the tab in ?tab=; assets/js/projects-settings.js fills every [data-k] control from the GET, shows each default underneath, and saves one tab at a time.
projectSettingDefinitions() returns key => [default, validator, tab]:
| Key | Default | Rule | Tab | Read by |
|---|---|---|---|---|
project_default_method |
simple |
method |
general | createProject() |
project_create_policy |
anyone |
create (anyone / managers) |
general | projectCanCreate() |
project_change_policy |
team |
change (anyone / team) |
general | projectCanChange() |
project_calendar |
all |
calendar (off / ends / all) |
general | projectSyncCalendar() |
project_disruption |
planned |
disruption (off / now / planned) |
general | ProjectToolsService::announce() |
project_priority_labels (3.3.0)
|
'' |
labels4 |
general | projectScaleLabels($conn, 'priority') |
project_portfolio_sort (3.3.0)
|
target |
sort (target / priority / health / name) |
general | lookups portfolio_sort
|
project_burnup_measure (3.3.0)
|
tasks |
measure (tasks / hours) |
general | lookups burnup_measure
|
project_baseline_auto (3.3.0)
|
stage |
baseline (off / start / stage) |
general | projectBaselineAuto() |
project_change_approver (3.3.0)
|
owner |
approver (team / owner / managers) |
general | projectCanDecideChange() |
project_change_self (3.3.0)
|
1 |
bool |
general | projectCanDecideChange() |
project_change_apply (3.3.0)
|
plan |
apply (baseline / plan) |
general | decideChangeRequest() |
project_proposal_approval (3.3.0)
|
forms |
proposal (forms / all / off) |
general | projectProposalNeedsApproval() |
project_proposal_approver (3.3.0)
|
managers |
approver2 (managers / person) |
general | projectCanDecideProposal() |
project_proposal_approver_id (3.3.0)
|
0 |
analyst |
general | projectProposalNamedApprover() |
project_proposal_on_approve (3.3.0)
|
proposed |
onapprove (proposed / active) |
general | decideProposal() |
project_benefit_review_months (3.3.0)
|
3 |
int:0:24 |
general | saveBenefit() |
project_gate_checklist (3.3.0)
|
block |
gatecheck (block / warn) |
general | decideGate() |
project_toolbox (3.3.0)
|
1 |
bool |
general | lookups toolbox -> projects-toolbox.js (§10) |
project_assistant_memory (3.3.0)
|
person |
memory (person / project) |
general |
projectChatThread() - one Ask AI conversation per person per project, or one shared (analyst_id NULL) |
project_benefit_notify (3.3.0)
|
both |
bnotify (both / owner) |
general | projectBenefitNotifyIds() |
project_default_visibility (3.3.0)
|
everyone |
visibility |
general |
createProject(), lookups |
project_overdue_digest (3.3.0)
|
weekly |
digest (weekly / daily / off) |
general | projectAlertsOverdueDigest() |
project_nudge_days (3.3.0)
|
3 |
int:0:30 |
general | projectAlertsStalled() |
project_amber_days |
14 |
int:1:120 |
health | projectHealthConfig() |
project_amber_progress |
75 |
int:1:100 |
health | projectHealthConfig() |
project_red_overdue_pct |
25 |
int:1:100 |
health | projectHealthConfig() |
project_ticket_amber |
5 |
int:0:500 |
health | projectTicketSpike() |
project_capacity_hours (3.3.0)
|
37.5 |
num:1:80 |
health | projectCapacity() |
project_capacity_amber (3.3.0)
|
85 |
int:50:100 |
health | projectCapacity() |
project_capacity_projects (3.3.0)
|
3 |
int:2:20 |
health | projectCapacity() |
project_capacity_days (3.3.0)
|
1,2,3,4,5 |
weekdays |
health | projectCapacityWorkdays() |
project_capacity_desk (3.3.0)
|
1 |
bool |
health | projectCapacity() |
project_health_milestones (3.3.0)
|
amber |
effect (off / amber / red) |
health | projectAutoHealth() |
project_health_raid_late (3.3.0)
|
amber |
effect |
health | projectAutoHealth() |
project_probability_labels |
'' |
labels5 |
raid | projectScaleLabels() |
project_impact_labels |
'' |
labels5 |
raid | projectScaleLabels() |
project_currency |
GBP |
currency (three letters) |
budget | projectInstallCurrency() |
project_currency_per_project |
0 |
bool |
budget | setCurrency() |
project_labour_mode |
hours |
labour (hours / rate / analyst) |
budget | projectLabour() |
project_cost_basis (3.3.0)
|
actual |
basis (actual / forecast) |
budget | projectBudgetTotals() |
project_forecast_labour (3.3.0)
|
1 |
bool |
budget | projectLabourToCome() |
project_hidden_templates |
'' |
builtin_keys |
templates | projectTemplateList() |
Notes on the rules (projectSettingValidate()):
-
int:min:max- digits only, in range;num:min:max- up to two decimals, a comma read as a point (37.5 hours). -
bool-1,trueoronbecome'1', anything else'0'. -
weekdays- a list or comma string of ISO days 1-7, de-duplicated and sorted; at least one. -
labels5/labels4- exactly five (four) non-blank words, each up to 40 characters; saving the defaults unchanged stores'', so the scale keeps following each viewer's language. -
builtin_keysalways throws:project_hidden_templatesis written only byProjectTemplatesService::setBuiltinHidden(), never by a settings save, so the Settings save cannot store an unchecked list. -
analystonly checks it is a whole number; the endpoint's caller is expected to check it names an active analyst.
/** Every setting with its default applied. Cached; $fresh after a save. */
function projectSettings(PDO $conn, bool $fresh = false): array
{
static $cache = null;
if ($cache !== null && !$fresh) return $cache;
$out = [];
foreach (projectSettingDefinitions() as $k => $d) $out[$k] = $d[0];
try {
$keys = array_keys($out);
$in = implode(',', array_fill(0, count($keys), '?'));
$st = $conn->prepare("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ($in)");
$st->execute($keys);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
if ($r['setting_value'] !== null && $r['setting_value'] !== '') $out[$r['setting_key']] = (string)$r['setting_value'];
}
} catch (Throwable $e) {
// Before Database Verification: the defaults.
}
return $cache = $out;
}One query for every key, defaults applied, cached per request. An empty stored value means "the default". projectSetting($conn, $key) reads one; projectSettingWrite() upserts into system_settings. The whole file sits inside if (!defined('PROJECT_SETTINGS_LOADED')) so it can be required from anywhere.
$tabCaps = [
'general' => Cap::PROJECTS_GENERAL,
'health' => Cap::PROJECTS_HEALTH,
'roles' => Cap::PROJECTS_ROLES,
'raid' => Cap::PROJECTS_RAID,
'budget' => Cap::PROJECTS_BUDGET,
];-
GET returns
settings(with each scale as its words, throughprojectSettingsForScreen()),definitions({key: {default, tab}}, the scale defaults as word lists),can_writeper tab,roles(within_usecounts), and - only for a Budget holder -ratesandanalysts. -
POST
{action:'save', tab, settings:{...}}- 403 without the tab's capability; every key must belong totab("That setting does not belong on this tab: ..."); each value throughprojectSettingValidate(); thenprojectSettingWrite(). Savingproject_calendarredraws the Calendar at once. -
POST
role_save/role_delete/role_reorder- Roles capability; a name up to 100 characters and unique. Deleting a role leaves its members on the project with no role (FKSET NULL). -
POST
rate_save/rate_delete/rate_list- Budget capability; default and analyst hourly rates with aneffective_fromdate (part 6).
POST api/projects/settings.php
{"action": "save", "tab": "health", "settings": {"project_amber_days": "21", "project_ticket_amber": "0"}}
{"success": true, "settings": {"project_amber_days": "21", "project_ticket_amber": "0", "...": "..."}}Always read them through projectScaleLabels($conn, 'probability'|'impact'|'priority'), never from the raw setting. It returns the saved words, or projectScaleDefaults() - the projects.scale.* strings in the viewer's language. projectScaleSize() says 4 for priority (3.3.0), 5 otherwise. A risk stores only the step number, so the words are display only and the scale can never gain or lose a step. projectScaleParse() also reads the comma format that the first 3.2.0 builds wrote, so no migration is needed. Lookups sends them as probability_labels, impact_labels and priority_labels.
projects.visibility is everyone (the default: anybody who can open Projects in the project's company) or members. A members-only project is seen only by its team - the project manager, its creator, and its members directly or through a team (the same rule as projectIsTeam()) - and by people who hold Manage Projects (admins always do). includes/projects/visibility.php is the one place that decides it.
function projectVisibleSql(PDO $conn, int $analystId, string $alias = 'p', bool $systemSeesAll = true): array
{
if (!projectVisibilityReady($conn)) return ['', []];
if ($analystId <= 0) return $systemSeesAll ? ['', []] : [" AND $alias.visibility <> 'members'", []];
if (projectSeesAllPrivate($conn, $analystId)) return ['', []];
return [" 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)))",
[$analystId, $analystId, $analystId, $analystId]];
}| Function | Use |
|---|---|
projectVisibilityReady($conn) |
Has Verification added the column? Before it, everything is visible (and lookups default_visibility is null, which hides the form field) |
projectVisibilityColumn($conn, $alias) |
p.visibility, or 'everyone' AS visibility before Verification - for a SELECT list |
projectSeesAllPrivate($conn, $id) |
projectIsManager(), cached per analyst |
projectVisibleSql($conn, $id, $alias, $systemSeesAll) |
[" AND (...)", $args] for a query over projects; empty when nothing is hidden. $systemSeesAll = false makes analyst 0 see only everyone projects |
projectVisibleTo($conn, $id, $row) |
The same for one row (needs id, owner_analyst_id, created_by_id, visibility) |
projectIdVisibleTo($conn, $id, $projectId) |
The same by id, for callers holding only an id |
Every caller (from a grep of the codebase at the time of writing):
| Caller | File | How |
|---|---|---|
ProjectsService::loadForActor() |
includes/services/projects.php |
projectVisibleTo() - so every endpoint that loads a project by id |
projectListRows() - the portfolio, the export, the portfolio charts |
includes/projects/read.php |
projectVisibleSql() |
| Global search | api/system/global_search.php |
projectVisibleSql() |
peopleProjects() |
includes/people.php |
projectVisibleSql() |
| The recent trail | includes/recent_trail.php |
projectIdVisibleTo() |
analystCanAccessProject() - Documents |
includes/tenancy.php |
projectIdVisibleTo(), checked before the company, on every install |
| Watchtower | includes/watchtower_queries.php |
projectVisibleSql() |
warbotProjectScope() |
includes/warbot/tools.php |
projectVisibleSql() as the asker |
mcpProjectScopeSql() |
includes/mcp/tools.php |
projectVisibleSql() as the key's analyst |
REST list and apiLoadProject()
|
api/v1/resources/projects.php |
projectVisibleSql() / projectVisibleTo() as the key's analyst |
rpProjectClause(), rpProjectChoices() - Report Packs |
includes/report_packs/blocks_projects.php |
projectVisibleSql(..., false) - a pack never carries a members-only project unless the person it is built for may see it, never for nobody |
projectCapacity() |
includes/projects/capacity.php |
projectVisibleSql() - the work still counts (people's load is real) but a project the viewer may not see is named projects.visibility.hidden_name
|
projectSyncCalendarKind() - the shared Calendar |
includes/projects/calendar.php |
AND p.visibility <> 'members' - a members-only project's dates never go on the Calendar |
🔑 A new reader of projects must use one of these. A project somebody may not see is not found to them, never "hidden". What it does not hide: tasks stay Tasks records, seen by whoever Tasks lets see them (an assignee who is not a member still sees their task).
Setting it: createProject() takes project_default_visibility unless the body says otherwise; changing it on an existing project needs the team or Manage Projects (§6). The banner shows #pvPrivate for a members-only project.
A project is scoped data: exactly one company, tenant_id NULL = Default. storeTenant() writes the Default company as NULL, the way every scoped table does:
private static function assertScope(PDO $conn, ActorContext $ctx, array $row, string $notFound): void
{
if ($ctx->companyScope === null || !isMultiTenant($conn)) return;
$tid = ($row['tenant_id'] === null) ? getDefaultTenantId($conn) : (int)$row['tenant_id'];
if (!in_array($tid, $ctx->companyScope, true)) {
throw new ServiceError('not_found', 'not_found', $notFound);
}
}-
loadForActor()starts every by-id method in both services. A project in a company the caller cannot reach throws not found, never forbidden, so ids cannot be probed.api/projects/get.phprelies on it; the page then shows#prjNotFound("does not exist, or it belongs to a company you cannot see"). -
Create:
projectApiTenantForCreate()takescompany_idfrom the body (checked withanalystCanAccessTenant()), else the active company;createProject()checks it againstcompanyScopetoo ("You cannot add projects for that company."). -
No move.
tenant_idis not infieldMap(), and the dialog only shows Company when creating. -
The portfolio filters in SQL with
activeTenantReadFilter($conn, $analystId, 'p')- one company, or every company the analyst can see in the All companies view (which never means "no filter": it names an explicit id list). Nothing in the JS decides who sees what. -
Global search uses
activeTenantFilter()(the active company only), like the other modules inapi/system/global_search.php. Watchtower also usesactiveTenantFilter(). Warbot usesactiveTenantReadFilter(); MCP uses the key'scompany_scopethroughmcpProjectCompanySql(). The recent trail gates a project's label by its company. -
Tasks created in a project take the project's company (
createTaskInProject()passes it explicitly toTasksService::saveTask());assignTask()refuses a task from another company (sameTenant(), NULL read as Default). -
People on a project: a member from People (
user_id) must be in the project's company (addMember()), and the People picker inapi/projects/tools.phpapplies the same filter so it never offers somebodyaddMember()would refuse. Analysts, teams and roles are install-wide. -
Links: an asset, change, ticket, CI or problem must be in the project's company; contracts and knowledge articles are judged by their own permissions only. A RAID entry's linked ticket goes through
projectLinkTargetOk($conn, $actor, 'ticket', ...), the same rule as a Connections ticket link (part 8).
See Multi-tenancy developer guide for the helpers themselves.
projectMethodologies() (includes/projects/methodologies.php) is defined in code, not in the database - the behaviour behind each key is code, so the key list must be (the same reason as the Warbot tool registry):
function projectMethodologies(): array
{
return [
'simple' => ['label_key' => 'projects.method.simple', 'desc_key' => 'projects.method.simple_desc', 'timebox' => 'phase', 'single_active' => false, 'tools' => ['people']],
'staged' => ['label_key' => 'projects.method.staged', 'desc_key' => 'projects.method.staged_desc', 'timebox' => 'stage', 'single_active' => true, 'tools' => ['people', 'scope', 'raci', 'raid', 'gates', 'budget', 'control', 'benefits']],
'agile' => ['label_key' => 'projects.method.agile', 'desc_key' => 'projects.method.agile_desc', 'timebox' => 'sprint', 'single_active' => true, 'tools' => ['people', 'scope', 'raid']],
];
}| Key | timebox |
single_active |
Tools on by default |
|---|---|---|---|
simple |
phase |
false | people |
staged |
stage |
true |
people, scope, raci, raid, gates, budget, control (3.3.0), benefits (3.3.0)
|
agile |
sprint |
true |
people, scope, raid
|
- A new time box takes the project's preset
timeboxas itskind(saveStage()). -
single_activeis enforced insaveStage(): setting a stage toactivewhile another is active throws "X" is still active. Close it before starting another. -
Switching method (
updateProject()seesmethodologychange) callsapplyMethodology()inside the same transaction as the update:
private static function applyMethodology(PDO $conn, int $projectId, string $method): void
{
$preset = projectMethodologies()[$method] ?? null;
if (!$preset) return;
$conn->prepare("UPDATE project_stages SET kind = ?, updated_datetime = UTC_TIMESTAMP()
WHERE project_id = ? AND status <> 'closed'")->execute([$preset['timebox'], $projectId]);
// A method that allows one active time box at a time keeps the earliest
// active one and returns any others to planned, so the switch never
// leaves the project in a state its new method forbids.
if ($preset['single_active']) {
$st = $conn->prepare("SELECT id FROM project_stages WHERE project_id = ? AND status = 'active' ORDER BY position, id");
$st->execute([$projectId]);
$ids = $st->fetchAll(PDO::FETCH_COLUMN);
if (count($ids) > 1) {
array_shift($ids);
$ph = implode(',', array_fill(0, count($ids), '?'));
$conn->prepare("UPDATE project_stages SET status = 'planned' WHERE id IN ($ph)")->execute(array_map('intval', $ids));
}
}
}- every time box whose status is not
closedgets the newkind. Closed ones keep the name they were run under - they are history; - if the new preset is
single_activeand more than one box is active, the earliest byposition, then id stays active and the rest go back toplanned.
The switch is audited as an ordinary methodology field change.
The other fixed lists in the same file: projectStatuses() (proposed, active, on_hold, closed, cancelled), projectFinishedStatuses() (closed, cancelled - no health, not live), projectPriorities() (low, medium, high, critical, 3.3.0), projectHealthValues() (auto, green, amber, red), projectStageKinds(), projectStageStatuses() (planned, active, closed).
projectToolDefinitions() lists the eight tools - people, scope, raci, raid, gates, budget, control (3.3.0), benefits (3.3.0) - each with label_key and desc_key. A project's own choices are stored as JSON in projects.tailoring ({"raci": true, "raid": false}), and projectEnabledTools() applies them over the preset:
/** The tools switched on for one project: its method's defaults, then its tailoring. */
function projectEnabledTools(array $project): array
{
$preset = projectMethodologies()[$project['methodology'] ?? 'simple'] ?? projectMethodologies()['simple'];
$on = array_fill_keys($preset['tools'], true);
$tail = json_decode((string)($project['tailoring'] ?? ''), true);
if (is_array($tail)) {
foreach ($tail as $k => $v) if (isset(projectToolDefinitions()[$k])) $on[$k] = (bool)$v;
}
return array_keys(array_filter($on));
}- The
tailoringfield type inProjectsService::validateField()keeps only known tool keys, casts each to bool, and stores NULL when nothing is left. NULL means "the method's defaults". -
projectDecorate()addstoolsto every project row, so the portfolio, the page and the alert scan all agree. - The page shows a tab only when its tool is in that list:
[data-tool]buttons inprojects/view.php, toggled inrenderAll(); a hidden tool's tab is never left open (it falls back tooverview). - The edit dialog's Tools section (edit only,
#pfTools) sends every box astailoring- except when the method was changed in the same save: then it sendsnull, so the project takes the new method's own set (assets/js/projects.js):
const tail = {};
document.querySelectorAll('#pfTools [data-tool-key]').forEach(c => { tail[c.dataset.toolKey] = c.checked; });
// A method change resets the tools to the new method's own set.
if (formState.method === formState.origMethod) body.tailoring = tail;
else body.tailoring = null;- The Toolbox on the Overview (below) is the other writer of
tailoring; both go throughsave.php. - 🔑 Switching a tool off hides it; it never deletes. The rows stay and come back when the tool is switched on again.
⚠️ Tailoring is presentation, not permission.ProjectToolsServicedoes not refuse a write to a tool that is switched off; the API will still accept it from someone allowed to change the project. The places tailoring changes behaviour are:projectExceptions()returns nothing unlessgatesis on (part 5), andprojectBaselineAuto()only acts withcontrolon (part 6).- History:
tailoringis audited, butauditDisplay()returns null for it, so the history row has no values.
A project starts with the tools its method switches on (Simple: People only). Somebody new to running projects should not meet a RACI matrix and earned value on day one, so the Overview offers the rest as the project grows (#2243). It is only a second way to write tailoring - nothing new is stored on the server.
| Piece | Where |
|---|---|
| The panel |
assets/js/projects-toolbox.js (window.PrjToolbox.render({data, L, projectId, refresh})), called from renderAll() in projects-view.js; it draws into <div id="pvToolbox" hidden>, which renderOverview() leaves between #pvProposal and #pvBriefing
|
| On or off for the install |
project_toolbox (bool, default '1', General tab), sent as lookups toolbox (projectSetting($conn, 'project_toolbox') === '1') |
| Words |
toolbox.* in lang/en/projects.php; each card also uses tools.<key> and tools.<key>_desc
|
It draws nothing (and empties the box) when the setting is off, the viewer cannot change the project, the project is closed or cancelled, every tool is already on, or the viewer hid it for this project:
if (!c.L.toolbox || !can || finished || !off.length || isHidden(p.id)) { box.hidden = true; box.innerHTML = ''; return; }-
The list is every tool in
ORDER(people,scope,raid,gates,budget,raci,control,benefits) not inproject.tools, each with what it is for and when you would want it (toolbox.when_<key>). -
Suggested first.
reason(tool, data)reads only whatget.phpalready sent and returns a reason or null: two or more assignees and no members (People), 12+ tasks (Scope), 4+ members (RACI), overdue tasks (RAID), 3+ stages (Gates), 90+ days long (Budget), the target end date changed in history (Change control), a business case written (Benefits). Cards with a reason sort first and carry a "Suggested" tag. -
Add merges into the project's current tailoring and saves it through
save.php- exactly what the Edit form's Tools boxes do, so it can be taken away there again. RACI needs its columns and rows, so adding it adds People and Scope too (NEEDS = { raci: ['people', 'scope'] }):
async function add(tool) {
const p = ctx.data.project;
let tail = {};
try { tail = JSON.parse(p.tailoring || '{}') || {}; } catch (e) { tail = {}; }
[tool].concat(NEEDS[tool] || []).forEach(k => { tail[k] = true; });
try {
await P.api('save.php', { id: ctx.projectId, tailoring: tail });
P.toast(T('added', { tool: P.T('tools.' + tool) }));
await ctx.refresh();
} catch (e) { P.toast(e.message, 'error'); }
}The server applies the usual rules: updateProject() checks the change policy, and validateField() keeps only known tools. The write is audited as tailoring.
-
Hide is per project, per browser:
localStoragekeyfreeitsm.projects.toolbox.hide.<project id>(insidetry, so a private window simply hides it until the next draw). Nothing goes to the server.
A new tool needs an entry in ORDER, ICONS, a reason() case if the project can suggest it, and toolbox.when_<key>.
Note
PRINCE2® is a registered trademark. "Staged", the Gates tool, tolerances and the seeded roles are described in FreeITSM's own words as a PRINCE2-style approach (see the header of includes/projects/methodologies.php). Never copy text from the manual into this module, and never describe it as certified or official.
An install that has pulled new code but not yet run Database Verification is missing the new tables and columns. The module is written so that state never takes a screen down: every new table or column is probed by a cached ...Ready() guard, and code that names it either skips the work or selects a placeholder ('medium' AS priority, NULL AS approval_status). projectsSchemaReady() exists for the Tasks module, whose board would otherwise break on a missing tasks.project_id. Part 2 lists every guard and what it protects. The rule for new code: a query in a shared read (the portfolio, the project page, Tasks, People, the alert scan) must not name a new table or column without a guard.
projects/includes/header.php sets, on every Projects page:
<script>window.PRJ_API = <?php echo json_encode(BASE_URL . 'api/projects/'); ?>; window.PRJ_BASE = <?php echo json_encode(BASE_URL); ?>; window.PRJ_ME = <?php echo (int)($_SESSION['analyst_id'] ?? 0); ?>;</script>Every URL is built from PRJ_API / PRJ_BASE, never a root-relative /api/..., which 404s on an install in a sub-directory. Words come from window.translations (namespaces common and projects) through window.t().
Loaded first on every Projects page. Its export:
window.Prj = {
T, TC, esc, api, lookups, icon, gradient, setPalette, ring, statusPill, healthBadge, priorityLabel, priorityChip, setPriorityLabels,
fmtDate, daysTo, todayStr, targetPhrase, initials, timeboxWord, toast,
openModal, closeModal, openProjectForm, celebrate, templateMeta,
};| Helper | What it does |
|---|---|
T(k, p) / TC(k, p)
|
window.t('projects.' + k, p) / window.t('common.' + k, p)
|
esc(v) |
HTML-escape; every value put into markup goes through it |
api(path, body?) |
fetch(PRJ_API + path); GET with no body, POST JSON with one. Throws Error(data.error) on success:false, so callers try { await P.api(...) } catch (e) { P.toast(e.message, 'error') }
|
lookups() |
api('lookups.php'), cached as one promise per page |
icon(key, size) |
The SVG for an icon key (falls back to rocket); plus and sparkles exist for the template picker and the AI, but are not project icons |
gradient(key) / setPalette(list)
|
A project's colour gradient; the palette comes from lookups colours
|
ring(progress, health, size, onDark) |
The health ring with the percentage inside |
statusPill, healthBadge, priorityLabel, priorityChip, setPriorityLabels
|
Pills and chips; Medium priority gets no chip |
fmtDate, todayStr, daysTo, targetPhrase
|
Dates ("Due in 12 days", "3 days late", "Finished 4 Mar") |
initials, timeboxWord(kind, plural), toast(msg, type)
|
Small helpers |
openModal(id) / closeModal(id)
|
Toggle .active and aria-hidden. A click on [data-prj-close="id"], on the backdrop, or Escape closes - only for modals whose id starts with prj
|
openProjectForm(project, onSaved) |
The create / edit dialog: the Start from template cards (new only), method cards, Tools (edit only), colours, icons, visibility, company (new, multi-company only) |
templateMeta(t) |
"3 stages, 24 tasks" for a template card |
celebrate(fromEl) |
A confetti burst; returns at once under prefers-reduced-motion: reduce, leaving only the toast. Fires on finishing a stage, closing a project, and a go at a gate |
It also closes an open details.prj-export menu on an outside click (3.3.0).
projects-view.js owns the page and its data. One call draws a page: load() fetches the project, the lookups and the links together, and refresh() re-fetches get.php and redraws everything after each change, so the ring, the counts, the plan and the tools can never disagree.
async function load() {
try {
const [d, lk, ln] = await Promise.all([P.api('get.php?id=' + projectId), P.lookups(), P.api('links.php?project_id=' + projectId).catch(() => null)]);
L = lk;
links = ln;
P.setPalette(L.colours);
P.setPriorityLabels(L.priority_labels);
data = d;
// A brand-new project has nothing in it yet: open the add box so the
// first thing on the Plan tab is somewhere to type.
if (!data.tasks.length && !data.stages.length) openAdd = '';
renderAll();
// The recent trail (#124).
if (window.trailVisit) window.trailVisit('project', projectId);
} catch (e) {
document.getElementById('prjNotFound').hidden = false;
document.querySelectorAll('.prj-tab-panel').forEach(s => { s.hidden = true; });
}
}renderAll() draws the parts it owns (banner, Overview, Plan, Connections, History), then hands the same data to every tool module. The context object passed to the modules is:
const toolCtx = { data: data, L: L, projectId: projectId, refresh: refresh, page: page };| Key | What it is |
|---|---|
data |
The whole get.php response: project (decorated, with tools, progress, shown_health, exceptions, effort...), stages, tasks, history, permissions, and every tool's data |
L |
The lookups.php response |
projectId |
The id |
refresh |
async () => { data = await get.php; renderAll(); } - call it after any write |
page |
#prjPage |
Who gets what, in the order renderAll() calls them:
| Module | Called with | Draws into |
|---|---|---|
PrjMilestones.render(toolCtx) |
full ctx |
#pvMilestones (before renderPlan(), whose lane heads call PrjMilestones.chips(stageId) and projectStrip()) |
PrjTools.render(toolCtx) |
full ctx |
#pvPeople, #pvScope, #pvRaci, #pvRaid, #pvGates - each only if its tool is on |
PrjTargets.render({data, projectId, refresh}) |
#pvTargets (left empty by renderOverview()) |
|
PrjBudget.render({data, projectId, refresh}) |
#pvBudget |
|
PrjControl.render({data, projectId, refresh}) |
#pvControl |
|
PrjIntake.render({data, projectId, refresh}) |
#pvProposal on the Overview |
|
PrjBenefits.render({data, projectId, refresh}) |
#pvBenefits |
|
PrjInsights.render({data, L, projectId}) |
the Overview charts (draw() on showing the Overview) |
|
PrjToolbox.render({data, L, projectId, refresh}) |
#pvToolbox on the Overview (§10) |
|
FreeITSMDocuments.mount(...) |
once per page load (docsMounted) |
#pvDocumentsPanel, canEdit from permissions.can_change
|
PrjReports.render({data, projectId, refresh}) |
#pvBriefing and #pvReports (fetches its own state once per project) |
|
PrjTimeline.render(toolCtx) |
full ctx, after showTab()
|
#pvTimeline - it draws only when visible |
Each module guards itself (if (window.PrjX)), wires its event handlers once (a wired flag), keeps the latest ctx in a closure variable, and returns early when its tool is off - for example:
window.PrjBenefits = {
render(c) { ctx = c; if (!(c.data.project.tools || []).includes('benefits')) return; wire(); render(); },
};Tabs. showTab(name) sets .active on #prjTabs [data-tab], shows the matching .prj-tab-panel[data-panel], and keeps the tab in the URL hash with history.replaceState. Things that measure their width draw only when shown: showTab('timeline') calls PrjTimeline.shown(), showTab('overview') redraws the burn-up and PrjInsights.draw(), showTab('budget') calls PrjBudget.shown(). Opening Gates after the Documents tab refreshes the data, so a newly attached document can satisfy a gate item. The hash tabs accepted on load are:
if (['overview', 'plan', 'timeline', 'people', 'scope', 'raci', 'raid', 'gates', 'budget', 'control', 'benefits', 'documents', 'reports', 'connections', 'history'].includes(start)) tab = start;
if (/[?&]new=1/.test(location.search)) tab = 'plan';?new=1 (set by the portfolio after creating a project) opens Plan with the add box open.
The task window is the one place a task is edited. Task rows link to tasks/?task=N; the project page never grows a second editor. Drag between Plan lanes is desktop HTML5 drag and drop (dragstart / dragover / drop on .prj-lane), saved through task_assign.php.
Script order in projects/view.php: projects.js, projects-tools.js, projects-targets.js, projects-budget.js, projects-control.js, projects-intake.js, projects-benefits.js, projects-gatecheck.js, projects-insights.js, the documents panel assets, projects-toolbox.js, projects-assistant.js, projects-reports.js, projects-charts.js, projects-milestones.js, projects-timeline.js, projects-view.js, projects-templates.js, mobile.js. projects-view.js runs on DOMContentLoaded, after every module has registered its global. Each <script> carries a ?v=N cache-buster - bump it when you change the file.
-
Loads once, filters in the browser.
load()callslist.phpwith no filters (the endpoint acceptsq,statusandmine, but the page does not use them); views and search are client-side over the already company-scoped list. Nothing here decides who may see what. -
Views (
inView()):live,mine,at_risk,proposed,approval(3.3.0),on_hold,finished, all. -
Sort (
sorted(), 3.3.0):target/priority/health/name; default from lookupsportfolio_sort. -
Layouts: cards,
roadmap()(built from the Timeline's.prj-tl-*classes) andcharts()(data fromportfolio_charts.php);healthStrip()above. -
Remembered per viewer in
localStorage, each read and write intry:freeitsm.projects.view,freeitsm.projects.sort,freeitsm.projects.layout. Conveniences only. -
Export (
exportView()) sends the ids it is showing, in order, toexport.php, which intersects them withprojectListRows(). - After creating,
newProject()goes toprojects/view.php?id=N&new=1.
-
assets/js/projects-settings.js- Settings; Roles (add, edit, deactivate, delete, drag to reorder) and Templates overtemplates.php. -
assets/js/projects-capacity.js- draws whatcapacity.phpreturns; the server works everything out. -
assets/js/project-links.js-ProjectLinks.mount(host, {kind, id, base, ...})for other modules' pages; words throughwindow.tf()with English fallbacks so a host page need not export theprojectsnamespace (part 8).
Suppose a new tool, lessons_board. Follow the pattern Benefits (3.3.0) used - it is the newest and cleanest whole tool: its own table, its own rules file, its own JS module.
1. Declare the tool in includes/projects/methodologies.php:
'lessons_board' => ['label_key' => 'projects.tools.lessons_board', 'desc_key' => 'projects.tools.lessons_board_desc'],Add the key to a preset's tools if a method should switch it on by default. Add tools.lessons_board and tools.lessons_board_desc to lang/en/projects.php. The Tools section in the edit dialog and the help page's per-method list pick it up by themselves; help.tools.p1 names the tools in words and needs the new one added by hand. So does the Toolbox: add the key to ORDER and ICONS in assets/js/projects-toolbox.js, a reason() case if the project can suggest it, and toolbox.when_lessons_board (§10). The help page's map (projects-tour.js) has its own NODES list naming the tool that switches each part on.
2. The schema - the table in database/freeitsm.sql and includes/db_verify_schema.php (with is_demo), its FKs in $projectFks in api/system/db_verify.php, then php scripts/gen_db_verify_indexes.php. See part 2.
3. The rules file includes/projects/lessons_board.php with a cached guard and the reads:
function projectLessonsBoardReady(PDO $conn): bool
{
static $ready = null;
if ($ready === null) {
try { $conn->query("SELECT 1 FROM project_lessons_board LIMIT 0"); $ready = true; }
catch (Throwable $e) { $ready = false; }
}
return $ready;
}4. The writes in ProjectToolsService, each starting with changeable() and ending with audit() / touchProject() (and afterChange() if it can move health):
public static function saveLessonCard(PDO $conn, ActorContext $ctx, int $projectId, array $in): int
{
require_once __DIR__ . '/../projects/lessons_board.php';
self::changeable($conn, $ctx, $projectId);
// validate; INSERT or UPDATE ... WHERE id = ? AND project_id = ?
ProjectsService::audit($conn, $projectId, $ctx->actorId, 'lesson_card_saved', null, $title, self::src($ctx));
ProjectsService::touchProject($conn, $projectId);
return $id;
}Add the table to the by-hand delete list in ProjectsService::deleteProject() (inside its own try), and to deleteStage() if rows hang off a stage.
5. The endpoint - an action in api/projects/tools.php (and a line in its header comment), and the read in api/projects/get.php as a closure so the require is lazy:
'lessons_board' => (function () use ($conn, $pid) {
require_once __DIR__ . '/../../includes/projects/lessons_board.php';
return projectLessonsBoard($conn, $pid);
})(),6. The page - in projects/view.php a tab button and a panel, a modal whose id starts with prj (so Escape and the backdrop close it), and the script tag before projects-view.js:
<button type="button" data-tab="lessons_board" data-tool="lessons_board" hidden><?php echo htmlspecialchars(t('projects.tools.lessons_board')); ?></button>
...
<section class="prj-tab-panel" data-panel="lessons_board" id="pvLessonsBoard" hidden></section>
...
<script src="../assets/js/projects-lessons-board.js?v=1"></script>7. The JS module assets/js/projects-lessons-board.js:
(function () {
'use strict';
const P = window.Prj;
const T = (k, p) => P.T('lessons_board.' + k, p);
const esc = P.esc;
let ctx = null, wired = false;
const call = body => P.api('tools.php', Object.assign({ project_id: ctx.projectId }, body));
const canChange = () => !!(ctx.data.permissions && ctx.data.permissions.can_change);
function render() {
const box = document.getElementById('pvLessonsBoard');
box.innerHTML = (ctx.data.lessons_board || []).map(c => '<div>' + esc(c.title) + '</div>').join('')
+ (canChange() ? '<button type="button" class="btn btn-primary prj-btn" data-lb-add>+ ' + esc(T('add')) + '</button>' : '');
}
function wire() {
if (wired) return; wired = true;
document.getElementById('pvLessonsBoard').addEventListener('click', async e => {
if (!e.target.closest('[data-lb-add]')) return;
try { await call({ action: 'lesson_card_save', title: '...' }); await ctx.refresh(); }
catch (er) { P.toast(er.message, 'error'); }
});
}
window.PrjLessonsBoard = {
render(c) { ctx = c; if (!(c.data.project.tools || []).includes('lessons_board')) return; wire(); render(); },
};
})();8. Wire it in projects-view.js: call it from renderAll() (if (window.PrjLessonsBoard) window.PrjLessonsBoard.render({ data: data, projectId: projectId, refresh: refresh });), and add 'lessons_board' to the hash allow-list in the DOMContentLoaded handler. If it measures its width, draw it from showTab() as the Timeline does.
9. The rest - history strings under history.* (and a case in historyItem() if the values need wording), a help section, the REST API if it should be there (part 3), templates if it is part of a plan (part 8), the demo data (part 9), a Feature Bingo card, the CHANGELOG row, and these wiki pages.
A plain tab (always on, like Documents or Reports) is the same without steps 1 and the data-tool attribute, and its module does not check tools.
| Trap | What happened, and the rule |
|---|---|
A class that sets display beats [hidden]
|
.prj-chip (display:inline-flex) still showed when hidden. assets/css/projects.css carries a TRAP: rule re-asserting [hidden]{display:none!important} inside the module |
$icon in projects/help.php
|
includes/header.php loops its nav as [$href, $label, $icon] and overwrote it. The help page's helper is $prjHelpIcon
|
| Settings keys in the manifest | would let the generic settings writer store them unvalidated. Keep them out (§7); only the AI tab declares keys, for the shared AI endpoints |
A new reader of projects without visibility |
would show members-only projects to everybody. Use projectVisibleSql() / projectVisibleTo() / loadForActor() (§8) |
| Naming a new column or table in a shared read | breaks the portfolio, the project page or the Tasks board before Verification. Guard it (part 2) |
A modal id not starting with prj
|
Prj's Escape and backdrop handlers ignore it |
| Expecting an HTTP status from a refusal | the UI endpoints answer a ServiceError with 200 and success:false (§3) |
| Only checking permissions in the page | the page hides what the server would refuse - courtesy only. The rule must be in the service (§6) |
More traps, by area, are in part 9.
FreeITSM — an open-source IT Service Management platform · github.com/edmozley/freeitsm · MIT licence
- Installation
- ⏰ Scheduled tasks (cron jobs)
- Architecture
- 🧪 Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- 📅 Date & Time Formats
- Theming & Dark Mode
- 🗂️ Recent — getting back to what you were doing
- ⌨️ Command palette (⌘K)
- 🔍 Searching inside tickets
- 📄 Attached documents
-
Mobile‑Friendly
- ↳ 🎫 Mobile: Tickets
- ↳ 💻 Mobile: Assets
- ↳ 📅 Mobile: Calendar
- ↳ 📚 Mobile: Knowledge
- ↳ 🚦 Mobile: Service Status
- ↳ 🗼 Mobile: Watchtower
- ↳ 🧩 Mobile: Problem Management
- ↳ 🔁 Mobile: Change Management
- ↳ 💿 Mobile: Software
- ↳ ✅ Mobile: Tasks
- ↳ 📝 Mobile: Forms
- ↳ 📄 Mobile: Contracts
- ↳ 📄 Mobile: Domains
- ↳ 📄 Mobile: People
- ↳ 🚀 Mobile: Projects
- ↳ 🎓 Mobile: LMS
- ↳ 🗺️ Mobile: CMDB
- ↳ 🗺️ Mobile: Network Mapper
- ↳ 🧭 Mobile: Process Mapper
- ↳ ⚙️ Mobile: Workflow
- ↳ 🖥️ Mobile: System
- ↳ 📊 Mobile: Reporting
- ↳ 📖 Mobile: System Wiki
- ↳ 🙋 Mobile: Self-Service Portal
- ↳ 🧰 Mobile: Techniques & Tricks
-
Security
- Layer 1 — which modules you can enter
- ↳ 🧩 Module Access Control
- ↳ 🛠️ Module Access — Developer Guide
- Layer 2 — what you can administer
- ↳ 🎭 Roles & Permissions
- ↳ 🛠️ Roles — Developer Guide
- ↳ 🔤 Why capabilities are constants
- Layer 3 — the System module
- ↳ 🔑 Admin Access Control
- Hardening
- ↳ 📄 Security review response 2026-08
- ↳ 🛡️ Security hardening 2026-08
- ↳ 🛠️ Security hardening 2026-08 — Developer Guide
- ↳ 🛡️ Round three — plain English
- ↳ 🛠️ Round three — Developer Guide
- ↳ 🛡️ CSRF protection (S4) — Developer Guide
- Single Sign-On (SSO)
- 🗂️ LDAP & Active Directory
- 📇 CardDAV contact sync
- Browser Extension
- API Reference
-
🔌 REST API — how it works
- ↳ 🎫 REST API: Tickets
- ↳ 💻 REST API: Assets
- ↳ 🔴 REST API: Problems
- ↳ 🟠 REST API: Changes
- ↳ 📚 REST API: Knowledge
- ↳ ✅ REST API: Tasks
- ↳ 🗄️ REST API: CMDB
- ↳ 📜 REST API: Contracts
- ↳ 🗓️ REST API: Calendar
- ↳ 💿 REST API: Software
- ↳ 🌐 REST API: Domains
- ↳ 🚦 REST API: Service Status
- ↳ ☀️ REST API: Morning Checks
- ↳ 📝 REST API: Forms
- ↳ ⚙️ REST API: Workflow
- ↳ 🏷️ REST API: Cost centres
- ↳ 🗺️ REST API: Network Mapper
- ↳ 🧭 Using the API docs page
- ↳ 📐 OpenAPI specification
- ↳ ✅ OpenAPI: kept correct
- ↳ 🛠️ Maintaining the catalogue
- Watchtower
-
Tickets
- ↳ 📋 Rota copy and paste — Developer Deep Dive
- ↳ ✅ Checklists & SOPs
- ↳ ☑️ Mandatory fields
- ↳ 🏷️ Ticket categories
- ↳ 👥 Assigning tickets to a team, and escalation
- ↳ 🏢 One board across every company
- ↳ Mailbox Authentication
- ↳ 📤 Email send log
- ↳ Basic IMAP mailboxes
- ↳ Email rendering & images
- ↳ SLA Management
- ↳ WhatsApp channel
-
↳
✈️ Telegram channel - ↳ ⭐ CSAT company scope and filters — Developer Guide
- ↳ 👥 Microsoft Teams channel
- ↳ 🗨️ Mattermost channel
- ↳ 💬 Web chat channel
- ↳ 🟣 Slack channel
- ↳ 🔗 Linking tickets
- ↳ ⓘ Record previews
- ↳ 📝 Ticket notes: internal or shared
- ↳ 🗒️ Canned responses
- ↳ ✉️ Limiting replies to particular senders
- ↳ 📨 Telling the analyst a ticket is theirs
- ↳ ✍️ Email signatures
- ↳ 🌐 The public web address
- ↳ 🔢 Ticket numbering
- ↳ 🙋 Raising a ticket for someone else
- ↳ 🔀 Merging tickets
- ↳ 🔒 Confidential tickets
- ↳ 👥 Portal managers
- ↳ 👁 Who has seen a ticket
- ↳ 📜 Reading long tickets
- ↳ ⑂ Splitting tickets
- ↳ ✅ Selecting several tickets
- ↳ 🗂️ The folder pane
- ↳ 🔽 Just my tickets, or no closed ones
- ↳ 🛠️ Snoozing tickets — Developer Guide
- ↳ 👥 Collision detection
- ↳ ⏱️ Time tracking
- ↳ 📅 Scheduled work in your own calendar
- Problem Management
- Tasks
- 🚀 Projects
-
Assets
- ↳ 🏢 Moving an asset between companies
- ↳ 📍 Shared asset locations
- ↳ 🧑💼 Assigning assets to analysts
- ↳ 📆 Warranty and lease alerts
- ↳ 🔭 Saved table views
- ↳ 🖨️ Recording anything, and importing it
- ↳ 🏷️ QR asset labels
- ↳ 📋 Who holds what, and handover documents
- ↳ 🖥️ The inventory agent (PowerShell)
- ↳ 🗄️ Proxmox VE servers
- ↳ ☁️ VMware Cloud Director servers
- ↳ 🔗 Linking equipment to tickets
- ↳ ☑️ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- ↳ 🎨 The form designer — Developer Guide
- ↳ 📐 Layout & the grid — Developer Guide
- ↳ 🗂️ Collections — grouping submissions
- ↳ 📄 Submissions as PDFs
- ↳ ⚡ What happens next — a form's own actions
- ↳ 🛠️ Sections & conditional logic — Developer Guide
- ↳ 🛠️ Lookup fields — Developer Guide
- ↳ 🛡️ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- 🔔 Notifications
- 🚨 War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- ↳ 📊 Progress tracker
- ↳ Concepts & vocabulary
- ↳ Email routing & mailboxes
- ↳ Settings: global vs per-company
- ↳ Users & self-service
- ↳ Staff cross-company access
- ↳ 🏢 One board across every company
- ↳ Worked examples
- ↳ Pitfalls & gotchas
- ↳ Scope: what it's for
- ↳ 🛠️ Developer Guide (make a module multi-company)
- ↳ 🗄️ Case study: CMDB (a linked graph)
- ↳ 🧪 Test harness (prove it's isolated)
-
🐞 Bugs resolved
- ↳ 🖼️ Logo and courses broke on Apache with PHP-FPM
- ↳ 🔢 Chat tickets ignored your ticket numbering
- ↳ 📅 Dates shown as a dash, or in server time
- ↳ 🔒 Assets → Users showed people from other companies
- ↳ 🔒 Restricted analysts could read other modules' data
- ↳ 🖼️ Replies with a picture in the thread failed to send
- ↳ 📎 Reply attachments never reached the customer
- ↳ 🛠️ Outbound email attachments — Developer Guide
- ↳ 🔑 A global SSO provider was missing from the portal
- ↳ 🔀 Behind a proxy, the SSO redirect said http
- ↳ ✏️ The portal tagline moved when you saved it
- ↳ 🎨 The portal settings screen forgot what you saved
- ↳ 🛡️ The approvals inbox said "Error" and nothing else
- ↳ 📄 A table's answers were missing from the PDF
- ↳ ◉ A single-select column let you tick every option
- ↳ 📐 The portal ignored a form's field widths
- ↳ 📋 The tasks board stopped taking clicks
- ↳ 🗂️ #121 The index list is out of date after upgrading
- ↳ 📅 #133 The calendar subscription was empty
- ↳ 📋 #131 Tasks always reopened on the board
- ↳ 💥 #129 Every page returned HTTP 500 after upgrading
- ↳ 🐳 #127 A PHP warning above the System page
- ↳ 🕐 #126 Notes stamped with the server's clock
- ↳ 🌍 Storing every date in UTC
- ↳ 🚪 The portal was down for everyone signed in
- ↳ ⚙️ #120 Workflow notes could never be written
- ↳ ⚙️ #123 Three errors when running Database Verification
- ↳ 📝 #122 The description box was a stub in the corner
- ↳ 💣 Demo data deleted real accounts
- ↳ 🔐 #117 Sign-in redirected to the wrong address
- ↳ 🎨 #108 The priority dot was invisible
- ↳ ⏱️ #116 Time logged from the right-click menu
- ↳ 🔑 #114 API keys refused by our own guard
- ↳ 🗂️ #110 Assigning a task told nobody
- ↳ 🚪 #107 Signed out while still working
- ↳ 📎 #103 "Share with Requester" reached nobody
- ↳ 🔍 #102 Search found nothing for hyphens
- ↳ 🪟 #101 Source code editor opened behind
- ↳ ☑️ #88 Subtasks could not be ticked off
- ↳ 💻 #84 Asset deep link selected nothing
- ↳ 🎫 #79 A new ticket arrived with no status
- ↳ 📧 #79 A ticket from email did not say so
- ↳ 🔔 #78 Bell opened to nothing
- ↳ 📬 #77 Mail only collected from Inbox
- ↳ 🔐 #74 The default password could not be changed
- ↳ 🚦 #70 Renaming an impact level
- ↳ 📤 #67 App-only mailboxes could not send
- ↳ 📭 #45 Verify only ever worked for Microsoft
- ↳ 📭 #45 IMAP reported as not authenticated
- ↳ ✉️ An email template stopped escaping itself
- ↳ 🕐 The portal dashboard showed the wrong time
- ↳ 🔢 The folder said 99 and the list showed 96