People Backend Documentation
Backend architecture, data model, services, cache, and runtime rules for the people domain (students, guardians, staff).
People Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/people/people.module.ts, apps/api/src/modules/people/guardians/guardians.module.ts | Imports, providers, exports, why GuardiansModule is nested rather than flattened. |
| Controllers | students/students.controller.ts, guardians/guardians.controller.ts, staff/staff.controller.ts | Route ownership, guard chains, thin-controller boundary. |
| Services | shared/*.service.ts, students/*.service.ts, guardians/guardians.service.ts, staff/*.service.ts | Business logic, transactions, cache keys, error mapping. |
| DTOs | dto/person.dto.ts, dto/account-action.dto.ts, students/dto/student.dto.ts, guardians/dto/guardian.dto.ts, staff/dto/staff.dto.ts | Field shapes, validators, allow-lists. |
| Schema | packages/db/src/schema/school/people.ts, packages/db/src/schema/identity.ts, packages/db/src/schema/school/lookups.ts | Tables, enums, indexes, generated columns, constraints. |
| Authorization | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | Module list, default role grants. |
| Cache | apps/api/src/common/utils/cache-key.util.ts, apps/api/src/services/redis/redis.service.ts | Key format, fail-soft semantics. |
| Pagination | apps/api/src/common/utils/pagination.util.ts, apps/api/src/common/dto/query.dto.ts | Defaults, caps, the shared QueryDto. |
| Search | packages/db/src/search/search.constants.ts, packages/db/src/search/escape-like-pattern.ts | Trigram threshold, LIKE escaping. |
| Tests | students/__tests__/students.service.integration.spec.ts, guardians/guardians.service.spec.ts, staff/staff.service.spec.ts | Confirmed behavior, cited per section below. |
2. Backend Scope and Boundaries
Owns
- The unified person write path shared by students, guardians and staff:
usersidentity fields, normalization, and the email-conflict rule (PersonWriterService). - Object-level access control over
students,guardians,staff, andusersrows — deciding which rows an actor's active role may see or touch (PeopleAccessService). - Admission-number and employee-code allocation, atomic and timezone-correct (
PeopleCodeService). - Soft delete and restore, profile-scoped, with the rule for when the underlying
usersrow goes too (PeopleDeletionService). - Account actions common to all three profile kinds: ban, unban, password-reset link, and granting or revoking sign-in access with its account invitation (
PeopleAccountService). - Field-level permission resolution for the two gated field groups,
StaffSalaryandStudentMedical, and the sign-in grant decision shared by all three create paths (PeoplePermissionsService). - The
students,guardians, andstaffCRUD surfaces themselves, including the student-guardian relationship (many-to-many, with a primary/legal/emergency/pickup/lives-with flag set per link) and the staff department/designation assignment. - Trigram-backed name/code/organisation search, with the transaction-scoped similarity threshold (
withSearchThreshold).
Does Not Own
- Authentication, session issuance, and JWT validation —
AuthModule/JwtAuthGuard/AuthSessionService(imported, not owned). - Role and permission definitions and the
RoleGuardthat enforces them —RoleModule. - The school's timezone value itself —
SchoolProfileModule/SchoolProfileService; this module only consumesgetTimezone(). - Department and designation lookup rows —
LookupsModuleowns their CRUD;packages/db/src/schema/school/lookups.tsowns their schema. This module only references them as foreign keys onstaff. - Ethnicity and mother-tongue lookup rows — also
LookupsModule(PersonClassificationsController), backed byethnicities/mother_tonguesinpackages/db/src/schema/identity.ts. This module only stores the foreign key onusersand resolves the display name at read time; renaming or deleting an entry there is aLookupsModulewrite, never one this module performs. - Redis connection management and the generic cache primitives —
RedisCacheService; this module only callsgetSoft/setSoft/delPatternSoft. - Email delivery and verification-token issuance for the password-reset flow —
AuthEmailService/VerificationTokenService, both fromAuthModule. - Superadmin protection logic itself —
ActorAuthorityService.assertNotLastSuperadminandassertMayActOnAccount; this module only calls the former before a ban or a deletion that could remove the last superadmin, and the latter before every ban, unban, and password-reset-link call. - Classes, sections, enrollment lifecycle (enrolled/promoted/graduated/transferred/withdrawn/struck-off) — deliberately absent from
students;record_statushere is a two-value record-level toggle only, not that vocabulary. - Bulk roll import/export — a separate
DataImport/DataExportpermission module.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| A person's identity fields (name, contact, demographics, address) | users table | Shared by every profile kind through PersonWriterService/PERSON_SELECTION. |
| Whether a profile is live | students.deleted_at / guardians.deleted_at / staff.deleted_at | Independent per profile; see §5.4 for why. |
| Whether the person exists at all | users.deleted_at | Set only when no profile remains live — computed by PeopleDeletionService.hasNoLiveProfile, never written directly by a profile-level delete. |
| Whether a person may sign in | users.can_login, users.banned | Two independent flags: a person can exist with can_login=false and never have a login to ban; a live account can be banned=true without touching the record. |
| A student's completeness | Computed at read time from student_guardian/guardians/users liveness | Never a stored column — see the schema comment on students. |
| Which rows an actor may see | PeopleAccessService.scopeFor, keyed on the active role | Never inferred from which profile rows a person happens to hold. |
| The academic year for code allocation | SchoolProfileService.getTimezone(), defaulting the format to Asia/Kathmandu-style offsets | Never Date.getFullYear() (UTC). |
| Role grants for a given user | packages/db/src/schema/identity.ts — user_role | Written by this module's create paths (guardian, staff, teacher) and read by RoleService. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
PeopleModule | Aggregate | apps/api/src/modules/people/people.module.ts | StudentsController, StaffController | All shared services plus StudentsService, StudentGuardiansService, StudentMedicalService, StaffService, StaffSalaryService | Every service above, plus GuardiansModule | Composes the whole people domain; imports ActorAuthorityModule, AuthModule, RoleModule, SchoolProfileModule, LookupsModule, GuardiansModule. |
GuardiansModule | Leaf, nested under PeopleModule | apps/api/src/modules/people/guardians/guardians.module.ts | GuardiansController | GuardiansService, and its own copies of PeopleAccessService, PeopleAccountService, PeopleDeletionService, PeoplePermissionsService, PersonWriterService | GuardiansService | Owns guardian CRUD; imports ActorAuthorityModule, AuthModule, RoleModule directly rather than relying on the parent to re-export them. |
GuardiansModule re-declares the shared services as its own providers rather than importing them from PeopleModule. Nest instantiates a second copy of each — PeopleAccessService, PeopleAccountService, PeopleDeletionService, PeoplePermissionsService, and PersonWriterService each exist twice in the running application, once per module. None of them carry per-instance state (each only holds injected singletons — the database handle, RoleService, etc.), so the duplication is a wiring redundancy, not a correctness bug; it does mean a change to one of these services' constructors must satisfy both modules' import lists independently.
4. File and Directory Map
apps/api/src/modules/people/
people.module.ts
dto/
person.dto.ts
account-action.dto.ts
index.ts
shared/
people-access.service.ts
people-account.service.ts
people-code.service.ts
people-deletion.service.ts
people-permissions.service.ts
person-writer.service.ts
trigram-search.ts
students/
students.controller.ts
students.service.ts
student-guardians.service.ts
student-medical.service.ts
dto/
student.dto.ts
index.ts
__tests__/
students.service.integration.spec.ts
index.ts
guardians/
guardians.module.ts
guardians.controller.ts
guardians.service.ts
guardians.service.spec.ts
dto/
guardian.dto.ts
index.ts
staff/
staff.controller.ts
staff.service.ts
staff-salary.service.ts
staff.service.spec.ts
dto/
staff.dto.ts
index.ts
index.ts
summary/
people-summary.controller.ts
people-summary.service.ts
people-summary-month.ts
dto/
people-summary.dto.ts
__tests__/
people-summary.integration.spec.ts
people-summary-month.spec.ts| File | Purpose | Key Exports | Notes |
|---|---|---|---|
people.module.ts | Wires the whole domain. | PeopleModule | Imports GuardiansModule rather than flattening it. |
dto/person.dto.ts | The identity block shared by every create/update form; PersonDto for responses. | PersonDto, PersonInputDto, QueryBoolean, GENDERS, BLOOD_GROUPS, MARITAL_STATUSES, DISABILITY_TYPES | QueryBoolean() is a Transform for query-string booleans; body booleans never need it. |
dto/account-action.dto.ts | Ban reason and reset-link confirmation shapes. | BanAccountDto, PasswordResetSentDto | Shared across students and guardians controllers. |
shared/people-access.service.ts | Object-level access control and cache-tag composition. | PeopleAccessService, PeopleEntity, PeopleScope | The single place scope predicates are built; see §5.2. |
shared/people-account.service.ts | Ban, unban, password-reset link, sign-in grant and revoke. | PeopleAccountService, AccountActionContext | Depends on AuthEmailService, AuthSessionService, VerificationTokenService, ActorAuthorityService, PeopleInvitationService. |
shared/people-invitation.service.ts | Mints account invitations and schedules their emails, always on the caller's transaction. | PeopleInvitationService, InvitationOutcome, InvitationSkipReason, ACCOUNT_INVITE_TTL_MS | Knows nothing about authority — that is PeopleAccountService's question. |
shared/people-code.service.ts | Atomic admission/employee code allocation. | PeopleCodeService, CodeScope | Depends on SchoolProfileService for the timezone. |
shared/people-deletion.service.ts | Soft delete and restore, profile-scoped. | PeopleDeletionService, ProfileKind | Depends on ActorAuthorityService, AuthSessionService. |
shared/people-permissions.service.ts | Resolves the active role's held permission codes for field-level gates, and owns the create-time sign-in grant decision. | PeoplePermissionsService | Wraps RoleService.getPermissionsForRoleId; can() is the authorization decision, heldPermissions() is not. |
shared/person-writer.service.ts | Builds insert/update column sets for users; the constraint-to-error-code translator; the shared PERSON_SELECTION, personTrigramMatch. | PersonWriterService, PersonColumns, PERSON_SELECTION, personTrigramMatch | The single point where "omitted vs. nulled" is decided. can_login is an argument to buildInsert, never read from the DTO, and never written by buildUpdate. |
shared/row-version.ts | The optimistic-concurrency token shared by all three profiles. versionOf VALIDATES rather than coercing — String(undefined) is "undefined", a token no row matches, which would refuse every save forever instead of failing loudly. | versionOf, matchesVersion | matchesVersion's row side is typed number only, so it cannot be called with two strings. |
shared/trigram-search.ts | Pins pg_trgm.similarity_threshold for one transaction. | withSearchThreshold | Wraps any query that uses the % operator. |
students/students.controller.ts | Student CRUD, account actions, guardian sub-resource, medical sub-resource routes. | StudentsController | Every :id handler passes through PeopleAccessService. |
students/students.service.ts | Student CRUD business logic, list caching, version check, and the pupil's student role grant. | StudentsService | All three profiles carry an optimistic-concurrency version. |
students/student-guardians.service.ts | The guardian relationship: list, phone lookup, full-set replace, link-writing, primary-guardian invariant. | StudentGuardiansService | assertGuardianSetValid is also called directly by StudentsService.create. |
students/student-medical.service.ts | Health-record read/update, gated by StudentMedical_READ/_UPDATE. | StudentMedicalService | Never touches StudentDto. |
guardians/guardians.controller.ts | Guardian CRUD, children sub-resource, account actions. | GuardiansController | Only controller in this module without ParseUUIDPipe on :id. |
guardians/guardians.service.ts | Guardian CRUD, mandatory pagination, organisation-name derivation, role grant on create. | GuardiansService | Unwraps drizzle's wrapped Postgres error before delegating to PersonWriterService.translate. |
staff/staff.controller.ts | Staff CRUD, account actions, and the salary sub-resource routes. | StaffController | Wired to the same shared PeopleAccountService as the student and guardian controllers. |
staff/staff.service.ts | Staff CRUD, department/designation projection, role grants (staff, and teacher for a teaching designation). | StaffService | Optimistic concurrency on staff.version, checked under the row lock. Query construction lives in staff-query.ts. |
staff/staff-query.ts | Filter, sort and pagination assembly for the staff directory. Knows nothing about actors — the caller applies scope to the conditions. | buildStaffConditions, runStaffListQuery, staffOrderBy | Same split as students-query.ts, for the same reason. |
staff/staff-salary.service.ts | Salary/bank read-update, gated by StaffSalary_READ/_UPDATE, pair-coherence validation. | StaffSalaryService | Never touches StaffDto. |
summary/people-summary.controller.ts | GET /api/people/summary. | PeopleSummaryController | Gated on Students_READ. |
summary/people-summary.service.ts | Headline counts per roll, scoped as the lists are, cached per section. | PeopleSummaryService | Cache keys sit under the students:, staff: and guardians: prefixes. |
summary/people-summary-month.ts | The Bikram Sambat month containing today as inclusive Gregorian bounds. | currentMonthBounds | Falls back to the Gregorian month with null BS fields outside the converter's range. |
summary/dto/people-summary.dto.ts | Response shapes. | PeopleSummaryDto, PeopleSummaryMonthDto, PeopleSummaryStudentsDto, PeopleSummaryStaffDto, PeopleSummaryGuardiansDto | — |
5. Data Model
5.1 Schema Source
packages/db/src/schema/
identity.ts # users, user_role, sessions, userDevice, lookups (ethnicities, motherTongues)
school/
people.ts # students, guardians, student_guardian, staff, code_counters
lookups.ts # departments, designations5.2 Tables and Collections
users
The unified person model — one row per human, whatever hats they wear. Owned by the identity schema, not this module, but every field below is what PersonWriterService/PERSON_SELECTION read and write on behalf of students, guardians and staff.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (v7) | No | generated | PK | Referenced by students.user_id, guardians.user_id, staff.user_id (each unique) | v7 UUID — time-ordered, unlike v4. |
first_name | text | No | — | — | — | For an organisation guardian, this holds the organisation's whole name. |
middle_name | text | Yes | NULL | — | — | |
last_name | text | Yes | NULL | — | — | Nullable: an organisation has no last name. |
full_name | text | Yes (generated) | computed | GIN trigram index (users_full_name_trgm_idx), B-tree prefix index on lower(full_name) (users_full_name_prefix_idx) | — | generatedAlwaysAs, built with regexp_replace, not concat_ws (which is STABLE, not IMMUTABLE, and Postgres rejects a generated column built on it). Also collapses an empty-string middle name so a CSV import never yields a double space. |
email | text | Yes | NULL | — | — | |
email_normalized | text | Yes (generated) | lower(btrim(email)) | Partial unique index users_email_unique on (email_normalized) WHERE deleted_at IS NULL AND email_normalized IS NOT NULL | — | The one normalisation rule, shared with account.account_id. |
email_verified | boolean | No | false | — | — | |
phone | text | Yes | NULL | Partial B-tree users_phone_idx (WHERE deleted_at IS NULL), GIN trigram users_phone_trgm_idx | — | Deliberately not unique — a household shares one number. |
phone_verified | boolean | No | false | — | — | |
image | text | Yes | NULL | — | — | |
can_login | boolean | No | true | Index users_can_login_idx | — | Whether this person may authenticate, and nothing else — it does not gate notification delivery. The column default is never exercised by this module: PersonWriterService.buildInsert takes the value as an argument and every caller states it, having first checked that it is allowed to. Revoking it deletes the person's sessions (PeopleAccountService.setSignIn). |
must_change_password | boolean | No | false | — | — | |
date_of_birth | date | Yes | NULL | — | — | |
gender | enum (gender) | Yes | NULL | — | — | male, female, other, prefer_not_to_say. |
blood_group | enum (blood_group) | Yes | NULL | — | — | A+,A-,B+,B-,AB+,AB-,O+,O-. |
ethnicity_id | integer | Yes | NULL | FK → ethnicities.id, ON DELETE SET NULL | — | School-editable lookup, not an enum — Nepal has 120+ languages/ethnic groups. Projected on every student/guardian/staff read alongside its resolved name — see PERSON_SELECTION below. |
mother_tongue_id | integer | Yes | NULL | FK → mother_tongues.id, ON DELETE SET NULL | — | Same reasoning, and the same read-time name resolution. |
disability_type | enum (disability_type) | Yes | NULL | — | — | none, visual, hearing, physical, intellectual, learning, speech, multiple, other. |
marital_status | enum (marital_status) | Yes | NULL | — | — | single, married, divorced, widowed, separated. |
permanent_province_id, permanent_district_id, permanent_municipality_id, permanent_ward_no, permanent_tole, permanent_house_no | integer/smallint/text | Yes | NULL | Composite FKs into geography.ts ((permanent_province_id, permanent_district_id) → districts, (permanent_district_id, permanent_municipality_id) → municipalities); CHECKs enforcing fill order (permanent_district_needs_province, permanent_municipality_needs_district, permanent_ward_needs_municipality, permanent_tole_needs_district, permanent_house_needs_tole, permanent_address_text_not_blank) | — | The permanent address, replacing the old flat address_line/street/city/state/pin_code block. Written and read as one group — see §6.6 for why it is replaced whole and never patched field by field. |
current_province_id, current_district_id, current_municipality_id, current_ward_no, current_tole, current_house_no | integer/smallint/text | Yes | NULL | Same composite-FK and fill-order CHECK shape, named current_* | — | The present address, independent of the permanent one. An all-NULL current address means not recorded, never "same as permanent" — there is deliberately no such-flag column, so a permanent address entered alone never silently implies a current one. |
bio | text | Yes | NULL | — | — | |
banned | boolean | No | false | CHECK users_ban_reason_requires_banned | — | Non-null, unlike the pre-merge customers.banned. |
ban_reason | text | Yes | NULL | Same CHECK | — | Must be NULL unless banned = true. |
banned_at, banned_by | timestamptz, uuid | Yes | NULL | Same CHECK (on banned_at) | — | |
created_at, updated_at | timestamptz | No | now() | — | — | updated_at has $onUpdateFn. |
deleted_at | timestamptz | Yes | NULL | Index users_deleted_at_idx | — | Set only when every profile the person holds is gone. |
Money, timezone, and JSON: none of users' own columns are money or JSON; date-of-birth and banned_at/timestamps follow the same timestamptz convention as every other table in this schema.
students
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (v7) | No | generated | PK | Referenced by student_guardian.student_id (ON DELETE CASCADE) | |
user_id | uuid | No | — | Unique, FK → users.id, ON DELETE CASCADE | One-to-one with users | Deleting the users row (never done directly by this module) cascades to students. |
admission_number | text | No | — | Partial unique students_admission_number_unique (WHERE deleted_at IS NULL), GIN trigram students_admission_number_trgm_idx | — | Released back to the pool when the student is soft-deleted — see §16.5 for the restore-time conflict this creates. |
student_id | text | No | allocated | FULL unique students_student_id_unique (no WHERE deleted_at IS NULL); CHECK students_student_id_format (^SID-[0-9]{4}-[0-9]{4,6}$) | — | The pupil's permanent identifier, distinct from admission_number. Allocated once and never reissued — the index is full precisely so a removed record's id can never be handed to a second pupil. Normally system-allocated; an operator may instead supply one on create (for a school migrating an existing roll) — see §6.3 for the bound that supplied value is put through. |
imeis_id | text | Yes | NULL | — | — | An optional government identifier, named for whatever the school calls it locally. |
admission_date | date | No | — | — | — | |
record_status | enum (student_record_status) | No | 'active' | Index students_record_status_idx | — | Two values only: active, inactive. Deliberately not the enrollment lifecycle (enrolled/promoted/graduated/transferred_out/withdrawn/struck_off) — that vocabulary belongs on a future student_enrollments table; putting it here would create the exact column that has to move once a year rolls over. |
transport_mode | enum (transport_mode) | Yes | NULL | — | — | none, school_bus, private, walking, public_transport. |
medical_conditions, allergies, special_needs | text | Yes | NULL | — | — | Health PII, gated by StudentMedical_READ/_UPDATE, never by Students_READ. |
interests_hobbies | text | Yes | NULL | — | — | |
created_at, updated_at | timestamptz | No | now() | — | — | updated_at is stamped by the bump_row_version trigger on every UPDATE, so it cannot drift from the row. It is not the concurrency token. |
version | bigint | No | 0 | — | — | The optimistic-concurrency token, incremented by the bump_row_version BEFORE UPDATE trigger. Server-owned: a client value is overridden. Never index it — nothing queries it and every update would gain an index write. |
deleted_at | timestamptz | Yes | NULL | Index students_deleted_at_idx | — | Independent of users.deleted_at. |
There is deliberately no is_complete column — see the Business Logic and Invariant Catalog (§16.5) and the Business Rules table in the feature doc.
guardians
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (v7) | No | generated | PK | Referenced by student_guardian.guardian_id (ON DELETE CASCADE) | |
user_id | uuid | No | — | Unique, FK → users.id, ON DELETE CASCADE | One-to-one with users | |
kind | enum (guardian_kind) | No | 'person' | — | — | person or organization. |
organization_name | text | Yes | NULL | CHECK guardian_org_has_name | — | Required and non-blank when kind = 'organization'; derived by the service from person.firstName, never entered as a separate field. |
occupation | text | Yes | NULL | — | — | |
created_at, updated_at | timestamptz | No | now() | — | — | |
deleted_at | timestamptz | Yes | NULL | Index guardians_deleted_at_idx | — |
The guardian_org_has_name CHECK is written as kind <> 'organization' OR (organization_name IS NOT NULL AND btrim(organization_name) <> '') — deliberately not the algebraically equivalent (kind = 'organization') = (organization_name IS NOT NULL) form, because AND with a false first operand evaluates to false (never NULL), while the equality form can evaluate to NULL and a CHECK whose expression is NULL passes.
student_guardian
The join table that makes siblings — and every irregular family situation — ordinary. No father/mother columns; a student has zero or more guardians of any relationship.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
student_id | uuid | No | — | Composite PK with guardian_id; FK → students.id, ON DELETE CASCADE | ||
guardian_id | uuid | No | — | Composite PK; FK → guardians.id, ON DELETE CASCADE (deliberately, not RESTRICT); Index student_guardian_guardian_id_idx | CASCADE because a RESTRICT here once fired two levels down from a users hard delete with no erasure path; the "cannot delete a guardian with live children" rule lives in PeopleDeletionService instead, where the error can name the children. | |
relationship | enum (guardian_relationship) | No | — | Unique per student together with student_id — student_guardian_one_per_relationship | Three SLOTS only: father, mother, local_guardian. Narrowed from an earlier eleven-value list in migration 0008 (the old values archived to student_guardian_relationship_archive); legal_guardian moved onto the is_legal_guardian boolean and organization moved onto guardians.kind, because a guardian being an organisation is a KIND, orthogonal to which slot it fills — any of the three slots may be one. Never preselected in any UI: a prefilled father becomes wrong data every time an operator tabs past it. | |
| — | — | — | — | student_guardian_one_per_relationship — UNIQUE on (student_id, relationship) | One guardian per slot per pupil, which is also what caps a pupil at three guardians — a consequence of the enum's arity, not a separately enforced number. Deliberately not partial on guardian liveness: this table has no deleted_at and a link survives a guardian's soft delete, so a link to a soft-deleted guardian still occupies its slot while reading as empty everywhere. Every slot-occupancy and minimum-guardian check filters guardians.deleted_at explicitly for this reason. | |
is_primary | boolean | No | false | Partial unique student_single_primary_guardian on (student_id) WHERE is_primary, not deferrable | Who is called first and billed. At most one is enforced by the index; at least one guardian is now required whenever the guardian set is non-empty, and the set itself may not be empty — StudentGuardiansService.assertGuardianSetValid refuses { guardians: [] } with 409 STUDENT_REQUIRES_ONE_GUARDIAN. This supersedes an earlier decision, still visible in this schema file's own comment and in CreateStudentDto's docblock, to permit zero guardians as a deliberately incomplete admission; the service now enforces a minimum of one everywhere the guardian set is written. | |
is_legal_guardian | boolean | No | false | — | Who signs consent — usually but not always the primary. | |
is_emergency_contact | boolean | No | false | — | ||
can_pickup | boolean | No | false | — | Separated parents and explicit exclusions are real; not inferred from relationship. | |
lives_with | boolean | No | false | — | ||
created_at, updated_at | timestamptz | No | now() | — |
No deleted_at on this table — a link is deleted outright (by setGuardians' delete-and-reinsert) rather than soft-deleted.
staff
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (v7) | No | generated | PK | ||
user_id | uuid | No | — | Unique, FK → users.id, ON DELETE CASCADE | One-to-one with users | |
employee_code | text | No | — | Partial unique staff_employee_code_unique (WHERE deleted_at IS NULL), GIN trigram staff_employee_code_trgm_idx | ||
joining_date | date | No | — | — | ||
experience_years | integer | Yes | NULL | CHECK staff_experience_years_non_negative | ||
qualification | text | Yes | NULL | — | ||
department_id | integer | Yes | NULL | FK → departments.id, ON DELETE RESTRICT; Index staff_department_id_idx; part of composite FK staff_designation_in_department_fk | ||
designation_id | integer | Yes | NULL | Index staff_designation_id_idx; part of composite FK staff_designation_in_department_fk; CHECK staff_designation_needs_department | ||
employment_status | enum (employment_status) | No | 'active' | Index staff_employment_status_idx | active, on_leave, suspended, resigned, terminated, retired. | |
basic_salary | numeric(12,2) | Yes | NULL | CHECK staff_basic_salary_range (0 to 99999999.99), CHECK staff_salary_pair_coherent | Gated by StaffSalary_READ. | |
allowances | numeric(12,2) | Yes | NULL | CHECK staff_allowances_range, CHECK staff_salary_pair_coherent | Must be NULL iff basic_salary is NULL. | |
total_salary | numeric(14,2), generated | Yes (follows the pair) | generatedAlwaysAs(basic_salary + allowances) | — | numeric(14,2), not (12,2) — two legal 12,2 inputs can overflow their sum (verified: 9999999999.99 + 9999999999.99 raises 22003). No coalesce, so an unset salary stays NULL rather than becoming a 0.00 indistinguishable from a genuine zero. | |
bank_name, account_number, branch, pan_number, citizenship_number | text | Yes | NULL | — | All gated behind StaffSalary_READ/_UPDATE alongside the salary figures. | |
ssf_number, cit_number | text | Yes | NULL | — | Social Security Fund and Citizen Investment Trust membership numbers — the school's monthly statutory contribution return is filed against both, so an employee missing either cannot be included in that month's filing. Free text, not format-validated: SSF numbering has changed shape since the scheme opened and CIT numbers vary by issuing office, so a CHECK/regex here would reject real employees' real numbers and the office would work around it by leaving the field blank, which is strictly worse than storing what they were given. Added by 0005_staff_ssf_cit_numbers.sql; gated behind StaffSalary_READ/_UPDATE alongside the rest of the bank block. | |
created_at, updated_at | timestamptz | No | now() | — | ||
deleted_at | timestamptz | Yes | NULL | Index staff_deleted_at_idx |
The composite foreign key staff_designation_in_department_fk targets (departments.id, designations.id) — it exists because MATCH SIMPLE (Postgres's default) skips a multi-column FK check when any referencing column is NULL, which is wanted for "department set, designation not yet chosen" but would otherwise leave a hole for (NULL, designation-from-a-different-department). The staff_designation_needs_department CHECK closes the case where designation_id is set but department_id is not.
code_counters
The atomic-allocation backing table for admission numbers and employee codes.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
scope | text | No | — | Composite PK with year; CHECK code_counters_scope_known (IN ('student', 'staff', 'student_id')) | student_id is a third scope beyond admission numbers and employee codes — it backs the permanent students.student_id allocator, kept separate from student (the admission number counter) because the two identifiers reset and reissue on different rules. | |
year | integer | No | — | Composite PK | The academic year in the school's own timezone, never UTC. | |
next_value | integer | No | — | CHECK code_counters_next_value_positive | Written by one upsert: INSERT ... ON CONFLICT (scope, year) DO UPDATE SET next_value = next_value + count RETURNING next_value. This table carries no $onUpdateFn column — Drizzle stamps such a column even when omitted from an onConflictDoUpdate set, and this statement must have no side effect beyond the counter. |
student_graduations
Owned by rollover's schema file (packages/db/src/schema/school/rollover.ts) but read and written
by this module's graduate-access surface, because a graduation is what turns a pupil read-only. One
row per graduation; revoked_at marks a row no longer live rather than deleting it.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
student_id | uuid | No | — | FK ON DELETE CASCADE | → students.id | |
graduated_on | date | No | — | CHECK student_graduations_access_after_graduation: portal_access_until >= graduated_on | ||
portal_access_until | date | No | — | Same CHECK | Last day (inclusive, school calendar) the graduate may use the portal read-only. Set from rollover_settings.graduate_access_days at graduation; an administrator may move it. | |
portal_access_ended_at | timestamptz | Yes | — | NULL while access is live. Moving portal_access_until into the future clears it again. | ||
login_disabled_by_expiry | boolean | No | false | CHECK student_graduations_login_disabled_needs_end: only true when portal_access_ended_at IS NOT NULL | True only when ending access is what switched users.can_login off — the graduate held no other live profile. Re-opening access restores sign-in only when this is set. | |
revoked_role_ids | integer[] | No | '{}' | CHECK student_graduations_revoked_roles_need_end: non-empty only when portal_access_ended_at IS NOT NULL | scope_kind = 'student' role grants ending access removed from a graduate who kept another live profile. Re-opening grants exactly these back. | |
portal_access_sweep_failed_at | timestamptz | Yes | — | Partial index student_graduations_access_expiry_idx (nullsFirst, live rows only) | When the expiry sweep last failed to end this row. The sweep orders by this, nulls first, so a row that keeps failing moves to the back instead of blocking every later expiry. Cleared when access ends. | |
portal_access_changed_at / _by_user_id / _change_reason | mixed | Yes | — | → users.id, ON DELETE SET NULL | Who last moved the window and why. | |
revoked_at / revoked_by_user_id / revoke_reason | mixed | Yes | — | → users.id, ON DELETE SET NULL | Set when the underlying graduation itself is undone (a rollover realignment), not when access merely ends. |
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 PeopleAccessService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
scopeFor(actor, entity, heldPermissions) | Every list/read/write method in StudentsService/GuardiansService/StaffService/StudentGuardiansService/StudentMedicalService/StaffSalaryService | Nothing (pure decision from actor/heldPermissions) | None | None | None |
applyScope(scope, conditions) | Same callers, when building a list query | None | Mutates the caller's conditions array | None | None |
assertCanAccess(actor, entity, id, heldPermissions) | Every :id-scoped method | One row from the entity's table, ANDed with the scope predicate | None | None | Throws the entity's own NOT_FOUND code (never a 403) when out of scope |
cacheTags(actor, entity, heldPermissions) | Every list method, before building its cache key | None (derives from scopeFor and the two field-gate permission codes) | None | None | None |
isEntityReachable(scopeKind, entity) | RoleResponseService, to tell the Roles screen which of a role's own grants its row visibility cannot reach | Nothing | None | None | None |
Superadmin (activeRole.isSuperadmin) resolves to { kind: "all" } before anything else is read. Otherwise the active role's scope_kind column decides — not its name. guardian and student get bespoke restricted predicates; all gets { kind: "all" } provided the caller also holds the entity's own Module_READ permission, falling back to selfOnlyScope when it does not; and self, together with anything unrecognised and any caller with no active role, gets selfOnlyScope — which is the sql-tagged literal false for students, guardians and classSubjects, and the actor's own row for users and staff.
The distinction between name and column is load-bearing and this page previously stated the wrong one. Deriving scope from a role's name meant every operator-created role fell through to a branch it was never meant to reach; the column exists so an arbitrary role is safe to assign.
scope_kind is a second, independent grant, not a filter on the first. A permission code answers "may this role read pupils at all"; scope_kind answers "which pupils". Neither implies the other, which is why the two are checked separately and why a role can hold Students_READ and still see nothing.
scope_kind is settable, through exactly one route
PUT /roles/:publicId/row-visibility, body { scopeKind: "self" | "all" }, behind Roles_UPDATE.
It is a dedicated route rather than a field on PATCH /roles/:publicId because that DTO is shared with create through PartialType(OmitType(CreateRoleDto, ...)), so one field on either would reach both write paths. scope_kind appears on neither DTO, and both write paths name their columns instead of spreading.
RoleService.setRowVisibility runs in one transaction that first reads the role row FOR UPDATE. Six constraints, enforced in this order:
| # | Constraint | Error |
|---|---|---|
| 1 | The role exists | 404 ROLE_NOT_FOUND |
| 2 | The role is not a system role | 403 ROLE_SYSTEM_SCOPE_FORBIDDEN |
| 3 | The caller does not hold the target role (read from user_role, not from the active role) | 403 ROLE_SELF_TARGET_FORBIDDEN |
| 4 | Widening to all requires activeRole.isSuperadmin. Narrowing needs only Roles_UPDATE | 403 ROLE_SCOPE_WIDEN_FORBIDDEN |
| 5 | Narrowing to guardian or student is refused while any staff row — live or soft-deleted — belongs to a holder of the role (user_role ⋈ staff on user_id) | 409 ROLE_SCOPE_HELD_BY_STAFF |
| 6 | @IsIn(["self", "all"]) on SetRoleRowVisibilityDto — guardian and student are portal audiences and the route's body cannot carry them | 400 VALIDATION_FAILED |
Constraint 6 is the global ValidationPipe, so over HTTP it answers before the service runs and constraint 5 is never reached through this route. Constraint 5 sits in the service so that no caller of setRowVisibility can turn a role that staff hold into a guardian- or pupil-scope role: staff surfaces never grant such a role (§6.11a), and narrowing one after the fact would hand every holder a parent's or a pupil's view. Soft-deleted staff count because a restore brings the person's grants back with them. The row lock is what makes the check hold against a concurrent staff edit, which reads the role FOR SHARE before granting it — the two serialise on the roles row (and, like any FOR UPDATE on a parent row, the lock also holds back concurrent user_role inserts for that role for its duration).
Constraint 2 is the only thing preventing a school-wide lockout. All five seeded roles are isSystemRole, and the seeded guardian role is scoped guardian precisely so a parent sees their own children — narrowing it to self would refuse every parent in the school, from a screen whose worst apparent outcome is "a dropdown changed".
Constraint 4 rests on a flag, not on a permission code, because Roles_UPDATE is not a superadmin proxy: "Roles" is absent from SUPERADMIN_ONLY_MODULES, so the admin catalogue includes it. Permission arithmetic cannot reach isSuperadmin.
Constraint 3 reads user_role. Reading activeRole.id instead would let a multi-role caller widen a role they hold but are not currently acting as.
After the transaction commits, the write is accompanied by an explicit recordActivity carrying { field: "scopeKind", from, to } and the target role. The audit interceptor derives module/action from @Permissions(), so its own entry for this request is Roles/UPDATE — indistinguishable from a description edit. The explicit call is what carries the detail.
RoleDto gained scopeKind, hasUnreachablePermissions, canSetRowVisibility and canWidenRowVisibility; RoleWithPermissionsDto gained unreachablePermissions. The last three are computed from the same constraints and the same user_role query the route enforces, so the admin panel renders them and derives no authority of its own. hasUnreachablePermissions and unreachablePermissions cover the five *_READ codes this resolver maps to an entity, and are derived from isEntityReachable rather than restated as a second table.
6.2 PeopleAccountService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
ban(actor, kind, profileId, reason) | StudentsService.ban, GuardiansService.ban, StaffService.ban | Resolves userId from the profile table; the target's full role set (via ActorAuthorityService) | users.banned/ban_reason/banned_at/banned_by inside a transaction | Deletes every session for the user via AuthSessionService.deleteSessionsForUser (outside the transaction, after commit) | USER_BAN_REASON_REQUIRED (blank reason), USER_CANNOT_DELETE_SELF (self-target), USER_SUPERADMIN_PROTECTED (a non-superadmin targeting a superadmin), USER_LAST_SUPERADMIN_PROTECTED (via ActorAuthorityService), the profile's NOT_FOUND |
unban(actor, kind, profileId) | StudentsService.unban, GuardiansService.unban, StaffService.unban | Resolves userId; the target's full role set | Clears all four ban fields | None | USER_SUPERADMIN_PROTECTED, the profile's NOT_FOUND |
sendPasswordResetLink(actor, kind, profileId, context) | StudentsService.sendPasswordResetLink, GuardiansService.sendPasswordResetLink, StaffService.sendPasswordResetLink | users.email/fullName/canLogin/banned; the target's full role set | None directly (a verification-token row is written by VerificationTokenService) | Sends an email via AuthEmailService.sendPasswordResetEmailSafe (fail-soft — never throws on delivery failure); logs the issuance | USER_SUPERADMIN_PROTECTED, USER_EMAIL_REQUIRED, USER_LOGIN_DISABLED, AUTH_ACCOUNT_BANNED, the profile's NOT_FOUND |
setSignIn(actor, kind, profileId, options) | StudentsController.grantSignIn/revokeSignIn and the guardian and staff equivalents, all behind Users_UPDATE | Resolves userId; users.email/fullName/canLogin/banned; the target's full role set | users.can_login, guarded by the value just observed; on a grant with invite, a verification row with purpose: "account_invite" | On a grant: an invitation email enqueued through the outbox. On a revoke: every session for that user deleted | USER_SUPERADMIN_PROTECTED, PERSON_SIGN_IN_STATE_CHANGED, the profile's NOT_FOUND |
inviteOnCreate and issue live on PeopleInvitationService, not here — see §6.2a.
All the ban/unban/reset methods are wired to all three controllers — StaffController exposes /staff/:id/ban, /:id/unban, and /:id/password-reset exactly like the student and guardian controllers, each behind Staff_UPDATE. The two :id/sign-in routes are wired the same way on all three, but behind Users_UPDATE.
setSignIn is the only post-create writer of can_login. buildUpdate emits no such key, so the sign-in transition cannot ride along on a person PATCH. That separation is what allows the write to be guarded: setSignIn issues UPDATE users SET can_login = :wanted WHERE id = :id AND can_login = :observed AND deleted_at IS NULL, so a lost race is a zero-row result and a 409 PERSON_SIGN_IN_STATE_CHANGED rather than a silent overwrite. Folding the same guard into buildUpdate is not possible — that returns one object applied as a single UPDATE, so a failed guard would discard the caller's name, phone and address edits too and answer 200 having written nothing.
Granting and inviting are separate questions, and only the revoke may short-circuit. A grant whose can_login already holds true skips the write and still decides the invitation. Returning early instead would make both documented recovery paths unreachable: invite: false promises "prepare an account and invite later", no_email promises "add an address and call again", and both bring the operator back to this route with the state already correct. They would then get a 200 that sent nothing — indistinguishable from success, with no other route that would ever send that invitation. A revoke has no second question left to answer, so it is the one direction that returns early.
The grant and its invitation share one transaction. PeopleInvitationService runs on that transaction and propagates: a failure to schedule the email rolls the grant back rather than answering sent: true for an invitation nobody will receive. Reporting a person as invited when they are not tells the operator something false about somebody who now holds credentials they will never be told about.
Three conditions skip the invitation without failing the grant, each named in the outcome: invite: false gives not_requested, no address on file gives no_email, and a suspended account gives banned — an invitation into an account JwtStrategy will refuse is a support call, not an activated account, which is the same rule people-invite.processor.ts applies to bulk invitations. A revoke always answers revoked.
Revoking deletes the sessions. JwtStrategy re-reads can_login on every request, so a revoke takes effect on the next call either way; deleting the rows removes the possibility of a refresh token staying redeemable, which is the same argument ban makes two methods above.
Input normalization: ban's reason is trimmed before the blank check, so whitespace-only input is refused. Validation order in ban: blank-reason check, then self-target check (actorId === targetUserId, exempt from every other check here), then ActorAuthorityService.assertMayActOnAccount (refuses a non-superadmin acting on a superadmin), then — inside the transaction — the last-superadmin count, then the write. unban and sendPasswordResetLink run assertMayActOnAccount but never the self-check or the last-superadmin count, since neither action can remove access from anyone. assertMayActOnAccount reads the target's full role set, not their currently active role, so a superadmin who happens to be acting as a guardian in the current session is still protected. Transaction boundary: the users update and the last-superadmin assertion share one transaction; the session sweep happens after commit, outside it; assertMayActOnAccount runs before the transaction opens on all three methods. Fail-open/closed: both the last-superadmin check and assertMayActOnAccount are fail-closed (throw rather than allowing an ambiguous state). Logger usage: sendPasswordResetLink logs at log level on success, naming the kind, profile id, and acting administrator — never the email address or the token.
6.2a PeopleInvitationService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
inviteOnCreate(tx, granted, person) | StudentsService.create, GuardiansService.create, StaffService.create, StudentGuardiansService (for a guardian created inside an admission) | The person's id, email and name, as already held by the caller | Delegates to issue | Returns an InvitationOutcome, or null when sign-in was not granted | Propagates whatever issue throws |
issue(executor, userId, email, fullName) | inviteOnCreate, and PeopleAccountService.setSignIn | Nothing | A verification row with purpose: "account_invite" and a 7-day expiry, on the caller's executor | Enqueues the invitation email on the same transaction | Propagates |
Separate from PeopleAccountService because the two answer different questions.
That service decides whether somebody may have an account — it reads
authority, guards the transition and sweeps sessions. This one only knows how to
tell a person they have one.
Every method takes an executor, and every method propagates. The token, the
notification event and its outbox row commit with the caller's business change or
not at all; a fire-and-forget call after the commit would lose the invitation
entirely on a restart between the two, leaving somebody who exists, believes they
were invited, and has nothing scheduled. Propagating rather than swallowing is the
other half of that contract, for the reason given under setSignIn above.
inviteOnCreate returns three distinguishable answers: null when sign-in was
not granted, so there was never anything to invite anybody to; { sent: false, reason: "no_email" } when it was granted but there is nowhere to send; and
{ sent: true, to } otherwise. sent: true means enqueued, not delivered —
the delivery row carries that.
Invitations last 7 days (ACCOUNT_INVITE_TTL_MS), stated explicitly on every
mint. Without it the record inherits OTP_EXPIRY_MINUTES, 15 minutes, and an
invitation that dies before the recipient next opens their inbox is not an
invitation. The one-time code is minted alongside — createPasswordReset always
mints one — but is stripped before the email is built, because consumeByOtp is
scoped to password_reset and an invitation is therefore redeemable by link only.
Rendering a code an invitation cannot be redeemed with would send the recipient
down a path that always refuses them.
6.3 PeopleCodeService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
allocate(scope, count, executor?) | allocateOne, and directly by any future bulk-import caller | SchoolProfileService.getTimezone() | code_counters via one upsert | None | None (the upsert cannot fail on a well-formed input) |
allocateOne(scope, executor?) | StudentsService.create, StaffService.create | Same | Same, count = 1 | None | None |
Input normalization: none needed — the scope is a closed union ("student" \| "staff" \| "student_id" \| "asset"), enforced by the type system and mirrored by the code_counters_scope_known CHECK as a database-level backstop. Idempotency: none — each call reserves a new block; a caller that must retry a failed admission without burning a number has to catch the failure before calling this. Response mapping: format() builds PREFIX-YEAR-0001, zero-padded to 4 digits as a minimum for student/staff/student_id, never a truncating fixed width (lpad('10000', 4, '0') returns '1000', which this code avoids via padStart, which only pads and never truncates). The asset scope (prefix AST, allocated from assets in apps/api/src/modules/assets) is the one exception to that width: it zero-pads to a fixed 6 digits (ASSET_SEQUENCE_WIDTH), matching the assets_code_format CHECK (^AST-[0-9]{4}-[0-9]{6}$) — a school issuing more than 999,999 assets in one year exhausts the range and AssetsService.create raises ASSET_CODE_RANGE_EXHAUSTED rather than silently truncating the sequence.
adoptSuppliedStudentId(supplied, executor) is a third path alongside allocate/allocateOne, used only when an operator types a studentId on POST /students instead of leaving it to be allocated. It canonicalises the value (trim, upper-case, zero-pad the sequence to at least 4 digits), rejects a shape that does not match SID-YYYY-NNNN with 400 STUDENT_ID_MALFORMED, and rejects a sequence above MAX_STUDENT_ID_SEQUENCE (999,999) with 400 STUDENT_ID_SEQUENCE_OUT_OF_RANGE — the bound exists because an unbounded supplied value once set code_counters.next_value to INT_MAX (SID-2026-2147483647), after which every auto-allocated admission for the rest of that calendar year raised 22003 integer out of range with no route able to reset the counter, and only Students_CREATE was needed to trigger it. The counter is then advanced with GREATEST(next_value, sequence), in the year the supplied value names, never the current year — advancing only the current year would let a future-dated SID-2027-0001 sail through today and then collide with the real allocation on 1 January 2027. GREATEST also means a supplied value can never rewind the counter and hand the next admission a number already in use.
6.4 PeopleDeletionService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
softDeleteProfile(kind, profileId, actorId) | StudentsService.remove, GuardiansService.remove, StaffService.remove | Resolves userId; for a guardian, checks for a live linked student; counts live profiles for the person | The profile's deleted_at; conditionally users.deleted_at, deletes the account row, deletes sessions | Session deletion via AuthSessionService | USER_CANNOT_DELETE_SELF, GUARDIAN_HAS_LINKED_STUDENTS, the profile's NOT_FOUND |
restoreProfile(kind, profileId) | StudentsService.restore, GuardiansService.restore, StaffService.restore | Locks the profile row FOR UPDATE; checks whether its code/email has been reissued to a live row | Clears the profile's deleted_at; conditionally clears users.deleted_at | None | STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICT, STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT, USER_RESTORE_EMAIL_CONFLICT, the profile's NOT_FOUND |
Transaction boundaries: both methods run entirely inside one db.transaction. softDeleteProfile's guardian-with-live-children check runs first, so the whole operation aborts before any write on a blocked guardian. Idempotency: restoreProfile is a no-op (returns without writing) when the row is already live — it does not throw. Fail-closed: assertNotLastSuperadmin inside softDeleteProfile is a hard stop with no fallback.
6.5 PeoplePermissionsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
heldPermissions(actor) | Every service in this module before building a scope or a cache tag | RoleService.getPermissionsForRoleId (Redis-cached, 1-hour TTL, invalidated on role edit) | None | None | None |
can(actor, permission) | StaffSalaryService.find/update, StudentMedicalService.findMedical/updateMedical, resolveSignInGrant | Same | None | None | None |
resolveSignInGrant(actor, input) | StudentsService.create, GuardiansService.create, StaffService.create | can(actor, "Users_UPDATE") | None | None | PERSON_SIGN_IN_FLAGS_CONFLICT, AUTH_FORBIDDEN |
can is the authorization decision; heldPermissions is not. can returns true for a superadmin without consulting the set at all. heldPermissions does not special-case superadmin — measured, a superadmin gets every seeded code back — and that is only harmless while the seed grants them all. grantToRoles uses onConflictDoNothing and never revokes, so a deployment upgraded without permissions:sync leaves superadmin missing whichever codes the release added, and a raw membership test would then deny the highest-privileged user with no way to tell why. Use heldPermissions to pass a resolved set into something that will itself go through can, or for a scope predicate where superadmin is handled separately; use can for anything that decides.
resolveSignInGrant is the single implementation of the create-time sign-in decision, shared by all three create paths. It accepts two spellings of the same intent — grantSignIn on the create body, and the older person.canLogin that the admin panel posts from a checkbox — because ValidationPipe runs forbidNonWhitelisted and dropping the older field would reject every create the panel sends. Both pass through the identical check, so neither is an unguarded route to the same state. A contradiction between them raises 400 PERSON_SIGN_IN_FLAGS_CONFLICT rather than being resolved by precedence: the caller has stated two intentions and the server cannot know which is the mistake. Absent means no. A grant then requires Users_UPDATE through can, and raises 403 AUTH_FORBIDDEN without it — granting sign-in creates a credential-bearing account, and POST /auth/password/forgot is public, so anybody with an email on their row can then obtain a session.
6.6 PersonWriterService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
buildInsert(dto, account) | Every entity's create | None | None (builds an object) | None | None |
buildUpdate(dto) | Every entity's update | None | None | None | None |
assertEmailFree(db, email, exceptUserId?) | Every entity's create/update, and StudentGuardiansService.resolveGuardian | users by email_normalized, live only | None | None | USER_EMAIL_ALREADY_EXISTS |
syncSignInEmail(tx, userId, nextEmail) | Every entity's update, after the users row is written | users.email (before) | account.account_id for the email provider; deletes every unconsumed verification row for the person | None | None |
PersonWriterService.translate(error) (static) | Every entity's transaction .catch() | None | None | None | Maps a constraint name to a typed exception; rethrows unrecognised errors as-is |
buildUpdate uses Object.hasOwn(dto, key) rather than !== undefined for every field — an explicit "lastName": null in the request body is a clearing instruction and must survive; an absent key must produce no key in the patch at all.
canLogin is an argument to buildInsert, not a field of the DTO it reads, and buildUpdate never emits it at all. buildInsert takes a second parameter, account: { canLogin: boolean }, and there is no default: every caller states the value, having already checked what it is allowed to state (PeoplePermissionsService.resolveSignInGrant for the three top-level creates, an explicit Users_UPDATE check in StudentGuardiansService for a guardian created inside an admission). Reading it from dto instead would put the decision here — silently, from a request body, with no permission check anywhere on the path — and simply removing the field from the DTO would not fix that, because dto.canLogin ?? true would still yield true and so would the column default behind it. The decision has to stop being made in this file, which is why the parameter is required rather than optional.
buildUpdate omitting it is the same rule on the other side: can_login taken from a PATCH body would let an actor holding only Students_UPDATE flip an existing person to login-capable. Post-create transitions go through PeopleAccountService.setSignIn, which checks the identity permission and guards the write.
An address group (permanentAddress/currentAddress) is replaced WHOLE, never patched field by field, and the Object.hasOwn discipline above governs only the flat, top-level keys — it does not reach through nesting on its own. When a group key is present on the DTO, all six of its columns are written from it and a missing key inside the group means NULL; when the group key is absent, the whole group is left untouched. A field-wise patch would let { "permanentAddress": { "provinceId": null } } clear the province while leaving the district in place, which raises the unmapped 23514 permanent_district_needs_province from a request the operator considers perfectly reasonable — the fill-order CHECKs on the schema are what make this discipline load-bearing rather than stylistic. An all-NULL current address, by the same rule, is what "not recorded" looks like: there is no separate flag for "same as permanent", so a family that has not given a different present address simply has six NULL columns rather than a copy of the permanent one.
A guardian's kind (person/organization) is orthogonal to the slot (relationship: father/mother/local_guardian) it occupies in student_guardian — either can vary independently, which is what lets a local guardian be an orphanage or a father slot be filled by an organisation acting in loco parentis. resolveGuardian in StudentGuardiansService reads kind directly from the payload rather than inferring it from relationship, because an earlier version inferred it from relationship === "organization", and narrowing the relationship enum to three slots made that comparison permanently false — every guardian created from the pupil form would have silently become a natural person, with guardian_org_has_name never able to fire on that path again. Response mapping: none — this service builds column sets and selections, never a response DTO. PersonWriterService.translate's constraint lookup (constraintOf) walks error.cause recursively up to depth 5, because drizzle-orm's node-postgres driver wraps the raw pg error (which carries .constraint) inside .cause rather than rethrowing it directly — reading error.constraint on the top-level object finds nothing on every single database error from this driver.
An administrator editing a person's email keeps the sign-in credential in step.
syncSignInEmail compares the address on users before the write to the incoming
one, both trimmed and lower-cased, and does nothing when they already agree. When
they differ it writes the same normalised value onto account.account_id for
that person's email-provider row — or user:<userId> when the address is
cleared to null, since account_id cannot itself be null — and deletes every
unconsumed verification row the person holds, of any purpose. That last step is
deliberate: a password-reset or email-change link minted against the address
being replaced must not go on working once an administrator has moved the person
to a different one. This runs on the same transaction as the users update, so
the two rows never observe each other's address as stale.
PERSON_SELECTION (also exported from this file) is the users column set every student, guardian, and staff read projects — six list/detail queries share it so the person shape cannot drift by call site. Alongside the plain columns it carries ethnicityName and motherTongueName as two correlated subqueries (SELECT name FROM ethnicities/mother_tongues WHERE id = users.ethnicity_id/mother_tongue_id) rather than LEFT JOINs: a join added at one of the six call sites would have to be replicated at the other five in the same position or the projected shape silently diverges, while both lookups are small, indexed on their primary key, and read once per row from Postgres's own cache, so the join buys nothing measurable and costs the invariant. Both the id and the name are returned together — the id is what an edit form posts back on the next write and what a filter compares against, the name is what a person reads, and a response carrying only the name would force the client to search the lookup list by string to re-select the value it was just given.
6.7 StudentsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(actor, query) | StudentsController.findAll | students joined to users, correlated guardian-liveness subqueries | None | Reads/writes students:list:* cache; throws if pagination=false | PAGINATION_LIMIT_INVALID |
findOne(actor, id) | StudentsController.findOne | Same shape, single row | None | None | STUDENT_NOT_FOUND |
create(dto) | StudentsController.create | Guardian rows if linking | users, students, student_guardian, code_counters | Invalidates students:* | STUDENT_REQUIRES_ONE_GUARDIAN, STUDENT_GUARDIAN_PRIMARY_REQUIRED/_MULTIPLE_PRIMARY, GUARDIAN_SLOT_TAKEN, USER_EMAIL_ALREADY_EXISTS, STUDENT_ADMISSION_NUMBER_TAKEN, STUDENT_ID_MALFORMED, STUDENT_ID_SEQUENCE_OUT_OF_RANGE |
update(actor, id, dto) | StudentsController.update | Current row locked FOR UPDATE | users, students | Invalidates students:* | PEOPLE_STALE_RECORD, USER_EMAIL_ALREADY_EXISTS, STUDENT_NOT_FOUND |
remove(actor, id) | StudentsController.remove | Delegates to PeopleDeletionService | Delegates | Invalidates students:* | Delegated |
restore(actor, id) | StudentsController.restore | Delegates | Delegates | Invalidates students:* | Delegated |
ban/unban/sendPasswordResetLink | StudentsController | Delegates to PeopleAccountService | Delegates | Invalidates students:* (ban/unban only) | Delegated |
Input normalization: admissionNumber?.trim() || allocateOne(...) — a supplied number that is blank after trimming is treated as absent. Validation order in create: guardian-set shape validated before the transaction opens (fail fast, no partial work to roll back for a purely structural error), then inside the transaction: email freedom, then the insert. Transaction boundaries: the entire admission — users insert, code allocation, students insert, guardian links — is one transaction; a half-admitted student is not a reachable state. Idempotency: none at the API layer; a client retry after a timeout can create a duplicate person unless the caller supplies its own admissionNumber and relies on the unique index to reject the second attempt. Response mapping: toDto computes isRecordComplete from guardianCount > 0 and version from versionOf(row.version). Fail-open/closed: the version check in update is fail-closed — a mismatch always aborts, never a warning. Logger usage: none in this service directly (logging for the account actions it delegates to lives in PeopleAccountService).
6.7a StudentGraduateAccessService and GraduateAccessService
StudentGraduateAccessService (people/students/student-graduate-access.service.ts) is the thin
admin read/write behind GET/PATCH /api/students/:id/graduate-access: it runs the same two-layer
authorization every single-record student route runs (@Permissions for "at all",
PeopleAccessService.assertCanAccess for "this one"), loads the pupil's live (unrevoked) graduation,
and delegates every decision to GraduateAccessService (people/shared/graduate-access.service.ts),
which five modules share — the guardian portal, attendance, leave, assignments and people itself.
The read side is pure computation over the stored row and today's school-calendar date:
graduateAccessState (utils/graduation/graduate-access.util.ts) returns none | pending | grace | ended, and ended also counts a portal_access_until that has simply passed, so a read never trusts
the expiry sweep to have reached the row yet. The write side never reads that computed state —
end, setUntil and endNow all decide on the stored portal_access_ended_at directly, because
deciding on the computed state would make "end now" a no-op for a row the sweep has not stamped.
end(tx, graduationId, cause, today, actor, effects, reason?) is idempotent (a revoked or
already-ended row is left alone) and, once it actually ends access:
- Removes every
scope_kind = 'student'role grant from the user, unconditionally — a grant left behind would hand full pupil-portal reads back to anyone who later gets a login for another reason (hired as staff, or becoming a parent). Their ids are stored onrevoked_role_idsso a re-opening grants back exactly those. - Detaches any live session's
active_role_idbefore deleting the grant (session.(user_id, active_role_id)isON DELETE SET NULLon both columns anduser_idisNOT NULL, so deleting a grant a session is currently acting as would otherwise fail the delete). - Switches
users.can_loginoff — throughwriteCanLogin, the same guarded writePeopleAccountService.setSignInuses — and deletes every session, but only when the account holds no other live profile (holdsOtherAccess). A graduate who is also a guardian or staff member keeps their login. - Records
GRADUATE_ACCESS_ENDEDthroughActivityRecordService, deferred until the transaction commits, with the cause, whether sign-in was disabled, and the revoked role ids.
Re-opening (setUntil moving the date back into the future, or undoForRevoke when a rollover
realignment undoes the graduation itself) gives back exactly what end took, and no more:
- Pupil-scope role grants come back only for roles that still exist, are still
scope_kind = 'student', and are not superadmin — a role edited wider or deleted since revocation is not silently handed back, and the skip is logged. - Sign-in comes back only when
endis what switched it off (login_disabled_by_expiry) and the caller may decide sign-in (Users_UPDATE) and the account has not gained other access since — an account that picked up another profile while dormant is not quietly re-activated by a graduate-access change; turning its sign-in on is an explicitsetSignIndecision instead.
Lock order is always student_graduations → users, matched by
PeopleAccountService.setSignIn, which clears the login_disabled_by_expiry flag before touching
users — see the sign-in toggle interaction below. The expiry sweep chooses its candidates without
a lock, so end(..., "expiry") re-checks the window has really passed once it holds the row, in case
an administrator extended it in between.
The sign-in toggle interaction. PeopleAccountService.setSignIn — the service behind the
ordinary POST/DELETE /:entity/:id/sign-in routes — calls clearGraduateLoginFlag before writing
users.can_login, whichever direction the toggle moves. That clears login_disabled_by_expiry on
any live graduation for that user, so an administrator's explicit sign-in decision permanently
overrides the automatic bookkeeping: a later re-opening of the graduation window never flips sign-in
back on top of a decision a human already made.
Guardian visibility. graduate-access.query.ts exports the read-side helpers the other four
modules use: graduateAccessEnded(studentIdColumn, today) is a SQL predicate — a live graduation with
portal_access_ended_at IS NOT NULL or portal_access_until < today. The guardian portal's own-child
resolution adds NOT graduateAccessEnded(...) to its identity filter, so once access has ended the
child stops resolving as this guardian's child at all: the same 404 a stranger's child would
produce, deliberately at the identity layer rather than the row-scope layer, because ending access is
an audited lifecycle transition and not a record-keeping toggle. A graduate in grace — access ended
in the future, not yet reached — is still visible; only ended removes them.
assertNotGraduatedReadOnly(executor, studentId, today) is the write-side guard the pupil's own
portal calls, and only ever after the caller has already resolved the student through its own
identity check (never on a raw id, which would let a 403 disclose that an arbitrary id belongs to a
graduate). It throws STUDENT_GRADUATED_READ_ONLY when the pupil's state is grace or ended.
6.8 StudentGuardiansService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findGuardians(actor, id) | StudentsController.findGuardians | student_guardian joined to guardians/users, live only | None | None | STUDENT_NOT_FOUND (via assertCanAccess) |
lookupGuardiansByPhone(actor, phone) | StudentsController.guardianLookup | guardians joined to users, scoped, WHERE phone = :phone, live only | None | None | None (empty array on no match) |
setGuardians(actor, id, dto) | StudentsController.setGuardians | Locks the student row FOR UPDATE | student_guardian (delete all, reinsert), students.updated_at | Invalidates students:* | Delegates to assertGuardianSetValid; profile/guardian NOT_FOUND |
writeGuardianLinks(tx, studentId, entries) | setGuardians, StudentsService.create | Resolves each entry via resolveGuardian | student_guardian inserts, then one UPDATE to flip the primary | None (caller invalidates) | STUDENT_GUARDIAN_ALREADY_LINKED (duplicate guardian in the set) |
resolveGuardian(tx, entry) | writeGuardianLinks | guardians joined to users by id, if guardianId given | users, guardians (if creating inline) | None | GUARDIAN_NOT_FOUND (id given but not live), a generic GUARDIAN_NOT_FOUND (neither id nor person given — reuses the not-found code rather than a dedicated one) |
assertGuardianSetValid(entries) (static) | writeGuardianLinks's callers, before any write | None | None | None | STUDENT_REQUIRES_ONE_GUARDIAN (empty set), STUDENT_GUARDIAN_PRIMARY_REQUIRED, STUDENT_GUARDIAN_MULTIPLE_PRIMARY, GUARDIAN_SLOT_TAKEN (two entries for the same relationship) |
Validation order in writeGuardianLinks: every entry is inserted with is_primary = false first (a full pass), and only after every row exists is the primary flipped in a second statement — because student_single_primary_guardian is a non-deferrable partial unique index evaluated per row as the statement runs, and inserting a batch whose primary lands ahead of a row about to be deleted (in setGuardians's delete-then-reinsert) can raise 23505 depending on heap visitation order, which passes in a test and fails in production against a differently laid-out table. Response mapping: loadGuardians orders primary first (desc(isPrimary)), then by guardian id, so the "who does the office call first" position never depends on insertion order.
A student must have at least one guardian. assertGuardianSetValid refuses an empty guardian set outright, before the transaction opens, with 400 STUDENT_REQUIRES_ONE_GUARDIAN — "add a father, a mother or a local guardian." This runs on both POST /students and PUT /students/:id/guardians; an earlier version let an empty set through on PUT specifically, which deleted every existing link and committed, defeating the minimum on the one route whose entire job is the guardian set. It also checks that entries do not repeat a relationship slot, returning 400 GUARDIAN_SLOT_TAKEN naming the slot before the request ever reaches student_guardian_one_per_relationship's 23505.
6.9 StudentMedicalService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findMedical(actor, id) | StudentsController.findMedical | students.medical_conditions/allergies/special_needs, live only | None | None | STUDENT_NOT_FOUND |
updateMedical(actor, id, dto) | StudentsController.updateMedical | Same three columns | Same three columns | Invalidates students:* (the list projects updatedAt, which this write moves) | STUDENT_NOT_FOUND |
Two layers of authorization, both required: @Permissions("StudentMedical_READ") on the route (fails closed on the handler if absent), and PeopleAccessService.assertCanAccess(actor, "students", id, held) inside the service (answers "this specific child", which the route-level permission cannot). Response shape: never merged into StudentDto — a field that appears or vanishes by permission would make the response shape a function of authorization.
6.10 GuardiansService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(actor, query) | GuardiansController.findAll | guardians joined to users, correlated child-liveness subquery | None | Reads/writes guardians:list:*; throws if pagination=false | PAGINATION_LIMIT_INVALID, PEOPLE_INVALID_SORT_FIELD |
findOne(actor, id) | GuardiansController.findOne | Same shape, single row | None | None | GUARDIAN_NOT_FOUND |
findChildren(actor, id) | GuardiansController.findStudents | student_guardian joined to students/users, live only | None | None | GUARDIAN_NOT_FOUND (via assertCanAccess) |
create(actor, dto) | GuardiansController.create | Role lookup for "guardian" | users, guardians, user_role | Invalidates guardians:* | USER_EMAIL_ALREADY_EXISTS, GUARDIAN_ORGANIZATION_NAME_REQUIRED, ROLE_NOT_FOUND (if the seed never ran) |
update(actor, id, dto) | GuardiansController.update | Current row (no lock beyond the implicit SELECT ... FOR UPDATE) | users, guardians | Invalidates guardians:* | USER_EMAIL_ALREADY_EXISTS, GUARDIAN_ORGANIZATION_NAME_REQUIRED, GUARDIAN_NOT_FOUND |
remove(actor, id) | GuardiansController.remove | Delegates | Delegates | Invalidates guardians:* | GUARDIAN_HAS_LINKED_STUDENTS, USER_CANNOT_DELETE_SELF |
restore(actor, id) | GuardiansController.restore | Delegates | Delegates | Invalidates guardians:* | USER_RESTORE_EMAIL_CONFLICT |
ban/unban/sendPasswordResetLink | GuardiansController | Delegates | Delegates | Invalidates guardians:* (ban/unban) | Delegated |
translateGuardianError (module-level function, not a class method) unwraps error.cause before calling PersonWriterService.translate, because in this service's specific call sites the raw pg error's .constraint field is one level deeper than PersonWriterService.translate looks by default. Input normalization: organizationName is always recomputed as (dto.person?.firstName ?? current.firstName).trim() on update, even when neither kind nor person.firstName is present in the request body — because the pair must stay coherent with the CHECK regardless of which half of it a given PATCH touches. Response mapping: toDto emits version from versionOf(row.version), and update compares it under the row's FOR UPDATE before writing — a guardian is routinely shared across siblings, so two operators editing one family from two pupil records is the ordinary case rather than the unlucky one.
6.11 StaffService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(actor, query) | StaffController.findAll | staff joined to users, left-joined departments/designations | None | Reads/writes staff:list:*; throws if pagination=false | PAGINATION_LIMIT_INVALID, PEOPLE_INVALID_SORT_FIELD |
findOne(actor, id) | StaffController.findOne | Same shape, single row; the person's managed roles when actor holds Staff_UPDATE | None | None | STAFF_NOT_FOUND |
create(actor, dto) | StaffController.create | Designation's is_teaching flag, role rows (FOR SHARE for an explicit rolePublicIds) | users, staff, code_counters, user_role | Invalidates staff:* | USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT, PEOPLE_ROLE_NOT_PERMITTED, STAFF_ROLE_TEACHER_JOB_ONLY, ROLE_NOT_FOUND, STAFF_ROLE_SCOPE_NOT_ASSIGNABLE |
update(actor, id, dto) | StaffController.update | Current row, then the same row FOR UPDATE; with roleSource, the designation's is_teaching, role rows and the person's user_role grants | users, staff, and user_role when roleSource is sent | Invalidates staff:*; after a role change, one audit entry and — when a role was removed — a close-user control message (§10) | USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT, STAFF_NOT_FOUND, PEOPLE_STALE_RECORD, plus the role-edit errors in §6.11a |
remove(actor, id) | StaffController.remove | Delegates | Delegates | Invalidates staff:* | USER_CANNOT_DELETE_SELF |
restore(actor, id) | StaffController.restore | Delegates, then the restored row as findOne reads it | Delegates | Invalidates staff:* | STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT |
ban(actor, id, reason)/unban(actor, id)/sendPasswordResetLink(actor, id, context) | StaffController | Delegates to PeopleAccessService.assertCanAccess, then PeopleAccountService | Delegates | Invalidates staff:* (ban/unban only) | Delegated — see §6.2 |
assignableRoles(actor) | StaffController.assignableRoles | ActorAuthorityService.assignableRoles | None | None | None |
assignableRolesFor(actor, id) | StaffController.assignableRolesFor | PeopleAccessService.assertCanAccess, then the same set as assignableRoles | None | None | STAFF_NOT_FOUND |
ban/unban/sendPasswordResetLink first run the same object-level scope check every other :id method here does, then hand off to the shared PeopleAccountService exactly as StudentsService and GuardiansService do — staff is not a special case among the three profile kinds for any of these three actions.
Single-record responses carry roles for viewers who could change them. findOne, update, create and restore return StaffDetailDto: StaffDto plus roles — { publicId, name } for every role the person holds except guardian- and pupil-scope roles, sorted by name — present only when the viewer's active role holds Staff_UPDATE (PeoplePermissionsService.can, so a superadmin passes). For anybody else the key is absent, not empty: a teacher reading a colleague's record learns nothing about who holds what authority. update reads roles inside its transaction, so they are the roles the edit left. The list stays StaffDto and its cache key's shape token is unchanged.
Choosing roles is an identity change. Sending rolePublicIds on create, or roleSource on update, requires Users_UPDATE on top of the staff permission — checked with PeoplePermissionsService.can, the same line sign-in access is held at. The refusal is 403 PEOPLE_ROLE_NOT_PERMITTED. On create it runs before the transaction opens; everything else about the chosen roles runs inside it, so a refusal rolls the whole admission back.
grantStaffRoles runs after the staff insert succeeds, so a given designationId is already known to name a real designation — the foreign key on the row just inserted enforced that. It uses onConflictDoNothing on (user_id, role_id), reasoning that a brand-new user cannot already hold either role but a role-grant failure should not be what fails a whole admission if it somehow occurs. create is the only write method in this module with no row lock (FOR UPDATE) before reading the current state. update takes one, and checks the required version token under it, so two concurrent PATCHes to the same staff row cannot silently overwrite each other — the second is refused with 409 PEOPLE_STALE_RECORD.
A role edit runs inside the update transaction, after the staff patch. update checks roleSource/rolePublicIds pairing (400) and Users_UPDATE (403), then hands the rest to StaffRolesService.changeRoles (§6.11a). Any refusal rolls back the staff patch and the role change together. After commit, and only when the edit changed something, afterRoleChange writes one audit entry through ActivityRecordService.recordActivity — module: "Staff", action: "UPDATE", resource: { id: <staff id>, type: "staff" }, metadata: { rolesAdded: string[], rolesRemoved: string[] } (role names) — because the interceptor's own entry for the request cannot see the diff. When a role was removed it then calls RealtimeControlPublisher.closeUserStreams(userId) (§10). Both are best-effort and neither can fail a write that has already committed.
6.11a StaffRolesService
The one home for every staff role rule. DataTransferModule provides its own instance for the bulk import writer, so its constructor takes only Database and ActorAuthorityService; anything else it needs is passed in.
The scope rule. STAFF_EXCLUDED_ROLE_SCOPES = ["guardian", "student"] and isStaffAssignableScope(scopeKind) are exported from staff-roles.service.ts. A guardian-scope role sees the rows of the holder's children and a student-scope role the holder's own pupil rows; neither means anything for an employee. Every staff role path reads this one predicate: both pickers, the create and edit writes, the set an edit replaces, the detail roles, and RoleService.setRowVisibility (§6.1).
| Method | Called By | Reads | Writes | Errors |
|---|---|---|---|---|
assignableRoles(actor) | StaffService.assignableRoles, assignableRolesFor | ActorAuthorityService.assignableRoles (superadmin rule plus permission subset), then drops guardian- and pupil-scope roles | None | None |
resolveRequestedRoles(tx, rolePublicIds) | resolveForCreate, changeRoles | roles by public_id, FOR SHARE in the caller's transaction | None | 400 ROLE_NOT_FOUND (an id names no role), then 400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE |
isTeachingDesignation(executor, designationId) | deriveJobRoleIds, the teaching check | designations.is_teaching; null is no designation | None | None |
deriveJobRoleIds(tx, designationId, { strict }) | grantStaffRoles (strict: false), changeRoles (strict: true) | roles named staff, plus teacher for a teaching designation | None | strict: 500 ROLE_NOT_CONFIGURED when a named role is missing. Otherwise an error log and the roles it did find |
grantStaffRoles(tx, userId, designationId, roleIds?) | StaffService.create, PeopleImportWriter.writeStaff | Explicit ids re-checked in the transaction; or the job's roles | user_role (onConflictDoNothing) | 400 ROLE_NOT_FOUND for an explicit id that vanished |
resolveForCreate(tx, actor, designationId, rolePublicIds) | StaffService.create | As below | None | 400 STAFF_ROLE_TEACHER_JOB_ONLY, then resolveRequestedRoles's errors, then assertMayAssignRole's |
managedRoles(userId, executor?) | replaceStaffRoles, StaffService.loadOne | The person's user_role grants, minus guardian- and pupil-scope roles, sorted by name | None | None |
changeRoles(tx, actor, target, edit) | StaffService.update | See the check order below | via replaceStaffRoles | See below |
replaceStaffRoles(tx, actor, { userId }, next) | changeRoles | managedRoles | user_role inserts and one DELETE ... WHERE user_id = $1 AND role_id IN (...) | assertMayAssignRole's errors, 403 USER_LAST_SUPERADMIN_PROTECTED |
roleSource. Absent, an edit leaves roles exactly as they are — a designation change never re-derives them, and neither does StaffDesignationService.apply on the salary path. job makes the managed set the roles the designation after the edit implies; explicit makes it exactly rolePublicIds, replace-only, so staff is not added implicitly. Only the managed set is replaced: a guardian- or pupil-scope grant the person holds through another profile is neither shown nor removed.
The edit's check order, each refusal distinct (steps 1–2 in StaffService, 3–10 here):
| # | Check | Error |
|---|---|---|
| 1 | rolePublicIds is sent with roleSource: "explicit" and only with it | 400 STAFF_ROLE_SOURCE_INVALID |
| 2 | The actor holds Users_UPDATE | 403 PEOPLE_ROLE_NOT_PERMITTED |
| 3 | The actor is not editing their own record | 403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN |
| 4 | ActorAuthorityService.assertMayActOnAccount — only a superadmin may act on a superadmin | 403 USER_SUPERADMIN_PROTECTED |
| 5 | explicit is refused when the RESULTING designation is a teaching one | 400 STAFF_ROLE_TEACHER_JOB_ONLY |
| 6 | The new set: resolveRequestedRoles for explicit; deriveJobRoleIds(..., { strict: true }) for job | 400 ROLE_NOT_FOUND, 400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE, 500 ROLE_NOT_CONFIGURED |
| 7 | Diff against the managed set: added and removed | — |
| 8 | ActorAuthorityService.assertMayAssignRole on every role in added and in removed — including the roles job derives | 403 PEOPLE_ROLE_NOT_PERMITTED, or 403 ROLE_NOT_FOUND |
| 9 | When the superadmin role is in removed, assertNotLastSuperadmin under its FOR UPDATE | 403 USER_LAST_SUPERADMIN_PROTECTED |
| 10 | The write | 400 ROLE_NOT_FOUND if an added role vanished |
Step 8 runs on removals as well as additions: an actor may not strip authority it does not itself hold, or emptying a peer's roles would act above the actor's own level. The superadmin role is checked by name and flag before any permission arithmetic, so emptying its grants never makes it assignable. A session acting as a removed role has its sessions.active_role_id nulled by the composite foreign key onto user_role (ON DELETE SET NULL (active_role_id), migration 0058); RoleGuard then refuses that session's next permissioned request with 403 AUTH_ACTIVE_ROLE_REQUIRED until it chooses a role. No permission cache needs invalidating: the cache is per role (rbac:role-perms:<roleId>), and JwtStrategy re-reads the person's roles on every request.
On create, rolePublicIds runs step 2 before the transaction, then the teaching check against the requested designation, resolveRequestedRoles, and assertMayAssignRole on every requested role. Without rolePublicIds, both create and the bulk import derive the job's roles non-strictly, logging and continuing when a seeded role is missing.
Lock order on an edit: the flagged principal designation (from PrincipalInvariantService), then staff FOR UPDATE, then users, then roles FOR SHARE, then the user_role rows, then the surviving superadmins' user_role rows FOR UPDATE inside assertNotLastSuperadmin. Two rare superadmin-only interleavings can still deadlock: an edit that changes a superadmin's person fields and removes their superadmin role, racing a ban of the same person (PeopleAccountService.ban locks the survivors first, then users); and two edits removing the superadmin role from two different superadmins at once (each locks the other's grant as a survivor, then deletes its own). Postgres aborts one side, and PersonWriterService.translate answers it as 409 PEOPLE_CONCURRENT_WRITE_CONFLICT; nothing was written and the request can be sent again.
6.12 StaffSalaryService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
find(actor, id) | StaffController.findSalary | staff salary/bank columns | None | None | STAFF_NOT_FOUND, PERMISSION_INSUFFICIENT (defense-in-depth, in addition to the route guard) |
update(actor, id, dto) | StaffController.updateSalary | Current basicSalary/allowances (only if either is touched), then the salary columns | Salary/bank columns | Invalidates staff:* (the same prefix as the base staff cache, even though the list never projects these fields — "any write to this staff row", not "any write to a field the list happens to project") | VALIDATION_FAILED (pair coherence), STAFF_NOT_FOUND, PERMISSION_INSUFFICIENT |
Validation order: the pair-coherence check only runs when basicSalary or allowances is actually present in the patch (touchesBasic/touchesAllowances) — a bank-detail-only PATCH never triggers it. Fail-closed: both methods re-check PeoplePermissionsService.can even though the route already declares the matching @Permissions, because the guard answers "may this actor call this handler," never "may this actor see this field," and the second question is the one that matters if either method is ever reached another way.
Once a staff member has any salary_assignments row, the salary module owns their pay. StaffSalaryService.find reads isManagedBySalaryModule(staffId) (a plain EXISTS against salary_assignments) and returns it as managedBySalaryModule on StaffSalaryDto, so the panel's salary form can render these fields read-only once the salary module has taken over. mirrorFromAssignment is the ONLY path allowed to write basic_salary/allowances on the staff row from that point on — called by SalaryAssignmentWriter inside its own transaction on every effective salary change, never by StaffSalaryService.update directly; a stop mirrors 0/0. A PATCH to these fields through StaffSalaryService.update once the staff member is salary-module-managed refuses with STAFF_SALARY_MANAGED_BY_SALARY_MODULE.
Staff deletion is refused while pay or a school asset is still attached. PeopleDeletionService.assertStaffHoldsNothing (apps/api/src/modules/people/shared/people-deletion.service.ts) takes the same per-staff advisory lock the salary assignment writer uses (SALARY_STAFF_LOCK_NAMESPACE), then checks in one query: an effective (non-stop) or scheduled salary_assignments row, an unsettled salary_payslips row (draft, or approved with payment_status <> 'paid' and a non-zero net), and any assets row whose custodian_staff_id is this staff member. Any one of those throws STAFF_HAS_ACTIVE_PAY_OR_ASSETS naming which condition held in details (effectiveSalary, scheduledSalaryChange, unsettledPayslips, assetsInCustody) — a soft delete fires no ON DELETE, so without this check payroll would keep paying, and the asset register would keep crediting, somebody the rest of the system says is gone. Stop their pay and return their assets first.
6.13 PrincipalInvariantService
At most one live, active staff member may hold the designation flagged is_principal in designations. This cannot be a database constraint: an index predicate cannot contain a subquery, so Postgres has no way to express "at most one row whose designation is the flagged one." The alternative would have been a denormalised staff.is_principal column maintained by every write path, with nothing in the schema tying that copy to its source — the two could desynchronise silently and no constraint would catch it. apps/api/src/modules/people/shared/principal-invariant.service.ts is the single locked write path taken instead, and there is no backstop: a write path that forgets to call it does not produce an error, it produces two principals and a report card signed by whichever row a query happens to return.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
assertSinglePrincipal(tx, candidate) | Every write listed below | designations (FOR UPDATE on the flagged row), then staff joined to users for an existing active holder | None — a pure guard | Returns { principalDepartmentId } when this write is the one taking the designation, so the caller can also write department_id alongside designation_id | PRINCIPAL_DESIGNATION_MISSING (409, the seed never ran), STAFF_PRINCIPAL_ALREADY_ASSIGNED (409, names the current holder) |
resolvePrincipal(tx) | SchoolProfileService.get/update | staff joined to users and designations, filtered on the flagged designation, deleted_at IS NULL, employment_status = 'active' | None | — | Returns null rather than throwing when nobody holds the role |
The five verified write sites that call assertSinglePrincipal, per the service's own docblock:
- Staff create — an
INSERT, which noUPDATE-shaped guard would cover. - Staff update — the designation patch.
- Bulk import — designation resolved by name, bypassing
StaffServiceentirely. - Restore of a soft-deleted ex-principal.
- Employment status returning to
"active".
Sites 1, 2, and 5 are three different transitions reachable through only two methods (create, update); the count of transitions and the count of call sites are not the same number, and both matter for different reasons — the transitions are what the tests name, the sites are what must remember to call this.
PrincipalCandidate is the resulting state of the write, never the raw DTO. PATCH { employmentStatus: 'active' } carries no designationId and PATCH { designationId } carries no employmentStatus, so every caller resolves dto-over-current-row into a PrincipalCandidate before calling assertSinglePrincipal. Passing the DTO's own values straight through would let either transition slip past the guard undetected: a status change that reactivates a principal, or a designation change on someone already active.
The lock order is fixed and load-bearing. The flagged designations row is always locked first, before any staff row, on every path — including restoreProfile, which already takes FOR UPDATE on the profile row and is careful to call this before that lock is taken. Reversing the order on any one path would have that path lock staff-then-designations while the others lock designations-then-staff, and two operators racing on one staff row would deadlock with an unmapped 40P01.
The INSERT-path exclusion is deliberate, not an oversight. Step 5 of assertSinglePrincipal excludes the candidate's own staff id from the "does someone else already hold it" query — but only when candidate.staffId !== null. On the create path staffId is null (there is no id yet), and the exclusion clause is omitted entirely rather than compared against null, because id <> NULL evaluates to NULL in SQL for every row, which would make the query return nothing and let this assert silently never fire on the one path that has no other backstop.
resolvePrincipal uses the exact same predicate as step 5 of assertSinglePrincipal — flagged designation, deleted_at IS NULL, employment_status = 'active' — deliberately. Resolving the school profile's principal by any looser rule (for example, ignoring employment_status) could display a principal the assert would not count as one, and the two would silently disagree about who holds the role.
Missing the flagged designation is refused, not treated as "nobody is principal." If no designations row has is_principal = true, FOR UPDATE matches nothing and therefore locks nothing — proceeding as if the invariant were satisfied would enforce it against nothing while reporting success, which is worse than either allowing or refusing outright. Both the migration and the seed path are expected to write this row; a database missing it is treated as misconfigured (409 PRINCIPAL_DESIGNATION_MISSING), not merely empty.
Assigning the Principal designation moves the staff member into that designation's department. The composite foreign key staff_designation_in_department_fk — (department_id, designation_id) → designations(department_id, id) — rejects any staff row whose department does not match its designation's department, so a write that sets designationId to the flagged row without also setting the matching departmentId is rejected at the database rather than silently filed under the wrong department. This is exactly why assertSinglePrincipal returns { principalDepartmentId } when it is the caller's write taking the designation: the caller uses it to set department_id in the same write, rather than leaving the caller to separately look up which department the Principal designation belongs to.
Two designation-level guards exist solely to protect this mechanism, documented in the school module: LookupsService.updateDesignation refuses to retire the flagged designation (PRINCIPAL_DESIGNATION_RETIRE_FORBIDDEN) and LookupsService.deleteDesignation refuses to delete it (PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN) — see the school module's backend doc. Both exist because retiring or deleting the flagged row would silently strand assertSinglePrincipal's lock target and resolvePrincipal's join target, with no designation left to appoint a successor to.
6.14 StaffDesignationService
No HTTP route — apply(tx, staffId, departmentId, designationId, actor) runs only inside another write's own transaction, currently the salary module's promotion/demotion path. It moves a staff member's department_id/designation_id in the CALLER's transaction, so a change of pay and a change of post commit together or not at all — a promotion that changed the designation but failed to also record the salary change (or vice versa) would leave the staff record and the pay record disagreeing about which post this person holds. It locks the staff row FOR UPDATE first, then calls PrincipalInvariantService.assertSinglePrincipal with exactly the lock order StaffService.update uses for an ordinary edit (the flagged designations row before the staff row), so a promotion racing an unrelated staff edit never deadlocks by taking that order in reverse. It invalidates the staff:* cache the same way every other staff write does.
6.15 PeopleSummaryService
Headline people counts for GET /api/people/summary.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
read(actor) | PeopleSummaryController.getSummary | The current academic_sessions row; students with users and student_class_enrollments; staff with users and designations; guardians with users; the school clock | None | Redis read per section, fire-and-forget Redis write on a miss | None of its own |
- Scope. Each section applies
PeopleAccessService.scopeForandapplyScopefor its own roll, the same predicates the matching list uses.wholeSchoolisscopeFor(actor, "students", held).kind === "all". - Optional sections.
staffandguardiansare computed only whenPeoplePermissionsService.canreportsStaff_READandGuardians_READrespectively; otherwise they arenulland no query runs. - Students.
activeEnrolledcounts distinct pupils withrecord_status = 'active'and anactiveenrolment in the current session; it isnullwith no current session.admittedThisMonthcountsadmission_datebetween the month bounds. Soft-deleted students and users are excluded. - Staff.
employedcountsemployment_statusin (active,on_leave,suspended);teachingnarrows that to designations withis_teaching. The designation join is a left join. - Guardians.
totalcounts live guardian rows with live users. - Month.
currentMonthBounds(today)converts the school-local date to Bikram Sambat and returns the month's first and last day as Gregorian dates. A converter failure falls back to the Gregorian month withbsYearandbsMonthnull. - Cache. Sections are cached for 60 seconds through
cacheGetFastandcacheSetFast(see §8).
7. Runtime Flows
7.1 Admit a student (create)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | StudentsController.create | Deserializes and validates CreateStudentDto. | 400 on any class-validator failure. |
| 2 | StudentGuardiansService.assertGuardianSetValid (static, called from StudentsService.create) | Checks the guardian array shape before opening a transaction. | 400 STUDENT_GUARDIAN_PRIMARY_REQUIRED/_MULTIPLE_PRIMARY/GUARDIAN_RELATIONSHIP_OTHER_REQUIRES_TEXT. |
| 2a | PeoplePermissionsService.resolveSignInGrant | Decides whether this pupil gets a portal account, from grantSignIn or the older person.canLogin. Runs before the transaction opens: an authorization refusal should cost no writes to roll back. | 400 PERSON_SIGN_IN_FLAGS_CONFLICT (the two flags disagree), 403 AUTH_FORBIDDEN (a grant without Users_UPDATE). |
| 3 | StudentsService.create → db.transaction | Inserts users with the resolved can_login, allocates or accepts the admission number, inserts students, writes guardian links. | 409 (email or admission-number conflict, mapped by PersonWriterService.translate). |
| 3a | PeopleAccountService.inviteOnCreate, on the same transaction | When sign-in was granted and an email address exists, mints a 7-day account_invite record and enqueues the invitation through the outbox. A missing address is reported in the outcome, not thrown. | None — an invitation problem never fails the admission, but a transaction rollback discards the invitation with the pupil. |
| 4 | loadOne | Reloads the full projection including guardian count. The invitation outcome is carried separately and merged into the response, because loadOne rebuilds the DTO from the database and whether an invitation was scheduled is not a column. | 404 is unreachable here — the row was just committed. |
| 5 | invalidate | Sweeps students:*. | Fail-soft — a Redis outage does not fail the request. |
Success path, validation failures, missing entity (n/a on create), duplicate request (no idempotency key — a retried request creates a second student unless the caller supplies and relies on its own admissionNumber), cache miss/hit (list cache is always a miss immediately after this write, by design), async enqueue (the invitation email only, and only when sign-in was granted — enqueued transactionally through the outbox rather than fired after the commit, so it cannot be lost to a restart between the two), race/concurrency (the admission-number allocation is race-safe via the atomic upsert; two simultaneous admissions never collide), guest vs. logged-in (not applicable — every route here requires a JWT).
7.2 Update a student (PATCH, with optimistic concurrency)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | StudentsController.update | Validates UpdateStudentDto, requires version. | 400 if version missing. |
| 2 | PeopleAccessService.assertCanAccess | Confirms the row is in the actor's scope. | 404 STUDENT_NOT_FOUND if out of scope or absent. |
| 3 | StudentsService.update | Locks the row FOR UPDATE, compares versions. | 409 PEOPLE_STALE_RECORD on mismatch — the row lock is what makes this comparison meaningful; reading unlocked would let two writers both pass. |
| 4 | Patch build | PersonWriterService.buildUpdate includes only sent keys. | None — an unsent field is simply absent from the SET clause. |
| 5 | students update | updated_at is always touched, even on a person-only edit, so the version token cannot go stale relative to a change that did happen. | None. |
7.3 Replace a student's guardians
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1-2 | setGuardians | Access check, shape validation. | 404, 400 as in §6.8. |
| 3 | db.transaction | Locks the student row, deletes every existing link. | 404 STUDENT_NOT_FOUND if the row vanished between the access check and the lock. |
| 4 | writeGuardianLinks | Resolves and inserts each entry non-primary first. | 409 STUDENT_GUARDIAN_ALREADY_LINKED on a duplicate guardian in the set; 404 GUARDIAN_NOT_FOUND on a dead guardianId. |
| 5 | Primary flip | One UPDATE sets the chosen guardian's is_primary = true. | Unreachable in practice — the non-deferrable index cannot be violated by this ordering. |
7.4 Soft delete and restore
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | softDeleteProfile | Resolves userId, refuses self-deletion. | 409 USER_CANNOT_DELETE_SELF. |
| 2 | Guardian-only check | Refuses if any live student still links to this guardian. | 409 GUARDIAN_HAS_LINKED_STUDENTS. |
| 3 | Profile update | Sets the profile's deleted_at. | None. |
| 4 | Liveness count | hasNoLiveProfile sums live rows across students+guardians+staff for the person. | None. |
| 5 | Person cascade | Only if the count is zero: users.deleted_at, delete account, delete sessions. | assertNotLastSuperadmin can abort this step with 403 USER_LAST_SUPERADMIN_PROTECTED. |
Restore mirrors this in reverse, with two additional checks (assertCodeStillFree, assertEmailStillFree) before any write, both raising a named conflict rather than an unmapped 23505.
7.5 Field-gated read (salary / medical)
Cache miss and cache hit: neither salary nor medical reads are cached — both query Postgres directly on every call, since they gate individual fields rather than a list. Race/concurrency: StaffSalaryService.update's pair-coherence check reads current and then writes in two separate statements (no row lock), so two concurrent salary PATCHes that each independently satisfy the pair check against a stale current can together leave the pair incoherent — the database's own staff_salary_pair_coherent CHECK is the actual backstop here, and a caller that races this way gets an unmapped 23514 translated through the generic driver-error path rather than the named VALIDATION_FAILED.
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
students:list:scope:<tag>-view:<tag>-q:<term>-status:<v>-gender:<v>-blood:<v>-transport:<v>-hasGuardians:<v>-from:<v>-to:<v>-deleted:<v>-sort:<v>-order:<v>-pagination:<v>-page:<n>-size:<n> | CacheKeyUtil.build("students:list:", [...]) | { data: StudentDto[], totalCount: number } | 120s | delPatternSoft("students:*") on every write to a student, its guardians, or its medical record | StudentsService.findAll |
guardians:list:scope:<tag>-view:<tag>-search:<v>-kind:<v>-phone:<v>-hasChildren:<v>-deleted:<v>-sort:<v>-order:<v>-page:<n>-size:<n> | CacheKeyUtil.build("guardians:list:", [...]) | { data: GuardianDto[], totalCount: number } | 120s | delPatternSoft("guardians:*") on every write | GuardiansService.findAll |
staff:list:scope:<tag>-view:<tag>-q:<v>-dept:<v>-desig:<v>-kind:<v>-status:<v>-gender:<v>-from:<v>-to:<v>-deleted:<v>-sort:<v>-order:<v>-page:<n>-size:<n> | CacheKeyUtil.build("staff:list:", [...]) | { data: StaffDto[], totalCount: number } | 120s | delPatternSoft("staff:*") on every write, including a salary-only update | StaffService.findAll, StaffSalaryService.update |
students:summary:shape:2-scope:<tag>-view:<tag>-today:<date>-session:<id> | CacheKeyUtil.build("students:summary:", [...]) | PeopleSummaryStudentsDto | 60s | Dropped by the students:* sweep on every student write and by ClassEnrollmentsService on an enrolment write; otherwise the TTL | PeopleSummaryService.read |
staff:summary:shape:2-scope:<tag>-view:<tag>-today:<date> | CacheKeyUtil.build("staff:summary:", [...]) | PeopleSummaryStaffDto | 60s | Dropped by the staff:* sweep on every staff write; the import worker and a designation's is_teaching edit bypass it, so the TTL bounds them | PeopleSummaryService.read |
guardians:summary:shape:2-scope:<tag>-view:<tag> | CacheKeyUtil.build("guardians:summary:", [...]) | PeopleSummaryGuardiansDto | 60s | Dropped by the guardians:* sweep on every guardian write; the import worker bypasses it, so the TTL bounds it | PeopleSummaryService.read |
role:permissions:<roleId> (outside this module, in RoleService) | RoleService.permissionCacheKey | PermissionCode[] | 3600s | Deleted immediately by any role-permission edit | PeoplePermissionsService.heldPermissions/.can (indirectly, via RoleService.getPermissionsForRoleId) |
Explain:
- Deterministic segment order.
CacheKeyUtil.buildjoins segments in the exact array order given, and every list method supplies the same segment order on every call — the key is a pure function of the filters, never dependent on object property iteration order. - Cache hit behavior.
getSoftdeserializes the JSON blob and returns it directly — no re-validation against the current permission set happens on a hit, which is exactly whyviewTagmust be part of the key rather than checked after the fact. - Cache miss behavior. Falls through to
queryList(wrapped inwithSearchThresholdif a search term is present), thensetSofts the result before returning. - Serialization shape. Plain
JSON.stringify— dates serialize to ISO strings; a consumer reading a cached hit getsDate-shaped strings exactly asJSON.parseon the controller boundary would produce anyway, since Nest's own response serialization does the same. - Invalidation on mutations. Every write method in all three services calls a private
invalidate()that sweeps the entity's entire*:*prefix — never a single targeted key, because the key space includes up to eleven independent filter dimensions and is not enumerable from the write site. - Failure handling. Both
getSoft(returnsnull, logged as awarn) anddelPatternSoft(logs awarn, does not throw) are fail-soft. AsetSoftfailure is likewise swallowed. None of the three ever turns a Redis outage into a failed request; the tradeoff is a stale list for up to 120 seconds after a failed invalidation.
Summary reads are time-bounded. The three summary keys are read with cacheGetFast (apps/api/src/common/utils/cache-race.util.ts), not getSoft alone. The shared Redis client queues commands while disconnected instead of rejecting them, so an awaited read would hold the request open through an outage. cacheGetFast races the read against a 250 ms budget and treats a timeout, an error or a miss identically: the service computes from the database. cacheSetFast writes without awaiting, so a slow or lost write costs the next reader a miss and never delays the response. The staff and guardian sections are skipped entirely, with no Redis call, when the caller lacks the matching read permission. wholeSchool, the session name and the month are recomputed on every request.
The scopeTag/viewTag pair (built by PeopleAccessService.cacheTags) is folded into every one of the three list keys above. scopeTag is "all" for an unrestricted actor or u:<userId>:r:<activeRoleId> for a restricted one — keyed on the (user, active role) pair, not the user alone, because one person can hold two restricted roles (a student who is also a sibling's guardian) and a user-only tag would serve their guardian-scoped result after they switch to acting as a student. viewTag is the sorted, comma-joined list of held field-gate permissions (StaffSalary_READ, StudentMedical_READ) or the literal string "base" if neither is held — its absence from a cache key was the specific defect this field exists to prevent: an HR user's staff list and a teacher's staff list would otherwise share one key, and whichever request populated the cache first (protected by getOrSet's lock in the general case, though these three list methods use plain getSoft/setSoft rather than getOrSet) would serve its rendering to the other by design, not by race.
9. BullMQ, Schedulers, and Async Work
None. Every write in this module is synchronous within the HTTP request — there is no queue producer, no processor, and no scheduled job anywhere in apps/api/src/modules/people/. Password-reset email delivery (AuthEmailService.sendPasswordResetEmailSafe) is called synchronously and is fail-soft on delivery failure, but it is not queued.
10. Realtime and Events
No Socket.IO gateway, no emitted domain event and no realtime room. One realtime control message:
- Trigger.
StaffService.update, after the transaction commits, when a role edit removed at least one role. - Publisher.
RealtimeControlPublisher.closeUserStreams(userId), fromRealtimeControlModule(modules/realtime/shared/realtime-control.module.ts).PeopleModuleimports that module and neverRealtimeSharedModule: the data-transfer worker importsPeopleModule, and the shared module would start a stream subscriber there. - Message.
{ kind: "close-user", userId }on the Redis channelrealtime:control(REALTIME_CONTROL_CHANNEL). It is not an outbox event and is not inrealtime.contract.ts. - Effect. Every API instance's
RealtimeStreamServiceends that person's open event streams. An open stream filters on the permissions it resolved at connect time; ending it makes the browser reconnect and resolve the permissions the person holds now. - Failure. Best-effort. The publisher's own connection has no offline queue and a 2-second command timeout, so during a Redis outage the publish fails at once, is logged at
warn, and the PATCH still answers 200. A lost message leaves that person's streams on their old permissions until they next reconnect. No queue, outbox row or retry is involved.
The realtime module's own pages describe the channel and the stream's attach re-check.
11. Security, Auth, and Abuse Controls
- Guards. Every controller in this module carries
@UseGuards(JwtAuthGuard, RoleGuard)at the class level — there is no public or guest-accessible route anywhere in/students,/guardians, or/staff. - Permissions. Every handler declares
@Permissions(...);RoleGuard's fail-open branch (for handlers declaring no permission at all) is scoped to non-admin surfaces only, and/students,/guardians,/staffare all classified as admin surfaces byisAdminSurface(anything not under/mobile), so a handler here that forgot@Permissions()would be refused outright rather than silently allowed. - Object-level authorization.
PeopleAccessServiceis the second, mandatory layer beneath every:idroute — see §6.1. A route-level permission alone cannot express "this specific child's guardian may see this specific child." - Account-action authorization. A third, orthogonal check gates
ban,unban, andsendPasswordResetLinkon all three entities:ActorAuthorityService.assertMayActOnAccountrefuses a non-superadmin acting on any account that holds the superadmin role, reading the target's full role set rather than their currently active one, so a superadmin cannot be suspended, restored, or sent a reset link by an office clerk merely because that clerk holds the entity's_UPDATEpermission and the target happens to be acting as something else in the current session. Self-targeting is exempt from this check — the specific self-actions that are dangerous (banning yourself) are refused by name where they occur, not by this gate. The same check protects the generic/users/:id/ban/:id/unbanroutes in theusersmodule, since those reach accounts — a superadmin with no student, parent, or staff profile at all — that the people screens never see. - Guest identity. Not applicable — no route in this module accepts an unauthenticated or guest caller.
- Admin identity.
AuthUser.activeRole(resolved fresh per request byJwtStrategy) is the sole discriminator for both permission checks and object-level scope;activeRole.isSuperadmin— a boolean column exposed through no DTO and writable through no route — is the universal-access bypass, deliberately never keyed on a role's mutablename. - Rate limits. None specific to this module observed; whatever global throttling
apps/apiapplies elsewhere is inherited, not configured here. - Anti-abuse rules. The 404-not-403 rule (§6.1, and the feature doc's business-rules table) is this module's primary anti-enumeration control — it denies an attacker the ability to distinguish "exists but not yours" from "does not exist" across a roll of children.
- Input normalization.
PersonWriterServicecentralizes trimming and blank-to-null conversion for every text field written tousers; email is additionally lower-cased before comparison and storage (emailNormalized). - Sensitive data redaction. Salary and health fields are omitted from their base DTOs entirely (never nulled — see §16.5);
PeopleAccountService.sendPasswordResetLink's log line names the kind and profile id, never the email address or the issued token. - Identity changes are gated apart from record changes. Granting sign-in access — on create through
grantSignIn/person.canLogin, or afterwards throughPOST /:id/sign-in— requiresUsers_UPDATE, never the profile-kind permission. A login-capable row carrying an email address is a route to a session, becausePOST /auth/password/forgotis@Public(). Revoking throughDELETE /:id/sign-inadditionally deletes every session the person holds. Choosing a staff member's roles —rolePublicIdsonPOST /staff,roleSourceonPATCH /staff/:id, and both role pickers — requiresUsers_UPDATEfor the same reason. - Role escalation. Every staff role write refuses guardian- and pupil-scope roles, runs
assertMayAssignRoleon every role it adds and every role it removes, refuses the actor's own record and — unless the actor is one — a superadmin's, and protects the last superadmin who can sign in (§6.11a). - Audit logs. No dedicated audit table for this module.
PeopleAccountService.banrecordsbanned_byon theusersrow itself, which is the only durable "who did this" trace for a suspension. A staff role edit that changed something writes one Mongo audit entry naming the roles added and removed (§6.11), beside the interceptor's own entry for the request. - Fail-closed behavior.
assertNotLastSuperadmin,assertMayActOnAccount, the version check, the guardian-with-live-children check, and every permission/scope check are all fail-closed — none has a fallback path that proceeds on ambiguity or error.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
STUDENT_NOT_FOUND | 404 | PeopleAccessService, StudentsService, StudentGuardiansService, StudentMedicalService, PeopleDeletionService, PeopleAccountService | Row absent, soft-deleted, or out of the actor's scope | Treat as not found; do not retry with the same id |
GUARDIAN_NOT_FOUND | 404 | Same set of services, guardian entity | Same | Same |
STAFF_NOT_FOUND | 404 | Same set, staff entity | Same | Same |
USER_NOT_FOUND | 404 | PeopleAccessService (the users entity variant) | Rare — reached only through a direct users-scoped access check | Same |
STUDENT_ADMISSION_NUMBER_TAKEN | 409 | PersonWriterService.translate (via students_admission_number_unique) | A supplied or reallocated admission number collides with a live row | Retry with a different number, or omit it to auto-allocate |
STAFF_EMPLOYEE_CODE_TAKEN | 409 | PersonWriterService.translate (via staff_employee_code_unique) | Same, for staff | Same |
STUDENT_GUARDIAN_PRIMARY_REQUIRED | 400 | StudentGuardiansService.assertGuardianSetValid | A non-empty guardian set names no primary | Mark exactly one guardian primary and resubmit |
STUDENT_GUARDIAN_MULTIPLE_PRIMARY | 400/409 | assertGuardianSetValid (400, pre-write) or PersonWriterService.translate via student_single_primary_guardian (409, if reached at the database) | More than one guardian marked primary | Mark exactly one primary |
STUDENT_GUARDIAN_ALREADY_LINKED | 409 | StudentGuardiansService.writeGuardianLinks | Same guardian resolved twice within one submitted set | De-duplicate the guardian list client-side |
STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICT | 409 | PeopleDeletionService.assertCodeStillFree | The number was reissued to a different live student while this one was deleted | Assign the record being restored a new number, then retry |
STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT | 409 | assertCodeStillFree | Same, for staff | Same |
USER_RESTORE_EMAIL_CONFLICT | 409 | PeopleDeletionService.assertEmailStillFree | The address was taken by a live account since this person was deleted | Change the email on one side, then retry |
USER_CANNOT_DELETE_SELF | 409 | PeopleDeletionService.softDeleteProfile, PeopleAccountService.assertNotSelf | Actor targets their own users.id for delete or ban | Ask another administrator to perform the action |
USER_LAST_SUPERADMIN_PROTECTED | 403 | ActorAuthorityService.assertNotLastSuperadmin (called from deletion and ban) | The action would leave no live, password-holding superadmin | Grant superadmin to another live account first |
GUARDIAN_HAS_LINKED_STUDENTS | 409 | PeopleDeletionService.softDeleteProfile (guardian kind) | Guardian still has at least one live linked student | Unlink the student(s) first, then delete |
GUARDIAN_ORGANIZATION_NAME_REQUIRED | 409 | PersonWriterService.translate via guardian_org_has_name | kind = 'organization' reached the database with a blank organizationName | Ensure person.firstName is non-blank when creating/updating an organisation guardian |
STUDENT_REQUIRES_ONE_GUARDIAN | 400 | StudentGuardiansService.assertGuardianSetValid | The submitted guardian set is empty | Add at least a father, a mother, or a local guardian |
GUARDIAN_SLOT_TAKEN | 400/409 | assertGuardianSetValid (400, pre-write) or PersonWriterService.translate via student_guardian_one_per_relationship (409, concurrent race) | Two entries name the same relationship slot for one pupil | Remove the duplicate slot, or resubmit after the race resolves |
ADDRESS_HIERARCHY_INVALID | 409 | PersonWriterService.translate, via the eight per-address fill-order CHECKs (permanent_district_needs_province, permanent_municipality_needs_district, permanent_ward_needs_municipality, permanent_tole_needs_district, permanent_house_needs_tole, permanent_address_text_not_blank, and the current_* equivalents) or the composite-FK constraints (users_permanent_district_fk, users_permanent_municipality_fk, users_current_district_fk, users_current_municipality_fk) | A district set without its province, a municipality without its district, a ward without a municipality, or a district/municipality that does not belong to the chosen parent | Fill the address hierarchy from the top (province → district → municipality → ward), matching only children of the chosen parent |
STUDENT_ID_MALFORMED | 400 | PeopleCodeService.adoptSuppliedStudentId | An operator-supplied studentId does not match SID-YYYY-NNNN | Correct the format and resubmit |
STUDENT_ID_SEQUENCE_OUT_OF_RANGE | 400 | adoptSuppliedStudentId | Supplied sequence exceeds 999,999 | Supply a sequence within range, or omit studentId to auto-allocate |
STUDENT_ID_TAKEN | 409 | PersonWriterService.translate via students_student_id_unique | The supplied or allocated student_id is already in use, including on a removed record | Choose a different value; student IDs are never reissued |
STAFF_DESIGNATION_NOT_IN_DEPARTMENT | 409 | PersonWriterService.translate via staff_designation_in_department_fk | Chosen designationId does not belong to the chosen departmentId | Choose a designation that belongs to the selected department |
USER_EMAIL_ALREADY_EXISTS | 409 | PersonWriterService.assertEmailFree, and .translate via users_email_unique as the database-level backstop | Email already belongs to another live person | Use a different email, or find and reuse the existing person |
USER_BAN_REASON_REQUIRED | 400 | PeopleAccountService.ban | Blank or whitespace-only reason | Supply a non-blank reason |
USER_SUPERADMIN_PROTECTED | 403 | ActorAuthorityService.assertMayActOnAccount, called from PeopleAccountService.ban/unban/sendPasswordResetLink | Actor is not a superadmin and the target holds the superadmin role (self-targeting is exempt) | Have another superadmin perform the action |
GRADUATE_ACCESS_NOT_FOUND | 404 | StudentGraduateAccessService.update, GraduateAccessService.setUntil/endNow | The pupil has no live (unrevoked) graduation | Nothing to change; the pupil is not a graduate |
GRADUATE_ACCESS_DATE_INVALID | 400 | GraduateAccessService.setUntil | The new last-day-of-access date is missing, malformed, precedes the graduation date, or is more than 3,650 days after the graduation date | Choose a date within the graduation's allowed range |
GRADUATE_ACCESS_NOT_STARTED | 409 | GraduateAccessService.endNow | action: "end_now" requested before the graduation date has arrived | Wait until the graduation date, or move portalAccessUntil instead |
GRADUATE_ACCESS_SIGN_IN_PERMISSION_REQUIRED | 403 | StudentGraduateAccessService.update | Actor holds Students_UPDATE but not Users_UPDATE; refused before the graduation is even loaded | Have an actor who also holds Users_UPDATE make the change |
STUDENT_GRADUATED_READ_ONLY | 403 | graduate-access.query.ts — assertNotGraduatedReadOnly, called from the guardian, attendance, leave and assignment portal write paths | The pupil's graduate-access state is grace or ended and a portal write was attempted for or by them | The pupil's portal is read-only; no client action re-enables it |
USER_EMAIL_REQUIRED | 409 | PeopleAccountService.sendPasswordResetLink | Target has no email on file | Add an email to the record first |
USER_LOGIN_DISABLED | 409 | sendPasswordResetLink | Target's canLogin = false | Grant sign-in access first (POST /:id/sign-in), which sends its own invitation, rather than a reset link that would not work |
PERSON_SIGN_IN_FLAGS_CONFLICT | 400 | PeoplePermissionsService.resolveSignInGrant | grantSignIn and person.canLogin were both sent on a create with different values | Send one of them, or the same value for both — the server will not guess which was intended |
PERSON_SIGN_IN_STATE_CHANGED | 409 | PeopleAccountService.setSignIn | Somebody else changed can_login between this request's read and its guarded write | Re-read the person and reissue the request if it is still wanted |
AUTH_FORBIDDEN | 403 | resolveSignInGrant, StudentGuardiansService (nested guardian create) | A sign-in grant was asked for by an actor without Users_UPDATE | Create the person without sign-in access, or have an administrator grant it |
AUTH_ACCOUNT_BANNED | 409 | sendPasswordResetLink | Target is currently banned | Unban first |
PEOPLE_STALE_RECORD | 409 | StudentsService.update, StaffService.update, StaffSalaryService.update, GuardiansService.update | Submitted version does not match the current row's version counter | Reload the record, reapply the edit, resubmit |
PEOPLE_INVALID_SORT_FIELD | 400 | StudentsService.orderBy, GuardiansService.resolveSort, StaffService.orderBy | sort/sortBy names a column outside the entity's allow-list | Use one of the documented sortable columns |
VALIDATION_FAILED | 400 | StaffSalaryService.update (basic/allowances pair broken); the global ValidationPipe via GuardianLookupDto (blank/missing phone) | See condition column | Supply both salary fields together or neither; supply a phone number to search |
PERMISSION_INSUFFICIENT | 403 | StaffSalaryService.resolveSalaryForCreate | The salary key is present on POST /staff (any value, including {}) and the actor lacks StaffSalary_UPDATE | Create the staff member without a salary block, or ask an administrator |
PRINCIPAL_DESIGNATION_MISSING | 409 | PrincipalInvariantService.assertSinglePrincipal | No designations row is flagged is_principal — the seed never ran | Run the database seed; this is a misconfiguration, not a normal operator error |
STAFF_PRINCIPAL_ALREADY_ASSIGNED | 409 | PrincipalInvariantService.assertSinglePrincipal | This write would create a second live, active holder of the flagged designation | Change the current holder's designation, or set them to inactive, first |
PRINCIPAL_DESIGNATION_RETIRE_FORBIDDEN | 409 | LookupsService.updateDesignation (school module) | isActive: false on the designation flagged is_principal | Do not retire this designation |
PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN | 409 | LookupsService.deleteDesignation (school module) | DELETE on the designation flagged is_principal | Do not delete this designation |
PAGINATION_LIMIT_INVALID | 400 | StudentsService.findAll, GuardiansService.findAll, StaffService.findAll | pagination=false requested on any of the three people lists | Do not disable pagination for these endpoints |
PERMISSION_INSUFFICIENT | 403 | StaffSalaryService, StudentMedicalService (defense-in-depth), RoleGuard (route-level) | Active role lacks the specific field-gate permission | Request the permission, or use an account that holds it |
ROLE_NOT_FOUND | 500 | grantProfileRole (every pupil and guardian creation path) | The student or guardian system role has not been seeded | Run the auth seed against the target database |
ROLE_NOT_FOUND | 400 | StaffRolesService.resolveRequestedRoles, grantExplicitRoles | A role public id on POST /staff or PATCH /staff/:id names no role, or the role was deleted while the request was in flight | Reload the role picker and choose again |
ROLE_NOT_FOUND | 403 | ActorAuthorityService.assertMayAssignRole | A role being added or removed disappeared between resolution and the authority check | Reload and retry |
PEOPLE_ROLE_NOT_PERMITTED | 403 | StaffService (no Users_UPDATE with rolePublicIds or roleSource), assertMayAssignRole | The actor may not choose roles, or a role being added or removed carries permissions the actor does not hold, or it is the superadmin role and the actor is not a superadmin | Ask an administrator who holds the role's permissions |
PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN | 403 | StaffRolesService.changeRoles | A role edit on the actor's own staff record | Ask another administrator |
USER_SUPERADMIN_PROTECTED | 403 | assertMayActOnAccount, from StaffRolesService.changeRoles | A role edit on a superadmin's record by somebody who is not one | Have a superadmin make the change |
USER_LAST_SUPERADMIN_PROTECTED | 403 | assertNotLastSuperadmin, from StaffRolesService.replaceStaffRoles | The edit would remove the superadmin role from the last superadmin who can sign in | Give another live, password-holding account the superadmin role first |
STAFF_ROLE_SCOPE_NOT_ASSIGNABLE | 400 | StaffRolesService.resolveRequestedRoles | A guardian- or pupil-scope role was requested for a staff member | Choose a staff role; guardian and pupil roles come with those profiles |
STAFF_ROLE_SOURCE_INVALID | 400 | StaffService.update | rolePublicIds without roleSource: "explicit", or explicit without rolePublicIds | Client defect — send both or neither |
STAFF_ROLE_TEACHER_JOB_ONLY | 400 | StaffRolesService (create and edit) | Explicit roles on a teaching designation (on an edit, the designation the edit leaves) | Use roleSource: "job"; a teacher's roles come from the designation |
ROLE_NOT_CONFIGURED | 500 | StaffRolesService.deriveJobRoleIds with strict: true | roleSource: "job" and the seeded staff or teacher role is missing | Run the auth seed; nothing was changed |
ROLE_SCOPE_HELD_BY_STAFF | 409 | RoleService.setRowVisibility | Narrowing a role to guardian or pupil scope while a live or soft-deleted staff member holds it (the route's own DTO refuses those values first) | Remove the role from the staff members first |
PEOPLE_CONCURRENT_WRITE_CONFLICT | 409 | PersonWriterService.translate, on every create and update path that routes errors through it | Postgres aborted the write with 40P01 (deadlock) or 40001 (serialization failure); nothing was written | Send the same request again |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | PeopleAccountService's Logger (PeopleAccountService.name) | Records every password-reset-link issuance (kind, profile id, acting administrator) at log level. |
| Log | RedisCacheService's Logger, invoked indirectly via getSoft/setSoft/delPatternSoft | warn-level entries on any Redis failure touching a people cache key, naming the key or pattern. |
| Metric | None found | No dedicated metric emission in this module. |
| Audit | users.banned_by/banned_at/ban_reason | The only durable, queryable "who suspended this account and why" trace; there is no separate audit-log table for the people domain. |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Integration (real database) | apps/api/src/modules/people/students/__tests__/students.service.integration.spec.ts | Admission (with/without guardians, sibling attach, consecutive code allocation), guardian-set validation (primary required/multiple/duplicate/other-needs-text), constraint mapping, email conflict, primary reassignment, stale-version PATCH, partial-PATCH field preservation ("clears a person field on an explicit null and leaves an omitted one alone" — including ethnicityId/ethnicityName round-tripping to null together), medical-field isolation, search (misspelling, admission-number substring, literal %), hasGuardians filter, completeness after guardian deletion, tied-updated_at pagination, mandatory-pagination refusal ("refuses to serve the whole roll in one unpaginated response"), soft delete/restore, restore conflict on a reissued admission number, cross-profile deletion safety, and the full account-action suite (ban/unban/reset-link, including all four refusal branches). |
| Unit/integration | apps/api/src/modules/people/guardians/guardians.service.spec.ts | Household phone-number sharing, organisation-guardian validation (blank vs. real name), object-level scope (self-only, 404-not-403), delete-refused-with-live-children, role grant on create, mandatory-pagination refusal, unknown-sort-field refusal. |
| Unit/integration | apps/api/src/modules/people/staff/staff.service.spec.ts | Admission with role grants (staff-only vs. staff+teacher), designation-department mismatch, employee-code collision mapping, salary field-gating (omitted/forbidden/permitted), salary pair-coherence refusal, ssfNumber/citNumber free-text patch, unknown-sort-field refusal, pagination=false refusal, self-scope restriction for a non-permissioned actor, soft delete/restore/list-hiding, restore conflict on a reissued employee code, department/designation-kind/search filtering. |
| Unit (real database) | apps/api/src/common/authorization/actor-authority.service.spec.ts | The "acting on a superadmin's account" suite backing assertMayActOnAccount: refuses a non-superadmin acting on a superadmin, permits a superadmin acting on another superadmin, permits anyone acting on an ordinary account, permits acting on your own account, and protects a superadmin currently acting in a non-superadmin role. |
Validation commands:
pnpm --filter @skoolsewa/api test -- students.service.integration.spec.ts
pnpm --filter @skoolsewa/api test -- guardians.service.spec.ts
pnpm --filter @skoolsewa/api test -- staff.service.spec.ts
pnpm --filter @skoolsewa/api build
pnpm turbo run build:docs16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
PeopleModule | Aggregate module | Wiring | ActorAuthorityModule, AuthModule, RoleModule, SchoolProfileModule, LookupsModule, GuardiansModule | AppModule | Instantiates every provider below | None directly | Missing import → InstanceLoader crash naming the consumer, not the missing module |
GuardiansModule | Leaf module, nested | Guardian wiring | ActorAuthorityModule, AuthModule, RoleModule (own copies, not inherited from parent) | PeopleModule | Instantiates GuardiansService and its own copies of the shared services | None directly | Same class of failure if its own imports drift from what its providers need |
StudentsController | Controller | /students route surface | StudentsService, StudentGuardiansService, StudentMedicalService | HTTP layer | Delegates to its three services | None directly | Missing @Permissions on an admin-surface handler → runtime ForbiddenException via RoleGuard's tripwire branch |
GuardiansController | Controller | /guardians route surface | GuardiansService | HTTP layer | Delegates | None directly | Same |
StaffController | Controller | /staff route surface | StaffService, StaffSalaryService | HTTP layer | Delegates | None directly | Same |
StudentsService | Service | Student CRUD, list caching, version check | PeopleAccessService, PeoplePermissionsService, PeopleCodeService, PeopleDeletionService, PersonWriterService, PeopleAccountService, StudentGuardiansService, RedisCacheService, Database | StudentsController | Every shared service, StudentGuardiansService.writeGuardianLinks | users, students, code_counters, students:* cache | Constraint violations mapped by PersonWriterService.translate; unmapped ones rethrow as 500 |
StudentGuardiansService | Service | Guardian relationship on a student | PeopleAccessService, PeoplePermissionsService, PersonWriterService, RedisCacheService, Database | StudentsController, StudentsService.create | resolveGuardian, database | student_guardian, users/guardians (if creating inline), students:* cache | 23505 on the primary index if the two-phase write is ever bypassed by a future change |
StudentMedicalService | Service | Health-record sub-resource | PeopleAccessService, PeoplePermissionsService, RedisCacheService, Database | StudentsController | Database | students (three columns), students:* cache | STUDENT_NOT_FOUND if the row vanished between the access check and the update |
GuardiansService | Service | Guardian CRUD, mandatory pagination | PeopleAccessService, PeoplePermissionsService, PeopleDeletionService, PersonWriterService, PeopleAccountService, RedisCacheService, Database | GuardiansController | grantProfileRole, database | users, guardians, user_role, guardians:* cache | ROLE_NOT_FOUND (500) if the guardian role is unseeded |
StaffService | Service | Staff CRUD, role grants and role edits, account actions | PeopleAccessService, PeoplePermissionsService, PeopleCodeService, PeopleDeletionService, PersonWriterService, PeopleAccountService, StaffRolesService, ActivityRecordService, RealtimeControlPublisher, RedisCacheService, Database | StaffController | StaffRolesService, PeopleAccountService.ban/unban/sendPasswordResetLink, recordActivity, the realtime:control channel, database | users, staff, code_counters, user_role, staff:* cache, Mongo audit_logs | PATCH requires a version token, checked under FOR UPDATE; a role edit runs in the same transaction |
StaffRolesService | Service | The staff role rules: scope, pickers, resolution, derivation, replacement | Database, ActorAuthorityService | StaffService, PeopleImportWriter (its own instance) | ActorAuthorityService.assignableRoles/assertMayAssignRole/assertMayActOnAccount/assertNotLastSuperadmin | roles (FOR SHARE), user_role, designations | Guardian- and pupil-scope roles are never granted through it |
StaffSalaryService | Service | Salary/bank sub-resource | PeopleAccessService, PeoplePermissionsService, RedisCacheService, Database | StaffController | Database | staff (salary/bank columns), staff:* cache | Two-statement pair-coherence check is not itself race-safe (§7.5); the database CHECK is the real backstop |
PeopleAccessService | Shared service | Object-level scope, cache tags | Database | Every other service in this module | Database (scope-membership SELECTs only) | None (read-only) | Defaults to sql\false`` for any unhandled actor/entity combination — fails closed |
PeopleAccountService | Shared service | Ban/unban/reset-link, sign-in grant and revoke, account invitations | ActorAuthorityService, AuthSessionService, VerificationTokenService, AuthEmailService, Database | StudentsService, GuardiansService, StaffService, StudentGuardiansService, and all three controllers directly for :id/sign-in | ActorAuthorityService.assertMayActOnAccount/assertNotLastSuperadmin, AuthSessionService.deleteSessionsForUser, email/token services | users (ban fields, and can_login under a guarded WHERE), sessions (deleted on ban and on revoke), verification-token rows (password_reset and 7-day account_invite) | Email delivery failure is caught and logged, never surfaced to the caller; a missing email address is reported as an invitation outcome rather than thrown |
PeopleCodeService | Shared service | Atomic code allocation | SchoolProfileService, Database | StudentsService.create, StaffService.create | SchoolProfileService.getTimezone | code_counters | None realistic — the upsert cannot fail on well-formed input |
PeopleDeletionService | Shared service | Soft delete/restore | ActorAuthorityService, AuthSessionService, Database | StudentsService, GuardiansService, StaffService | ActorAuthorityService.assertNotLastSuperadmin, AuthSessionService.deleteSessionsForUser | The profile table, conditionally users/account/sessions | Restore-time code/email conflicts named explicitly rather than surfacing as 23505 |
PeopleInvitationService | Shared service | Minting account invitations and scheduling their emails | VerificationTokenService, AuthEmailService | PeopleAccountService, all three create methods, StudentGuardiansService | VerificationTokenService.createPasswordReset, AuthEmailService.sendPasswordResetEmailSafe | A verification row (account_invite, 7 days) plus the notification event and its outbox row, all on the caller's executor | Propagates rather than swallowing — a sent: true for an unscheduled invitation would be a false report about somebody's credentials |
PeoplePermissionsService | Shared service | Field-permission resolution, and the create-time sign-in grant decision | RoleService | StaffSalaryService, StudentMedicalService, all three create methods, every list method (for cacheTags) | RoleService.getPermissionsForRoleId | None (read-only) | Superadmin short-circuits in can() without consulting the set — heldPermissions() does not, and is not an authorization decision |
PersonWriterService | Shared service | Column-set building, error translation, shared selection/version helpers | None (pure logic plus one Database read in assertEmailFree) | Every entity service | Database (only in assertEmailFree) | None directly; translate throws typed exceptions on behalf of every caller | Unrecognised constraint names fall through to a rethrown raw driver error |
trigram-search.ts (withSearchThreshold) | Shared helper (not a class) | Transaction-scoped similarity threshold | Database | Every entity's findAll, when a search term is present | db.transaction, set_config | None persisted — the setting is transaction-local | None realistic |
Every file in apps/api/src/modules/people/ that affects runtime behavior is represented above; dto/index.ts, students/index.ts, staff/index.ts, and the various dto/index.ts re-export barrels carry no logic and are intentionally omitted as non-runtime.
16.2 UML and Architecture Diagram Pack
Component diagram:
Class diagram (representative — the students subtree):
ER diagram: §5.3 above.
Sequence diagrams: §7.1-§7.5 above.
State diagram: §7 of the feature doc (profile liveness / login).
Deployment/runtime diagram:
No BullMQ, no realtime gateway, and no external service call exists in this module's runtime path — the deployment diagram above is deliberately smaller than the generic template because there is nothing more to show.
Data lineage diagram (an admission, from request to response):
16.3 Code Flow Narrative
StudentsService.create(dto)
| Step | Code Location | What Happens | Why It Happens | Failure/Edge Case |
|---|---|---|---|---|
| 1 | students.controller.ts create | Entry point; @Body() deserializes and class-validator validates CreateStudentDto. | Standard NestJS pipeline. | 400 on shape/type failure. |
| 2 | students.service.ts create, first line | StudentGuardiansService.assertGuardianSetValid(dto.guardians ?? []) runs before any transaction opens. | A purely structural error (no primary, two primaries, missing free text) should fail fast without touching the database at all. | 400 with the specific guardian-set code. |
| 3 | Inside db.transaction | PersonWriterService.buildInsert(dto.person) builds the full users column set; assertEmailFree checks it against live rows. | Every optional field must resolve to an explicit value or NULL — there is no prior row to leave anything untouched. | 409 USER_EMAIL_ALREADY_EXISTS. |
| 4 | Same transaction | INSERT INTO users ... RETURNING id. | Establishes the identity row the profile will reference. | Constraint violation caught by the outer .catch(PersonWriterService.translate). |
| 5 | Same transaction | `admissionNumber = dto.admissionNumber?.trim() | await codes.allocateOne("student", tx)`. | |
| 6 | Same transaction | INSERT INTO students ... with the resolved admission number and the rest of the profile fields, each independently trimmed/nulled. | One profile row per student, holding only student-specific columns. | Same constraint-translation path. |
| 7 | Same transaction | StudentGuardiansService.writeGuardianLinks(tx, studentRow.id, dto.guardians ?? []). | Resolves each entry (link existing or create inline), inserts non-primary, flips the primary last. | GUARDIAN_NOT_FOUND, STUDENT_GUARDIAN_ALREADY_LINKED. |
| 8 | After .catch() | await this.invalidate() sweeps students:*. | The just-created student must not be missing from the next list read. | Fail-soft — a Redis failure here does not fail the request; a warn is logged instead. |
| 9 | Return | await this.loadOne(created) re-reads the full row plus computed fields and maps to StudentDto. | Guarantees the response reflects exactly what was committed, including the live guardian count. | Unreachable 404 — the row was just committed inside the same request. |
Auth/identity assumptions: create takes no actor parameter at all — it is reachable only because StudentsController.create is gated by @Permissions("Students_CREATE") at the route, and creation carries no object-level scope question (there is no existing row to be "out of scope" of). Logs/metrics/audit: none emitted by this method itself. Known tradeoffs: no idempotency key, so a client-side retry after a network timeout with no supplied admissionNumber can create two students for one admission event; the office's own process (checking the roll before resubmitting) is the only safeguard today.
16.4 Data Layer Deep Dive
Every table's field-level detail, indexes, and constraints are given in full in §5.2 above; this section adds what that section's format does not have room for.
- Ownership.
usersis owned by the identity schema (outside this module) but every column this module writes is enumerated inPersonColumns/PERSON_SELECTION;students,guardians,staff,student_guardian, andcode_countersare owned entirely by this module. - Versioning fields.
students.version,guardians.versionandstaff.versionare the optimistic-concurrency tokens, rendered byversionOf/matchesVersioninshared/row-version.tsand compared against the client-suppliedversionon every profilePATCH. Each is a counter maintained by thebump_row_versionBEFORE UPDATE trigger, which also stampsupdated_at; no application code increments it.updated_atis not a versioning field — it was, and a millisecond-resolution timestamp let two writes in one millisecond share a token. - Audit fields.
users.banned_byis the only actor-attribution column in this whole data model; no table carries a generalcreated_by/updated_by. - Money units.
staff.basic_salary,.allowances,.total_salaryarenumeric(exact decimal), neverfloat/double precision, and travel over the API as decimal strings (StaffSalaryDto's fields are typedstring | null) — anumericvalue does not survive a JSON-number round-trip without precision loss. - Timezone and date interpretation.
admission_date/joining_dateare plaindate(no time-of-day, no zone). The one place a timezone genuinely matters is code allocation, where the year is taken viaIntl.DateTimeFormatagainstSchoolProfileService.getTimezone(), neverDate.getFullYear(). - JSON schema examples. No JSON/JSONB column exists anywhere in this data model — every field is a scalar, an enum, or a foreign key.
- Migration history. Not independently inspected for this documentation pass; the schema files above are read as the current, authoritative shape.
- Seed data dependency.
guardians.createandstaff.createboth depend on the five system roles (superadmin,staff,teacher,guardian,student) existing inrole— seeded byseed-auth.ts. Aguardian-role lookup miss throwsROLE_NOT_FOUND(500) rather than silently skipping the grant.
Index rationale table:
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
users_email_unique | email_normalized (partial, live only) | Unique B-tree | One live account per normalised address | Two contact-only people cannot share an email; a soft-deleted holder's address is free for reuse |
users_full_name_trgm_idx | full_name | GIN trigram | The % similarity operator for name search | Extra index-maintenance cost on every users write |
users_full_name_prefix_idx | lower(full_name) | B-tree, text_pattern_ops | Left-anchored prefix search, which a trigram GIN cannot serve | A second index to maintain alongside the trigram one |
users_phone_idx | phone (partial, live only) | B-tree | Fast lookup by phone (the sibling-attach flow) | Not unique — deliberately permits sharing |
students_admission_number_unique | admission_number (partial, live only) | Unique B-tree | One live admission number at a time | Releases the number on soft delete, which is what makes a restore-time conflict possible |
students_admission_number_trgm_idx | admission_number | GIN trigram | Partial/misspelt admission-number search | Extra maintenance cost |
staff_employee_code_unique | employee_code (partial, live only) | Unique B-tree | One live employee code at a time | Same restore-time tradeoff as above |
student_single_primary_guardian | student_id (partial, WHERE is_primary) | Unique B-tree, not deferrable | At most one primary guardian per student | Forces the two-phase insert-then-flip write pattern; a naive single-pass insert can raise 23505 depending on row order |
student_guardian_guardian_id_idx | guardian_id | B-tree | "This parent's children" query (findChildren, lookupGuardiansByPhone's linkedStudentCount) | Extra write cost on every link change |
staff_designation_in_department_fk | (department_id, designation_id) → (designations.department_id, designations.id) | Composite FK | Prevents a staff row's designation from belonging to a different department | Requires designations_department_id_id_unique to exist as a target, and requires that unique to be a table CONSTRAINT rather than a uniqueIndex so drizzle-kit emits it before the referencing FK |
staff_designation_needs_department | department_id, designation_id | CHECK | Closes the MATCH SIMPLE hole where designation_id is set but department_id is NULL | None beyond the CHECK's own evaluation cost |
code_counters PK | (scope, year) | Composite PK | One counter row per scope per year | None |
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| A student has zero or more guardians of any relationship; no father/mother columns | student_guardian schema shape, guardianRelationshipEnum | See the feature doc's Business Rules table | N/A (a schema shape, not a runtime check) | students.service.integration.spec.ts |
| At most one primary guardian per student | student_single_primary_guardian (non-deferrable partial unique index) | Exactly one number the office calls first | 409 (mapped from 23505 if ever reached at the database) | students.service.integration.spec.ts — primary reassignment |
| At least one primary once any guardian exists, enforced only at the service layer | StudentGuardiansService.assertGuardianSetValid | Cannot be a schema CHECK — zero guardians overall must remain legal | 400 STUDENT_GUARDIAN_PRIMARY_REQUIRED | Same file |
An organisation guardian has a non-blank organizationName | CHECK guardian_org_has_name, plus the service deriving it from person.firstName | The database is the final authority even if a future code path bypasses the service | 409 GUARDIAN_ORGANIZATION_NAME_REQUIRED | guardians.service.spec.ts |
relationship = 'other' requires relationshipOther | CHECK relationship_other_required, plus assertGuardianSetValid | Same reasoning | 400/409 | students.service.integration.spec.ts |
| A staff designation must belong to the staff's own department | Composite FK staff_designation_in_department_fk plus CHECK staff_designation_needs_department | Prevents "Librarian filed under Accounts" | 409 STAFF_DESIGNATION_NOT_IN_DEPARTMENT | staff.service.spec.ts |
basic_salary and allowances are both NULL or both set | CHECK staff_salary_pair_coherent, plus the service-level pre-check in StaffSalaryService.update | total_salary's generated expression must never silently produce 0.00 from one missing term | 400 VALIDATION_FAILED (service) or an unmapped 23514 (if the two-statement race in §7.5 is hit) | staff.service.spec.ts |
| Salary and medical fields never appear in the base entity's response | Separate DTOs (StaffSalaryDto/StudentMedicalDto), separate services, separate permission codes, separate SELECT lists | See the feature doc's rationale | N/A — an absence, not an error | staff.service.spec.ts — "keeps salary out of the staff response" |
| Deletion is profile-scoped; the person only goes when no profile remains | PeopleDeletionService.hasNoLiveProfile, counted across all three profile tables | See the feature doc's rationale | N/A | students.service.integration.spec.ts — "does not remove the person when they still hold another live profile" |
| Creating a person never grants an account by itself | PersonWriterService.buildInsert takes canLogin as a required argument with no default; PeoplePermissionsService.resolveSignInGrant resolves it | A pupil record and a credential-bearing account are different things, and only the second is an identity change | 403 AUTH_FORBIDDEN when asked for without Users_UPDATE | students.service.integration.spec.ts |
| The two spellings of the sign-in grant can never disagree silently | resolveSignInGrant compares grantSignIn against person.canLogin and refuses a mismatch | Resolving by precedence would apply an intention the caller did not state | 400 PERSON_SIGN_IN_FLAGS_CONFLICT | students.service.integration.spec.ts |
can_login changes only through a guarded, observed-value write | PeopleAccountService.setSignIn; buildUpdate emits no can_login key | A lost race must be reported, not silently applied over somebody else's decision | 409 PERSON_SIGN_IN_STATE_CHANGED | The guarded write itself is exercised by students.service.integration.spec.ts; the lost-race branch has no dedicated assertion in the files reviewed |
| Revoking sign-in ends every live session | setSignIn calls AuthSessionService.deleteSessionsForUser on the revoke branch | A refresh token left redeemable is the weaker half of the pair ban already implements | N/A — a side effect, not an error | students.service.integration.spec.ts |
| An invitation commits with the person it invites | inviteOnCreate runs on the caller's transaction | A post-commit call loses the invitation on a restart, leaving somebody who believes they were invited with nothing scheduled | N/A | students.service.integration.spec.ts |
can_login gates authentication only | Absent from the notification audience resolver and the channel send path | A person with no portal account still has a phone number, an address, and a right to be told things | N/A — an absence, not an error | Notification module specs |
| Out-of-scope access is a 404, never a 403 | PeopleAccessService.assertCanAccess uses the entity's own NOT_FOUND unconditionally | Anti-enumeration | The entity's *_NOT_FOUND code | guardians.service.spec.ts — "404s (never 403)" |
| Admission/employee codes allocate atomically, one upsert per request | code_counters upsert in PeopleCodeService.allocate | Prevents the max()+1 race under concurrent admissions | N/A (race-free by construction) | students.service.integration.spec.ts — "allocates consecutive admission numbers without collision" |
| Search's similarity threshold is transaction-scoped | withSearchThreshold | pg_trgm.similarity_threshold is a SESSION setting on a pooled connection | N/A | students.service.integration.spec.ts — misspelling search test (indirect evidence; the threshold pinning itself has no dedicated assertion) |
A cache key without viewTag would leak a wider view to a narrower one | PeopleAccessService.cacheTags always includes viewTag | See §8 | N/A — a documented design constraint, not a runtime-checkable invariant | Not directly covered by an automated test in the files reviewed |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
GuardiansModule re-provides the five shared services rather than importing them from PeopleModule | Nest module composition | Duplicate provider declarations in both modules | Have PeopleModule export the shared services and have GuardiansModule import PeopleModule (or a shared submodule) | Not documented in the source; observed as the current shape | Two live instances of five stateless services; any future stateful shared service would need a different wiring | If any of these services ever gain per-instance state, the duplication becomes a correctness bug, not just redundancy |
| The token covers the profile row, not the person | Concurrent edit safety | version on students, staff and guardians, maintained by the bump_row_version trigger | Extend it to users, so a concurrent rename through ban/unban or the users module is caught too | The profile row is where every module-owned write lands; users has four other writers with their own concurrency questions | A person-field edit through another module is not caught by a profile token | A reported lost-update on a person field |
student_medical and student_guardians take no token | Concurrent edit safety | Both UPDATE students with no lock and no staleness check | Tokenise both | Neither was in scope when the token was introduced | Two concurrent medical edits, or two concurrent guardian-set replacements, silently lose one — and the latter carries canPickup | A reported lost-update on a health record or a pickup list |
| Account actions protect against acting on a superadmin, as a check separate from the last-superadmin count | Account-action authorization | ActorAuthorityService.assertMayActOnAccount, consulted alongside assertNotLastSuperadmin | Rely on the last-superadmin count alone | The count answers "would the system still have a working superadmin", which a clerk holding Staff_UPDATE can satisfy trivially whenever two or more superadmins exist — it says nothing about whether that clerk was entitled to touch this particular account | Without the separate check, any holder of the entity's _UPDATE permission could suspend, restore, or trigger a reset link against a superadmin's account for as long as another superadmin remained on file, closing off the very account that could reverse the action | actor-authority.service.spec.ts's "acting on a superadmin's account" suite |
Restore permission inconsistency (Students_UPDATE vs. Guardians_RESTORE/Staff_RESTORE) | Permission granularity for the restore action | Students restore is gated by the general _UPDATE code; guardians and staff restore are gated by the dedicated _RESTORE code | Gate all three uniformly under _RESTORE | Not documented in the source; observed as the current shape | A role granted Students_UPDATE but not Students_RESTORE can restore students but not guardians/staff under an otherwise-parallel grant | A permissions audit that assumes uniform _RESTORE gating across the catalogue |
student_guardian FK on guardian_id is CASCADE, not RESTRICT | Referential action on guardian deletion | CASCADE, with the "cannot delete with live children" rule enforced in the service instead | RESTRICT at the database | A RESTRICT here once fired two levels down from a users hard delete with no erasure path, and a referential action cannot produce a message naming the children | The database alone will not stop an unusual code path (e.g. a direct hard delete bypassing the service) from cascading silently | A future hard-delete path on guardians that does not route through PeopleDeletionService |
| Fail-soft cache for all people list reads | Redis availability vs. correctness | getSoft/setSoft/delPatternSoft everywhere | Fail-closed (rethrow on Redis error) | A Redis outage should degrade the directory to slower, not down | Up to 120 seconds of staleness after a failed invalidation | A use case where a stale list is unacceptable (none identified in this domain today) |
Money as numeric, transmitted as a string | Precision | numeric(12,2)/numeric(14,2), DTO fields typed string | null | float/double precision, or a number DTO field | A float does not round-trip numeric precision exactly | Every consumer must parse the string itself for arithmetic (none is done client-side today per the reviewed code) | A future computed display that needs numeric math on the client |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Cache | redis-cli KEYS "students:list:*" (or guardians:*/staff:*); application logs for warn-level Redis messages | Keys present with the expected TTL (≤120s); no repeated warn entries | Repeated "Redis unavailable" warnings in the API log | Confirm Redis connectivity; the application continues serving from Postgres in the meantime — no immediate action required for correctness, only for latency |
| Code allocation | SELECT * FROM code_counters WHERE scope IN ('student','staff') ORDER BY year DESC; | next_value increasing monotonically per (scope, year) with no gaps larger than the number of failed admissions | A stuck or unexpectedly large next_value after a bulk-import failure (a reserved block that was never consumed) | Gaps are expected and harmless — the counter is a reservation, not a strict sequence guarantee; no recovery action needed |
| DB constraints | \d+ students, \d+ guardians, \d+ staff, \d+ student_guardian in psql | All listed CHECK/unique/FK constraints present and VALID | An unmapped 500 in the API log citing a Postgres error code (23505/23514/23503) not present in PersonWriterService.translate's switch | Add the constraint name to PersonWriterService.translate (or the guardian-specific unwrap in guardians.service.ts) so future occurrences map to a typed client error |
| Role/permission cache | redis-cli GET "role:permissions:<roleId>" | A JSON array of permission codes, refreshed within the last hour or immediately after any role edit | A colleague reports access that should have been revoked still working | Confirm the role-edit path called RoleService's cache-invalidation method; the 1-hour TTL is only a backstop for a delete that never landed |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Duplicate admission from a client retry | StudentsService.create | A second users/students row for one real admission event, if no explicit admissionNumber was supplied and the office does not notice before resubmitting | The unique index catches a duplicate only when the same admissionNumber is reused; an auto-allocated retry gets a fresh number and succeeds twice | No idempotency key on the create endpoint |
| Salary pair-coherence race | StaffSalaryService.update | Two concurrent salary PATCHes can each pass the application-level pair check against a stale current read, together leaving basic_salary/allowances incoherent at the database | staff_salary_pair_coherent CHECK is the real backstop and will reject the second write with 23514 | That 23514 is not in PersonWriterService.translate's switch, so it surfaces as an unmapped 500 rather than a named VALIDATION_FAILED |
| Person-field edits outside the profile token | UsersService, PeopleAccountService ban/unban/password-reset | A concurrent rename or ban is not caught by a profile row's token, because it writes users and never touches the profile | Database-level column consistency only | The token's scope is the profile row by design; users is written by four other routes |
student_medical and student_guardians | StudentMedicalService.update, StudentGuardiansService.setGuardians | Both write students with no lock and no token, so a concurrent edit silently discards one | None | Out of scope when the token was introduced; setGuardians replaces the whole link set, including canPickup |
| Restore-time code/email conflict | PeopleDeletionService.restoreProfile | A deleted record cannot be restored until its reissued code or email is resolved elsewhere | Named, actionable conflict codes rather than a bare 23505 | The office must manually resolve the collision; there is no "restore under a new number" convenience path |
A clerk holding only Staff_UPDATE could otherwise suspend a superadmin's staff account | PeopleAccountService.ban/unban/sendPasswordResetLink | The office suspends, restores, or resets the credentials of the one account that could reverse the action | ActorAuthorityService.assertMayActOnAccount refuses any of the three actions when the target holds the superadmin role and the caller does not, reading the target's full role set rather than their active one | None significant — the check runs on every call to all three methods, ahead of the last-superadmin count |
No idempotency on guardian/staff role grants beyond onConflictDoNothing | grantProfileRole, StaffRolesService.grantStaffRoles | A retried create after a partial failure (unlikely, since both are inside the same transaction as the profile insert) would not double-grant a role even if it somehow re-ran the grant step alone | onConflictDoNothing on (user_id, role_id) | None significant — this mitigation is sufficient for the scenario it covers |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime — §4, §16.1.
- Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented — §4, §5, §6, §16.1.
- Every method with business behavior has a code-flow narrative — §6 (per-method tables), §16.3 (the representative narrative for
create). - Every table/collection/cache object/job payload has field-level detail — §5.2, §8 (no job payloads exist).
- Every index, constraint, relation, and delete behavior has rationale — §5.2, §16.4.
- Every lifecycle/status transition has a state diagram and transition table — feature doc §7 (referenced; this domain's lifecycle is documented once, in the feature doc, to avoid duplicating the same table).
- Every read/write/action/job flow has sequence and activity diagrams — §7, §16.2 (no job flows exist to diagram).
- Every business invariant is cataloged — §16.5.
- Every cache key, invalidation path, queue job, realtime event, and external call is documented — §8, §9 (none), §10 (none), §11.
- Every architectural tradeoff is documented with alternatives and revisit triggers — §16.6.
- Every operational failure mode has a runbook entry — §16.7, §16.8.
18. Backend Completion Checklist
- Module boundaries are documented — §2.
- Every controller, service, DTO, schema file, job, cache key, and event is covered — §3-§6, §8-§10.
- Every database table/collection has a field table and relationship diagram — §5.2, §5.3.
- Every runtime flow has a diagram and branch notes — §7.
- API, feature/flows, and TDD docs are linked — See Also, below (no TDD doc exists for this module in this repository).
- No claim is made without a source file or documented source reference — every table and narrative above cites the file it was read from.
Profile roles are granted wherever a profile is created
Every pupil holds the seeded student role and every guardian the seeded guardian role, from the
transaction that creates the profile. PeopleAccessService.scopeFor and every portal route switch on
the active role's scopeKind, so a profile with no role falls to the self default: once given
sign-in, that person logs in and reaches nothing, with no error saying why.
One function grants them, grantProfileRole (shared/profile-role.ts), and every creation path
calls it in its own transaction:
| Path | Profile | Call site |
|---|---|---|
POST /students | pupil | StudentsService.create |
POST /guardians | guardian | GuardiansService.create |
A new guardian in a pupil's guardians list | guardian | StudentGuardiansService.resolveGuardian |
| Bulk import, student sheet | pupil, and a guardian it creates (a guardian matched by phone already holds its role) | PeopleImportWriter.writeStudent, findOrCreateGuardian |
| Bulk import, guardian sheet | guardian | PeopleImportWriter.writeGuardian |
Staff get staff, plus teacher for a teaching designation, from StaffRolesService.grantStaffRoles
on both the API and the import path, unless POST /staff names the roles explicitly. That path
logs and continues when a seeded staff role is missing; only an edit with roleSource: "job"
refuses (500 ROLE_NOT_CONFIGURED). Staff are never given a guardian- or pupil-scope role through
a staff surface — §6.11a.
The grant throws ROLE_NOT_FOUND (500) and aborts the whole write when the role is not seeded,
rather than skipping. A silent skip would produce a 201, a correct-looking record and no access at
all, with nothing connecting the two events. It is idempotent on (user_id, role_id).
The grant is narrow: student carries scope_kind = 'student', which resolves to the pupil's own
record and their own guardians, and guardian resolves to the guardian's own children. Neither holds
an admin-catalogue permission.
Two migrations repair rows written before a path granted the role. 0013_student_role_backfill
covers pupils admitted before admissions granted it; 0042_profile_roles_backfill covers pupils and
guardians written by the import or the pupil form. Both include soft-deleted profiles, because
restoring a profile clears its deletion marks and never touches user_role. 0042 skips any pupil
with a student_graduations row: graduate access revokes the pupil-scoped roles itself and only its
own restore path hands them back.
See Also
- API doc: /docs/developer/people/api
- Features and flows doc: /docs/developer/people/feature
People Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the people domain (students, guardians, staff).
People API Reference
Complete API contracts for students, guardians, and staff, including routes, auth, DTOs, responses, errors, and examples.