Skoolsewa - Ecommerce 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.tsuser_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
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.

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_nametextNoFor an organisation guardian, this holds the organisation's whole name.
middle_nametextYesNULL
last_nametextYesNULLNullable: 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 NULLThe 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_idxDeliberately not unique — a household shares one number.
phone_verifiedbooleanNofalse
imagetextYesNULL
can_loginbooleanNotrueIndex users_can_login_idxWhether 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)YesNULLmale, female, other, prefer_not_to_say.
blood_groupenum (blood_group)YesNULLA+,A-,B+,B-,AB+,AB-,O+,O-.
ethnicity_idintegerYesNULLFK → ethnicities.id, ON DELETE SET NULLSchool-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 NULLSame reasoning, and the same read-time name resolution.
disability_typeenum (disability_type)YesNULLnone, visual, hearing, physical, intellectual, learning, speech, multiple, other.
marital_statusenum (marital_status)YesNULLsingle, 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_bannedNon-null, unlike the pre-merge customers.banned.
ban_reasontextYesNULLSame CHECKMust 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_idxSet 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_iduuidNoUnique, FK → users.id, ON DELETE CASCADEOne-to-one with usersDeleting the users row (never done directly by this module) cascades to students.
admission_numbertextNoPartial unique students_admission_number_unique (WHERE deleted_at IS NULL), GIN trigram students_admission_number_trgm_idxReleased 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_idtextYesNULLAn optional government identifier, named for whatever the school calls it locally.
admission_datedateNo
record_statusenum (student_record_status)No'active'Index students_record_status_idxTwo 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)YesNULLnone, school_bus, private, walking, public_transport.
medical_conditions, allergies, special_needstextYesNULLHealth 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.
versionbigintNo0The 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_idxIndependent 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_iduuidNoUnique, FK → users.id, ON DELETE CASCADEOne-to-one with users
kindenum (guardian_kind)No'person'person or organization.
organization_nametextYesNULLCHECK guardian_org_has_nameRequired 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_iduuidNoComposite PK with guardian_id; FK → students.id, ON DELETE CASCADE
guardian_iduuidNoComposite 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)NoUnique per student together with student_idstudent_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_relationshipUNIQUE 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_guardianbooleanNofalseWho signs consent — usually but not always the primary.
is_emergency_contactbooleanNofalse
can_pickupbooleanNofalseSeparated 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_iduuidNoUnique, FK → users.id, ON DELETE CASCADEOne-to-one with users
employee_codetextNoPartial 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_numbertextYesNULLAll gated behind StaffSalary_READ/_UPDATE alongside the salary figures.
ssf_number, cit_numbertextYesNULLSocial 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
scopetextNoComposite 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.
yearintegerNoComposite PKThe academic year in the school's own timezone, never UTC.
next_valueintegerNoCHECK 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.

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

Superadmin (activeRole.isSuperadmin) and holding the entity's own Module_READ permission both resolve to { kind: "all" }. Otherwise the active role's name decides: guardian and student get bespoke restricted predicates; everything else (including staff/teacher without the module permission, and anyone with no active role) gets selfOnlyScope, which is sql\false`for every entity exceptusers/staff` where it resolves to the actor's own row.

6.2 PeopleAccountService

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"), enforced by the type system and mirrored by the code_counters_scope_known CHECK as a database-level backstop. Idempotency: none — each call reserves a new block; a caller that must retry a failed admission without burning a number has to catch the failure before calling this. Response mapping: format() builds PREFIX-YEAR-0001, zero-padded to 4 digits as a minimum, never a truncating fixed width (lpad('10000', 4, '0') returns '1000', which this code avoids via padStart, which only pads and never truncates).

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

6.4 PeopleDeletionService

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

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.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 rowNoneNoneSTAFF_NOT_FOUND
create(dto)StaffController.createDesignation's is_teaching flag (if a designation is given), role lookupsusers, staff, code_counters, user_roleInvalidates staff:*USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT
update(actor, id, dto)StaffController.updateCurrent row (plain SELECT, no lock)users, staffInvalidates staff:*USER_EMAIL_ALREADY_EXISTS, STAFF_DESIGNATION_NOT_IN_DEPARTMENT, STAFF_NOT_FOUND
remove(actor, id)StaffController.removeDelegatesDelegatesInvalidates staff:*USER_CANNOT_DELETE_SELF
restore(actor, id)StaffController.restoreDelegatesDelegatesInvalidates 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

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

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

6.12 StaffSalaryService

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.

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'NoneReturns 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.

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

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

9. BullMQ, Schedulers, and Async Work

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

10. Realtime and Events

None. No Socket.IO gateway, no emitted domain event, and no realtime room is referenced anywhere in this module.

11. Security, Auth, and Abuse Controls

  • Guards. Every controller in this module carries @UseGuards(JwtAuthGuard, RoleGuard) at the class level — there is no public or guest-accessible route anywhere in /students, /guardians, or /staff.
  • Permissions. Every handler declares @Permissions(...); RoleGuard's fail-open branch (for handlers declaring no permission at all) is scoped to non-admin surfaces only, and /students, /guardians, /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.
  • 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.
  • 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
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_FOUND500GuardiansService.grantGuardianRoleThe guardian system role has not been seededRun the auth seed against the target database

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, DatabaseGuardiansControllergrantGuardianRole, databaseusers, guardians, user_role, guardians:* cacheROLE_NOT_FOUND (500) if the guardian role is unseeded
StaffServiceServiceStaff CRUD, role grants, account actionsPeopleAccessService, PeoplePermissionsService, PeopleCodeService, PeopleDeletionService, PersonWriterService, PeopleAccountService, RedisCacheService, DatabaseStaffControllergrantStaffRoles, PeopleAccountService.ban/unban/sendPasswordResetLink, databaseusers, staff, code_counters, user_role, staff:* cachePATCH requires a version token, checked under FOR UPDATE
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 onConflictDoNothingGuardiansService.grantGuardianRole, StaffService.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.

The admission-time role grant

StudentsService.create writes a user_role row granting the pupil the seeded student role, inside the same transaction as the admission. Guardians and staff have always received their roles on creation; pupils did not, so every pupil in the database had no active role and the consumer portal refused them.

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

The grant is narrow: student carries scope_kind = 'student', which resolves to the pupil's own record and their own guardians, and the role holds no admin-catalogue permission. A pupil moves from holding no role and reaching nothing, to holding a role scoped to themselves.

Migration 0013_student_role_backfill covers pupils admitted before this change. It deliberately does not filter deleted_at, because restoring a soft-deleted pupil does not re-grant the role — restoreProfile clears the deletion marks and never touches user_role.

See Also