Skoolsewa - School Management System Docs
Developer ResourcesPeople

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

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/people/people.module.ts, apps/api/src/modules/people/guardians/guardians.module.tsImports, providers, exports, why GuardiansModule is nested rather than flattened.
Controllersstudents/students.controller.ts, guardians/guardians.controller.ts, staff/staff.controller.tsRoute ownership, guard chains, thin-controller boundary.
Servicesshared/*.service.ts, students/*.service.ts, guardians/guardians.service.ts, staff/*.service.tsBusiness logic, transactions, cache keys, error mapping.
DTOsdto/person.dto.ts, dto/account-action.dto.ts, students/dto/student.dto.ts, guardians/dto/guardian.dto.ts, staff/dto/staff.dto.tsField shapes, validators, allow-lists.
Schemapackages/db/src/schema/school/people.ts, packages/db/src/schema/identity.ts, packages/db/src/schema/school/lookups.tsTables, enums, indexes, generated columns, constraints.
Authorizationpackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsModule list, default role grants.
Cacheapps/api/src/common/utils/cache-key.util.ts, apps/api/src/services/redis/redis.service.tsKey format, fail-soft semantics.
Paginationapps/api/src/common/utils/pagination.util.ts, apps/api/src/common/dto/query.dto.tsDefaults, caps, the shared QueryDto.
Searchpackages/db/src/search/search.constants.ts, packages/db/src/search/escape-like-pattern.tsTrigram threshold, LIKE escaping.
Testsstudents/__tests__/students.service.integration.spec.ts, guardians/guardians.service.spec.ts, staff/staff.service.spec.tsConfirmed behavior, cited per section below.

2. Backend Scope and Boundaries

Owns

  • The unified person write path shared by students, guardians and staff: users identity fields, normalization, and the email-conflict rule (PersonWriterService).
  • Object-level access control over students, guardians, staff, and users rows — 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 users row 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, StaffSalary and StudentMedical, and the sign-in grant decision shared by all three create paths (PeoplePermissionsService).
  • The students, guardians, and staff CRUD 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 RoleGuard that enforces them — RoleModule.
  • The school's timezone value itself — SchoolProfileModule/SchoolProfileService; this module only consumes getTimezone().
  • Department and designation lookup rows — LookupsModule owns their CRUD; packages/db/src/schema/school/lookups.ts owns their schema. This module only references them as foreign keys on staff.
  • Ethnicity and mother-tongue lookup rows — also LookupsModule (PersonClassificationsController), backed by ethnicities/mother_tongues in packages/db/src/schema/identity.ts. This module only stores the foreign key on users and resolves the display name at read time; renaming or deleting an entry there is a LookupsModule write, never one this module performs.
  • Redis connection management and the generic cache primitives — RedisCacheService; this module only calls getSoft/setSoft/delPatternSoft.
  • Email delivery and verification-token issuance for the password-reset flow — AuthEmailService/VerificationTokenService, both from AuthModule.
  • Superadmin protection logic itself — ActorAuthorityService.assertNotLastSuperadmin and assertMayActOnAccount; 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_status here is a two-value record-level toggle only, not that vocabulary.
  • Bulk roll import/export — a separate DataImport/DataExport permission module.

Source of Truth

ConcernSource of TruthNotes
A person's identity fields (name, contact, demographics, address)users tableShared by every profile kind through PersonWriterService/PERSON_SELECTION.
Whether a profile is livestudents.deleted_at / guardians.deleted_at / staff.deleted_atIndependent per profile; see §5.4 for why.
Whether the person exists at allusers.deleted_atSet only when no profile remains live — computed by PeopleDeletionService.hasNoLiveProfile, never written directly by a profile-level delete.
Whether a person may sign inusers.can_login, users.bannedTwo 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 completenessComputed at read time from student_guardian/guardians/users livenessNever a stored column — see the schema comment on students.
Which rows an actor may seePeopleAccessService.scopeFor, keyed on the active roleNever inferred from which profile rows a person happens to hold.
The academic year for code allocationSchoolProfileService.getTimezone(), defaulting the format to Asia/Kathmandu-style offsetsNever Date.getFullYear() (UTC).
Role grants for a given userpackages/db/src/schema/identity.ts — user_roleWritten by this module's create paths (guardian, staff, teacher) and read by RoleService.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
PeopleModuleAggregateapps/api/src/modules/people/people.module.tsStudentsController, StaffControllerAll shared services plus StudentsService, StudentGuardiansService, StudentMedicalService, StaffService, StaffSalaryServiceEvery service above, plus GuardiansModuleComposes the whole people domain; imports ActorAuthorityModule, AuthModule, RoleModule, SchoolProfileModule, LookupsModule, GuardiansModule.
GuardiansModuleLeaf, nested under PeopleModuleapps/api/src/modules/people/guardians/guardians.module.tsGuardiansControllerGuardiansService, and its own copies of PeopleAccessService, PeopleAccountService, PeopleDeletionService, PeoplePermissionsService, PersonWriterServiceGuardiansServiceOwns guardian CRUD; imports ActorAuthorityModule, AuthModule, RoleModule directly rather than relying on the parent to re-export them.

GuardiansModule re-declares the shared services as its own providers rather than importing them from PeopleModule. Nest instantiates a second copy of each — PeopleAccessService, PeopleAccountService, PeopleDeletionService, PeoplePermissionsService, and PersonWriterService each exist twice in the running application, once per module. None of them carry per-instance state (each only holds injected singletons — the database handle, RoleService, etc.), so the duplication is a wiring redundancy, not a correctness bug; it does mean a change to one of these services' constructors must satisfy both modules' import lists independently.

4. File and Directory Map

apps/api/src/modules/people/
  people.module.ts
  dto/
    person.dto.ts
    account-action.dto.ts
    index.ts
  shared/
    people-access.service.ts
    people-account.service.ts
    people-code.service.ts
    people-deletion.service.ts
    people-permissions.service.ts
    person-writer.service.ts
    trigram-search.ts
  students/
    students.controller.ts
    students.service.ts
    student-guardians.service.ts
    student-medical.service.ts
    dto/
      student.dto.ts
      index.ts
    __tests__/
      students.service.integration.spec.ts
    index.ts
  guardians/
    guardians.module.ts
    guardians.controller.ts
    guardians.service.ts
    guardians.service.spec.ts
    dto/
      guardian.dto.ts
      index.ts
  staff/
    staff.controller.ts
    staff.service.ts
    staff-salary.service.ts
    staff.service.spec.ts
    dto/
      staff.dto.ts
      index.ts
    index.ts
  summary/
    people-summary.controller.ts
    people-summary.service.ts
    people-summary-month.ts
    dto/
      people-summary.dto.ts
    __tests__/
      people-summary.integration.spec.ts
      people-summary-month.spec.ts
FilePurposeKey ExportsNotes
people.module.tsWires the whole domain.PeopleModuleImports GuardiansModule rather than flattening it.
dto/person.dto.tsThe identity block shared by every create/update form; PersonDto for responses.PersonDto, PersonInputDto, QueryBoolean, GENDERS, BLOOD_GROUPS, MARITAL_STATUSES, DISABILITY_TYPESQueryBoolean() is a Transform for query-string booleans; body booleans never need it.
dto/account-action.dto.tsBan reason and reset-link confirmation shapes.BanAccountDto, PasswordResetSentDtoShared across students and guardians controllers.
shared/people-access.service.tsObject-level access control and cache-tag composition.PeopleAccessService, PeopleEntity, PeopleScopeThe single place scope predicates are built; see §5.2.
shared/people-account.service.tsBan, unban, password-reset link, sign-in grant and revoke.PeopleAccountService, AccountActionContextDepends on AuthEmailService, AuthSessionService, VerificationTokenService, ActorAuthorityService, PeopleInvitationService.
shared/people-invitation.service.tsMints account invitations and schedules their emails, always on the caller's transaction.PeopleInvitationService, InvitationOutcome, InvitationSkipReason, ACCOUNT_INVITE_TTL_MSKnows nothing about authority — that is PeopleAccountService's question.
shared/people-code.service.tsAtomic admission/employee code allocation.PeopleCodeService, CodeScopeDepends on SchoolProfileService for the timezone.
shared/people-deletion.service.tsSoft delete and restore, profile-scoped.PeopleDeletionService, ProfileKindDepends on ActorAuthorityService, AuthSessionService.
shared/people-permissions.service.tsResolves the active role's held permission codes for field-level gates, and owns the create-time sign-in grant decision.PeoplePermissionsServiceWraps RoleService.getPermissionsForRoleId; can() is the authorization decision, heldPermissions() is not.
shared/person-writer.service.tsBuilds insert/update column sets for users; the constraint-to-error-code translator; the shared PERSON_SELECTION, personTrigramMatch.PersonWriterService, PersonColumns, PERSON_SELECTION, personTrigramMatchThe 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.tsThe 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, matchesVersionmatchesVersion's row side is typed number only, so it cannot be called with two strings.
shared/trigram-search.tsPins pg_trgm.similarity_threshold for one transaction.withSearchThresholdWraps any query that uses the % operator.
students/students.controller.tsStudent CRUD, account actions, guardian sub-resource, medical sub-resource routes.StudentsControllerEvery :id handler passes through PeopleAccessService.
students/students.service.tsStudent CRUD business logic, list caching, version check, and the pupil's student role grant.StudentsServiceAll three profiles carry an optimistic-concurrency version.
students/student-guardians.service.tsThe guardian relationship: list, phone lookup, full-set replace, link-writing, primary-guardian invariant.StudentGuardiansServiceassertGuardianSetValid is also called directly by StudentsService.create.
students/student-medical.service.tsHealth-record read/update, gated by StudentMedical_READ/_UPDATE.StudentMedicalServiceNever touches StudentDto.
guardians/guardians.controller.tsGuardian CRUD, children sub-resource, account actions.GuardiansControllerOnly controller in this module without ParseUUIDPipe on :id.
guardians/guardians.service.tsGuardian CRUD, mandatory pagination, organisation-name derivation, role grant on create.GuardiansServiceUnwraps drizzle's wrapped Postgres error before delegating to PersonWriterService.translate.
staff/staff.controller.tsStaff CRUD, account actions, and the salary sub-resource routes.StaffControllerWired to the same shared PeopleAccountService as the student and guardian controllers.
staff/staff.service.tsStaff CRUD, department/designation projection, role grants (staff, and teacher for a teaching designation).StaffServiceOptimistic concurrency on staff.version, checked under the row lock. Query construction lives in staff-query.ts.
staff/staff-query.tsFilter, sort and pagination assembly for the staff directory. Knows nothing about actors — the caller applies scope to the conditions.buildStaffConditions, runStaffListQuery, staffOrderBySame split as students-query.ts, for the same reason.
staff/staff-salary.service.tsSalary/bank read-update, gated by StaffSalary_READ/_UPDATE, pair-coherence validation.StaffSalaryServiceNever touches StaffDto.
summary/people-summary.controller.tsGET /api/people/summary.PeopleSummaryControllerGated on Students_READ.
summary/people-summary.service.tsHeadline counts per roll, scoped as the lists are, cached per section.PeopleSummaryServiceCache keys sit under the students:, staff: and guardians: prefixes.
summary/people-summary-month.tsThe Bikram Sambat month containing today as inclusive Gregorian bounds.currentMonthBoundsFalls back to the Gregorian month with null BS fields outside the converter's range.
summary/dto/people-summary.dto.tsResponse shapes.PeopleSummaryDto, PeopleSummaryMonthDto, PeopleSummaryStudentsDto, PeopleSummaryStaffDto, PeopleSummaryGuardiansDto—

5. Data Model

5.1 Schema Source

packages/db/src/schema/
  identity.ts        # users, user_role, sessions, userDevice, lookups (ethnicities, motherTongues)
  school/
    people.ts         # students, guardians, student_guardian, staff, code_counters
    lookups.ts         # departments, designations

5.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.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (v7)NogeneratedPKReferenced by students.user_id, guardians.user_id, staff.user_id (each unique)v7 UUID — time-ordered, unlike v4.
first_nametextNo———For an organisation guardian, this holds the organisation's whole name.
middle_nametextYesNULL——
last_nametextYesNULL——Nullable: an organisation has no last name.
full_nametextYes (generated)computedGIN 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.
emailtextYesNULL——
email_normalizedtextYes (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_verifiedbooleanNofalse——
phonetextYesNULLPartial 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_verifiedbooleanNofalse——
imagetextYesNULL——
can_loginbooleanNotrueIndex 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_passwordbooleanNofalse——
date_of_birthdateYesNULL——
genderenum (gender)YesNULL——male, female, other, prefer_not_to_say.
blood_groupenum (blood_group)YesNULL——A+,A-,B+,B-,AB+,AB-,O+,O-.
ethnicity_idintegerYesNULLFK → 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_idintegerYesNULLFK → mother_tongues.id, ON DELETE SET NULL—Same reasoning, and the same read-time name resolution.
disability_typeenum (disability_type)YesNULL——none, visual, hearing, physical, intellectual, learning, speech, multiple, other.
marital_statusenum (marital_status)YesNULL——single, married, divorced, widowed, separated.
permanent_province_id, permanent_district_id, permanent_municipality_id, permanent_ward_no, permanent_tole, permanent_house_nointeger/smallint/textYesNULLComposite 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_nointeger/smallint/textYesNULLSame 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.
biotextYesNULL——
bannedbooleanNofalseCHECK users_ban_reason_requires_banned—Non-null, unlike the pre-merge customers.banned.
ban_reasontextYesNULLSame CHECK—Must be NULL unless banned = true.
banned_at, banned_bytimestamptz, uuidYesNULLSame CHECK (on banned_at)—
created_at, updated_attimestamptzNonow()——updated_at has $onUpdateFn.
deleted_attimestamptzYesNULLIndex 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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (v7)NogeneratedPKReferenced by student_guardian.student_id (ON DELETE CASCADE)
user_iduuidNo—Unique, FK → users.id, ON DELETE CASCADEOne-to-one with usersDeleting the users row (never done directly by this module) cascades to students.
admission_numbertextNo—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_idtextNoallocatedFULL 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_idtextYesNULL——An optional government identifier, named for whatever the school calls it locally.
admission_datedateNo———
record_statusenum (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_modeenum (transport_mode)YesNULL——none, school_bus, private, walking, public_transport.
medical_conditions, allergies, special_needstextYesNULL——Health PII, gated by StudentMedical_READ/_UPDATE, never by Students_READ.
interests_hobbiestextYesNULL——
created_at, updated_attimestamptzNonow()——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.
versionbigintNo0——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_attimestamptzYesNULLIndex 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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (v7)NogeneratedPKReferenced by student_guardian.guardian_id (ON DELETE CASCADE)
user_iduuidNo—Unique, FK → users.id, ON DELETE CASCADEOne-to-one with users
kindenum (guardian_kind)No'person'——person or organization.
organization_nametextYesNULLCHECK guardian_org_has_name—Required and non-blank when kind = 'organization'; derived by the service from person.firstName, never entered as a separate field.
occupationtextYesNULL——
created_at, updated_attimestamptzNonow()——
deleted_attimestamptzYesNULLIndex 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.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
student_iduuidNo—Composite PK with guardian_id; FK → students.id, ON DELETE CASCADE
guardian_iduuidNo—Composite PK; FK → guardians.id, ON DELETE CASCADE (deliberately, not RESTRICT); Index student_guardian_guardian_id_idxCASCADE 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.
relationshipenum (guardian_relationship)No—Unique per student together with student_id — student_guardian_one_per_relationshipThree 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_primarybooleanNofalsePartial unique student_single_primary_guardian on (student_id) WHERE is_primary, not deferrableWho 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_guardianbooleanNofalse—Who signs consent — usually but not always the primary.
is_emergency_contactbooleanNofalse—
can_pickupbooleanNofalse—Separated parents and explicit exclusions are real; not inferred from relationship.
lives_withbooleanNofalse—
created_at, updated_attimestamptzNonow()—

No deleted_at on this table — a link is deleted outright (by setGuardians' delete-and-reinsert) rather than soft-deleted.

staff

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (v7)NogeneratedPK
user_iduuidNo—Unique, FK → users.id, ON DELETE CASCADEOne-to-one with users
employee_codetextNo—Partial unique staff_employee_code_unique (WHERE deleted_at IS NULL), GIN trigram staff_employee_code_trgm_idx
joining_datedateNo——
experience_yearsintegerYesNULLCHECK staff_experience_years_non_negative
qualificationtextYesNULL—
department_idintegerYesNULLFK → departments.id, ON DELETE RESTRICT; Index staff_department_id_idx; part of composite FK staff_designation_in_department_fk
designation_idintegerYesNULLIndex staff_designation_id_idx; part of composite FK staff_designation_in_department_fk; CHECK staff_designation_needs_department
employment_statusenum (employment_status)No'active'Index staff_employment_status_idxactive, on_leave, suspended, resigned, terminated, retired.
basic_salarynumeric(12,2)YesNULLCHECK staff_basic_salary_range (0 to 99999999.99), CHECK staff_salary_pair_coherentGated by StaffSalary_READ.
allowancesnumeric(12,2)YesNULLCHECK staff_allowances_range, CHECK staff_salary_pair_coherentMust be NULL iff basic_salary is NULL.
total_salarynumeric(14,2), generatedYes (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_numbertextYesNULL—All gated behind StaffSalary_READ/_UPDATE alongside the salary figures.
ssf_number, cit_numbertextYesNULL—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_attimestamptzNonow()—
deleted_attimestamptzYesNULLIndex 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.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
scopetextNo—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.
yearintegerNo—Composite PKThe academic year in the school's own timezone, never UTC.
next_valueintegerNo—CHECK code_counters_next_value_positiveWritten by one upsert: INSERT ... ON CONFLICT (scope, year) DO UPDATE SET next_value = next_value + count RETURNING next_value. This table carries no $onUpdateFn column — Drizzle stamps such a column even when omitted from an onConflictDoUpdate set, and this statement must have no side effect beyond the counter.

student_graduations

Owned by rollover's schema file (packages/db/src/schema/school/rollover.ts) but read and written by this module's graduate-access surface, because a graduation is what turns a pupil read-only. One row per graduation; revoked_at marks a row no longer live rather than deleting it.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
student_iduuidNo—FK ON DELETE CASCADE→ students.id
graduated_ondateNo—CHECK student_graduations_access_after_graduation: portal_access_until >= graduated_on
portal_access_untildateNo—Same CHECKLast day (inclusive, school calendar) the graduate may use the portal read-only. Set from rollover_settings.graduate_access_days at graduation; an administrator may move it.
portal_access_ended_attimestamptzYes—NULL while access is live. Moving portal_access_until into the future clears it again.
login_disabled_by_expirybooleanNofalseCHECK student_graduations_login_disabled_needs_end: only true when portal_access_ended_at IS NOT NULLTrue only when ending access is what switched users.can_login off — the graduate held no other live profile. Re-opening access restores sign-in only when this is set.
revoked_role_idsinteger[]No'{}'CHECK student_graduations_revoked_roles_need_end: non-empty only when portal_access_ended_at IS NOT NULLscope_kind = 'student' role grants ending access removed from a graduate who kept another live profile. Re-opening grants exactly these back.
portal_access_sweep_failed_attimestamptzYes—Partial index student_graduations_access_expiry_idx (nullsFirst, live rows only)When the expiry sweep last failed to end this row. The sweep orders by this, nulls first, so a row that keeps failing moves to the back instead of blocking every later expiry. Cleared when access ends.
portal_access_changed_at / _by_user_id / _change_reasonmixedYes—→ users.id, ON DELETE SET NULLWho last moved the window and why.
revoked_at / revoked_by_user_id / revoke_reasonmixedYes—→ users.id, ON DELETE SET NULLSet when the underlying graduation itself is undone (a rollover realignment), not when access merely ends.

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 PeopleAccessService

MethodCalled ByReadsWritesSide EffectsErrors
scopeFor(actor, entity, heldPermissions)Every list/read/write method in StudentsService/GuardiansService/StaffService/StudentGuardiansService/StudentMedicalService/StaffSalaryServiceNothing (pure decision from actor/heldPermissions)NoneNoneNone
applyScope(scope, conditions)Same callers, when building a list queryNoneMutates the caller's conditions arrayNoneNone
assertCanAccess(actor, entity, id, heldPermissions)Every :id-scoped methodOne row from the entity's table, ANDed with the scope predicateNoneNoneThrows 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 keyNone (derives from scopeFor and the two field-gate permission codes)NoneNoneNone
isEntityReachable(scopeKind, entity)RoleResponseService, to tell the Roles screen which of a role's own grants its row visibility cannot reachNothingNoneNoneNone

Superadmin (activeRole.isSuperadmin) resolves to { kind: "all" } before anything else is read. Otherwise the active role's scope_kind column decides — not its name. guardian and student get bespoke restricted predicates; all gets { kind: "all" } provided the caller also holds the entity's own Module_READ permission, falling back to selfOnlyScope when it does not; and self, together with anything unrecognised and any caller with no active role, gets selfOnlyScope — which is the sql-tagged literal false for students, guardians and classSubjects, and the actor's own row for users and staff.

The distinction between name and column is load-bearing and this page previously stated the wrong one. Deriving scope from a role's name meant every operator-created role fell through to a branch it was never meant to reach; the column exists so an arbitrary role is safe to assign.

scope_kind is a second, independent grant, not a filter on the first. A permission code answers "may this role read pupils at all"; scope_kind answers "which pupils". Neither implies the other, which is why the two are checked separately and why a role can hold Students_READ and still see nothing.

scope_kind is settable, through exactly one route

PUT /roles/:publicId/row-visibility, body { scopeKind: "self" | "all" }, behind Roles_UPDATE.

It is a dedicated route rather than a field on PATCH /roles/:publicId because that DTO is shared with create through PartialType(OmitType(CreateRoleDto, ...)), so one field on either would reach both write paths. scope_kind appears on neither DTO, and both write paths name their columns instead of spreading.

RoleService.setRowVisibility runs in one transaction that first reads the role row FOR UPDATE. Six constraints, enforced in this order:

#ConstraintError
1The role exists404 ROLE_NOT_FOUND
2The role is not a system role403 ROLE_SYSTEM_SCOPE_FORBIDDEN
3The caller does not hold the target role (read from user_role, not from the active role)403 ROLE_SELF_TARGET_FORBIDDEN
4Widening to all requires activeRole.isSuperadmin. Narrowing needs only Roles_UPDATE403 ROLE_SCOPE_WIDEN_FORBIDDEN
5Narrowing to guardian or student is refused while any staff row — live or soft-deleted — belongs to a holder of the role (user_role ⋈ staff on user_id)409 ROLE_SCOPE_HELD_BY_STAFF
6@IsIn(["self", "all"]) on SetRoleRowVisibilityDto — guardian and student are portal audiences and the route's body cannot carry them400 VALIDATION_FAILED

Constraint 6 is the global ValidationPipe, so over HTTP it answers before the service runs and constraint 5 is never reached through this route. Constraint 5 sits in the service so that no caller of setRowVisibility can turn a role that staff hold into a guardian- or pupil-scope role: staff surfaces never grant such a role (§6.11a), and narrowing one after the fact would hand every holder a parent's or a pupil's view. Soft-deleted staff count because a restore brings the person's grants back with them. The row lock is what makes the check hold against a concurrent staff edit, which reads the role FOR SHARE before granting it — the two serialise on the roles row (and, like any FOR UPDATE on a parent row, the lock also holds back concurrent user_role inserts for that role for its duration).

Constraint 2 is the only thing preventing a school-wide lockout. All five seeded roles are isSystemRole, and the seeded guardian role is scoped guardian precisely so a parent sees their own children — narrowing it to self would refuse every parent in the school, from a screen whose worst apparent outcome is "a dropdown changed".

Constraint 4 rests on a flag, not on a permission code, because Roles_UPDATE is not a superadmin proxy: "Roles" is absent from SUPERADMIN_ONLY_MODULES, so the admin catalogue includes it. Permission arithmetic cannot reach isSuperadmin.

Constraint 3 reads user_role. Reading activeRole.id instead would let a multi-role caller widen a role they hold but are not currently acting as.

After the transaction commits, the write is accompanied by an explicit recordActivity carrying { field: "scopeKind", from, to } and the target role. The audit interceptor derives module/action from @Permissions(), so its own entry for this request is Roles/UPDATE — indistinguishable from a description edit. The explicit call is what carries the detail.

RoleDto gained scopeKind, hasUnreachablePermissions, canSetRowVisibility and canWidenRowVisibility; RoleWithPermissionsDto gained unreachablePermissions. The last three are computed from the same constraints and the same user_role query the route enforces, so the admin panel renders them and derives no authority of its own. hasUnreachablePermissions and unreachablePermissions cover the five *_READ codes this resolver maps to an entity, and are derived from isEntityReachable rather than restated as a second table.

6.2 PeopleAccountService

MethodCalled ByReadsWritesSide EffectsErrors
ban(actor, kind, profileId, reason)StudentsService.ban, GuardiansService.ban, StaffService.banResolves userId from the profile table; the target's full role set (via ActorAuthorityService)users.banned/ban_reason/banned_at/banned_by inside a transactionDeletes 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.unbanResolves userId; the target's full role setClears all four ban fieldsNoneUSER_SUPERADMIN_PROTECTED, the profile's NOT_FOUND
sendPasswordResetLink(actor, kind, profileId, context)StudentsService.sendPasswordResetLink, GuardiansService.sendPasswordResetLink, StaffService.sendPasswordResetLinkusers.email/fullName/canLogin/banned; the target's full role setNone directly (a verification-token row is written by VerificationTokenService)Sends an email via AuthEmailService.sendPasswordResetEmailSafe (fail-soft — never throws on delivery failure); logs the issuanceUSER_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_UPDATEResolves userId; users.email/fullName/canLogin/banned; the target's full role setusers.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 deletedUSER_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

MethodCalled ByReadsWritesSide EffectsErrors
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 callerDelegates to issueReturns an InvitationOutcome, or null when sign-in was not grantedPropagates whatever issue throws
issue(executor, userId, email, fullName)inviteOnCreate, and PeopleAccountService.setSignInNothingA verification row with purpose: "account_invite" and a 7-day expiry, on the caller's executorEnqueues the invitation email on the same transactionPropagates

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

MethodCalled ByReadsWritesSide EffectsErrors
allocate(scope, count, executor?)allocateOne, and directly by any future bulk-import callerSchoolProfileService.getTimezone()code_counters via one upsertNoneNone (the upsert cannot fail on a well-formed input)
allocateOne(scope, executor?)StudentsService.create, StaffService.createSameSame, count = 1NoneNone

Input normalization: none needed — the scope is a closed union ("student" \| "staff" \| "student_id" \| "asset"), enforced by the type system and mirrored by the code_counters_scope_known CHECK as a database-level backstop. Idempotency: none — each call reserves a new block; a caller that must retry a failed admission without burning a number has to catch the failure before calling this. Response mapping: format() builds PREFIX-YEAR-0001, zero-padded to 4 digits as a minimum for student/staff/student_id, never a truncating fixed width (lpad('10000', 4, '0') returns '1000', which this code avoids via padStart, which only pads and never truncates). The asset scope (prefix AST, allocated from assets in apps/api/src/modules/assets) is the one exception to that width: it zero-pads to a fixed 6 digits (ASSET_SEQUENCE_WIDTH), matching the assets_code_format CHECK (^AST-[0-9]{4}-[0-9]{6}$) — a school issuing more than 999,999 assets in one year exhausts the range and AssetsService.create raises ASSET_CODE_RANGE_EXHAUSTED rather than silently truncating the sequence.

adoptSuppliedStudentId(supplied, executor) is a third path alongside allocate/allocateOne, used only when an operator types a studentId on POST /students instead of leaving it to be allocated. It canonicalises the value (trim, upper-case, zero-pad the sequence to at least 4 digits), rejects a shape that does not match SID-YYYY-NNNN with 400 STUDENT_ID_MALFORMED, and rejects a sequence above MAX_STUDENT_ID_SEQUENCE (999,999) with 400 STUDENT_ID_SEQUENCE_OUT_OF_RANGE — the bound exists because an unbounded supplied value once set code_counters.next_value to INT_MAX (SID-2026-2147483647), after which every auto-allocated admission for the rest of that calendar year raised 22003 integer out of range with no route able to reset the counter, and only Students_CREATE was needed to trigger it. The counter is then advanced with GREATEST(next_value, sequence), in the year the supplied value names, never the current year — advancing only the current year would let a future-dated SID-2027-0001 sail through today and then collide with the real allocation on 1 January 2027. GREATEST also means a supplied value can never rewind the counter and hand the next admission a number already in use.

6.4 PeopleDeletionService

MethodCalled ByReadsWritesSide EffectsErrors
softDeleteProfile(kind, profileId, actorId)StudentsService.remove, GuardiansService.remove, StaffService.removeResolves userId; for a guardian, checks for a live linked student; counts live profiles for the personThe profile's deleted_at; conditionally users.deleted_at, deletes the account row, deletes sessionsSession deletion via AuthSessionServiceUSER_CANNOT_DELETE_SELF, GUARDIAN_HAS_LINKED_STUDENTS, the profile's NOT_FOUND
restoreProfile(kind, profileId)StudentsService.restore, GuardiansService.restore, StaffService.restoreLocks the profile row FOR UPDATE; checks whether its code/email has been reissued to a live rowClears the profile's deleted_at; conditionally clears users.deleted_atNoneSTUDENT_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

MethodCalled ByReadsWritesSide EffectsErrors
heldPermissions(actor)Every service in this module before building a scope or a cache tagRoleService.getPermissionsForRoleId (Redis-cached, 1-hour TTL, invalidated on role edit)NoneNoneNone
can(actor, permission)StaffSalaryService.find/update, StudentMedicalService.findMedical/updateMedical, resolveSignInGrantSameNoneNoneNone
resolveSignInGrant(actor, input)StudentsService.create, GuardiansService.create, StaffService.createcan(actor, "Users_UPDATE")NoneNonePERSON_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

MethodCalled ByReadsWritesSide EffectsErrors
buildInsert(dto, account)Every entity's createNoneNone (builds an object)NoneNone
buildUpdate(dto)Every entity's updateNoneNoneNoneNone
assertEmailFree(db, email, exceptUserId?)Every entity's create/update, and StudentGuardiansService.resolveGuardianusers by email_normalized, live onlyNoneNoneUSER_EMAIL_ALREADY_EXISTS
syncSignInEmail(tx, userId, nextEmail)Every entity's update, after the users row is writtenusers.email (before)account.account_id for the email provider; deletes every unconsumed verification row for the personNoneNone
PersonWriterService.translate(error) (static)Every entity's transaction .catch()NoneNoneNoneMaps a constraint name to a typed exception; rethrows unrecognised errors as-is

buildUpdate uses Object.hasOwn(dto, key) rather than !== undefined for every field — an explicit "lastName": null in the request body is a clearing instruction and must survive; an absent key must produce no key in the patch at all.

canLogin is an argument to buildInsert, not a field of the DTO it reads, and buildUpdate never emits it at all. buildInsert takes a second parameter, account: { canLogin: boolean }, and there is no default: every caller states the value, having already checked what it is allowed to state (PeoplePermissionsService.resolveSignInGrant for the three top-level creates, an explicit Users_UPDATE check in StudentGuardiansService for a guardian created inside an admission). Reading it from dto instead would put the decision here — silently, from a request body, with no permission check anywhere on the path — and simply removing the field from the DTO would not fix that, because dto.canLogin ?? true would still yield true and so would the column default behind it. The decision has to stop being made in this file, which is why the parameter is required rather than optional.

buildUpdate omitting it is the same rule on the other side: can_login taken from a PATCH body would let an actor holding only Students_UPDATE flip an existing person to login-capable. Post-create transitions go through PeopleAccountService.setSignIn, which checks the identity permission and guards the write.

An address group (permanentAddress/currentAddress) is replaced WHOLE, never patched field by field, and the Object.hasOwn discipline above governs only the flat, top-level keys — it does not reach through nesting on its own. When a group key is present on the DTO, all six of its columns are written from it and a missing key inside the group means NULL; when the group key is absent, the whole group is left untouched. A field-wise patch would let { "permanentAddress": { "provinceId": null } } clear the province while leaving the district in place, which raises the unmapped 23514 permanent_district_needs_province from a request the operator considers perfectly reasonable — the fill-order CHECKs on the schema are what make this discipline load-bearing rather than stylistic. An all-NULL current address, by the same rule, is what "not recorded" looks like: there is no separate flag for "same as permanent", so a family that has not given a different present address simply has six NULL columns rather than a copy of the permanent one.

A guardian's kind (person/organization) is orthogonal to the slot (relationship: father/mother/local_guardian) it occupies in student_guardian — either can vary independently, which is what lets a local guardian be an orphanage or a father slot be filled by an organisation acting in loco parentis. resolveGuardian in StudentGuardiansService reads kind directly from the payload rather than inferring it from relationship, because an earlier version inferred it from relationship === "organization", and narrowing the relationship enum to three slots made that comparison permanently false — every guardian created from the pupil form would have silently become a natural person, with guardian_org_has_name never able to fire on that path again. Response mapping: none — this service builds column sets and selections, never a response DTO. PersonWriterService.translate's constraint lookup (constraintOf) walks error.cause recursively up to depth 5, because drizzle-orm's node-postgres driver wraps the raw pg error (which carries .constraint) inside .cause rather than rethrowing it directly — reading error.constraint on the top-level object finds nothing on every single database error from this driver.

An administrator editing a person's email keeps the sign-in credential in step. syncSignInEmail compares the address on users before the write to the incoming one, both trimmed and lower-cased, and does nothing when they already agree. When they differ it writes the same normalised value onto account.account_id for that person's email-provider row — or user:<userId> when the address is cleared to null, since account_id cannot itself be null — and deletes every unconsumed verification row the person holds, of any purpose. That last step is deliberate: a password-reset or email-change link minted against the address being replaced must not go on working once an administrator has moved the person to a different one. This runs on the same transaction as the users update, so the two rows never observe each other's address as stale.

PERSON_SELECTION (also exported from this file) is the users column set every student, guardian, and staff read projects — six list/detail queries share it so the person shape cannot drift by call site. Alongside the plain columns it carries ethnicityName and motherTongueName as two correlated subqueries (SELECT name FROM ethnicities/mother_tongues WHERE id = users.ethnicity_id/mother_tongue_id) rather than LEFT JOINs: a join added at one of the six call sites would have to be replicated at the other five in the same position or the projected shape silently diverges, while both lookups are small, indexed on their primary key, and read once per row from Postgres's own cache, so the join buys nothing measurable and costs the invariant. Both the id and the name are returned together — the id is what an edit form posts back on the next write and what a filter compares against, the name is what a person reads, and a response carrying only the name would force the client to search the lookup list by string to re-select the value it was just given.

6.7 StudentsService

MethodCalled ByReadsWritesSide EffectsErrors
findAll(actor, query)StudentsController.findAllstudents joined to users, correlated guardian-liveness subqueriesNoneReads/writes students:list:* cache; throws if pagination=falsePAGINATION_LIMIT_INVALID
findOne(actor, id)StudentsController.findOneSame shape, single rowNoneNoneSTUDENT_NOT_FOUND
create(dto)StudentsController.createGuardian rows if linkingusers, students, student_guardian, code_countersInvalidates 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.updateCurrent row locked FOR UPDATEusers, studentsInvalidates students:*PEOPLE_STALE_RECORD, USER_EMAIL_ALREADY_EXISTS, STUDENT_NOT_FOUND
remove(actor, id)StudentsController.removeDelegates to PeopleDeletionServiceDelegatesInvalidates students:*Delegated
restore(actor, id)StudentsController.restoreDelegatesDelegatesInvalidates students:*Delegated
ban/unban/sendPasswordResetLinkStudentsControllerDelegates to PeopleAccountServiceDelegatesInvalidates students:* (ban/unban only)Delegated

Input normalization: admissionNumber?.trim() || allocateOne(...) — a supplied number that is blank after trimming is treated as absent. Validation order in create: guardian-set shape validated before the transaction opens (fail fast, no partial work to roll back for a purely structural error), then inside the transaction: email freedom, then the insert. Transaction boundaries: the entire admission — users insert, code allocation, students insert, guardian links — is one transaction; a half-admitted student is not a reachable state. Idempotency: none at the API layer; a client retry after a timeout can create a duplicate person unless the caller supplies its own admissionNumber and relies on the unique index to reject the second attempt. Response mapping: toDto computes isRecordComplete from guardianCount > 0 and version from versionOf(row.version). Fail-open/closed: the version check in update is fail-closed — a mismatch always aborts, never a warning. Logger usage: none in this service directly (logging for the account actions it delegates to lives in PeopleAccountService).

6.7a StudentGraduateAccessService and GraduateAccessService

StudentGraduateAccessService (people/students/student-graduate-access.service.ts) is the thin admin read/write behind GET/PATCH /api/students/:id/graduate-access: it runs the same two-layer authorization every single-record student route runs (@Permissions for "at all", PeopleAccessService.assertCanAccess for "this one"), loads the pupil's live (unrevoked) graduation, and delegates every decision to GraduateAccessService (people/shared/graduate-access.service.ts), which five modules share — the guardian portal, attendance, leave, assignments and people itself.

The read side is pure computation over the stored row and today's school-calendar date: graduateAccessState (utils/graduation/graduate-access.util.ts) returns none | pending | grace | ended, and ended also counts a portal_access_until that has simply passed, so a read never trusts the expiry sweep to have reached the row yet. The write side never reads that computed state — end, setUntil and endNow all decide on the stored portal_access_ended_at directly, because deciding on the computed state would make "end now" a no-op for a row the sweep has not stamped.

end(tx, graduationId, cause, today, actor, effects, reason?) is idempotent (a revoked or already-ended row is left alone) and, once it actually ends access:

  1. Removes every scope_kind = 'student' role grant from the user, unconditionally — a grant left behind would hand full pupil-portal reads back to anyone who later gets a login for another reason (hired as staff, or becoming a parent). Their ids are stored on revoked_role_ids so a re-opening grants back exactly those.
  2. Detaches any live session's active_role_id before deleting the grant (session.(user_id, active_role_id) is ON DELETE SET NULL on both columns and user_id is NOT NULL, so deleting a grant a session is currently acting as would otherwise fail the delete).
  3. Switches users.can_login off — through writeCanLogin, the same guarded write PeopleAccountService.setSignIn uses — and deletes every session, but only when the account holds no other live profile (holdsOtherAccess). A graduate who is also a guardian or staff member keeps their login.
  4. Records GRADUATE_ACCESS_ENDED through ActivityRecordService, deferred until the transaction commits, with the cause, whether sign-in was disabled, and the revoked role ids.

Re-opening (setUntil moving the date back into the future, or undoForRevoke when a rollover realignment undoes the graduation itself) gives back exactly what end took, and no more:

  • Pupil-scope role grants come back only for roles that still exist, are still scope_kind = 'student', and are not superadmin — a role edited wider or deleted since revocation is not silently handed back, and the skip is logged.
  • Sign-in comes back only when end is what switched it off (login_disabled_by_expiry) and the caller may decide sign-in (Users_UPDATE) and the account has not gained other access since — an account that picked up another profile while dormant is not quietly re-activated by a graduate-access change; turning its sign-in on is an explicit setSignIn decision instead.

Lock order is always student_graduations → users, matched by PeopleAccountService.setSignIn, which clears the login_disabled_by_expiry flag before touching users — see the sign-in toggle interaction below. The expiry sweep chooses its candidates without a lock, so end(..., "expiry") re-checks the window has really passed once it holds the row, in case an administrator extended it in between.

The sign-in toggle interaction. PeopleAccountService.setSignIn — the service behind the ordinary POST/DELETE /:entity/:id/sign-in routes — calls clearGraduateLoginFlag before writing users.can_login, whichever direction the toggle moves. That clears login_disabled_by_expiry on any live graduation for that user, so an administrator's explicit sign-in decision permanently overrides the automatic bookkeeping: a later re-opening of the graduation window never flips sign-in back on top of a decision a human already made.

Guardian visibility. graduate-access.query.ts exports the read-side helpers the other four modules use: graduateAccessEnded(studentIdColumn, today) is a SQL predicate — a live graduation with portal_access_ended_at IS NOT NULL or portal_access_until < today. The guardian portal's own-child resolution adds NOT graduateAccessEnded(...) to its identity filter, so once access has ended the child stops resolving as this guardian's child at all: the same 404 a stranger's child would produce, deliberately at the identity layer rather than the row-scope layer, because ending access is an audited lifecycle transition and not a record-keeping toggle. A graduate in grace — access ended in the future, not yet reached — is still visible; only ended removes them.

assertNotGraduatedReadOnly(executor, studentId, today) is the write-side guard the pupil's own portal calls, and only ever after the caller has already resolved the student through its own identity check (never on a raw id, which would let a 403 disclose that an arbitrary id belongs to a graduate). It throws STUDENT_GRADUATED_READ_ONLY when the pupil's state is grace or ended.

6.8 StudentGuardiansService

MethodCalled ByReadsWritesSide EffectsErrors
findGuardians(actor, id)StudentsController.findGuardiansstudent_guardian joined to guardians/users, live onlyNoneNoneSTUDENT_NOT_FOUND (via assertCanAccess)
lookupGuardiansByPhone(actor, phone)StudentsController.guardianLookupguardians joined to users, scoped, WHERE phone = :phone, live onlyNoneNoneNone (empty array on no match)
setGuardians(actor, id, dto)StudentsController.setGuardiansLocks the student row FOR UPDATEstudent_guardian (delete all, reinsert), students.updated_atInvalidates students:*Delegates to assertGuardianSetValid; profile/guardian NOT_FOUND
writeGuardianLinks(tx, studentId, entries)setGuardians, StudentsService.createResolves each entry via resolveGuardianstudent_guardian inserts, then one UPDATE to flip the primaryNone (caller invalidates)STUDENT_GUARDIAN_ALREADY_LINKED (duplicate guardian in the set)
resolveGuardian(tx, entry)writeGuardianLinksguardians joined to users by id, if guardianId givenusers, guardians (if creating inline)NoneGUARDIAN_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 writeNoneNoneNoneSTUDENT_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

MethodCalled ByReadsWritesSide EffectsErrors
findMedical(actor, id)StudentsController.findMedicalstudents.medical_conditions/allergies/special_needs, live onlyNoneNoneSTUDENT_NOT_FOUND
updateMedical(actor, id, dto)StudentsController.updateMedicalSame three columnsSame three columnsInvalidates 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

MethodCalled ByReadsWritesSide EffectsErrors
findAll(actor, query)GuardiansController.findAllguardians joined to users, correlated child-liveness subqueryNoneReads/writes guardians:list:*; throws if pagination=falsePAGINATION_LIMIT_INVALID, PEOPLE_INVALID_SORT_FIELD
findOne(actor, id)GuardiansController.findOneSame shape, single rowNoneNoneGUARDIAN_NOT_FOUND
findChildren(actor, id)GuardiansController.findStudentsstudent_guardian joined to students/users, live onlyNoneNoneGUARDIAN_NOT_FOUND (via assertCanAccess)
create(actor, dto)GuardiansController.createRole lookup for "guardian"users, guardians, user_roleInvalidates guardians:*USER_EMAIL_ALREADY_EXISTS, GUARDIAN_ORGANIZATION_NAME_REQUIRED, ROLE_NOT_FOUND (if the seed never ran)
update(actor, id, dto)GuardiansController.updateCurrent row (no lock beyond the implicit SELECT ... FOR UPDATE)users, guardiansInvalidates guardians:*USER_EMAIL_ALREADY_EXISTS, GUARDIAN_ORGANIZATION_NAME_REQUIRED, GUARDIAN_NOT_FOUND
remove(actor, id)GuardiansController.removeDelegatesDelegatesInvalidates guardians:*GUARDIAN_HAS_LINKED_STUDENTS, USER_CANNOT_DELETE_SELF
restore(actor, id)GuardiansController.restoreDelegatesDelegatesInvalidates guardians:*USER_RESTORE_EMAIL_CONFLICT
ban/unban/sendPasswordResetLinkGuardiansControllerDelegatesDelegatesInvalidates 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

MethodCalled ByReadsWritesSide EffectsErrors
findAll(actor, query)StaffController.findAllstaff joined to users, left-joined departments/designationsNoneReads/writes staff:list:*; throws if pagination=falsePAGINATION_LIMIT_INVALID, PEOPLE_INVALID_SORT_FIELD
findOne(actor, id)StaffController.findOneSame shape, single row; the person's managed roles when actor holds Staff_UPDATENoneNoneSTAFF_NOT_FOUND
create(actor, dto)StaffController.createDesignation's is_teaching flag, role rows (FOR SHARE for an explicit rolePublicIds)users, staff, code_counters, user_roleInvalidates staff:*USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT, PEOPLE_ROLE_NOT_PERMITTED, STAFF_ROLE_TEACHER_JOB_ONLY, ROLE_NOT_FOUND, STAFF_ROLE_SCOPE_NOT_ASSIGNABLE
update(actor, id, dto)StaffController.updateCurrent row, then the same row FOR UPDATE; with roleSource, the designation's is_teaching, role rows and the person's user_role grantsusers, staff, and user_role when roleSource is sentInvalidates staff:*; after a role change, one audit entry and — when a role was removed — a close-user control message (§10)USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT, STAFF_NOT_FOUND, PEOPLE_STALE_RECORD, plus the role-edit errors in §6.11a
remove(actor, id)StaffController.removeDelegatesDelegatesInvalidates staff:*USER_CANNOT_DELETE_SELF
restore(actor, id)StaffController.restoreDelegates, then the restored row as findOne reads itDelegatesInvalidates staff:*STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT
ban(actor, id, reason)/unban(actor, id)/sendPasswordResetLink(actor, id, context)StaffControllerDelegates to PeopleAccessService.assertCanAccess, then PeopleAccountServiceDelegatesInvalidates staff:* (ban/unban only)Delegated — see §6.2
assignableRoles(actor)StaffController.assignableRolesActorAuthorityService.assignableRolesNoneNoneNone
assignableRolesFor(actor, id)StaffController.assignableRolesForPeopleAccessService.assertCanAccess, then the same set as assignableRolesNoneNoneSTAFF_NOT_FOUND

ban/unban/sendPasswordResetLink first run the same object-level scope check every other :id method here does, then hand off to the shared PeopleAccountService exactly as StudentsService and GuardiansService do — staff is not a special case among the three profile kinds for any of these three actions.

Single-record responses carry roles for viewers who could change them. findOne, update, create and restore return StaffDetailDto: StaffDto plus roles — { publicId, name } for every role the person holds except guardian- and pupil-scope roles, sorted by name — present only when the viewer's active role holds Staff_UPDATE (PeoplePermissionsService.can, so a superadmin passes). For anybody else the key is absent, not empty: a teacher reading a colleague's record learns nothing about who holds what authority. update reads roles inside its transaction, so they are the roles the edit left. The list stays StaffDto and its cache key's shape token is unchanged.

Choosing roles is an identity change. Sending rolePublicIds on create, or roleSource on update, requires Users_UPDATE on top of the staff permission — checked with PeoplePermissionsService.can, the same line sign-in access is held at. The refusal is 403 PEOPLE_ROLE_NOT_PERMITTED. On create it runs before the transaction opens; everything else about the chosen roles runs inside it, so a refusal rolls the whole admission back.

grantStaffRoles runs after the staff insert succeeds, so a given designationId is already known to name a real designation — the foreign key on the row just inserted enforced that. It uses onConflictDoNothing on (user_id, role_id), reasoning that a brand-new user cannot already hold either role but a role-grant failure should not be what fails a whole admission if it somehow occurs. create is the only write method in this module with no row lock (FOR UPDATE) before reading the current state. update takes one, and checks the required version token under it, so two concurrent PATCHes to the same staff row cannot silently overwrite each other — the second is refused with 409 PEOPLE_STALE_RECORD.

A role edit runs inside the update transaction, after the staff patch. update checks roleSource/rolePublicIds pairing (400) and Users_UPDATE (403), then hands the rest to StaffRolesService.changeRoles (§6.11a). Any refusal rolls back the staff patch and the role change together. After commit, and only when the edit changed something, afterRoleChange writes one audit entry through ActivityRecordService.recordActivity — module: "Staff", action: "UPDATE", resource: { id: <staff id>, type: "staff" }, metadata: { rolesAdded: string[], rolesRemoved: string[] } (role names) — because the interceptor's own entry for the request cannot see the diff. When a role was removed it then calls RealtimeControlPublisher.closeUserStreams(userId) (§10). Both are best-effort and neither can fail a write that has already committed.

6.11a StaffRolesService

The one home for every staff role rule. DataTransferModule provides its own instance for the bulk import writer, so its constructor takes only Database and ActorAuthorityService; anything else it needs is passed in.

The scope rule. STAFF_EXCLUDED_ROLE_SCOPES = ["guardian", "student"] and isStaffAssignableScope(scopeKind) are exported from staff-roles.service.ts. A guardian-scope role sees the rows of the holder's children and a student-scope role the holder's own pupil rows; neither means anything for an employee. Every staff role path reads this one predicate: both pickers, the create and edit writes, the set an edit replaces, the detail roles, and RoleService.setRowVisibility (§6.1).

MethodCalled ByReadsWritesErrors
assignableRoles(actor)StaffService.assignableRoles, assignableRolesForActorAuthorityService.assignableRoles (superadmin rule plus permission subset), then drops guardian- and pupil-scope rolesNoneNone
resolveRequestedRoles(tx, rolePublicIds)resolveForCreate, changeRolesroles by public_id, FOR SHARE in the caller's transactionNone400 ROLE_NOT_FOUND (an id names no role), then 400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE
isTeachingDesignation(executor, designationId)deriveJobRoleIds, the teaching checkdesignations.is_teaching; null is no designationNoneNone
deriveJobRoleIds(tx, designationId, { strict })grantStaffRoles (strict: false), changeRoles (strict: true)roles named staff, plus teacher for a teaching designationNonestrict: 500 ROLE_NOT_CONFIGURED when a named role is missing. Otherwise an error log and the roles it did find
grantStaffRoles(tx, userId, designationId, roleIds?)StaffService.create, PeopleImportWriter.writeStaffExplicit ids re-checked in the transaction; or the job's rolesuser_role (onConflictDoNothing)400 ROLE_NOT_FOUND for an explicit id that vanished
resolveForCreate(tx, actor, designationId, rolePublicIds)StaffService.createAs belowNone400 STAFF_ROLE_TEACHER_JOB_ONLY, then resolveRequestedRoles's errors, then assertMayAssignRole's
managedRoles(userId, executor?)replaceStaffRoles, StaffService.loadOneThe person's user_role grants, minus guardian- and pupil-scope roles, sorted by nameNoneNone
changeRoles(tx, actor, target, edit)StaffService.updateSee the check order belowvia replaceStaffRolesSee below
replaceStaffRoles(tx, actor, { userId }, next)changeRolesmanagedRolesuser_role inserts and one DELETE ... WHERE user_id = $1 AND role_id IN (...)assertMayAssignRole's errors, 403 USER_LAST_SUPERADMIN_PROTECTED

roleSource. Absent, an edit leaves roles exactly as they are — a designation change never re-derives them, and neither does StaffDesignationService.apply on the salary path. job makes the managed set the roles the designation after the edit implies; explicit makes it exactly rolePublicIds, replace-only, so staff is not added implicitly. Only the managed set is replaced: a guardian- or pupil-scope grant the person holds through another profile is neither shown nor removed.

The edit's check order, each refusal distinct (steps 1–2 in StaffService, 3–10 here):

#CheckError
1rolePublicIds is sent with roleSource: "explicit" and only with it400 STAFF_ROLE_SOURCE_INVALID
2The actor holds Users_UPDATE403 PEOPLE_ROLE_NOT_PERMITTED
3The actor is not editing their own record403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN
4ActorAuthorityService.assertMayActOnAccount — only a superadmin may act on a superadmin403 USER_SUPERADMIN_PROTECTED
5explicit is refused when the RESULTING designation is a teaching one400 STAFF_ROLE_TEACHER_JOB_ONLY
6The new set: resolveRequestedRoles for explicit; deriveJobRoleIds(..., { strict: true }) for job400 ROLE_NOT_FOUND, 400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE, 500 ROLE_NOT_CONFIGURED
7Diff against the managed set: added and removed—
8ActorAuthorityService.assertMayAssignRole on every role in added and in removed — including the roles job derives403 PEOPLE_ROLE_NOT_PERMITTED, or 403 ROLE_NOT_FOUND
9When the superadmin role is in removed, assertNotLastSuperadmin under its FOR UPDATE403 USER_LAST_SUPERADMIN_PROTECTED
10The write400 ROLE_NOT_FOUND if an added role vanished

Step 8 runs on removals as well as additions: an actor may not strip authority it does not itself hold, or emptying a peer's roles would act above the actor's own level. The superadmin role is checked by name and flag before any permission arithmetic, so emptying its grants never makes it assignable. A session acting as a removed role has its sessions.active_role_id nulled by the composite foreign key onto user_role (ON DELETE SET NULL (active_role_id), migration 0058); RoleGuard then refuses that session's next permissioned request with 403 AUTH_ACTIVE_ROLE_REQUIRED until it chooses a role. No permission cache needs invalidating: the cache is per role (rbac:role-perms:<roleId>), and JwtStrategy re-reads the person's roles on every request.

On create, rolePublicIds runs step 2 before the transaction, then the teaching check against the requested designation, resolveRequestedRoles, and assertMayAssignRole on every requested role. Without rolePublicIds, both create and the bulk import derive the job's roles non-strictly, logging and continuing when a seeded role is missing.

Lock order on an edit: the flagged principal designation (from PrincipalInvariantService), then staff FOR UPDATE, then users, then roles FOR SHARE, then the user_role rows, then the surviving superadmins' user_role rows FOR UPDATE inside assertNotLastSuperadmin. Two rare superadmin-only interleavings can still deadlock: an edit that changes a superadmin's person fields and removes their superadmin role, racing a ban of the same person (PeopleAccountService.ban locks the survivors first, then users); and two edits removing the superadmin role from two different superadmins at once (each locks the other's grant as a survivor, then deletes its own). Postgres aborts one side, and PersonWriterService.translate answers it as 409 PEOPLE_CONCURRENT_WRITE_CONFLICT; nothing was written and the request can be sent again.

6.12 StaffSalaryService

MethodCalled ByReadsWritesSide EffectsErrors
find(actor, id)StaffController.findSalarystaff salary/bank columnsNoneNoneSTAFF_NOT_FOUND, PERMISSION_INSUFFICIENT (defense-in-depth, in addition to the route guard)
update(actor, id, dto)StaffController.updateSalaryCurrent basicSalary/allowances (only if either is touched), then the salary columnsSalary/bank columnsInvalidates staff:* (the same prefix as the base staff cache, even though the list never projects these fields — "any write to this staff row", not "any write to a field the list happens to project")VALIDATION_FAILED (pair coherence), STAFF_NOT_FOUND, PERMISSION_INSUFFICIENT

Validation order: the pair-coherence check only runs when basicSalary or allowances is actually present in the patch (touchesBasic/touchesAllowances) — a bank-detail-only PATCH never triggers it. Fail-closed: both methods re-check PeoplePermissionsService.can even though the route already declares the matching @Permissions, because the guard answers "may this actor call this handler," never "may this actor see this field," and the second question is the one that matters if either method is ever reached another way.

Once a staff member has any salary_assignments row, the salary module owns their pay. StaffSalaryService.find reads isManagedBySalaryModule(staffId) (a plain EXISTS against salary_assignments) and returns it as managedBySalaryModule on StaffSalaryDto, so the panel's salary form can render these fields read-only once the salary module has taken over. mirrorFromAssignment is the ONLY path allowed to write basic_salary/allowances on the staff row from that point on — called by SalaryAssignmentWriter inside its own transaction on every effective salary change, never by StaffSalaryService.update directly; a stop mirrors 0/0. A PATCH to these fields through StaffSalaryService.update once the staff member is salary-module-managed refuses with STAFF_SALARY_MANAGED_BY_SALARY_MODULE.

Staff deletion is refused while pay or a school asset is still attached. PeopleDeletionService.assertStaffHoldsNothing (apps/api/src/modules/people/shared/people-deletion.service.ts) takes the same per-staff advisory lock the salary assignment writer uses (SALARY_STAFF_LOCK_NAMESPACE), then checks in one query: an effective (non-stop) or scheduled salary_assignments row, an unsettled salary_payslips row (draft, or approved with payment_status <> 'paid' and a non-zero net), and any assets row whose custodian_staff_id is this staff member. Any one of those throws STAFF_HAS_ACTIVE_PAY_OR_ASSETS naming which condition held in details (effectiveSalary, scheduledSalaryChange, unsettledPayslips, assetsInCustody) — a soft delete fires no ON DELETE, so without this check payroll would keep paying, and the asset register would keep crediting, somebody the rest of the system says is gone. Stop their pay and return their assets first.

6.13 PrincipalInvariantService

At most one live, active staff member may hold the designation flagged is_principal in designations. This cannot be a database constraint: an index predicate cannot contain a subquery, so Postgres has no way to express "at most one row whose designation is the flagged one." The alternative would have been a denormalised staff.is_principal column maintained by every write path, with nothing in the schema tying that copy to its source — the two could desynchronise silently and no constraint would catch it. apps/api/src/modules/people/shared/principal-invariant.service.ts is the single locked write path taken instead, and there is no backstop: a write path that forgets to call it does not produce an error, it produces two principals and a report card signed by whichever row a query happens to return.

MethodCalled ByReadsWritesSide EffectsErrors
assertSinglePrincipal(tx, candidate)Every write listed belowdesignations (FOR UPDATE on the flagged row), then staff joined to users for an existing active holderNone — a pure guardReturns { principalDepartmentId } when this write is the one taking the designation, so the caller can also write department_id alongside designation_idPRINCIPAL_DESIGNATION_MISSING (409, the seed never ran), STAFF_PRINCIPAL_ALREADY_ASSIGNED (409, names the current holder)
resolvePrincipal(tx)SchoolProfileService.get/updatestaff 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:

  1. Staff create — an INSERT, which no UPDATE-shaped guard would cover.
  2. Staff update — the designation patch.
  3. Bulk import — designation resolved by name, bypassing StaffService entirely.
  4. Restore of a soft-deleted ex-principal.
  5. Employment status returning to "active".

Sites 1, 2, and 5 are three different transitions reachable through only two methods (create, update); the count of transitions and the count of call sites are not the same number, and both matter for different reasons — the transitions are what the tests name, the sites are what must remember to call this.

PrincipalCandidate is the resulting state of the write, never the raw DTO. PATCH { employmentStatus: 'active' } carries no designationId and PATCH { designationId } carries no employmentStatus, so every caller resolves dto-over-current-row into a PrincipalCandidate before calling assertSinglePrincipal. Passing the DTO's own values straight through would let either transition slip past the guard undetected: a status change that reactivates a principal, or a designation change on someone already active.

The lock order is fixed and load-bearing. The flagged designations row is always locked first, before any staff row, on every path — including restoreProfile, which already takes FOR UPDATE on the profile row and is careful to call this before that lock is taken. Reversing the order on any one path would have that path lock staff-then-designations while the others lock designations-then-staff, and two operators racing on one staff row would deadlock with an unmapped 40P01.

The INSERT-path exclusion is deliberate, not an oversight. Step 5 of assertSinglePrincipal excludes the candidate's own staff id from the "does someone else already hold it" query — but only when candidate.staffId !== null. On the create path staffId is null (there is no id yet), and the exclusion clause is omitted entirely rather than compared against null, because id <> NULL evaluates to NULL in SQL for every row, which would make the query return nothing and let this assert silently never fire on the one path that has no other backstop.

resolvePrincipal uses the exact same predicate as step 5 of assertSinglePrincipal — flagged designation, deleted_at IS NULL, employment_status = 'active' — deliberately. Resolving the school profile's principal by any looser rule (for example, ignoring employment_status) could display a principal the assert would not count as one, and the two would silently disagree about who holds the role.

Missing the flagged designation is refused, not treated as "nobody is principal." If no designations row has is_principal = true, FOR UPDATE matches nothing and therefore locks nothing — proceeding as if the invariant were satisfied would enforce it against nothing while reporting success, which is worse than either allowing or refusing outright. Both the migration and the seed path are expected to write this row; a database missing it is treated as misconfigured (409 PRINCIPAL_DESIGNATION_MISSING), not merely empty.

Assigning the Principal designation moves the staff member into that designation's department. The composite foreign key staff_designation_in_department_fk — (department_id, designation_id) → designations(department_id, id) — rejects any staff row whose department does not match its designation's department, so a write that sets designationId to the flagged row without also setting the matching departmentId is rejected at the database rather than silently filed under the wrong department. This is exactly why assertSinglePrincipal returns { principalDepartmentId } when it is the caller's write taking the designation: the caller uses it to set department_id in the same write, rather than leaving the caller to separately look up which department the Principal designation belongs to.

Two designation-level guards exist solely to protect this mechanism, documented in the school module: LookupsService.updateDesignation refuses to retire the flagged designation (PRINCIPAL_DESIGNATION_RETIRE_FORBIDDEN) and LookupsService.deleteDesignation refuses to delete it (PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN) — see the school module's backend doc. Both exist because retiring or deleting the flagged row would silently strand assertSinglePrincipal's lock target and resolvePrincipal's join target, with no designation left to appoint a successor to.

6.14 StaffDesignationService

No HTTP route — apply(tx, staffId, departmentId, designationId, actor) runs only inside another write's own transaction, currently the salary module's promotion/demotion path. It moves a staff member's department_id/designation_id in the CALLER's transaction, so a change of pay and a change of post commit together or not at all — a promotion that changed the designation but failed to also record the salary change (or vice versa) would leave the staff record and the pay record disagreeing about which post this person holds. It locks the staff row FOR UPDATE first, then calls PrincipalInvariantService.assertSinglePrincipal with exactly the lock order StaffService.update uses for an ordinary edit (the flagged designations row before the staff row), so a promotion racing an unrelated staff edit never deadlocks by taking that order in reverse. It invalidates the staff:* cache the same way every other staff write does.

6.15 PeopleSummaryService

Headline people counts for GET /api/people/summary.

MethodCalled ByReadsWritesSide EffectsErrors
read(actor)PeopleSummaryController.getSummaryThe current academic_sessions row; students with users and student_class_enrollments; staff with users and designations; guardians with users; the school clockNoneRedis read per section, fire-and-forget Redis write on a missNone of its own
  • Scope. Each section applies PeopleAccessService.scopeFor and applyScope for its own roll, the same predicates the matching list uses. wholeSchool is scopeFor(actor, "students", held).kind === "all".
  • Optional sections. staff and guardians are computed only when PeoplePermissionsService.can reports Staff_READ and Guardians_READ respectively; otherwise they are null and no query runs.
  • Students. activeEnrolled counts distinct pupils with record_status = 'active' and an active enrolment in the current session; it is null with no current session. admittedThisMonth counts admission_date between the month bounds. Soft-deleted students and users are excluded.
  • Staff. employed counts employment_status in (active, on_leave, suspended); teaching narrows that to designations with is_teaching. The designation join is a left join.
  • Guardians. total counts live guardian rows with live users.
  • Month. currentMonthBounds(today) converts the school-local date to Bikram Sambat and returns the month's first and last day as Gregorian dates. A converter failure falls back to the Gregorian month with bsYear and bsMonth null.
  • Cache. Sections are cached for 60 seconds through cacheGetFast and cacheSetFast (see §8).

7. Runtime Flows

7.1 Admit a student (create)

StepCode PathBehaviorFailure Case
1StudentsController.createDeserializes and validates CreateStudentDto.400 on any class-validator failure.
2StudentGuardiansService.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.
2aPeoplePermissionsService.resolveSignInGrantDecides 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).
3StudentsService.create → db.transactionInserts 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).
3aPeopleAccountService.inviteOnCreate, on the same transactionWhen 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.
4loadOneReloads 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.
5invalidateSweeps 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)

StepCode PathBehaviorFailure Case
1StudentsController.updateValidates UpdateStudentDto, requires version.400 if version missing.
2PeopleAccessService.assertCanAccessConfirms the row is in the actor's scope.404 STUDENT_NOT_FOUND if out of scope or absent.
3StudentsService.updateLocks 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.
4Patch buildPersonWriterService.buildUpdate includes only sent keys.None — an unsent field is simply absent from the SET clause.
5students updateupdated_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

StepCode PathBehaviorFailure Case
1-2setGuardiansAccess check, shape validation.404, 400 as in §6.8.
3db.transactionLocks the student row, deletes every existing link.404 STUDENT_NOT_FOUND if the row vanished between the access check and the lock.
4writeGuardianLinksResolves 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.
5Primary flipOne 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

StepCode PathBehaviorFailure Case
1softDeleteProfileResolves userId, refuses self-deletion.409 USER_CANNOT_DELETE_SELF.
2Guardian-only checkRefuses if any live student still links to this guardian.409 GUARDIAN_HAS_LINKED_STUDENTS.
3Profile updateSets the profile's deleted_at.None.
4Liveness counthasNoLiveProfile sums live rows across students+guardians+staff for the person.None.
5Person cascadeOnly 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 PatternBuilderValueTTLInvalidationCaller
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 }120sdelPatternSoft("students:*") on every write to a student, its guardians, or its medical recordStudentsService.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 }120sdelPatternSoft("guardians:*") on every writeGuardiansService.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 }120sdelPatternSoft("staff:*") on every write, including a salary-only updateStaffService.findAll, StaffSalaryService.update
students:summary:shape:2-scope:<tag>-view:<tag>-today:<date>-session:<id>CacheKeyUtil.build("students:summary:", [...])PeopleSummaryStudentsDto60sDropped by the students:* sweep on every student write and by ClassEnrollmentsService on an enrolment write; otherwise the TTLPeopleSummaryService.read
staff:summary:shape:2-scope:<tag>-view:<tag>-today:<date>CacheKeyUtil.build("staff:summary:", [...])PeopleSummaryStaffDto60sDropped by the staff:* sweep on every staff write; the import worker and a designation's is_teaching edit bypass it, so the TTL bounds themPeopleSummaryService.read
guardians:summary:shape:2-scope:<tag>-view:<tag>CacheKeyUtil.build("guardians:summary:", [...])PeopleSummaryGuardiansDto60sDropped by the guardians:* sweep on every guardian write; the import worker bypasses it, so the TTL bounds itPeopleSummaryService.read
role:permissions:<roleId> (outside this module, in RoleService)RoleService.permissionCacheKeyPermissionCode[]3600sDeleted immediately by any role-permission editPeoplePermissionsService.heldPermissions/.can (indirectly, via RoleService.getPermissionsForRoleId)

Explain:

  • Deterministic segment order. CacheKeyUtil.build joins 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. getSoft deserializes the JSON blob and returns it directly — no re-validation against the current permission set happens on a hit, which is exactly why viewTag must be part of the key rather than checked after the fact.
  • Cache miss behavior. Falls through to queryList (wrapped in withSearchThreshold if a search term is present), then setSofts the result before returning.
  • Serialization shape. Plain JSON.stringify — dates serialize to ISO strings; a consumer reading a cached hit gets Date-shaped strings exactly as JSON.parse on 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 (returns null, logged as a warn) and delPatternSoft (logs a warn, does not throw) are fail-soft. A setSoft failure is likewise swallowed. None of the three ever turns a Redis outage into a failed request; the tradeoff is a stale list for up to 120 seconds after a failed invalidation.

Summary reads are time-bounded. The three summary keys are read with cacheGetFast (apps/api/src/common/utils/cache-race.util.ts), not getSoft alone. The shared Redis client queues commands while disconnected instead of rejecting them, so an awaited read would hold the request open through an outage. cacheGetFast races the read against a 250 ms budget and treats a timeout, an error or a miss identically: the service computes from the database. cacheSetFast writes without awaiting, so a slow or lost write costs the next reader a miss and never delays the response. The staff and guardian sections are skipped entirely, with no Redis call, when the caller lacks the matching read permission. wholeSchool, the session name and the month are recomputed on every request.

The scopeTag/viewTag pair (built by PeopleAccessService.cacheTags) is folded into every one of the three list keys above. scopeTag is "all" for an unrestricted actor or u:<userId>:r:<activeRoleId> for a restricted one — keyed on the (user, active role) pair, not the user alone, because one person can hold two restricted roles (a student who is also a sibling's guardian) and a user-only tag would serve their guardian-scoped result after they switch to acting as a student. viewTag is the sorted, comma-joined list of held field-gate permissions (StaffSalary_READ, StudentMedical_READ) or the literal string "base" if neither is held — its absence from a cache key was the specific defect this field exists to prevent: an HR user's staff list and a teacher's staff list would otherwise share one key, and whichever request populated the cache first (protected by getOrSet's lock in the general case, though these three list methods use plain getSoft/setSoft rather than getOrSet) would serve its rendering to the other by design, not by race.

9. BullMQ, Schedulers, and Async Work

None. Every write in this module is synchronous within the HTTP request — there is no queue producer, no processor, and no scheduled job anywhere in apps/api/src/modules/people/. Password-reset email delivery (AuthEmailService.sendPasswordResetEmailSafe) is called synchronously and is fail-soft on delivery failure, but it is not queued.

10. Realtime and Events

No Socket.IO gateway, no emitted domain event and no realtime room. One realtime control message:

  • Trigger. StaffService.update, after the transaction commits, when a role edit removed at least one role.
  • Publisher. RealtimeControlPublisher.closeUserStreams(userId), from RealtimeControlModule (modules/realtime/shared/realtime-control.module.ts). PeopleModule imports that module and never RealtimeSharedModule: the data-transfer worker imports PeopleModule, and the shared module would start a stream subscriber there.
  • Message. { kind: "close-user", userId } on the Redis channel realtime:control (REALTIME_CONTROL_CHANNEL). It is not an outbox event and is not in realtime.contract.ts.
  • Effect. Every API instance's RealtimeStreamService ends that person's open event streams. An open stream filters on the permissions it resolved at connect time; ending it makes the browser reconnect and resolve the permissions the person holds now.
  • Failure. Best-effort. The publisher's own connection has no offline queue and a 2-second command timeout, so during a Redis outage the publish fails at once, is logged at warn, and the PATCH still answers 200. A lost message leaves that person's streams on their old permissions until they next reconnect. No queue, outbox row or retry is involved.

The realtime module's own pages describe the channel and the stream's attach re-check.

11. Security, Auth, and Abuse Controls

  • Guards. Every controller in this module carries @UseGuards(JwtAuthGuard, RoleGuard) at the class level — there is no public or guest-accessible route anywhere in /students, /guardians, or /staff.
  • Permissions. Every handler declares @Permissions(...); RoleGuard's fail-open branch (for handlers declaring no permission at all) is scoped to non-admin surfaces only, and /students, /guardians, /staff are all classified as admin surfaces by isAdminSurface (anything not under /mobile), so a handler here that forgot @Permissions() would be refused outright rather than silently allowed.
  • Object-level authorization. PeopleAccessService is the second, mandatory layer beneath every :id route — 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, and sendPasswordResetLink on all three entities: ActorAuthorityService.assertMayActOnAccount refuses 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 _UPDATE permission 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/unban routes in the users module, 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 by JwtStrategy) 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 mutable name.
  • Rate limits. None specific to this module observed; whatever global throttling apps/api applies 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. PersonWriterService centralizes trimming and blank-to-null conversion for every text field written to users; 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 through POST /:id/sign-in — requires Users_UPDATE, never the profile-kind permission. A login-capable row carrying an email address is a route to a session, because POST /auth/password/forgot is @Public(). Revoking through DELETE /:id/sign-in additionally deletes every session the person holds. Choosing a staff member's roles — rolePublicIds on POST /staff, roleSource on PATCH /staff/:id, and both role pickers — requires Users_UPDATE for the same reason.
  • Role escalation. Every staff role write refuses guardian- and pupil-scope roles, runs assertMayAssignRole on every role it adds and every role it removes, refuses the actor's own record and — unless the actor is one — a superadmin's, and protects the last superadmin who can sign in (§6.11a).
  • Audit logs. No dedicated audit table for this module. PeopleAccountService.ban records banned_by on the users row itself, which is the only durable "who did this" trace for a suspension. A staff role edit that changed something writes one Mongo audit entry naming the roles added and removed (§6.11), beside the interceptor's own entry for the request.
  • Fail-closed behavior. assertNotLastSuperadmin, assertMayActOnAccount, the version check, the guardian-with-live-children check, and every permission/scope check are all fail-closed — none has a fallback path that proceeds on ambiguity or error.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
STUDENT_NOT_FOUND404PeopleAccessService, StudentsService, StudentGuardiansService, StudentMedicalService, PeopleDeletionService, PeopleAccountServiceRow absent, soft-deleted, or out of the actor's scopeTreat as not found; do not retry with the same id
GUARDIAN_NOT_FOUND404Same set of services, guardian entitySameSame
STAFF_NOT_FOUND404Same set, staff entitySameSame
USER_NOT_FOUND404PeopleAccessService (the users entity variant)Rare — reached only through a direct users-scoped access checkSame
STUDENT_ADMISSION_NUMBER_TAKEN409PersonWriterService.translate (via students_admission_number_unique)A supplied or reallocated admission number collides with a live rowRetry with a different number, or omit it to auto-allocate
STAFF_EMPLOYEE_CODE_TAKEN409PersonWriterService.translate (via staff_employee_code_unique)Same, for staffSame
STUDENT_GUARDIAN_PRIMARY_REQUIRED400StudentGuardiansService.assertGuardianSetValidA non-empty guardian set names no primaryMark exactly one guardian primary and resubmit
STUDENT_GUARDIAN_MULTIPLE_PRIMARY400/409assertGuardianSetValid (400, pre-write) or PersonWriterService.translate via student_single_primary_guardian (409, if reached at the database)More than one guardian marked primaryMark exactly one primary
STUDENT_GUARDIAN_ALREADY_LINKED409StudentGuardiansService.writeGuardianLinksSame guardian resolved twice within one submitted setDe-duplicate the guardian list client-side
STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICT409PeopleDeletionService.assertCodeStillFreeThe number was reissued to a different live student while this one was deletedAssign the record being restored a new number, then retry
STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT409assertCodeStillFreeSame, for staffSame
USER_RESTORE_EMAIL_CONFLICT409PeopleDeletionService.assertEmailStillFreeThe address was taken by a live account since this person was deletedChange the email on one side, then retry
USER_CANNOT_DELETE_SELF409PeopleDeletionService.softDeleteProfile, PeopleAccountService.assertNotSelfActor targets their own users.id for delete or banAsk another administrator to perform the action
USER_LAST_SUPERADMIN_PROTECTED403ActorAuthorityService.assertNotLastSuperadmin (called from deletion and ban)The action would leave no live, password-holding superadminGrant superadmin to another live account first
GUARDIAN_HAS_LINKED_STUDENTS409PeopleDeletionService.softDeleteProfile (guardian kind)Guardian still has at least one live linked studentUnlink the student(s) first, then delete
GUARDIAN_ORGANIZATION_NAME_REQUIRED409PersonWriterService.translate via guardian_org_has_namekind = 'organization' reached the database with a blank organizationNameEnsure person.firstName is non-blank when creating/updating an organisation guardian
STUDENT_REQUIRES_ONE_GUARDIAN400StudentGuardiansService.assertGuardianSetValidThe submitted guardian set is emptyAdd at least a father, a mother, or a local guardian
GUARDIAN_SLOT_TAKEN400/409assertGuardianSetValid (400, pre-write) or PersonWriterService.translate via student_guardian_one_per_relationship (409, concurrent race)Two entries name the same relationship slot for one pupilRemove the duplicate slot, or resubmit after the race resolves
ADDRESS_HIERARCHY_INVALID409PersonWriterService.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 parentFill the address hierarchy from the top (province → district → municipality → ward), matching only children of the chosen parent
STUDENT_ID_MALFORMED400PeopleCodeService.adoptSuppliedStudentIdAn operator-supplied studentId does not match SID-YYYY-NNNNCorrect the format and resubmit
STUDENT_ID_SEQUENCE_OUT_OF_RANGE400adoptSuppliedStudentIdSupplied sequence exceeds 999,999Supply a sequence within range, or omit studentId to auto-allocate
STUDENT_ID_TAKEN409PersonWriterService.translate via students_student_id_uniqueThe supplied or allocated student_id is already in use, including on a removed recordChoose a different value; student IDs are never reissued
STAFF_DESIGNATION_NOT_IN_DEPARTMENT409PersonWriterService.translate via staff_designation_in_department_fkChosen designationId does not belong to the chosen departmentIdChoose a designation that belongs to the selected department
USER_EMAIL_ALREADY_EXISTS409PersonWriterService.assertEmailFree, and .translate via users_email_unique as the database-level backstopEmail already belongs to another live personUse a different email, or find and reuse the existing person
USER_BAN_REASON_REQUIRED400PeopleAccountService.banBlank or whitespace-only reasonSupply a non-blank reason
USER_SUPERADMIN_PROTECTED403ActorAuthorityService.assertMayActOnAccount, called from PeopleAccountService.ban/unban/sendPasswordResetLinkActor is not a superadmin and the target holds the superadmin role (self-targeting is exempt)Have another superadmin perform the action
GRADUATE_ACCESS_NOT_FOUND404StudentGraduateAccessService.update, GraduateAccessService.setUntil/endNowThe pupil has no live (unrevoked) graduationNothing to change; the pupil is not a graduate
GRADUATE_ACCESS_DATE_INVALID400GraduateAccessService.setUntilThe new last-day-of-access date is missing, malformed, precedes the graduation date, or is more than 3,650 days after the graduation dateChoose a date within the graduation's allowed range
GRADUATE_ACCESS_NOT_STARTED409GraduateAccessService.endNowaction: "end_now" requested before the graduation date has arrivedWait until the graduation date, or move portalAccessUntil instead
GRADUATE_ACCESS_SIGN_IN_PERMISSION_REQUIRED403StudentGraduateAccessService.updateActor holds Students_UPDATE but not Users_UPDATE; refused before the graduation is even loadedHave an actor who also holds Users_UPDATE make the change
STUDENT_GRADUATED_READ_ONLY403graduate-access.query.ts — assertNotGraduatedReadOnly, called from the guardian, attendance, leave and assignment portal write pathsThe pupil's graduate-access state is grace or ended and a portal write was attempted for or by themThe pupil's portal is read-only; no client action re-enables it
USER_EMAIL_REQUIRED409PeopleAccountService.sendPasswordResetLinkTarget has no email on fileAdd an email to the record first
USER_LOGIN_DISABLED409sendPasswordResetLinkTarget's canLogin = falseGrant 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_CONFLICT400PeoplePermissionsService.resolveSignInGrantgrantSignIn and person.canLogin were both sent on a create with different valuesSend one of them, or the same value for both — the server will not guess which was intended
PERSON_SIGN_IN_STATE_CHANGED409PeopleAccountService.setSignInSomebody else changed can_login between this request's read and its guarded writeRe-read the person and reissue the request if it is still wanted
AUTH_FORBIDDEN403resolveSignInGrant, StudentGuardiansService (nested guardian create)A sign-in grant was asked for by an actor without Users_UPDATECreate the person without sign-in access, or have an administrator grant it
AUTH_ACCOUNT_BANNED409sendPasswordResetLinkTarget is currently bannedUnban first
PEOPLE_STALE_RECORD409StudentsService.update, StaffService.update, StaffSalaryService.update, GuardiansService.updateSubmitted version does not match the current row's version counterReload the record, reapply the edit, resubmit
PEOPLE_INVALID_SORT_FIELD400StudentsService.orderBy, GuardiansService.resolveSort, StaffService.orderBysort/sortBy names a column outside the entity's allow-listUse one of the documented sortable columns
VALIDATION_FAILED400StaffSalaryService.update (basic/allowances pair broken); the global ValidationPipe via GuardianLookupDto (blank/missing phone)See condition columnSupply both salary fields together or neither; supply a phone number to search
PERMISSION_INSUFFICIENT403StaffSalaryService.resolveSalaryForCreateThe salary key is present on POST /staff (any value, including {}) and the actor lacks StaffSalary_UPDATECreate the staff member without a salary block, or ask an administrator
PRINCIPAL_DESIGNATION_MISSING409PrincipalInvariantService.assertSinglePrincipalNo designations row is flagged is_principal — the seed never ranRun the database seed; this is a misconfiguration, not a normal operator error
STAFF_PRINCIPAL_ALREADY_ASSIGNED409PrincipalInvariantService.assertSinglePrincipalThis write would create a second live, active holder of the flagged designationChange the current holder's designation, or set them to inactive, first
PRINCIPAL_DESIGNATION_RETIRE_FORBIDDEN409LookupsService.updateDesignation (school module)isActive: false on the designation flagged is_principalDo not retire this designation
PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN409LookupsService.deleteDesignation (school module)DELETE on the designation flagged is_principalDo not delete this designation
PAGINATION_LIMIT_INVALID400StudentsService.findAll, GuardiansService.findAll, StaffService.findAllpagination=false requested on any of the three people listsDo not disable pagination for these endpoints
PERMISSION_INSUFFICIENT403StaffSalaryService, StudentMedicalService (defense-in-depth), RoleGuard (route-level)Active role lacks the specific field-gate permissionRequest the permission, or use an account that holds it
ROLE_NOT_FOUND500grantProfileRole (every pupil and guardian creation path)The student or guardian system role has not been seededRun the auth seed against the target database
ROLE_NOT_FOUND400StaffRolesService.resolveRequestedRoles, grantExplicitRolesA role public id on POST /staff or PATCH /staff/:id names no role, or the role was deleted while the request was in flightReload the role picker and choose again
ROLE_NOT_FOUND403ActorAuthorityService.assertMayAssignRoleA role being added or removed disappeared between resolution and the authority checkReload and retry
PEOPLE_ROLE_NOT_PERMITTED403StaffService (no Users_UPDATE with rolePublicIds or roleSource), assertMayAssignRoleThe actor may not choose roles, or a role being added or removed carries permissions the actor does not hold, or it is the superadmin role and the actor is not a superadminAsk an administrator who holds the role's permissions
PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN403StaffRolesService.changeRolesA role edit on the actor's own staff recordAsk another administrator
USER_SUPERADMIN_PROTECTED403assertMayActOnAccount, from StaffRolesService.changeRolesA role edit on a superadmin's record by somebody who is not oneHave a superadmin make the change
USER_LAST_SUPERADMIN_PROTECTED403assertNotLastSuperadmin, from StaffRolesService.replaceStaffRolesThe edit would remove the superadmin role from the last superadmin who can sign inGive another live, password-holding account the superadmin role first
STAFF_ROLE_SCOPE_NOT_ASSIGNABLE400StaffRolesService.resolveRequestedRolesA guardian- or pupil-scope role was requested for a staff memberChoose a staff role; guardian and pupil roles come with those profiles
STAFF_ROLE_SOURCE_INVALID400StaffService.updaterolePublicIds without roleSource: "explicit", or explicit without rolePublicIdsClient defect — send both or neither
STAFF_ROLE_TEACHER_JOB_ONLY400StaffRolesService (create and edit)Explicit roles on a teaching designation (on an edit, the designation the edit leaves)Use roleSource: "job"; a teacher's roles come from the designation
ROLE_NOT_CONFIGURED500StaffRolesService.deriveJobRoleIds with strict: trueroleSource: "job" and the seeded staff or teacher role is missingRun the auth seed; nothing was changed
ROLE_SCOPE_HELD_BY_STAFF409RoleService.setRowVisibilityNarrowing a role to guardian or pupil scope while a live or soft-deleted staff member holds it (the route's own DTO refuses those values first)Remove the role from the staff members first
PEOPLE_CONCURRENT_WRITE_CONFLICT409PersonWriterService.translate, on every create and update path that routes errors through itPostgres aborted the write with 40P01 (deadlock) or 40001 (serialization failure); nothing was writtenSend the same request again

14. Observability

SignalLocationPurpose
LogPeopleAccountService's Logger (PeopleAccountService.name)Records every password-reset-link issuance (kind, profile id, acting administrator) at log level.
LogRedisCacheService's Logger, invoked indirectly via getSoft/setSoft/delPatternSoftwarn-level entries on any Redis failure touching a people cache key, naming the key or pattern.
MetricNone foundNo dedicated metric emission in this module.
Auditusers.banned_by/banned_at/ban_reasonThe 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 TypeFilesCoverage
Integration (real database)apps/api/src/modules/people/students/__tests__/students.service.integration.spec.tsAdmission (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/integrationapps/api/src/modules/people/guardians/guardians.service.spec.tsHousehold 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/integrationapps/api/src/modules/people/staff/staff.service.spec.tsAdmission 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.tsThe "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:docs

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
PeopleModuleAggregate moduleWiringActorAuthorityModule, AuthModule, RoleModule, SchoolProfileModule, LookupsModule, GuardiansModuleAppModuleInstantiates every provider belowNone directlyMissing import → InstanceLoader crash naming the consumer, not the missing module
GuardiansModuleLeaf module, nestedGuardian wiringActorAuthorityModule, AuthModule, RoleModule (own copies, not inherited from parent)PeopleModuleInstantiates GuardiansService and its own copies of the shared servicesNone directlySame class of failure if its own imports drift from what its providers need
StudentsControllerController/students route surfaceStudentsService, StudentGuardiansService, StudentMedicalServiceHTTP layerDelegates to its three servicesNone directlyMissing @Permissions on an admin-surface handler → runtime ForbiddenException via RoleGuard's tripwire branch
GuardiansControllerController/guardians route surfaceGuardiansServiceHTTP layerDelegatesNone directlySame
StaffControllerController/staff route surfaceStaffService, StaffSalaryServiceHTTP layerDelegatesNone directlySame
StudentsServiceServiceStudent CRUD, list caching, version checkPeopleAccessService, PeoplePermissionsService, PeopleCodeService, PeopleDeletionService, PersonWriterService, PeopleAccountService, StudentGuardiansService, RedisCacheService, DatabaseStudentsControllerEvery shared service, StudentGuardiansService.writeGuardianLinksusers, students, code_counters, students:* cacheConstraint violations mapped by PersonWriterService.translate; unmapped ones rethrow as 500
StudentGuardiansServiceServiceGuardian relationship on a studentPeopleAccessService, PeoplePermissionsService, PersonWriterService, RedisCacheService, DatabaseStudentsController, StudentsService.createresolveGuardian, databasestudent_guardian, users/guardians (if creating inline), students:* cache23505 on the primary index if the two-phase write is ever bypassed by a future change
StudentMedicalServiceServiceHealth-record sub-resourcePeopleAccessService, PeoplePermissionsService, RedisCacheService, DatabaseStudentsControllerDatabasestudents (three columns), students:* cacheSTUDENT_NOT_FOUND if the row vanished between the access check and the update
GuardiansServiceServiceGuardian CRUD, mandatory paginationPeopleAccessService, PeoplePermissionsService, PeopleDeletionService, PersonWriterService, PeopleAccountService, RedisCacheService, DatabaseGuardiansControllergrantProfileRole, databaseusers, guardians, user_role, guardians:* cacheROLE_NOT_FOUND (500) if the guardian role is unseeded
StaffServiceServiceStaff CRUD, role grants and role edits, account actionsPeopleAccessService, PeoplePermissionsService, PeopleCodeService, PeopleDeletionService, PersonWriterService, PeopleAccountService, StaffRolesService, ActivityRecordService, RealtimeControlPublisher, RedisCacheService, DatabaseStaffControllerStaffRolesService, PeopleAccountService.ban/unban/sendPasswordResetLink, recordActivity, the realtime:control channel, databaseusers, staff, code_counters, user_role, staff:* cache, Mongo audit_logsPATCH requires a version token, checked under FOR UPDATE; a role edit runs in the same transaction
StaffRolesServiceServiceThe staff role rules: scope, pickers, resolution, derivation, replacementDatabase, ActorAuthorityServiceStaffService, PeopleImportWriter (its own instance)ActorAuthorityService.assignableRoles/assertMayAssignRole/assertMayActOnAccount/assertNotLastSuperadminroles (FOR SHARE), user_role, designationsGuardian- and pupil-scope roles are never granted through it
StaffSalaryServiceServiceSalary/bank sub-resourcePeopleAccessService, PeoplePermissionsService, RedisCacheService, DatabaseStaffControllerDatabasestaff (salary/bank columns), staff:* cacheTwo-statement pair-coherence check is not itself race-safe (§7.5); the database CHECK is the real backstop
PeopleAccessServiceShared serviceObject-level scope, cache tagsDatabaseEvery other service in this moduleDatabase (scope-membership SELECTs only)None (read-only)Defaults to sql\false`` for any unhandled actor/entity combination — fails closed
PeopleAccountServiceShared serviceBan/unban/reset-link, sign-in grant and revoke, account invitationsActorAuthorityService, AuthSessionService, VerificationTokenService, AuthEmailService, DatabaseStudentsService, GuardiansService, StaffService, StudentGuardiansService, and all three controllers directly for :id/sign-inActorAuthorityService.assertMayActOnAccount/assertNotLastSuperadmin, AuthSessionService.deleteSessionsForUser, email/token servicesusers (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
PeopleCodeServiceShared serviceAtomic code allocationSchoolProfileService, DatabaseStudentsService.create, StaffService.createSchoolProfileService.getTimezonecode_countersNone realistic — the upsert cannot fail on well-formed input
PeopleDeletionServiceShared serviceSoft delete/restoreActorAuthorityService, AuthSessionService, DatabaseStudentsService, GuardiansService, StaffServiceActorAuthorityService.assertNotLastSuperadmin, AuthSessionService.deleteSessionsForUserThe profile table, conditionally users/account/sessionsRestore-time code/email conflicts named explicitly rather than surfacing as 23505
PeopleInvitationServiceShared serviceMinting account invitations and scheduling their emailsVerificationTokenService, AuthEmailServicePeopleAccountService, all three create methods, StudentGuardiansServiceVerificationTokenService.createPasswordReset, AuthEmailService.sendPasswordResetEmailSafeA verification row (account_invite, 7 days) plus the notification event and its outbox row, all on the caller's executorPropagates rather than swallowing — a sent: true for an unscheduled invitation would be a false report about somebody's credentials
PeoplePermissionsServiceShared serviceField-permission resolution, and the create-time sign-in grant decisionRoleServiceStaffSalaryService, StudentMedicalService, all three create methods, every list method (for cacheTags)RoleService.getPermissionsForRoleIdNone (read-only)Superadmin short-circuits in can() without consulting the set — heldPermissions() does not, and is not an authorization decision
PersonWriterServiceShared serviceColumn-set building, error translation, shared selection/version helpersNone (pure logic plus one Database read in assertEmailFree)Every entity serviceDatabase (only in assertEmailFree)None directly; translate throws typed exceptions on behalf of every callerUnrecognised constraint names fall through to a rethrown raw driver error
trigram-search.ts (withSearchThreshold)Shared helper (not a class)Transaction-scoped similarity thresholdDatabaseEvery entity's findAll, when a search term is presentdb.transaction, set_configNone persisted — the setting is transaction-localNone 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)

StepCode LocationWhat HappensWhy It HappensFailure/Edge Case
1students.controller.ts createEntry point; @Body() deserializes and class-validator validates CreateStudentDto.Standard NestJS pipeline.400 on shape/type failure.
2students.service.ts create, first lineStudentGuardiansService.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.
3Inside db.transactionPersonWriterService.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.
4Same transactionINSERT INTO users ... RETURNING id.Establishes the identity row the profile will reference.Constraint violation caught by the outer .catch(PersonWriterService.translate).
5Same transaction`admissionNumber = dto.admissionNumber?.trim()await codes.allocateOne("student", tx)`.
6Same transactionINSERT 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.
7Same transactionStudentGuardiansService.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.
8After .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.
9Returnawait 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. users is owned by the identity schema (outside this module) but every column this module writes is enumerated in PersonColumns/PERSON_SELECTION; students, guardians, staff, student_guardian, and code_counters are owned entirely by this module.
  • Versioning fields. students.version, guardians.version and staff.version are the optimistic-concurrency tokens, rendered by versionOf/matchesVersion in shared/row-version.ts and compared against the client-supplied version on every profile PATCH. Each is a counter maintained by the bump_row_version BEFORE UPDATE trigger, which also stamps updated_at; no application code increments it. updated_at is not a versioning field — it was, and a millisecond-resolution timestamp let two writes in one millisecond share a token.
  • Audit fields. users.banned_by is the only actor-attribution column in this whole data model; no table carries a general created_by/updated_by.
  • Money units. staff.basic_salary, .allowances, .total_salary are numeric (exact decimal), never float/double precision, and travel over the API as decimal strings (StaffSalaryDto's fields are typed string | null) — a numeric value does not survive a JSON-number round-trip without precision loss.
  • Timezone and date interpretation. admission_date/joining_date are plain date (no time-of-day, no zone). The one place a timezone genuinely matters is code allocation, where the year is taken via Intl.DateTimeFormat against SchoolProfileService.getTimezone(), never Date.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.create and staff.create both depend on the five system roles (superadmin, staff, teacher, guardian, student) existing in role — seeded by seed-auth.ts. A guardian-role lookup miss throws ROLE_NOT_FOUND (500) rather than silently skipping the grant.

Index rationale table:

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
users_email_uniqueemail_normalized (partial, live only)Unique B-treeOne live account per normalised addressTwo contact-only people cannot share an email; a soft-deleted holder's address is free for reuse
users_full_name_trgm_idxfull_nameGIN trigramThe % similarity operator for name searchExtra index-maintenance cost on every users write
users_full_name_prefix_idxlower(full_name)B-tree, text_pattern_opsLeft-anchored prefix search, which a trigram GIN cannot serveA second index to maintain alongside the trigram one
users_phone_idxphone (partial, live only)B-treeFast lookup by phone (the sibling-attach flow)Not unique — deliberately permits sharing
students_admission_number_uniqueadmission_number (partial, live only)Unique B-treeOne live admission number at a timeReleases the number on soft delete, which is what makes a restore-time conflict possible
students_admission_number_trgm_idxadmission_numberGIN trigramPartial/misspelt admission-number searchExtra maintenance cost
staff_employee_code_uniqueemployee_code (partial, live only)Unique B-treeOne live employee code at a timeSame restore-time tradeoff as above
student_single_primary_guardianstudent_id (partial, WHERE is_primary)Unique B-tree, not deferrableAt most one primary guardian per studentForces the two-phase insert-then-flip write pattern; a naive single-pass insert can raise 23505 depending on row order
student_guardian_guardian_id_idxguardian_idB-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 FKPrevents a staff row's designation from belonging to a different departmentRequires 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_departmentdepartment_id, designation_idCHECKCloses the MATCH SIMPLE hole where designation_id is set but department_id is NULLNone beyond the CHECK's own evaluation cost
code_counters PK(scope, year)Composite PKOne counter row per scope per yearNone

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
A student has zero or more guardians of any relationship; no father/mother columnsstudent_guardian schema shape, guardianRelationshipEnumSee the feature doc's Business Rules tableN/A (a schema shape, not a runtime check)students.service.integration.spec.ts
At most one primary guardian per studentstudent_single_primary_guardian (non-deferrable partial unique index)Exactly one number the office calls first409 (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 layerStudentGuardiansService.assertGuardianSetValidCannot be a schema CHECK — zero guardians overall must remain legal400 STUDENT_GUARDIAN_PRIMARY_REQUIREDSame file
An organisation guardian has a non-blank organizationNameCHECK guardian_org_has_name, plus the service deriving it from person.firstNameThe database is the final authority even if a future code path bypasses the service409 GUARDIAN_ORGANIZATION_NAME_REQUIREDguardians.service.spec.ts
relationship = 'other' requires relationshipOtherCHECK relationship_other_required, plus assertGuardianSetValidSame reasoning400/409students.service.integration.spec.ts
A staff designation must belong to the staff's own departmentComposite FK staff_designation_in_department_fk plus CHECK staff_designation_needs_departmentPrevents "Librarian filed under Accounts"409 STAFF_DESIGNATION_NOT_IN_DEPARTMENTstaff.service.spec.ts
basic_salary and allowances are both NULL or both setCHECK staff_salary_pair_coherent, plus the service-level pre-check in StaffSalaryService.updatetotal_salary's generated expression must never silently produce 0.00 from one missing term400 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 responseSeparate DTOs (StaffSalaryDto/StudentMedicalDto), separate services, separate permission codes, separate SELECT listsSee the feature doc's rationaleN/A — an absence, not an errorstaff.service.spec.ts — "keeps salary out of the staff response"
Deletion is profile-scoped; the person only goes when no profile remainsPeopleDeletionService.hasNoLiveProfile, counted across all three profile tablesSee the feature doc's rationaleN/Astudents.service.integration.spec.ts — "does not remove the person when they still hold another live profile"
Creating a person never grants an account by itselfPersonWriterService.buildInsert takes canLogin as a required argument with no default; PeoplePermissionsService.resolveSignInGrant resolves itA pupil record and a credential-bearing account are different things, and only the second is an identity change403 AUTH_FORBIDDEN when asked for without Users_UPDATEstudents.service.integration.spec.ts
The two spellings of the sign-in grant can never disagree silentlyresolveSignInGrant compares grantSignIn against person.canLogin and refuses a mismatchResolving by precedence would apply an intention the caller did not state400 PERSON_SIGN_IN_FLAGS_CONFLICTstudents.service.integration.spec.ts
can_login changes only through a guarded, observed-value writePeopleAccountService.setSignIn; buildUpdate emits no can_login keyA lost race must be reported, not silently applied over somebody else's decision409 PERSON_SIGN_IN_STATE_CHANGEDThe 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 sessionsetSignIn calls AuthSessionService.deleteSessionsForUser on the revoke branchA refresh token left redeemable is the weaker half of the pair ban already implementsN/A — a side effect, not an errorstudents.service.integration.spec.ts
An invitation commits with the person it invitesinviteOnCreate runs on the caller's transactionA post-commit call loses the invitation on a restart, leaving somebody who believes they were invited with nothing scheduledN/Astudents.service.integration.spec.ts
can_login gates authentication onlyAbsent from the notification audience resolver and the channel send pathA person with no portal account still has a phone number, an address, and a right to be told thingsN/A — an absence, not an errorNotification module specs
Out-of-scope access is a 404, never a 403PeopleAccessService.assertCanAccess uses the entity's own NOT_FOUND unconditionallyAnti-enumerationThe entity's *_NOT_FOUND codeguardians.service.spec.ts — "404s (never 403)"
Admission/employee codes allocate atomically, one upsert per requestcode_counters upsert in PeopleCodeService.allocatePrevents the max()+1 race under concurrent admissionsN/A (race-free by construction)students.service.integration.spec.ts — "allocates consecutive admission numbers without collision"
Search's similarity threshold is transaction-scopedwithSearchThresholdpg_trgm.similarity_threshold is a SESSION setting on a pooled connectionN/Astudents.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 onePeopleAccessService.cacheTags always includes viewTagSee §8N/A — a documented design constraint, not a runtime-checkable invariantNot directly covered by an automated test in the files reviewed

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
GuardiansModule re-provides the five shared services rather than importing them from PeopleModuleNest module compositionDuplicate provider declarations in both modulesHave PeopleModule export the shared services and have GuardiansModule import PeopleModule (or a shared submodule)Not documented in the source; observed as the current shapeTwo live instances of five stateless services; any future stateful shared service would need a different wiringIf 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 personConcurrent edit safetyversion on students, staff and guardians, maintained by the bump_row_version triggerExtend it to users, so a concurrent rename through ban/unban or the users module is caught tooThe profile row is where every module-owned write lands; users has four other writers with their own concurrency questionsA person-field edit through another module is not caught by a profile tokenA reported lost-update on a person field
student_medical and student_guardians take no tokenConcurrent edit safetyBoth UPDATE students with no lock and no staleness checkTokenise bothNeither was in scope when the token was introducedTwo concurrent medical edits, or two concurrent guardian-set replacements, silently lose one — and the latter carries canPickupA 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 countAccount-action authorizationActorAuthorityService.assertMayActOnAccount, consulted alongside assertNotLastSuperadminRely on the last-superadmin count aloneThe 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 accountWithout 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 actionactor-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 actionStudents restore is gated by the general _UPDATE code; guardians and staff restore are gated by the dedicated _RESTORE codeGate all three uniformly under _RESTORENot documented in the source; observed as the current shapeA role granted Students_UPDATE but not Students_RESTORE can restore students but not guardians/staff under an otherwise-parallel grantA permissions audit that assumes uniform _RESTORE gating across the catalogue
student_guardian FK on guardian_id is CASCADE, not RESTRICTReferential action on guardian deletionCASCADE, with the "cannot delete with live children" rule enforced in the service insteadRESTRICT at the databaseA 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 childrenThe database alone will not stop an unusual code path (e.g. a direct hard delete bypassing the service) from cascading silentlyA future hard-delete path on guardians that does not route through PeopleDeletionService
Fail-soft cache for all people list readsRedis availability vs. correctnessgetSoft/setSoft/delPatternSoft everywhereFail-closed (rethrow on Redis error)A Redis outage should degrade the directory to slower, not downUp to 120 seconds of staleness after a failed invalidationA use case where a stale list is unacceptable (none identified in this domain today)
Money as numeric, transmitted as a stringPrecisionnumeric(12,2)/numeric(14,2), DTO fields typed string | nullfloat/double precision, or a number DTO fieldA float does not round-trip numeric precision exactlyEvery 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

OperationHow to InspectHealthy StateFailure SignalRecovery
Cacheredis-cli KEYS "students:list:*" (or guardians:*/staff:*); application logs for warn-level Redis messagesKeys present with the expected TTL (≤120s); no repeated warn entriesRepeated "Redis unavailable" warnings in the API logConfirm Redis connectivity; the application continues serving from Postgres in the meantime — no immediate action required for correctness, only for latency
Code allocationSELECT * 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 admissionsA 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 psqlAll listed CHECK/unique/FK constraints present and VALIDAn unmapped 500 in the API log citing a Postgres error code (23505/23514/23503) not present in PersonWriterService.translate's switchAdd 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 cacheredis-cli GET "role:permissions:<roleId>"A JSON array of permission codes, refreshed within the last hour or immediately after any role editA colleague reports access that should have been revoked still workingConfirm 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

