Skip to content

People Developer Guide

Ed Mozley edited this page Oct 4, 2026 · 3 revisions

People β€” Developer Guide

User guide: People Β· Issue: #153 step 2 Β· Plan: roadmap

A read-only module that assembles one person (a users row) or one company (a tenants row) from every module that already holds data about them. No tables of its own β€” nothing is stored, copied or synchronised.


Files

File Role
includes/people.php The data layer: lists, personDetail(), companyDetail(), and one function per section
people/index.php People list (search + filters, fetches api/people/list.php)
people/companies.php Companies list (server-rendered)
people/person.php, people/company.php The record pages (server-rendered from one personDetail() / companyDetail() call)
people/suppliers.php, people/supplier.php, people/contact.php Suppliers list (server-rendered, filtered in the browser) and the supplier / supplier-contact pages, from peopleSupplierRows(), supplierDetail(), supplierContactDetail() (3.1.0)
people/includes/header.php, render.php Module header; shared section cards, <head>, helpers (ppl*)
api/people/list.php GET ?q=&company=&status=active|leavers|all β€” up to 300 rows
assets/css/people.css All styling, including the header-nav rule and the accent (--ppl-accent, local to the file so theme.css did not need re-versioning)
lang/en/people.php The people namespace

Registered in: getModuleRegistry() (module access), includes/waffle-menu.php (waffle + ⌘K module list), includes/module-colors.php, the landing page, common.modules.people, entityLink('person'|'company'), RECENT_TRAIL_MODULES, api/system/global_search.php and assets/js/command-palette.js (types person, company, and since 3.1.0 supplier, supplier_contact).

The two gates

Every section passes both, and both belong to the module that owns the data:

  1. Module β€” analystCanAccessModule($conn, $aid, '<module>'). A section the reader may not see is absent from $sections, not empty: "no tickets" and "you may not see tickets" are different answers, and the renderer never has to know about permissions.
  2. Company β€” the page needs analystCanAccessUser() / analystCanAccessTenant(); each section then applies its module's own rule:
Section Module Query Company rule
tickets tickets tickets.user_id / company match, deleted_datetime IS NULL allAccessibleTenantsFilter(t.tenant_id)
assets assets users_assets.user_id / assets.tenant_id allAccessibleTenantsFilter(a.tenant_id)
contracts contracts party_type='customer' and customer_user_id / contractCustomerTenantExpr() contractVisibilitySql()
domains domains domains.customer_user_id / domains.tenant_id allAccessibleTenantsFilter(d.tenant_id)
courses lms person: lmsMyCourses(LmsLearner::user()); company: lms_progress grouped by course LMS has no company scope
forms forms form_submissions.submitted_by_user_id forms have no company; the ticket link is shown only if analystCanAccessTicket()

The manager and each report are people too: shown only if the reader could open their page.

πŸ”‘ Accessible companies, not the active one. A person page is reached by id from anywhere β€” a contract, a domain, ⌘K. Filtering to the header's active company would show a real person with "no tickets" whenever the picker pointed elsewhere. Every row shown is one the analyst may already open in its own module.

A person with no company belongs to the Default company (peopleCompanyMatch() adds OR col IS NULL for Default), as everywhere else.

