Skip to content

People Groups Developer Guide

Ed Mozley edited this page Sep 9, 2026 · 2 revisions

People Groups β€” Developer Guide

Companion to People Groups.


Tables

knowledge_user_groups          id, name (UNIQUE), description, is_active, created_by_id, …
knowledge_user_group_members   id, group_id, member_type, member_id, expires_at, created_datetime
                               UNIQUE (group_id, member_type, member_id)
                               KEY    (member_type, member_id)

member_type is 'analyst' or 'user'. Membership is polymorphic and deliberately not foreign-keyed β€” there is no single table to point at.

Both tables, and their indexes, were already declared in includes/db_verify_schema.php and the generated index list. Adding the screen required no schema change at all.

The knowledge_ prefix is historical β€” do not rename it

The table is the product's general grouping of people now, managed on tickets/users.php. Knowledge is simply the first thing that grants access to one.

πŸ”΄ Renaming it would be a silent data loss. db_verify only ever creates tables. Under a new name it makes an empty one and leaves the populated one orphaned beside it β€” every membership stops granting anything, with a green tick on the verification screen. knowledgeViewerPrincipals() catches its own PDOException and reads a missing table as "no groups yet", so nothing reports the loss. It fails closed, but silently.

The rationale is repeated in a comment above the table in database/freeitsm.sql, because that is where somebody tidying up will look.


The endpoint

api/tickets/user_groups.php, action-dispatched (list, get, search, create, update, delete, add_member, remove_member) in the same shape as api/knowledge/permissions.php.

Reads are module-gated; writes are administrator-only

case 'list': case 'get': case 'search':   // requireModuleAccessJson('tickets')
default:                                  // + requireAdminJson($conn)

A group grants nothing by itself. But once one is on a folder's access list, adding somebody to it is a grant β€” made from a Tickets-gated screen, to a folder governed by Knowledge. Without the floor, anyone holding Tickets could put themselves in "Payroll".

Same rule api/knowledge/permissions.php states for editing an access list, and the same reasoning that put analyst and team management in the System module.

Members are added one at a time, never by posting a replacement list

πŸ”΄ A replace-all has to re-insert every row, which silently resets each member's expires_at and the date they joined. Editing a group to add one person would quietly give six other people permanent access.

api/lms/group.php does exactly that for LMS learning groups; it gets away with it only because that membership carries no expiry to lose.

add_member uses ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at) β€” so re-adding somebody already present is how you extend or clear their end date, and the date they originally joined survives.


The expiry is applied at READ time

Nothing sweeps expired rows. knowledgeViewerPrincipals() and lmsAssignmentReachSql() both carry:

AND (um.expires_at IS NULL OR um.expires_at > UTC_TIMESTAMP())

So the row stays and stops counting. That is what makes "who had access, and until when" answerable, and it means there is no job to fail.

It is an instant, and the UI must not re-convert it

expires_at is a UTC instant, because that is what the access check compares against. It is written as the end of the picked day in the installation's zone: end of the chosen day, not the start, because somebody who types the 14th means the 14th is their last day.

⚠️ The display must not run that instant through another timezone conversion. It did, and "until the 30th" rendered as "until the 1st" for anyone whose display zone sat ahead of the installation's. The endpoint now returns a separate expires_on β€” the calendar day, converted back in the zone it was picked in β€” and the page renders it with fmtNaiveDate(). The instant still decides access.

This is the fourth kind of stored date. See Timezones and Time Handling.


Company scoping

The member list and the search scope portal users with activeTenantFilter($conn, $analystId, 'u'), exactly as api/tickets/get_users.php does. Analysts are staff and are not scoped.

Validation on add_member re-applies the same filter: without it the picker's scope is decoration and you could add anybody by guessing an id.

Anything the scope removes is counted and returned as hidden_count, never silently dropped.


Gotchas

  • --danger is not a token. theme.css has --danger-text, -bg, -border, -accent and no bare --danger. Grep every var(--x) against theme.css before using it.
  • Page state declared as a top-level let is not on window. Inline onclick handlers resolve it through the global scope chain, but a test harness reading w.someState gets undefined.
  • The dialogue uses the canonical three-pane modal (modal-header / modal-body / modal-footer with an inline max-width), which inbox.css already styles including neutralising page-level padding. tickets/users.php previously held the only bespoke #xModal .modal-content override in the codebase; it is gone.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally