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| 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. |
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. |
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 |
Superadmin (activeRole.isSuperadmin) and holding the entity's own Module_READ permission both resolve to { kind: "all" }. Otherwise the active role's name decides: guardian and student get bespoke restricted predicates; everything else (including staff/teacher without the module permission, and anyone with no active role) gets selfOnlyScope, which is sql\false`for every entity exceptusers/staff` where it resolves to the actor's own row.
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"), 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, never a truncating fixed width (lpad('10000', 4, '0') returns '1000', which this code avoids via padStart, which only pads and never truncates).
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 |
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.
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.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 | None | None | STAFF_NOT_FOUND |
create(dto) | StaffController.create | Designation's is_teaching flag (if a designation is given), role lookups | users, staff, code_counters, user_role | Invalidates staff:* | USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT |
update(actor, id, dto) | StaffController.update | Current row (plain SELECT, no lock) | users, staff | Invalidates staff:* | USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT, STAFF_NOT_FOUND |
remove(actor, id) | StaffController.remove | Delegates | Delegates | Invalidates staff:* | USER_CANNOT_DELETE_SELF |
restore(actor, id) | StaffController.restore | Delegates | 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 |
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.
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.
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.
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.
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 |
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.
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
None. No Socket.IO gateway, no emitted domain event, and no realtime room is referenced anywhere in this module.
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. - 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. - 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 |
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 | GuardiansService.grantGuardianRole | The guardian system role has not been seeded | Run the auth seed against the target database |
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 | grantGuardianRole, database | users, guardians, user_role, guardians:* cache | ROLE_NOT_FOUND (500) if the guardian role is unseeded |
StaffService | Service | Staff CRUD, role grants, account actions | PeopleAccessService, PeoplePermissionsService, PeopleCodeService, PeopleDeletionService, PersonWriterService, PeopleAccountService, RedisCacheService, Database | StaffController | grantStaffRoles, PeopleAccountService.ban/unban/sendPasswordResetLink, database | users, staff, code_counters, user_role, staff:* cache | PATCH requires a version token, checked under FOR UPDATE |
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 | GuardiansService.grantGuardianRole, StaffService.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.
The admission-time role grant
StudentsService.create writes a user_role row granting the pupil the seeded student role,
inside the same transaction as the admission. Guardians and staff have always received their roles
on creation; pupils did not, so every pupil in the database had no active role and the consumer
portal refused them.
The grant throws ROLE_NOT_FOUND (500) and aborts the whole admission 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.
The grant is narrow: student carries scope_kind = 'student', which resolves to the pupil's own
record and their own guardians, and the role holds no admin-catalogue permission. A pupil moves from
holding no role and reaching nothing, to holding a role scoped to themselves.
Migration 0013_student_role_backfill covers pupils admitted before this change. It deliberately
does not filter deleted_at, because restoring a soft-deleted pupil does not re-grant the role —
restoreProfile clears the deletion marks and never touches user_role.
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.