RiskAreaImpactCurrent MitigationRemaining Gap
Duplicate admission from a client retryStudentsService.createA second users/students row for one real admission event, if no explicit admissionNumber was supplied and the office does not notice before resubmittingThe unique index catches a duplicate only when the same admissionNumber is reused; an auto-allocated retry gets a fresh number and succeeds twiceNo idempotency key on the create endpoint
Salary pair-coherence raceStaffSalaryService.updateTwo concurrent salary PATCHes can each pass the application-level pair check against a stale current read, together leaving basic_salary/allowances incoherent at the databasestaff_salary_pair_coherent CHECK is the real backstop and will reject the second write with 23514That 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 tokenUsersService, PeopleAccountService ban/unban/password-resetA concurrent rename or ban is not caught by a profile row's token, because it writes users and never touches the profileDatabase-level column consistency onlyThe token's scope is the profile row by design; users is written by four other routes
student_medical and student_guardiansStudentMedicalService.update, StudentGuardiansService.setGuardiansBoth write students with no lock and no token, so a concurrent edit silently discards oneNoneOut of scope when the token was introduced; setGuardians replaces the whole link set, including canPickup
Restore-time code/email conflictPeopleDeletionService.restoreProfileA deleted record cannot be restored until its reissued code or email is resolved elsewhereNamed, actionable conflict codes rather than a bare 23505The 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 accountPeopleAccountService.ban/unban/sendPasswordResetLinkThe office suspends, restores, or resets the credentials of the one account that could reverse the actionActorAuthorityService.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 oneNone 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 onConflictDoNothinggrantProfileRole, StaffRolesService.grantStaffRolesA 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 aloneonConflictDoNothing on (user_id, role_id)None significant — this mitigation is sufficient for the scenario it covers

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented or explicitly marked non-runtime — §4, §16.1.
  • Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented — §4, §5, §6, §16.1.
  • Every method with business behavior has a code-flow narrative — §6 (per-method tables), §16.3 (the representative narrative for create).
  • Every table/collection/cache object/job payload has field-level detail — §5.2, §8 (no job payloads exist).
  • Every index, constraint, relation, and delete behavior has rationale — §5.2, §16.4.
  • Every lifecycle/status transition has a state diagram and transition table — feature doc §7 (referenced; this domain's lifecycle is documented once, in the feature doc, to avoid duplicating the same table).
  • Every read/write/action/job flow has sequence and activity diagrams — §7, §16.2 (no job flows exist to diagram).
  • Every business invariant is cataloged — §16.5.
  • Every cache key, invalidation path, queue job, realtime event, and external call is documented — §8, §9 (none), §10 (none), §11.
  • Every architectural tradeoff is documented with alternatives and revisit triggers — §16.6.
  • Every operational failure mode has a runbook entry — §16.7, §16.8.

18. Backend Completion Checklist

  • Module boundaries are documented — §2.
  • Every controller, service, DTO, schema file, job, cache key, and event is covered — §3-§6, §8-§10.
  • Every database table/collection has a field table and relationship diagram — §5.2, §5.3.
  • Every runtime flow has a diagram and branch notes — §7.
  • API, feature/flows, and TDD docs are linked — See Also, below (no TDD doc exists for this module in this repository).
  • No claim is made without a source file or documented source reference — every table and narrative above cites the file it was read from.

Profile roles are granted wherever a profile is created

Every pupil holds the seeded student role and every guardian the seeded guardian role, from the transaction that creates the profile. PeopleAccessService.scopeFor and every portal route switch on the active role's scopeKind, so a profile with no role falls to the self default: once given sign-in, that person logs in and reaches nothing, with no error saying why.

One function grants them, grantProfileRole (shared/profile-role.ts), and every creation path calls it in its own transaction:

PathProfileCall site
POST /studentspupilStudentsService.create
POST /guardiansguardianGuardiansService.create
A new guardian in a pupil's guardians listguardianStudentGuardiansService.resolveGuardian
Bulk import, student sheetpupil, and a guardian it creates (a guardian matched by phone already holds its role)PeopleImportWriter.writeStudent, findOrCreateGuardian
Bulk import, guardian sheetguardianPeopleImportWriter.writeGuardian

Staff get staff, plus teacher for a teaching designation, from StaffRolesService.grantStaffRoles on both the API and the import path, unless POST /staff names the roles explicitly. That path logs and continues when a seeded staff role is missing; only an edit with roleSource: "job" refuses (500 ROLE_NOT_CONFIGURED). Staff are never given a guardian- or pupil-scope role through a staff surface — §6.11a.

The grant throws ROLE_NOT_FOUND (500) and aborts the whole write when the role is not seeded, rather than skipping. A silent skip would produce a 201, a correct-looking record and no access at all, with nothing connecting the two events. It is idempotent on (user_id, role_id).

The grant is narrow: student carries scope_kind = 'student', which resolves to the pupil's own record and their own guardians, and guardian resolves to the guardian's own children. Neither holds an admin-catalogue permission.

Two migrations repair rows written before a path granted the role. 0013_student_role_backfill covers pupils admitted before admissions granted it; 0042_profile_roles_backfill covers pupils and guardians written by the import or the pupil form. Both include soft-deleted profiles, because restoring a profile clears its deletion marks and never touches user_role. 0042 skips any pupil with a student_graduations row: graduate access revokes the pupil-scoped roles itself and only its own restore path hands them back.

See Also

On this page

People Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tables and Collectionsusersstudentsguardiansstudent_guardianstaffcode_countersstudent_graduations5.3 Relationship Diagram6. Services and Responsibilities6.1 PeopleAccessServicescope_kind is settable, through exactly one route6.2 PeopleAccountService6.2a PeopleInvitationService6.3 PeopleCodeService6.4 PeopleDeletionService6.5 PeoplePermissionsService6.6 PersonWriterService6.7 StudentsService6.7a StudentGraduateAccessService and GraduateAccessService6.8 StudentGuardiansService6.9 StudentMedicalService6.10 GuardiansService6.11 StaffService6.11a StaffRolesService6.12 StaffSalaryService6.13 PrincipalInvariantService6.14 StaffDesignationService6.15 PeopleSummaryService7. Runtime Flows7.1 Admit a student (create)7.2 Update a student (PATCH, with optimistic concurrency)7.3 Replace a student's guardians7.4 Soft delete and restore7.5 Field-gated read (salary / medical)8. Caching9. BullMQ, Schedulers, and Async Work10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error Handling14. Observability15. Testing and Validation16. Mandatory Backend Deep-Dive Pack16.1 Submodule Coverage Matrix16.2 UML and Architecture Diagram Pack16.3 Code Flow Narrative16.4 Data Layer Deep Dive16.5 Business Logic and Invariant Catalog16.6 Tradeoffs, Alternatives, and ADR Notes16.7 Operational Runbook16.8 Backend Risk Register17. Zero-Omission Backend Checklist18. Backend Completion ChecklistProfile roles are granted wherever a profile is createdSee Also