Suppliers and contacts (#153 step 3, #162)

Shown, not moved. A supplier is a row in suppliers, a supplier contact a row in contacts β€” both owned and edited by Contracts. The roadmap's step 3 first imagined moving contacts into users; that would have made every supplier contact a potential portal sign-in, requester and directory-sync target, and needed a migration on every install. Giving them pages over the tables they already live in got the "one place to look" without any of that.

Rule
Module gate peopleCanSeeSuppliers() = People and Contracts. supplierDetail() / supplierContactDetail() return null without it (the page then reads as not found); suppliers.php calls requireModuleAccess('contracts'); the header hides the tab; ⌘K and the recent trail apply the same pair.
Company Suppliers and contacts have no company β€” install-wide, as in Contracts. Each section still applies its own module's rule: contracts via contractVisibilitySql(), assets and domains via peopleScope() (accessible companies).
Sections supplier: contacts (always), contracts (supplier contracts β€” the page already needs Contracts), assets (assets.supplier_id, gated by Assets), domains (gated by Domains). contact: domains.
Domain roles peopleSupplierDomains($who) with ['supplier' => id] or ['contact' => id] selects one is_<role> flag per condition β€” supplier: registrar_supplier_id, customer_supplier_id, and tech_contact_id IN (its contacts); contact: tech_contact_id, customer_contact_id β€” and returns roles per row. pplSectionDomains() adds an As column only when rows carry roles, so person and company pages are unchanged.
Labels pplStats() takes a $labels map and pplSectionContracts() / pplSectionAssets() / pplSectionDomains() a title, because on a supplier "Contracts as customer" would be wrong. pplSectionContracts(…, false) drops the With column.
Entity types entityLink('supplier' | 'supplier_contact'), RECENT_TRAIL_MODULES (both people, gated on Contracts in recentTrailLabels()).

⚠️ Page variables again: the first contact page used $c and was overwritten by the waffle (below) β€” a fatal on the first load. The pages use $sup and $ct.

Adding a section

  1. A function in includes/people.php taking $who = ['user' => id] or ['company' => id], returning ['total' => …, 'rows' => […]], with that module's company rule.
  2. Call it from personDetail() / companyDetail() behind $can('<module>').
  3. A renderer in people/includes/render.php, added to pplSections() and pplStats().
  4. A hidden + positive-control pair in tests/people-scope.php.
  5. If the companies page should show its count, add a grouped query to peopleCompanyCards() (below).

The companies cards

peopleCompanyCards() is peopleCompanies() plus figures for each card. It runs one grouped query per figure rather than companyDetail() per company, and each query follows the company page's own rules so a card and the page it opens cannot disagree:

  • a module's figure is null (not shown) unless the analyst can open that module - 0 would be a claim;
  • peopleScope() limits it to accessible companies;
  • COALESCE(tenant_id, <default>) counts a record with no company under Default, as peopleCompanyMatch() does;
  • People counts everyone, leavers included, because the company page's People figure counts its list, which shows leavers. peopleCompanies() itself (used by the list page's Company drop-down) still counts current people only.

Proved by fetching every company's page and comparing its figures with its card.

The guide

people/help.php is the house guide layout (help.css), the Domains guide's shape: a $sections array of id => paragraph count, words in lang/en/people.php under help.<id>.*, with **bold** allowed in paragraphs. help.css is passed to pplHead()'s third argument, which links page stylesheets before mobile.css so the mobile layer keeps winning its ties.

Variable names on the pages

⚠️ includes/waffle-menu.php runs in the page's global scope and uses $c, $key, $row, $stmt and $conn. A page variable called $c was silently overwritten by the waffle's colour loop β€” the company page lost its name and showed PHP warnings. Use descriptive names ($company) on module pages.

⚠️ And people/includes/header.php loops its nav as [$href, $label, $icon]. The guide's first draft had a closure called $icon; after the header it was a string of SVG paths, and calling it was a fatal error half-way down the page. It is $pplHelpIcon now. Any variable a page defines before include 'includes/header.php' and uses after it must not share a name with either include's locals.

Scrolling

inbox.css makes <body> overflow: hidden, so a page that relies on the document scrolling cannot be scrolled at all. .ppl-page therefore scrolls itself (height: calc(100vh - 62px); overflow-y: auto), like .ppl-main-list on the list page; on a phone LAYER 42a hands it the remainder of the flex body instead.

Tests

php tests/people-scope.php β€” 26 checks inside an always-rolled-back transaction: a restricted analyst cannot list, open or follow a person or company outside their companies (with positive controls), sees only the reports and manager they may see, and an analyst with only People + Tickets gets exactly one section. It also covers the Assets β†’ Users fix (#2095) found while building this, and (3.1.0) that a supplier's and a contact's domains keep to the analyst's companies β€” with positive controls and the roles reported β€” and that People without Contracts opens neither page. The restricted analyst is now picked by SQL alone and given every module inside the transaction, because the module check caches per analyst for the life of the process.


See also: People Β· Customer contracts β€” Developer Guide Β· Multi-tenancy β€” Developer Guide

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally