Organisation Chart Developer Guide - edmozley/freeitsm GitHub Wiki

๐Ÿ› ๏ธ 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 reviews that brought it in: Organisation chart - PR review (#242, and #265 for the script file, the wheel and the totals).


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, the Mouse and keyboard dialog. Hands the script its server values as window.PEOPLE_ORG
assets/js/people-org.js All the page's JavaScript - tree, chart, side panel, levels, zoom and drag, export (moved out of the page in #265 so the browser caches it; bump its ?v= in people/org.php on every change)
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 94 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).

3a. Everybody under somebody (total, #265)

Each node carries reports (their own team) and total (everybody under them, at every level). peopleOrgNodes() asks peopleOrgTotals() once for the whole list, and only for the people who have a team:

$totals = peopleOrgTotals($conn, $analystId, array_map('intval', array_column(array_filter($rows, fn($r) => (int)$r['reports'] > 0), 'id')), $leavers);
...
'total' => max((int)$r['reports'], $totals[(int)$r['id']] ?? 0),

peopleOrgTotals() is one recursive query (MySQL 8, as Knowledge's folder walk already uses):

WITH RECURSIVE under_them (root, id, depth) AS (
    SELECT r.manager_id, r.id, 1 FROM users r
     WHERE r.manager_id IN (?, ?, ...) AND r.id <> r.manager_id  {scope r} {shown r}
    UNION ALL
    SELECT s.root, c.id, s.depth + 1 FROM under_them s JOIN users c ON c.manager_id = s.id
     WHERE c.id <> c.manager_id AND s.depth < 32  {scope c} {shown c}
)
SELECT root, COUNT(DISTINCT id) AS total FROM under_them WHERE id <> root GROUP BY root
  • root carries the person being counted for down every level, so one query answers for the whole list.
  • Both halves apply peopleScope() and peopleOrgShownSql(). A person out of reach is neither counted nor walked through, so the count stops exactly where the tree does - the same rule as the reports TRAP above.
  • depth < PEOPLE_ORG_MAX_DEPTH ends a loop in bad data, and COUNT(DISTINCT id) with id <> root makes sure a loop (A โ†’ B โ†’ A) never counts anybody twice or counts the person under themselves.
  • The walk runs off idx_users_manager (users.manager_id).

TRAP: placeholder order again. The arguments go $ids, then the anchor's scope and shown arguments, then the recursive part's: array_merge($ids, $argsR, $argsSR, $argsC, $argsSC) - the order the ? appear in the SQL.

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 places it, as a picture, through FreeITSM's shared PDF writer (assets/js/pdf-writer.js, FitsmPdf.create(), on PDFKit since #254) as a JPEG, because a high-quality JPEG of a chart on a white ground looks the same and is far smaller. The browser drew the picture, so every language in it is already right. 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.
  • The script is a file (assets/js/people-org.js). It needs only four values from the server, which the page sets first through json_encode() with the HTML-safe flags: window.PEOPLE_ORG = { api, list, levelsDefault, levelsMax }. Nothing else in the script is PHP - keep it that way, so it can be cached and read on its own. (While it was inline, the XML declaration in the exports had to be written '<' + '?xml', because <? opens PHP with short_open_tag on; in a .js file it is written whole.)
  • The wheel scrolls, and only Ctrl/Cmd with the wheel zooms - a trackpad pinch arrives as exactly that:
chartEl.addEventListener('wheel', function (e) {
    if (!e.target.closest('.ppl-org-canvas')) return;
    if (!e.ctrlKey && !e.metaKey) return;   // a plain scroll: the browser's
    e.preventDefault();
    var dy = e.deltaMode === 1 ? e.deltaY * 33 : e.deltaY;
    zoomTo(state.zoom * Math.exp(-Math.max(-300, Math.min(300, dy)) * 0.0015), e.clientX, e.clientY);
}, { passive: false });

passive: false is what lets preventDefault() stop the browser zooming the whole page when Ctrl is held over the drawing.

  • drawChart(keepView, keepZoom): a refresh keeps zoom and scroll; a layout switch (Cards / Compact, the direction) passes keepZoom so the same boxes stay the same size; only a new drawing is fitted to the width. Fitting again on a layout switch could land a slightly narrower Compact drawing on the next 10% step, making it look bigger.

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; after #265, 94 and 43. The five new checks count with and without leavers, across companies, through a loop, and for somebody with nobody under them.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