Skip to content

Organisation Chart Developer Guide

Ed Mozley edited this page Nov 2, 2026 · 1 revision

πŸ› οΈ Organisation chart - Developer Guide

The internals of People β†’ Organisation (4.8.0, #241, PR #242 by vsc55). Read People - Developer Guide first: the chart follows that module's two gates and its peopleScope() visibility rule. The review that brought it in: Organisation chart - PR review.


1. πŸ“ Files

File What it does
includes/people.php (the "Organisation chart" block at the end) Every query: peopleOrgLevel(), peopleOrgPath(), peopleOrgPerson(), peopleOrgBranch(), peopleOrgContext(), peopleOrgDepartments(), peopleOrgGaps(), with peopleOrgNodes() and peopleOrgShownSql() beneath them and peopleOrgChartLevels() for the levels a request asks for
api/people/org.php GET only. action = level, path, person, branch, context, gaps. Needs the People module (requireModuleAccessJson('people'))
people/org.php The page: sidebar (find, view, leavers, gaps), the tree, the chart, the side panel, export. All its JavaScript is in the page
assets/css/people.css The ppl-org-* classes
people/includes/header.php, people/person.php The Organisation tab, and Show in the chart on a person's page (only for somebody with a manager or reports)
tests/people-org.php 89 checks on the functions, in a transaction that is always rolled back
tests/people-org-api.php 43 checks on the endpoint over HTTP: what it makes of what a URL sends

No schema change. The chart reads users.manager_id (one pointer, pointing up), with job_title, department, is_active, is_managed and the company.

2. πŸ”‘ One visibility rule for every answer

Every function takes the analyst and applies the same rule, so the tree, the chart, the side panel, the gaps and the exports cannot disagree:

  • A node is somebody the reader could open - peopleScope() on the person and analystCanAccessUser() on any id that arrives in a request.
  • A manager in a company the reader cannot access is never named. The people under them start a branch of their own, as if they had no manager. peopleOrgLevel(0) - the top - is "people who manage somebody visible and have no manager the reader may see", which is exactly where those branches surface.
  • Out of reach reads as nobody. A person the reader cannot see gives null / an empty level, and the endpoint answers "Person not found" - never a different error that would confirm the person exists.
  • Leavers are hidden unless asked for, except a leaver who still has current people reporting to them (peopleOrgShownSql()): hiding them would hide the gap.

TRAP: every count runs the same rule. reports on a node is a subquery with its own peopleScope() and peopleOrgShownSql(), so a manager's number counts only the people this reader would see under them. A plain COUNT(*) of manager_id would leak how many people sit in a company the reader cannot access.

TRAP: placeholder order. In peopleOrgNodes() the reports subquery sits in the SELECT list, before the WHERE, so its arguments go first: array_merge($argsR, $argsSR, $whereArgs, $argsU, $argsS). Adding a condition to either part means adding its arguments in the matching place.

3. πŸ” Walking bad data safely

Reporting lines come from directories and from hand edits, so they can be wrong: a person their own manager, a loop (A β†’ B β†’ A), a chain deeper than any real organisation.

  • Upwards (peopleOrgPath()): at most PEOPLE_ORG_MAX_DEPTH (32) steps, with a visited set, stopping at the first manager the reader may not see - or, with leavers hidden, a leaver the tree would not show - which is the same place the tree starts their branch.
  • Downwards (peopleOrgBranch()): bounded by the levels asked for and by PEOPLE_ORG_CHART_MAX (150) boxes in total, with truncated saying people were left out.
  • Self-managers (manager_id = id) are treated as having no manager everywhere (u.id <> ?, u.manager_id = u.id at the top).

4. πŸ”’ The limits

Constant Value Why
PEOPLE_ORG_MAX_DEPTH 32 How far a line is ever walked; real organisations never get near it
PEOPLE_ORG_CHART_LEVELS 3 Levels below the chosen person when none are asked for
PEOPLE_ORG_CHART_LEVELS_MAX 32 "All" and 0 mean this
PEOPLE_ORG_CHART_MAX 150 Boxes in one chart, past which it is unreadable
PEOPLE_ORG_LEVEL_LIMIT 500 People in one tree level; more says there are others
PEOPLE_ORG_GAPS_LIMIT 50 People named in each list of gaps (the total is exact)
PEOPLE_ORG_CONTEXT_PEERS 12 Colleagues drawn beside each manager on the way up, in In the organisation

peopleOrgChartLevels() turns whatever a URL sends into one of these: all and 0 (however written, 00 too) are the maximum; anything not a whole number - a list (levels[]=5), hex, padding - is the default; more than the maximum is held to it. The endpoint does the same with id (is_scalar() first, because (int) of an array is 1 - somebody nobody asked for).

5. 🌐 The endpoint

api/people/org.php reads and never writes, so it is GET only and needs no CSRF token. Errors are fixed strings; an exception is logged, never echoed. gaps also returns the departments, because the page hands out its colours by them (peopleOrgDepartments(): the current people's departments, sorted, so each keeps its colour wherever it appears).

6. πŸ–₯️ The page

  • Tree: one level request per branch opened. The open branches survive F5 in sessionStorage (people.org.open - a new tab starts closed); the layouts (Cards / Compact / Columns, chart direction, levels...) are remembered in localStorage (people.org.*). Both are wrapped in try/catch, so a browser that blocks storage simply forgets.
  • Chart: branch (their part) or context (in the organisation), drawn as HTML boxes and lines.
  • Export: the images are drawn from the data, as an SVG built for the purpose, not captured from the screen. The first version used the bundled html2canvas 1.4.1, which cannot parse modern CSS colour functions. PNG comes from that SVG through a canvas; PDF puts it in the bundled jsPDF (assets/js/vendor/jspdf.umd.min.js) as a JPEG, because jsPDF stores a PNG almost uncompressed. CSV, JSON and XML are the same people as data.
  • Escaping: every value goes through esc() (which escapes quotes too) before it reaches innerHTML; ids are numbers from the server.

7. βž• Adding to it

  • A new field on a node: select it in peopleOrgNodes() and map it in the array_map at its end. Ask first whether the person page shows the same reader that field - peopleOrgPerson() deliberately returns only what personDetail() already shows, so the chart never tells anybody anything new.
  • A new action: add the function in includes/people.php taking $analystId and applying peopleScope() / analystCanAccessUser() like the others, a case in the endpoint that returns "Person not found" for null, and checks in both tests - including a reader who cannot see the person.
  • Writing reporting lines from the chart is deliberately not here. Lines are owned by the directory, or by Tickets β†’ Users; a chart that edited them would be overwritten by the next import.

8. βœ… Tests

FREEITSM_TEST_DATABASE=<throwaway db> php tests/people-org.php
FREEITSM_TEST_URL=http://localhost/<install> FREEITSM_TEST_DATABASE=<its db> php tests/people-org-api.php

people-org.php builds two companies and a small organisation - a manager in the other company, leavers with and without reports, a reporting line that loops, a long chain and a manager of 160 people - inside a transaction that is always rolled back. people-org-api.php cannot use a transaction - the web server must see its rows - so it tags every row it makes and deletes them in finally, and stops first if the server cannot see them. Both were run on the test install during the review: 89 and 43 checks, all passing.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally