People Developer Guide - edmozley/freeitsm GitHub Wiki
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.
| 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).
Every section passes both, and both belong to the module that owns the data:
-
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. -
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.
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()). |
$c and was overwritten by the waffle (below) โ a fatal on the first load. The pages use $sup and $ct.
- A function in
includes/people.phptaking$who = ['user' => id]or['company' => id], returning['total' => โฆ, 'rows' => [โฆ]], with that module's company rule. - Call it from
personDetail()/companyDetail()behind$can('<module>'). - A renderer in
people/includes/render.php, added topplSections()andpplStats(). - A hidden + positive-control pair in
tests/people-scope.php. - If the companies page should show its count, add a grouped query to
peopleCompanyCards()(below).
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 -0would be a claim; -
peopleScope()limits it to accessible companies; -
COALESCE(tenant_id, <default>)counts a record with no company under Default, aspeopleCompanyMatch()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.
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.
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.
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.
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.
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