Skoolsewa - Ecommerce Docs
Developer ResourcesPeople

People API Reference

Complete API contracts for students, guardians, and staff, including routes, auth, DTOs, responses, errors, and examples.

People - API Reference

Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin-facing APIs owned by PeopleModule (StudentsController, StaffController) and its nested GuardiansModule (GuardiansController). No public or mobile-facing route exists in this module.

1. Documentation Evidence

AreaFiles InspectedWhat Was Verified
Controllersapps/api/src/modules/people/students/students.controller.ts, apps/api/src/modules/people/guardians/guardians.controller.ts, apps/api/src/modules/people/staff/staff.controller.tsEvery route, method, guard, permission decorator, and param pipe — grepped directly against @Get|@Post|@Patch|@Put|@Delete|@Permissions.
DTOsapps/api/src/modules/people/dto/person.dto.ts, dto/account-action.dto.ts, students/dto/student.dto.ts, guardians/dto/guardian.dto.ts, staff/dto/staff.dto.tsEvery field, type, optionality, default, validator, and sort allow-list.
Servicesstudents/students.service.ts, students/student-guardians.service.ts, students/student-medical.service.ts, guardians/guardians.service.ts, staff/staff.service.ts, staff/staff-salary.service.ts, shared/*.service.tsBehavior, transactions, cache invalidation, response mapping, error mapping.
Schemapackages/db/src/schema/school/people.tsTables, enums, unique indexes, CHECK constraints, generated columns, foreign keys.
Shared query baseapps/api/src/common/dto/query.dto.tsInherited pagination, page, size, sort, order, search fields and their defaults.
Response envelopeapps/api/src/common/dto/response-dto.tsExact success envelope shape, including when pagination fields are present.
Error envelopeapps/api/src/common/filters/all-exceptions.filter.tsExact error envelope shape, the unique-violation fallback, and the default-error-code-by-status table.
Errorsapps/api/src/common/types/error-codes.tsEvery error code this module's routes can produce, including the shared global ones.
Authapps/api/src/modules/auth/guards/jwt-auth.guard.ts, apps/api/src/common/authorization/role.guard.tsGuard chain, identity shape, the active-role rule, the superadmin flag bypass.
Permissionspackages/db/src/authorization/permission-catalog.tsThe five people-domain permission modules and the five actions each carries.
Object-level accessapps/api/src/modules/people/shared/people-access.service.tsScope resolution per actor/entity, the 404-not-403 rule, cache-tag derivation.
Account actionsapps/api/src/modules/people/shared/people-account.service.tsBan/unban/password-reset-link behavior and every error each can throw.
Deletion/restoreapps/api/src/modules/people/shared/people-deletion.service.tsProfile-scoped soft delete, person-level cascade rule, restore's code/email re-checks.
Code allocationapps/api/src/modules/people/shared/people-code.service.tsAtomic admission/employee number allocation, timezone-correct year.
Person writerapps/api/src/modules/people/shared/person-writer.service.tsShared identity write path, omitted-vs-nulled rule, constraint-to-error-code mapping.
Paginationapps/api/src/common/utils/pagination.util.tsDefault/max size, offset math, why the DTO's own @Max(100) is what actually rejects an oversized page.
Searchapps/api/src/modules/people/shared/trigram-search.ts, packages/db/src/search/search.constants.tsTrigram threshold (0.3), why it must be pinned per-transaction, the ILIKE+trigram OR.
Authorization internalsapps/api/src/common/authorization/actor-authority.service.tsThe last-superadmin protection consulted by ban and by delete, and the separate superadmin-acting-on-superadmin protection consulted by ban, unban, and password-reset.
Module wiringapps/api/src/modules/people/people.module.ts, guardians/guardians.module.tsWhy GuardiansModule is nested, what each shared service's importing module supplies it.
Testsstudents/__tests__/students.service.integration.spec.ts, guardians/guardians.service.spec.ts, staff/staff.service.spec.tsConfirmed edge-case behavior, cited per section below.

2. Module Summary

FieldValue
Module namePeopleModule (students, staff), GuardiansModule (nested inside it)
Module slugpeople
Primary actorsSuperadmin (universal bypass); an administrator role holding Students/Guardians/Staff/StaffSalary/StudentMedical permissions; staff/teacher (seeded read-only directory access); guardian (own children only); student (own record only)
API surfacesAdmin only — no @Public() route and no /api/mobile/... route exists in any of the three controllers
Base route prefixes/api/students, /api/guardians, /api/staff (the global api prefix is set in apps/api/src/main.ts; controllers declare students, guardians, staff locally)
Auth modelJwtAuthGuard + RoleGuard, class-level on all three controllers, plus a second, object-level access check (PeopleAccessService) inside every handler that names an :id
PersistencePostgreSQL (students, guardians, student_guardian, staff, users, code_counters); Redis (list-result caching only — see 11)
Runtime source of truthstudents/guardians/staff tables joined to users for identity, always read live on a cache miss; nothing here is served stale on a write path the way school-profile is
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
PersonThe users row: name, contact, demographics, address, login/ban state. Every student, guardian and staff member is a person plus one profile row.packages/db/src/schema/identity.ts; selected via PERSON_SELECTION in shared/person-writer.service.ts:267-296person on every response DTO; person/PersonInputDto on every write DTO.
ProfileThe role-specific row (students, guardians, or staff) that turns a person into a pupil, a parent, or an employee. One person may hold more than one profile.packages/db/src/schema/school/people.tsEvery route in this module operates on exactly one profile kind.
Admission numberA student's school-facing identifier, e.g. STU-2026-0041. Allocated atomically, unique among live students only.people-code.service.ts, people.ts:99,125-127StudentDto.admissionNumber; the students_admission_number_unique partial index.
Employee codeA staff member's school-facing identifier, e.g. EMP-2026-0007. Same allocation mechanism as admission numbers, different prefix.people-code.service.ts:11-14, people.ts:273,320-322StaffDto.employeeCode.
versionAn opaque optimistic-concurrency token: the decimal string of the profile row's version counter. Sent back on every read and required on the matching write. All three profiles carry it — students, staff and guardians. The counter is maintained by the bump_row_version BEFORE UPDATE trigger, which also stamps updated_at; no application code increments it, so no writer can forget it and raw SQL cannot slip past it. It is deliberately NOT a timestamp: String(updatedAt.getTime()) is millisecond-resolution, so two transactions committing inside one millisecond produced the same token and a stale write passed its check. The row lock serialises those writes; it does not make their timestamps distinct. For staff the one token covers BOTH write routes, because both write that one staff row. Person fields live on users and are outside every profile's token.shared/row-version.ts (versionOf, matchesVersion); bump_row_version in migration 0012StudentDto/UpdateStudentDto, StaffDto/UpdateStaffDto, StaffSalaryDto/PatchStaffSalaryDto, GuardianDto/UpdateGuardianDto — all .version.
isRecordCompleteComputed per request, never stored: true exactly when a student has at least one guardian link whose guardian row and whose guardian's users row are both live. A link surviving the guardian's soft delete does not count.students.service.ts:315-345,585StudentDto.isRecordComplete.
Primary guardianThe one guardian per student marked isPrimary: true — the number the office calls first. Enforced by a non-deferrable partial unique index, never by application logic alone.people.ts:253-255 (student_single_primary_guardian)StudentGuardianLinkDto.isPrimary; UpsertStudentGuardianDto.isPrimary.
Guardian kindperson or organization. An organisation guardian (an orphanage trust, a hostel) stores its whole name in person.firstName; organizationName is a second, database-enforced-non-empty column derived from it, never entered separately.guardian.dto.ts:14; people.ts:175-178 (guardian_org_has_name)GuardianDto.kind/organizationName; CreateGuardianDto.kind.
Teacher (not an entity)There is no teachers table and no Teachers module. A teacher is a staff row whose designation's isTeaching flag is true — filtered on the staff list via designationKind=teaching, and granted the teacher role alongside staff at creation.staff.service.ts:83-86,519-554ListStaffQueryDto.designationKind; the teacher role grant on POST /staff.
Active roleThe role the caller's session is currently acting as — never the union of every role the person holds. Object-level scope and field-gated permissions both key off this, not off the person's full role set.people-access.service.ts:50-61,99-126; role.guard.ts:129-148Every scope decision and every field-gate check in this module.
Sign-in access (can_login)Whether a person may authenticate. It is not a role, not a permission, and not a statement about conduct — a person may hold the guardian role, be listed as an emergency contact, and receive every notification the school sends while holding can_login = false. Granted and revoked only by POST/DELETE /:id/sign-in and by the create-time grantSignIn flag, all four of which require Users_UPDATE.identity.ts (users.can_login); shared/people-account.service.ts (setSignIn), shared/people-permissions.service.ts (resolveSignInGrant)PersonDto.canLogin; SignInAccessDto.canLogin; the six :id/sign-in routes.
Account invitationThe emailed link that lets somebody who has just been granted sign-in access choose their first password. An account_invite verification record valid for 7 days, redeemed at POST /api/auth/password/reset. Enqueued in the same transaction as the grant, so the two commit together or not at all.shared/people-account.service.ts (ACCOUNT_INVITE_TTL_MS, inviteOnCreate); auth.ts (verification_purpose)InvitationOutcomeDto; invitation on every create response.
Field-gated groupA set of fields withheld from a response and served only by their own endpoint behind their own permission: StudentMedical (health data, off StudentDto) and StaffSalary (money and bank details, off StaffDto).students/student-medical.service.ts, staff/staff-salary.service.tsStudentMedicalDto, StaffSalaryDto.
Guardian lookupThe create-or-select step at admission: POST /students/guardian-lookup finds existing guardians by exact phone match so a second sibling attaches to the same guardian row instead of duplicating a parent.student-guardians.service.ts:99-146POST /students/guardian-lookup.
Trigram searchName search combining Postgres pg_trgm similarity (% operator, threshold 0.3, pinned per-transaction) with a literal, escaped ILIKE substring match — catches both misspellings and exact prefixes.shared/trigram-search.ts, shared/person-writer.service.ts:307-319, packages/db/src/search/search.constants.ts:18The search filter on all three list endpoints.
record_statusA two-value toggle (active/inactive) on students only. Deliberately not the enrollment lifecycle (enrolled/promoted/graduated/…) — that vocabulary belongs to a future student_enrollments table, not to this module.people.ts:69-72StudentDto.recordStatus; ListStudentsQueryDto.recordStatus.
Ethnicity / mother tongueNepal's national caste/ethnic-group and language-spoken-at-home classifications, owned by the lookups module rather than this one and listed at GET /api/lookups/ethnicities/GET /api/lookups/mother-tongues behind PersonClassifications_READ — a permission granted to staff and teachers by default, unlike every other module this doc covers, because both people forms render the lists as select boxes and a colleague who cannot read them sees two empty boxes with no way to tell it is a permissions problem. PersonDto stores and returns the integer id (what an edit form posts back) alongside the resolved name (what a person reads); the write codes stay administrator-only.person.dto.ts:101-117; person-writer.service.ts:272-322 (PERSON_SELECTION)PersonDto.ethnicityId/ethnicityName/motherTongueId/motherTongueName; PersonInputDto.ethnicityId/motherTongueId.

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/studentsAdmin/staff/guardian/student (scoped)JwtAuthGuard, RoleGuardStudents_READStudentsControllerList/search students, paginated, scope-restricted.
AdminPOST/api/students/guardian-lookupAdminJwtAuthGuard, RoleGuardGuardians_READStudentsControllerFind existing guardians by exact phone match.
AdminPOST/api/studentsAdminJwtAuthGuard, RoleGuardStudents_CREATEStudentsControllerAdmit a student, optionally linking or creating guardians.
AdminGET/api/students/:idAdmin/scopedJwtAuthGuard, RoleGuardStudents_READStudentsControllerGet one student.
AdminPATCH/api/students/:idAdmin/scopedJwtAuthGuard, RoleGuardStudents_UPDATEStudentsControllerUpdate a student's identity or record fields.
AdminDELETE/api/students/:idAdmin/scopedJwtAuthGuard, RoleGuardStudents_DELETEStudentsControllerSoft-delete a student.
AdminPOST/api/students/:id/restoreAdmin/scopedJwtAuthGuard, RoleGuardStudents_UPDATEStudentsControllerRestore a soft-deleted student.
AdminPOST/api/students/:id/banAdmin/scopedJwtAuthGuard, RoleGuardStudents_UPDATEStudentsControllerSuspend the student's sign-in.
AdminPOST/api/students/:id/unbanAdmin/scopedJwtAuthGuard, RoleGuardStudents_UPDATEStudentsControllerLift a suspension.
AdminPOST/api/students/:id/password-resetAdmin/scopedJwtAuthGuard, RoleGuardStudents_UPDATEStudentsControllerEmail a password-reset link.
AdminPOST/api/students/:id/sign-inAdmin/scopedJwtAuthGuard, RoleGuardUsers_UPDATEStudentsControllerGrant sign-in access and, by default, send the invitation.
AdminDELETE/api/students/:id/sign-inAdmin/scopedJwtAuthGuard, RoleGuardUsers_UPDATEStudentsControllerRevoke sign-in access and delete every live session.
AdminGET/api/students/:id/guardiansAdmin/scopedJwtAuthGuard, RoleGuardStudents_READ and Guardians_READStudentsControllerList a student's guardian links.
AdminPUT/api/students/:id/guardiansAdmin/scopedJwtAuthGuard, RoleGuardStudents_UPDATE and Guardians_UPDATEStudentsControllerReplace a student's whole guardian set.
AdminGET/api/students/:id/medicalAdmin/scopedJwtAuthGuard, RoleGuardStudentMedical_READStudentsControllerGet a student's health record.
AdminPATCH/api/students/:id/medicalAdmin/scopedJwtAuthGuard, RoleGuardStudentMedical_UPDATEStudentsControllerUpdate a student's health record.
AdminGET/api/guardiansAdmin/scopedJwtAuthGuard, RoleGuardGuardians_READGuardiansControllerList/search guardians, paginated (cannot be turned off).
AdminGET/api/guardians/:id/studentsAdmin/scopedJwtAuthGuard, RoleGuardGuardians_READGuardiansControllerList a guardian's linked students.
AdminGET/api/guardians/:idAdmin/scopedJwtAuthGuard, RoleGuardGuardians_READGuardiansControllerGet a guardian.
AdminPOST/api/guardiansAdminJwtAuthGuard, RoleGuardGuardians_CREATEGuardiansControllerCreate a guardian and grant the guardian role.
AdminPATCH/api/guardians/:idAdmin/scopedJwtAuthGuard, RoleGuardGuardians_UPDATEGuardiansControllerUpdate a guardian. No version/staleness check.
AdminDELETE/api/guardians/:idAdmin/scopedJwtAuthGuard, RoleGuardGuardians_DELETEGuardiansControllerSoft-delete a guardian; refused while linked to a live student.
AdminPOST/api/guardians/:id/banAdmin/scopedJwtAuthGuard, RoleGuardGuardians_UPDATEGuardiansControllerSuspend the guardian's sign-in.
AdminPOST/api/guardians/:id/unbanAdmin/scopedJwtAuthGuard, RoleGuardGuardians_UPDATEGuardiansControllerLift a suspension.
AdminPOST/api/guardians/:id/password-resetAdmin/scopedJwtAuthGuard, RoleGuardGuardians_UPDATEGuardiansControllerEmail a password-reset link.
AdminPOST/api/guardians/:id/sign-inAdmin/scopedJwtAuthGuard, RoleGuardUsers_UPDATEGuardiansControllerGrant sign-in access and, by default, send the invitation.
AdminDELETE/api/guardians/:id/sign-inAdmin/scopedJwtAuthGuard, RoleGuardUsers_UPDATEGuardiansControllerRevoke sign-in access and delete every live session.
AdminPOST/api/guardians/:id/restoreAdmin/scopedJwtAuthGuard, RoleGuardGuardians_RESTOREGuardiansControllerRestore a soft-deleted guardian.
AdminGET/api/staffAdmin/scopedJwtAuthGuard, RoleGuardStaff_READStaffControllerList/search staff, paginated (cannot be turned off).
AdminPOST/api/staffAdminJwtAuthGuard, RoleGuardStaff_CREATEStaffControllerAdmit a staff member, granting staff (and teacher if applicable).
AdminGET/api/staff/:idAdmin/scopedJwtAuthGuard, RoleGuardStaff_READStaffControllerGet one staff member.
AdminPATCH/api/staff/:idAdmin/scopedJwtAuthGuard, RoleGuardStaff_UPDATEStaffControllerUpdate a staff member. No version/staleness check.
AdminDELETE/api/staff/:idAdmin/scopedJwtAuthGuard, RoleGuardStaff_DELETEStaffControllerSoft-delete a staff member.
AdminPOST/api/staff/:id/restoreAdmin/scopedJwtAuthGuard, RoleGuardStaff_RESTOREStaffControllerRestore a soft-deleted staff member.
AdminPOST/api/staff/:id/banAdmin/scopedJwtAuthGuard, RoleGuardStaff_UPDATEStaffControllerSuspend the staff member's sign-in.
AdminPOST/api/staff/:id/unbanAdmin/scopedJwtAuthGuard, RoleGuardStaff_UPDATEStaffControllerLift a suspension.
AdminPOST/api/staff/:id/password-resetAdmin/scopedJwtAuthGuard, RoleGuardStaff_UPDATEStaffControllerEmail a password-reset link.
AdminPOST/api/staff/:id/sign-inAdmin/scopedJwtAuthGuard, RoleGuardUsers_UPDATEStaffControllerGrant sign-in access and, by default, send the invitation.
AdminDELETE/api/staff/:id/sign-inAdmin/scopedJwtAuthGuard, RoleGuardUsers_UPDATEStaffControllerRevoke sign-in access and delete every live session.
AdminGET/api/staff/:id/salaryAdmin/scopedJwtAuthGuard, RoleGuardStaffSalary_READStaffControllerGet a staff member's salary and bank details.
AdminPATCH/api/staff/:id/salaryAdmin/scopedJwtAuthGuard, RoleGuardStaffSalary_UPDATEStaffControllerUpdate a staff member's salary and bank details.

41 routes total (16 on StudentsController, 12 on GuardiansController, 13 on StaffController) — verified against the complete contents of all three controller files. The six :id/sign-in routes are the only ones in this module that ask for Users_UPDATE rather than a profile-kind permission: granting somebody sign-in access creates a credential-bearing account rather than editing a pupil record, and POST /api/auth/password/forgot is public, so an email address on a login-capable row is a route to a session. No alias routes exist. guardian-lookup is declared before :id on StudentsController deliberately, or Nest would match it as a student id.

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
All 41 routes@UseGuards(JwtAuthGuard, RoleGuard) at the controller class levelreq.user populated by the JWT strategy; activeRole resolved from itOne of the codes in 4NoNo route in this module carries @Public().

Two layers of authorization apply to every :id route, and both are load-bearing:

  • Layer one — @Permissions() / RoleGuard. Answers "may this actor call this handler at all". Resolved from the caller's active role only, never the union of every role held — a teacher who is also a guardian, viewing as Guardian, does not carry staff permissions into that context. A role with is_superadmin = true bypasses the permission list entirely, keyed on the boolean flag rather than the role's name (a mutable text column a rename could otherwise turn into an escalation path). A caller with no active role — either no role at all, or several with none chosen — is refused 403 PERMISSION_ROLE_NOT_ASSIGNED or 403 AUTH_ACTIVE_ROLE_REQUIRED respectively, before this module's own logic ever runs.
  • Layer two — PeopleAccessService.assertCanAccess. Answers "may this actor read/write this row". Superadmin always gets unrestricted scope. The order of the next two checks is load-bearing and deliberately role-name-first, not permission-first: an active role literally named guardian or student is scoped to that role's restriction (guardian sees their own children and those children's co-guardians; student sees their own record and their own guardians) before the module permission is ever consulted — holding Students_READ/Guardians_READ on top of a guardian/student role does not widen it to all. Only for a role named anything else does holding the module permission grant all; failing that, the caller sees only their own profile row. The restricted predicate is always ANDed inside the SQL, never filtered after the fact — filtering afterward would page over rows the actor cannot see and under-report count.

Why the ordering matters, concretely: the reverse ordering (permission checked first) was tried and reverted — checking the module permission before the role name made both portal branches unreachable, because every route reaching this method is already gated on that same permission by RoleGuard. The failure it produced was not theoretical: an administrator builds a "Parent Portal" role, grants it Students_READ so parents can see their own children, and assigns it to the guardian cohort — under the permission-first ordering every parent holding that role then received every pupil's name, date of birth, home address, and phone number from GET /students, with no error anywhere and a green test suite, because the guardian-scope branch was dead code. Role-name-first is what the code does today and is what closes that path.

A record outside the caller's scope answers 404, never 403, carrying the entity's own *_NOT_FOUND code (STUDENT_NOT_FOUND, GUARDIAN_NOT_FOUND, STAFF_NOT_FOUND) — byte-for-byte identical to a genuinely absent id. A 403 would confirm the record exists, turning the id space into an enumeration oracle against a roll of children. There is deliberately no NOT_YOUR_RECORD code; its existence would be the leak.

Sign-in access is gated on Users_UPDATE, not on the profile-kind permission. The six :id/sign-in routes, and the create-time grantSignIn/person.canLogin flags, all ask for the same identity permission whatever the profile kind — Students_CREATE alone cannot mint a login-capable pupil, and Staff_UPDATE alone cannot give a caretaker a portal account. The reason is that POST /api/auth/password/forgot is public: a login-capable row carrying an email address is a route to a session, so granting one is an identity change rather than a record edit. resolveSignInGrant resolves the create-time decision through PeoplePermissionsService.can, which lets a superadmin bypass — 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.

Field-gated groups are checked twice. StaffSalary_READ/StaffSalary_UPDATE and StudentMedical_READ/StudentMedical_UPDATE are declared on the route via @Permissions(), and checked again inside StaffSalaryService/StudentMedicalService via PeoplePermissionsService.can — because the guard answers "may this actor call this handler", not "may this actor see this field", and the second question is the one that matters if either method is ever reached another way. A caller who reaches the handler but somehow fails the in-service check gets 403 PERMISSION_INSUFFICIENT.

None of the five people-domain permission modules (Students, Guardians, Staff, StaffSalary, StudentMedical) is held by any of the five seeded roles except superadmin by default — a school creates an administrator role that holds them explicitly. Staff and teacher are seeded with Students_READ/Guardians_READ/Staff_READ only (read-only directory access), per STAFF_PERMISSIONS in seed-auth.ts (see the feature doc's actor matrix). PersonClassifications_READ is the one exception seeded to staff and teacher alongside those three — both the student and staff person forms render an ethnicity and a mother-tongue select, and a colleague who cannot read the lists sees two empty boxes with no way to tell it is a permissions problem rather than an empty table; the write codes (PersonClassifications_CREATE/_UPDATE/_DELETE) stay administrator-only.

Headers parsed but not trusted: not applicable — no route in this module reads any identity-bearing header other than the standard Authorization: Bearer <jwt> consumed by JwtAuthGuard.

Malformed :id behaves differently by controller. StudentsController and StaffController apply ParseUUIDPipe to every :id param, so a non-UUID id is refused with a clean 400 VALIDATION_FAILED before any query runs. GuardiansController applies no such pipe to any of its eight :id routes — a malformed id reaches guardians.id = '<value>' as a raw comparison against a uuid column, Postgres raises invalid input syntax for type uuid (SQLSTATE 22P02), and AllExceptionsFilter — which only special-cases 23505 — falls through to a bare 500 SYS_INTERNAL_ERROR. A consumer building against GuardiansController should validate UUID shape client-side rather than relying on the API to reject it cleanly.

6. DTO and Model Reference

6.1 PersonDto (response — the identity block on every profile)

FieldTypeRequiredDefaultValidationExampleSource
idstring (UUID)YesServer-generatedN/A — the users row's own id"01922e2a-6b1e-7c3a-9d2e-1a2b3c4d5e6f"person.dto.ts:49
firstNamestringYes"Sita"person.dto.ts:50
middleNamestring | nullYes (nullable)nullnullperson.dto.ts:51
lastNamestring | nullYes (nullable)null"Rai"person.dto.ts:52
fullNamestring | nullYesDatabase-generated from the three name partsNever assembled in application code — cannot disagree with them"Sita Rai"person.dto.ts:53-57
emailstring | nullYes (nullable)nullnullperson.dto.ts:58
emailVerifiedbooleanYesfalsefalseperson.dto.ts:59
phonestring | nullYes (nullable)null"+977-9841002233"person.dto.ts:60
phoneVerifiedbooleanYesfalsefalseperson.dto.ts:61
imagestring | nullYes (nullable)nullnullperson.dto.ts:62
canLoginbooleanYesfalse unless sign-in was explicitly granted on createWhether this person may authenticate, and nothing else. Most people on a school's roll hold false: a sweeper needs a payroll record and an emergency contact, not a login. It does not affect notifications — a person with no portal account still receives every message addressed to themfalseperson.dto.ts:88-92
mustChangePasswordbooleanYesfalseperson.dto.ts:68
dateOfBirthstring | null (ISO date)Yes (nullable)null"2012-03-04"person.dto.ts:69
genderstring | null, enum GENDERSYes (nullable)null"female"person.dto.ts:70-71
bloodGroupstring | null, enum BLOOD_GROUPSYes (nullable)null"O+"person.dto.ts:72-73
disabilityTypestring | null, enum DISABILITY_TYPESYes (nullable)null"none"person.dto.ts:74-75
maritalStatusstring | null, enum MARITAL_STATUSESYes (nullable)nullnullperson.dto.ts:76-77
ethnicityIdnumber | nullYes (nullable)nullThe caste/ethnic group, as an id from GET /api/lookups/ethnicities4person.dto.ts:101-106
ethnicityNamestring | nullYes (nullable)nullRead-only — resolved server-side from ethnicityId via a correlated subquery, never accepted on write"Newar"person.dto.ts:107-109
motherTongueIdnumber | nullYes (nullable)nullThe language spoken at home, as an id from GET /api/lookups/mother-tongues2person.dto.ts:110-115
motherTongueNamestring | nullYes (nullable)nullRead-only, resolved the same way as ethnicityName"Nepal Bhasa"person.dto.ts:116-117
permanentAddressAddressDtoYesAll fields nullThe pupil's permanent Nepali address — province, district, municipality, ward, tole, house number, plus each id's resolved nameSee §6.1aaddress.dto.ts
currentAddressAddressDtoYesAll fields nullThe present address, independent of the permanent one. All-null means not recorded — there is no "same as permanent" flagSee §6.1aaddress.dto.ts
biostring | nullYes (nullable)nullnullperson.dto.ts:83
bannedbooleanYesfalsefalseperson.dto.ts:84
banReasonstring | nullYes (nullable)nullnullperson.dto.ts:85
createdAtDateYesServer-generated"2026-04-15T04:15:00.000Z"person.dto.ts:86
updatedAtDateYesServer-generated"2026-04-15T04:15:00.000Z"person.dto.ts:87
deletedAtDate | nullYes (nullable)nullnullperson.dto.ts:88

ethnicityId and motherTongueId round-trip: accepted on write via PersonInputDto (below), and projected back by PERSON_SELECTION (person-writer.service.ts:272-322) on every student, guardian, and staff read. The matching nameethnicityName/motherTongueName — comes back alongside each id as a read-only field, resolved by a correlated subquery against ethnicities/mother_tongues rather than a LEFT JOIN, because the shared projection is read by six separate list and detail queries and a join added at one call site would have to be replicated at all six in the same order or the response shape silently diverges between them. The id is returned because it is what an edit form posts back on the next PATCH; the name is returned because it is what a person reads — 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. Neither name is ever accepted on write: PersonInputDto has no ethnicityName/motherTongueName field, and sending one is rejected outright by the global ValidationPipe's forbidNonWhitelisted, which refuses any field the DTO does not declare.

6.2 PersonInputDto (request — nested under every create/update body's person field)

FieldTypeRequiredDefaultValidationExampleSource
firstNamestringYes@IsString, @MinLength(1), @MaxLength(120)"Sita"person.dto.ts:93-97
middleNamestringNo@IsOptional, @IsString, @MaxLength(120)person.dto.ts:99-103
lastNamestringNo@IsOptional, @IsString, @MaxLength(120) — required in practice for a natural person, omitted for an organisation whose whole name goes in firstName"Rai"person.dto.ts:105-112
emailstringNo@IsOptional, @IsEmailperson.dto.ts:114-117
phonestringNo@IsOptional, @IsString, @MaxLength(40)"+977-9841002233"person.dto.ts:119-123
dateOfBirthstring (ISO date)No@IsOptional, @IsDateString"2012-03-04"person.dto.ts:125-128
genderenum GENDERSNo@IsOptional, @IsEnum(GENDERS)"female"person.dto.ts:130-133
bloodGroupenum BLOOD_GROUPSNo@IsOptional, @IsEnum(BLOOD_GROUPS)"O+"person.dto.ts:135-138
disabilityTypeenum DISABILITY_TYPESNo@IsOptional, @IsEnum(DISABILITY_TYPES)"none"person.dto.ts:140-143
maritalStatusenum MARITAL_STATUSESNo@IsOptional, @IsEnum(MARITAL_STATUSES)person.dto.ts:145-148
ethnicityIdnumberNo@IsOptional, @Type(() => Number), @IsIntperson.dto.ts:150-154
motherTongueIdnumberNo@IsOptional, @Type(() => Number), @IsIntperson.dto.ts:156-160
permanentAddressAddressInputDtoNoUntouched on PATCH if the key is absent; on create, every column becomes NULL if omitted@IsOptional, @ValidateNested, @Type(() => AddressInputDto) — see §6.1a for the whole-group replacement ruleaddress.dto.ts
currentAddressAddressInputDtoNoSame as permanentAddressSameaddress.dto.ts
biostringNo@IsOptional, @IsString, @MaxLength(2000)person.dto.ts:172-173
imagestringNo@IsOptional, @IsString, @MaxLength(500)person.dto.ts:174-175
canLoginbooleanNofalseThe older spelling of grantSignIn. Accepted only on create, where it is gated on Users_UPDATE exactly as grantSignIn is; ignored entirely by PATCHtrueperson.dto.ts:237-254

person.canLogin is a create-time grant, not an editable field. It is one of two accepted spellings of the same decision — grantSignIn on the create body is the other — and both are checked against Users_UPDATE before anything is written. Absent means no: a person created without either flag gets a record and no account. Sending both with different values is refused with 400 PERSON_SIGN_IN_FLAGS_CONFLICT rather than resolved by precedence, because the caller has stated two intentions and the server cannot know which one is the mistake.

PATCH never writes it. PersonWriterService.buildUpdate emits no canLogin key whatever the body contains, so a person's sign-in access is changed only through POST/DELETE /:id/sign-in (8.10a, 8.10b and their guardian and staff equivalents), which check the identity permission and guard the write. A guardian entry inside CreateStudentDto.guardians or SetStudentGuardiansDto.guardians reads both spellings the same way, and refuses the same contradiction (6.8).

Omitted is not nulled, on update. PersonWriterService.buildUpdate (shared/person-writer.service.ts:85-128) emits a key only for a field the caller's person object actually contains (Object.hasOwn, not !== undefined) — so an explicit "lastName": null clears the field, while an absent lastName key leaves the stored value untouched. On create every optional field not sent becomes NULL — there is no prior value to preserve. Every string field is trimmed server-side, and an empty string after trimming is stored as NULL, never as "".

6.1a AddressDto and AddressInputDto (a Nepali address)

Every person carries two of these — permanentAddress and currentAddress — in place of the old five flat columns (addressLine, street, city, state, pinCode).

AddressDto (response, nested under permanentAddress/currentAddress):

FieldTypeNotesSource
provinceIdnumber | nullFrom GET /api/lookups/provinces.address.dto.ts
provinceNamestring | nullResolved server-side, read-only.address.dto.ts
districtIdnumber | nullFrom GET /api/lookups/districts?provinceId=.address.dto.ts
districtNamestring | nullResolved, read-only.address.dto.ts
municipalityIdnumber | nullFrom GET /api/lookups/municipalities?districtId=.address.dto.ts
municipalityNamestring | nullResolved, read-only.address.dto.ts
municipalityTypestring | nullResolved, read-only — one of MUNICIPALITY_TYPES.address.dto.ts
wardNonumber | null1-35.address.dto.ts
tolestring | nullStreet or locality.address.dto.ts
houseNostring | nulladdress.dto.ts

The names are returned alongside the ids on purpose, not for convenience. Geography rows are retired with isActive: false, so a form's pick list is active-only; a person whose district was retired after their record was written would otherwise find their district missing from the select, the field rendering blank, and the next save of any unrelated field silently clearing it — and the municipality fill-order CHECK would then clear the municipality too. Returning the resolved name lets a form keep the person's current value in its options whether or not it is still active. It also answers a portal question: guardian and student roles hold zero catalogue permissions by design, so denormalising the name here means no Geography_READ grant is ever needed just to render a family's own address.

AddressInputDto (request, nested under permanentAddress/currentAddress on write):

FieldTypeValidationNotes
provinceIdnumber | null@IsOptional, @Type(() => Number), @IsIntRequired whenever a district is given — a district without its province leaves the composite foreign key unenforced, because it is MATCH SIMPLE.
districtIdnumber | nullSameMust belong to provinceId or the write is refused with ADDRESS_HIERARCHY_INVALID.
municipalityIdnumber | nullSameMust belong to districtId.
wardNonumber | null@IsOptional, @Type(() => Number), @IsInt, @Min(1), @Max(35)Only meaningful with a municipality. The bound is bounded here as well as in the database: ward_no is a smallint, which overflows at 32768 with an unmapped 22003 before the CHECK is ever consulted — the DTO bound is what turns that into a 400 naming the field.
tolestring | null@IsOptional, @IsString, @MaxLength(200)
houseNostring | null@IsOptional, @IsString, @MaxLength(60)

The group is replaced WHOLE, never patched field by field, and this is not optional. PersonWriterService.buildUpdate distinguishes an omitted key from an explicit null one level up (Object.hasOwn), but that discipline does not extend through nesting on its own. If permanentAddress is present anywhere in the request body, all six columns are written from it and a missing key inside it means NULL; if the key is absent from the body, the whole group is left untouched. Sending { "permanentAddress": { "provinceId": null } } therefore clears the entire permanent address, not just the province — sending { "provinceId": null, "districtId": null, ... } explicitly for every column achieves the same result more legibly. The alternative (patching only the sent sub-fields) 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 that looks perfectly reasonable to the caller.

An all-null currentAddress means NOT RECORDED, never "same as permanent." There is deliberately no boolean flag for that — a family that has not given a separate present address simply has six null columns, and a client rendering the form must not infer or copy the permanent address into it.

6.3 BanAccountDto (body — every POST /:id/ban)

FieldTypeRequiredDefaultValidationExampleSource
reasonstringYes@IsString, @MinLength(1), @MaxLength(500)"Fees outstanding since Baisakh; readmission pending."account-action.dto.ts:4-13

A body of only whitespace (" ") passes @MinLength(1) but is refused server-side after trimming — see 8's ban entries.

6.4 PasswordResetSentDto (response — every POST /:id/password-reset)

FieldTypeRequiredDefaultValidationExampleSource
sentTostringYesThe person's own email, echoed backN/A — response-only"sita.parent@example.com"account-action.dto.ts:16-21

6.4a GrantSignInDto (body — every POST /:id/sign-in)

FieldTypeRequiredDefaultValidationExampleSource
invitebooleanNotrue@IsOptional, @IsBoolean — send the account invitation as well as granting accessfalsesign-in-access.dto.ts:9-21

The default is true because granting somebody an account and not telling them leaves them holding credentials they cannot use and no way to learn they have them. Send false to prepare an account and invite later; calling the same route again with invite: true sends the invitation then.

DELETE /:id/sign-in takes no body — there is nothing to choose when revoking.

6.4b InvitationOutcomeDto (response — inside SignInAccessDto and every create response)

FieldTypeRequiredDefaultValidationExampleSource
sentbooleanYesWhether an invitation was enqueuedtruesign-in-access.dto.ts:32
tostringNoAbsent when nothing was sentThe address it went to"sita.parent@example.com"sign-in-access.dto.ts:35
reason"not_requested" | "no_email" | "banned" | "revoked"NoPresent whenever sent is falseWhy nothing was sent"no_email"people-invitation.service.ts (InvitationSkipReason)

sent: false always carries a reason. An outcome saying only that something did not happen is the one thing an operator cannot act on. The four values, and what a consumer should do about each:

reasonMeaningWhat to do
not_requestedThe caller sent invite: false.Nothing, unless the operator changes their mind — call the grant route again with invite: true.
no_emailThe person has no address on file. The account is real either way.Add an address, then call the grant route again. It invites without needing the state to change.
bannedThe account is suspended, so a sign-in reached through the invitation would be refused anyway.Lift the suspension, then call the grant route again.
revokedSign-in access is absent — this is what a DELETE always answers.Nothing; there is nothing to invite anybody to.

On a create response only no_email can appear, because the other three describe states a person being created cannot be in.

sent: true means ENQUEUED, not delivered. The notification event and its outbox row are committed in the same transaction as the grant; whether the email reached anybody is carried by the delivery row, and an unconfigured provider or a bounced address shows up there rather than here. A consumer must not report "invitation delivered" from this field.

Note that the Swagger schema for this field declares no_email alone. The other three values are produced by the same responses; read InvitationSkipReason in people-invitation.service.ts as the authority.

6.4c SignInAccessDto (response — every POST/DELETE /:id/sign-in)

FieldTypeRequiredDefaultValidationExampleSource
canLoginbooleanYesThe state after the changetruesign-in-access.dto.ts:50
invitationInvitationOutcomeDtoYesAlways present; sent: false on a revoke, and on a grant that sent nothingsign-in-access.dto.ts:52-53

6.4d CreatedStudentDto / CreatedGuardianDto / CreatedStaffDto (responses — the three POST creates)

Each extends its read DTO — StudentDto, GuardianDto, StaffDto — and adds one field:

FieldTypeRequiredDefaultValidationExampleSource
invitationInvitationOutcomeDto | nullYes (nullable)null when sign-in was not granted{ "sent": true, "to": "sita.parent@example.com" }student.dto.ts:588-591, guardian.dto.ts:254-257, staff.dto.ts:510-513

The three values a consumer must distinguish:

  • null — sign-in was not granted, so there was never anything to invite anybody to. This is the ordinary case; most people on a roll have a record and no account.
  • { "sent": true, "to": "..." } — access was granted and an invitation was enqueued to that address.
  • { "sent": false, "reason": "no_email" } — access was granted, but the person has no email address. Add one and grant again through 8.10a to send the invitation.

The field lives on a separate class rather than on StudentDto/GuardianDto/ StaffDto, because those are also returned by the list, the detail read, the PATCH and the restore — an invitation there would be permanently null on four responses that never had one.

6.5 StudentDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idstring (UUID)YesServer-generated"01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f"student.dto.ts:49
admissionNumberstringYesAuto-allocated unless supplied on create"STU-2026-0041"student.dto.ts:50
studentIdstringYesAlways system-allocatedNever accepted on any write — see the comparison table below"SID-2026-0001"student.dto.ts
imeisIdstring | nullYes (nullable)nullnullstudent.dto.ts:51
admissionDatestring (ISO date)Yes"2026-04-15"student.dto.ts:52
recordStatusenum STUDENT_RECORD_STATUSESYes"active""active"student.dto.ts:53-54
transportModestring | null, enum TRANSPORT_MODESYes (nullable)null"school_bus"student.dto.ts:55-56
interestsHobbiesstring | nullYes (nullable)nullnullstudent.dto.ts:57-58
isRecordCompletebooleanYesComputed, never storedtrue when at least one guardian link is live end-to-endtruestudent.dto.ts:59-63
guardianCountnumberYesComputed, never storedCount of live guardian links only1student.dto.ts:64
currentClassStudentClassSummaryDto | nullYesComputed, never storedThe pupil's active enrolment in the current academic session. null when they have no class, and for every pupil while no session is current — both ordinary statessee belowstudent.dto.tsStudentClassSummaryDto
personPersonDtoYesSee 6.1student.dto.ts:65
createdAtDateYesServer-generatedstudent.dto.ts:66
updatedAtDateYesServer-generatedstudent.dto.ts:67
deletedAtDate | nullYes (nullable)nullnullstudent.dto.ts:68
versionstringYesString(students.version)Opaque — send back verbatim on PATCH; never parse it"1776123300000"student.dto.ts:69-73

StudentClassSummaryDto carries classId (the class publicId), gradeName, sectionName, shift (morning | day) and the optional class name. It deliberately carries no capacity or occupancy: those belong to the class, and the one screen that needs them reads /classes/options, the same endpoint the capacity chart uses, so the two cannot disagree.

medicalConditions, allergies, and specialNeeds are never present here — they live only on StudentMedicalDto, behind StudentMedical_READ, at 6.7. Confirmed by the integration test asserting Object.keys(student) excludes both.

studentId vs admissionNumber. The two look similar and are not interchangeable.

admissionNumberstudentId
What it belongs toThe ADMISSION — reissued when a school migrates a roll or re-admits under a new numberThe PUPIL — allocated once, permanent
Unique indexPartial: students_admission_number_unique on (admission_number) WHERE deleted_at IS NULLFull: students_student_id_unique on (student_id), no WHERE clause
Reissued after the record is removed?Yes — the partial index only guards live rows, so a soft-deleted student's admission number is free to give to somebody elseNever — the full index guards every row, including soft-deleted ones
Allocation scope (code_counters)student, prefix STUstudent_id, prefix SID — a separate counter, deliberately, so admission-number reissues after removals cannot drift the two apart
Accepted on POST /students?Yes, optionally (admissionNumber on CreateStudentDto) — for importing an existing rollNever. No CreateStudentDto/UpdateStudentDto field exists for it; a supplied value has no field to land in and is dropped by forbidNonWhitelisted before validation even inspects the body's other fields, or (if the payload also carries an unrelated unknown field) surfaces as the generic unknown-property 400.
Sourcepackages/db/src/schema/school/people.ts (students.studentId/students.admissionNumber column comments); apps/api/src/modules/people/shared/people-code.service.ts; apps/api/src/modules/people/students/students.service.ts (create)

Because student_id is never reissued and is unique across every row a school has ever created, it is the identifier safe to hold in a transcript, a ledger entry, or any external system that must keep pointing at the same pupil forever — admissionNumber is not, because the exact same string can legitimately belong to a different pupil later if the original record was removed.

6.6 StudentGuardianLinkDto (response — one entry on GET/PUT /students/:id/guardians)

FieldTypeRequiredDefaultValidationExampleSource
guardianIdstring (UUID)Yes"01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f"student.dto.ts:34
fullNamestring | nullYesDenormalized from the guardian's users row"Ram Bahadur Rai"student.dto.ts:35
phonestring | nullYes (nullable)null"+977-9841002233"student.dto.ts:36
relationshipenum GUARDIAN_RELATIONSHIPSYesThe SLOT this guardian fills — father, mother, or local_guardian, and only one guardian may occupy each slot per pupil"father"student.dto.ts:48-49
kindenum GUARDIAN_KINDSYesOrthogonal to the slot: a father, a mother, OR a local guardian may be an organisation"person"student.dto.ts:50-54
organizationNamestring | nullYes (nullable)nullSet only for an organisation guardian, derived server-side from person.firstName — never entered separatelynullstudent.dto.ts:56-57
isPrimarybooleanYesfalseAt most one true per student, DB-enforcedtruestudent.dto.ts:58
isLegalGuardianbooleanYesfalsefalsestudent.dto.ts:42
isEmergencyContactbooleanYesfalsefalsestudent.dto.ts:43
canPickupbooleanYesfalsetruestudent.dto.ts:44
livesWithbooleanYesfalsetruestudent.dto.ts:45

6.7 StudentMedicalDto (response and, structurally, the update shape — behind StudentMedical_READ/_UPDATE, never part of StudentDto)

FieldTypeRequiredDefaultValidationExampleSource
medicalConditionsstring | nullYes (nullable)null"Asthma"student.dto.ts:78-79
allergiesstring | nullYes (nullable)null"Peanuts"student.dto.ts:80
specialNeedsstring | nullYes (nullable)nullnullstudent.dto.ts:81

6.8 UpsertStudentGuardianDto (body — one entry inside CreateStudentDto.guardians and SetStudentGuardiansDto.guardians)

FieldTypeRequiredDefaultValidationExampleSource
guardianIdstring (UUID)No@IsOptional, @IsUUID — link an EXISTING guardian; omit to create one from person"01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f"student.dto.ts:89-91
personPersonInputDtoNo (required if guardianId is absent)@IsOptional, @ValidateNested, @Type(() => PersonInputDto)student.dto.ts:93-97
relationshipenum GUARDIAN_RELATIONSHIPSYes@IsEnumnever defaulted, in any UI; a prefilled "father" becomes wrong data every time an operator tabs past it. Only three values exist: father, mother, local_guardian. Two entries naming the same slot for one pupil are refused with 400 GUARDIAN_SLOT_TAKEN before the transaction opens"father"student.dto.ts:124-130
kindenum GUARDIAN_KINDSNo"person"@IsOptional, @IsEnum — a KIND, not a relationship, and orthogonal to relationship: for an organisation the whole name goes in person.firstName, which guardian_org_has_name then requires to be non-blank"organization"student.dto.ts:144-147
isPrimarybooleanNofalse@IsOptional, @IsBooleantruestudent.dto.ts:163-166
isLegalGuardianbooleanNofalse@IsOptional, @IsBooleanfalsestudent.dto.ts:115-116
isEmergencyContactbooleanNofalse@IsOptional, @IsBooleanfalsestudent.dto.ts:117-118
canPickupbooleanNofalse@IsOptional, @IsBooleantruestudent.dto.ts:119-120
livesWithbooleanNofalse@IsOptional, @IsBooleantruestudent.dto.ts:121-122
grantSignInbooleanNofalse@IsOptional, @IsBoolean — give this guardian a portal account. Requires the actor to hold Users_UPDATE on top of Guardians_CREATE; refused with 403 AUTH_FORBIDDEN otherwise. Only read when the entry creates a new guardian from person, never when it links an existing one by guardianIdtruestudent.dto.ts:190-200

A guardian granted sign-in through this nested entry is invited here too, in the same transaction as the pupil's admission — the flag would otherwise mean "account without an invitation" on this path and "account with one" on the three top-level creates, and a parent is the person most likely to actually use the login.

Both spellings are read here too — grantSignIn on the entry, and person.canLogin inside it — and a contradiction between them is refused with 400 PERSON_SIGN_IN_FLAGS_CONFLICT, exactly as on the three top-level creates. Honouring only grantSignIn would make one word mean two things on two paths, with this the path that silently discarded the other.

Neither guardianId nor person is itself marked required by a class-validator conditional — supplying neither reaches StudentGuardiansService.resolveGuardian, which throws 400 GUARDIAN_NOT_FOUND at runtime rather than a DTO-level VALIDATION_FAILED.

6.9 CreateStudentDto (body — POST /students)

FieldTypeRequiredDefaultValidationExampleSource
personPersonInputDtoYes@ValidateNested, @Type(() => PersonInputDto)student.dto.ts:126-129
grantSignInbooleanNofalse@IsOptional, @IsBoolean — give the pupil a portal account. Requires Users_UPDATE on top of Students_CREATE; person.canLogin is the older spelling of the same thing and is gated identicallytruestudent.dto.ts:253-256
admissionNumberstringNoAuto-allocated (STU-<year>-<seq>)@IsOptional, @IsString, @MaxLength(64) — supply one only when importing an existing rollstudent.dto.ts:131-138
imeisIdstringNo@IsOptional, @IsString, @MaxLength(64)student.dto.ts:140-141
admissionDatestring (ISO date)Yes@IsDateString"2026-04-15"student.dto.ts:143-145
recordStatusenum STUDENT_RECORD_STATUSESNo"active"@IsOptional, @IsEnumstudent.dto.ts:147-150
transportModeenum TRANSPORT_MODESNonull@IsOptional, @IsEnum"school_bus"student.dto.ts:152-155
interestsHobbiesstringNo@IsOptional, @IsString, @MaxLength(2000)student.dto.ts:157-158
medicalConditionsstringNo@IsOptional, @IsString, @MaxLength(2000) — write-only; never echoed by the create response, only reachable afterward via GET /students/:id/medical"Asthma"student.dto.ts:160-161
allergiesstringNo@IsOptional, @IsString, @MaxLength(2000)"Peanuts"student.dto.ts:162-163
specialNeedsstringNo@IsOptional, @IsString, @MaxLength(2000)student.dto.ts:164-165
guardiansUpsertStudentGuardianDto[]No (@IsOptional at the DTO level)@IsArray, @ValidateNested({ each: true }), @Type(() => UpsertStudentGuardianDto)at least one guardian is required on create. StudentsService.create treats an absent key the same as an empty array, so the DTO's own optionality does not make guardians skippable; either shape is refused with 400 STUDENT_REQUIRES_ONE_GUARDIAN, and exactly one entry must be isPrimarystudent.dto.ts:259-268

6.10 UpdateStudentDto (body — PATCH /students/:id)

FieldTypeRequiredDefaultValidationExampleSource
versionstringYes@IsString — the version from the record you loaded; a mismatch is 409 PEOPLE_STALE_RECORD"1776123300000"student.dto.ts:180-182
personPersonInputDtoNoUnchanged if omitted@IsOptional, @ValidateNested, @Type(() => PersonInputDto)student.dto.ts:184-188
imeisIdstringNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(64)student.dto.ts:190-191
admissionDatestring (ISO date)NoUnchanged if omitted@IsOptional, @IsDateStringstudent.dto.ts:192-193
recordStatusenum STUDENT_RECORD_STATUSESNoUnchanged if omitted@IsOptional, @IsEnum"inactive"student.dto.ts:194-196
transportModeenum TRANSPORT_MODESNoUnchanged if omitted; explicit null clears it@IsOptional, @IsEnumstudent.dto.ts:197-199
interestsHobbiesstringNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(2000)student.dto.ts:200-201
classIdstring (uuid)NoOmitted leaves the class unchanged@IsOptional, @IsUUID — a class publicId. Not nullable: no value clears a classstudent.dto.tsUpdateStudentDto
enrolledOnstring (ISO date)NoToday in the school's timezone@IsOptional, @IsDateString. Ignored when classId is absent"2026-04-15"student.dto.tsUpdateStudentDto
allowOverCapacitybooleanNofalse@IsOptional, @IsBoolean. Recorded in the activity log as an overridetruestudent.dto.tsUpdateStudentDto

Moving a pupil's class through this endpoint. Sending classId enrols or transfers, in the same transaction as the rest of the edit, through the same enroll() the class roster uses — so the previous enrolment is closed as transferred and the same activity record is written. Three rules apply:

  • There is no value that clears a class. Taking a pupil out of one with nowhere to put them is a withdrawal — it needs a date and a reason and it changes the class's roll — so it stays on DELETE /classes/:publicId/enrollments/:studentId.
  • The class must belong to the current session, or the write is refused with ENROLLMENT_SESSION_NOT_CURRENT. POST /classes/:publicId/enrollments does not apply this rule: it names its class in the URL, so choosing another year there is deliberate. Without it this endpoint would return 200, write a second active enrolment in a session currentClass does not read from, and appear to have done nothing.
  • version now covers the class. Every enrolment write touches students.updated_at, so a transfer made from the class roster invalidates an already-open pupil edit form and its save is refused with PEOPLE_STALE_RECORD rather than silently moving the pupil back.

Additional failures this endpoint can now return: CLASS_NOT_FOUND, CLASS_INACTIVE, CLASS_AT_CAPACITY (retry with allowOverCapacity: true), ENROLLMENT_SESSION_NOT_CURRENT, ENROLLMENT_DATE_OUTSIDE_SESSION, ENROLLMENT_DATE_BEFORE_ADMISSION.

Cannot rename/re-allocate admissionNumber through this endpoint — the field is absent from UpdateStudentDto entirely; the global ValidationPipe's forbidNonWhitelisted rejects an attempt with a plain 400, not a domain error. Cannot touch health fields here either — use PATCH /students/:id/medical. Cannot touch guardians here — use PUT /students/:id/guardians.

6.11 UpdateStudentMedicalDto (body — PATCH /students/:id/medical)

FieldTypeRequiredDefaultValidationExampleSource
medicalConditionsstringNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(2000)"Asthma, controlled"student.dto.ts:205-206
allergiesstringNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(2000)"Peanuts, shellfish"student.dto.ts:207-208
specialNeedsstringNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(2000)student.dto.ts:209-210

No version field — this endpoint carries no optimistic-concurrency check at all, unlike the main student PATCH.

6.12 SetStudentGuardiansDto (body — PUT /students/:id/guardians)

FieldTypeRequiredDefaultValidationExampleSource
guardiansUpsertStudentGuardianDto[]Yes@IsArray, @ValidateNested({ each: true }), @Type(() => UpsertStudentGuardianDto) — the WHOLE set; an empty array removes every guardian[] or a list of 6.8 entriesstudent.dto.ts:214-218

6.13 ListStudentsQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
recordStatusenum STUDENT_RECORD_STATUSESNoUnset (no filter)@IsOptional, @IsEnum?recordStatus=activestudent.dto.ts:237-239
genderenum GENDERSNoUnset@IsOptional, @IsEnum?gender=femalestudent.dto.ts:241-243
bloodGroupenum BLOOD_GROUPSNoUnset@IsOptional, @IsEnumstudent.dto.ts:245-247
transportModeenum TRANSPORT_MODESNoUnset@IsOptional, @IsEnumstudent.dto.ts:249-251
hasGuardiansbooleanNoUnsetQuery-string boolean transform (QueryBoolean), @IsBoolean — filters on a LIVE guardian link only?hasGuardians=falsestudent.dto.ts:253-255
admissionDateFromstring (ISO date)NoUnset@IsOptional, @IsDateString?admissionDateFrom=2026-01-01student.dto.ts:257-258
admissionDateTostring (ISO date)NoUnset@IsOptional, @IsDateStringstudent.dto.ts:259-260
recordVisibilityenum RECORD_VISIBILITIES (current | removed | all)No"current" (DEFAULT_RECORD_VISIBILITY)@IsOptional, @IsIn(RECORD_VISIBILITIES)?recordVisibility=removedstudent.dto.ts
includeDeletedbooleanNoRetired, and ignored — replaced by recordVisibility@IsOptional, QueryBoolean, @IsBoolean (still validated, never read)?includeDeleted=truestudent.dto.ts:262-263
sortByenum STUDENT_SORTABLE (admissionNumber, admissionDate, fullName, createdAt, updatedAt)NoupdatedAt@IsOptional, @IsIn(STUDENT_SORTABLE) — a distinct field from inherited sort; see 10?sortBy=admissionNumberstudent.dto.ts:228-234,265-268
pagination, page, size, sort, order, searchInherited from QueryDtoNoSee 6.25Inheritedquery.dto.ts

pagination cannot be turned off hereStudentsService.findAll throws 400 PAGINATION_LIMIT_INVALID when query.pagination === false, the same code the guardians and staff lists use for the identical shape of refusal: an unpaginated roll of every pupil's name, date of birth, address, and guardians is the largest of the three tables, so it is refused before the scope predicate is even built, not merely bounded.

6.14 GuardianDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idstring (UUID)YesServer-generated"01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f"guardian.dto.ts:45
kindenum GUARDIAN_KINDSYes"person""person"guardian.dto.ts:46-47
organizationNamestring | nullYes (nullable)nullSet only when kind is "organization", derived from person.firstName — never entered separatelynullguardian.dto.ts:48-53
occupationstring | nullYes (nullable)null"Farmer"guardian.dto.ts:54
personPersonDtoYesSee 6.1guardian.dto.ts:55
childCountnumberYesComputed, never storedLive linked students only — a deleted child does not count1guardian.dto.ts:56-59
createdAtDateYesServer-generatedguardian.dto.ts:60
updatedAtDateYesServer-generatedguardian.dto.ts:61
deletedAtDate | nullYes (nullable)nullnullguardian.dto.ts:62

No version field. Unlike StudentDto, a guardian's PATCH carries no optimistic-concurrency token at all — see 6.17.

6.15 GuardianChildDto (response — one entry on GET /guardians/:id/students)

FieldTypeRequiredDefaultValidationExampleSource
studentIdstring (UUID)Yes"01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f"guardian.dto.ts:67
admissionNumberstringYes"STU-2026-0041"guardian.dto.ts:68
fullNamestring | nullYes (nullable)nullDenormalized from the student's users row"Sita Rai"guardian.dto.ts:69
relationshipenum GUARDIAN_RELATIONSHIPSYesThe slot this guardian fills for this student — father, mother, or local_guardian"father"guardian.dto.ts:87-88
isPrimarybooleanYesfalsetrueguardian.dto.ts:89
isLegalGuardianbooleanYesfalsefalseguardian.dto.ts:75
isEmergencyContactbooleanYesfalsefalseguardian.dto.ts:76
canPickupbooleanYesfalsetrueguardian.dto.ts:77
livesWithbooleanYesfalsetrueguardian.dto.ts:78

This is the same underlying student_guardian row as 6.6, read from the guardian's side of the relationship — the inverse view, not a different table.

6.16 CreateGuardianDto (body — POST /guardians)

FieldTypeRequiredDefaultValidationExampleSource
personPersonInputDtoYes@ValidateNested, @Type(() => PersonInputDto)guardian.dto.ts:82-85
grantSignInbooleanNofalse@IsOptional, @IsBoolean — give the guardian a portal account. Requires Users_UPDATE on top of Guardians_CREATE; person.canLogin is the older spelling of the same thing and is gated identicallytrueguardian.dto.ts:131-134
kindenum GUARDIAN_KINDSNo"person"@IsOptional, @IsEnumorganizationName is derived from person.firstName when this is "organization"; there is no separate organizationName input field"organization"guardian.dto.ts:87-95
occupationstringNo@IsOptional, @IsString, @MaxLength(120)"Farmer"guardian.dto.ts:97-98

6.17 UpdateGuardianDto (body — PATCH /guardians/:id)

FieldTypeRequiredDefaultValidationExampleSource
personPersonInputDtoNoUnchanged if omitted@IsOptional, @ValidateNested, @Type(() => PersonInputDto)guardian.dto.ts:102-106
kindenum GUARDIAN_KINDSNoUnchanged if omitted@IsOptional, @IsEnumguardian.dto.ts:108-111
occupationstringNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(120)guardian.dto.ts:113-114

No version field, and no optimistic-concurrency check of any kind. GuardiansService.update reads the current row, applies the patch, and writes — two concurrent PATCH requests both succeed, and the second's write silently wins with no 409 ever returned. organizationName is always recomputed on write, from whichever of dto.person.firstName or the row's existing firstName applies, whenever kind resolves to "organization" — not only when kind itself is present in the body, because the name must stay in sync with firstName on every edit that could change either half of the pair.

6.18 ListGuardiansQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
kindenum GUARDIAN_KINDSNoUnset@IsOptional, @IsEnum?kind=organizationguardian.dto.ts:123-126
phonestringNoUnset@IsOptional, @IsString, @MaxLength(40) — exact match, not a partial search; this is the create-or-select lookup, not a separate /guardians/search route?phone=%2B977-9841002233guardian.dto.ts:128-135
hasChildrenbooleanNoUnset@IsOptional, QueryBoolean, @IsBoolean — live linked student, or none?hasChildren=trueguardian.dto.ts:137-143
recordVisibilityenum RECORD_VISIBILITIES (current | removed | all)No"current"@IsOptional, @IsIn(RECORD_VISIBILITIES)?recordVisibility=allguardian.dto.ts
includeDeletedbooleanNoRetired, and ignored — replaced by recordVisibility@IsOptional, QueryBoolean, @IsBoolean (still validated, never read)guardian.dto.ts:145-146
pagination, page, size, sort, order, searchInherited from QueryDtoNoSee 6.25Inherited — sort is validated against GUARDIAN_SORTABLE (fullName, createdAt, updatedAt) in the service, not by a DTO enumquery.dto.ts; guardian.dto.ts:42

pagination cannot be turned offGuardiansService.findAll throws 400 PAGINATION_LIMIT_INVALID if ?pagination=false, because the restricted-scope predicate is a subquery per row and an unpaginated read over the whole roll is the query this endpoint must never run. The students and staff lists refuse pagination=false with the same error code, for the same shape of reason.

6.19 StaffDto (response), with StaffDepartmentRefDto / StaffDesignationRefDto

FieldTypeRequiredDefaultValidationExampleSource
idstring (UUID)YesServer-generated"01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f"staff.dto.ts:50
employeeCodestringYesAuto-allocated (EMP-<year>-<seq>)"EMP-2026-0007"staff.dto.ts:51
joiningDatestring (ISO date)Yes"2026-04-15"staff.dto.ts:52
experienceYearsnumber | nullYes (nullable)null4staff.dto.ts:53-54
qualificationstring | nullYes (nullable)null"M.Ed."staff.dto.ts:55-56
departmentStaffDepartmentRefDto | null ({ id: number; name: string })Yes (nullable)nullLeft-joined; null when the staff row has no department{ "id": 3, "name": "Science" }staff.dto.ts:38-41,57-58
designationStaffDesignationRefDto | null ({ id: number; name: string; isTeaching: boolean })Yes (nullable)nullLeft-joined; null when the staff row has no designation{ "id": 7, "name": "Senior Teacher", "isTeaching": true }staff.dto.ts:43-47,59-60
employmentStatusenum EMPLOYMENT_STATUSESYes"active""active"staff.dto.ts:61-62
personPersonDtoYesSee 6.1staff.dto.ts:63
createdAtDateYesServer-generatedstaff.dto.ts:64
updatedAtDateYesServer-generatedstaff.dto.ts:65
deletedAtDate | nullYes (nullable)nullnullstaff.dto.ts
versionstringYesServer-generatedOpaque; the decimal string of staff.version"1757308800000"staff.mapper.ts

Send version back on the next write. It is required by both PATCH /staff/:id and PATCH /staff/:id/salary, and both advance it — so a client that saves through both in one flow must send the token the FIRST call returned, not the one it loaded the page with. Its scope is the staff row: a concurrent edit to the person's users row through ban, unban, password reset or the users module does not move it.

basicSalary, allowances, totalSalary, and every bank field are never present here — behind StaffSalary_READ, at 6.20.

6.20 StaffSalaryDto (response and, structurally, the update shape — behind StaffSalary_READ/_UPDATE, never part of StaffDto)

FieldTypeRequiredDefaultValidationExampleSource
basicSalarystring | null (decimal)Yes (nullable)nullnumeric(12,2), >= 0, <= 99999999.99; travels as a STRING, never a number"45000.00"staff.dto.ts:79-80
allowancesstring | null (decimal)Yes (nullable)nullnumeric(12,2), >= 0, <= 99999999.99; must be set/unset together with basicSalary"5000.00"staff.dto.ts:81
totalSalarystring | null (decimal)Yes (nullable)Database-generated as basic_salary + allowancesRead-only — never accept this on write; the schema uses numeric(14,2) deliberately wider than the two inputs' numeric(12,2) because two maximal legal inputs can overflow a same-width sum"50000.00"staff.dto.ts:82-86
bankNamestring | nullYes (nullable)null"Nabil Bank"staff.dto.ts:87-88
accountNumberstring | nullYes (nullable)null"01234567890"staff.dto.ts:89
branchstring | nullYes (nullable)null"New Baneshwor"staff.dto.ts:90
panNumberstring | nullYes (nullable)null"301234567"staff.dto.ts:91-92
citizenshipNumberstring | nullYes (nullable)null"27-01-70-12345"staff.dto.ts:93-94
ssfNumberstring | nullYes (nullable)nullSocial Security Fund membership number — the school's monthly contribution return is filed against it, so an employee without one cannot be included in that month's filing"SSF-0041-2026"staff.dto.ts:95-100
citNumberstring | nullYes (nullable)nullCitizen Investment Trust membership number"CIT-778812"staff.dto.ts
versionstringYesServer-generatedOpaque; the SAME token StaffDto.version carries, from staff.version"1757308800000"staff-salary.service.ts (toStaffSalaryDto)

version is returned by the salary write as well as the read, so a client saving through both staff routes can chain the second call off the first. updatedAt is not part of this response at all — the salary projection carries version and renders it as the token.

Both are free text with no format validation beyond a length cap — SSF numbering changed shape when the scheme opened and CIT numbers vary by the office that issued them, so a regex would reject a real employee's real number, and the office would work around it by leaving the field blank, which is strictly worse than storing what they were given.

Money is a decimal string in every direction — a consumer that parses it to a JavaScript number risks precision loss on a large payroll figure and must reserialize it as a string, byte-for-byte, on any subsequent write.

6.21 CreateStaffDto (body — POST /staff)

FieldTypeRequiredDefaultValidationExampleSource
personPersonInputDtoYes@ValidateNested, @Type(() => PersonInputDto)staff.dto.ts:97-100
grantSignInbooleanNofalse@IsOptional, @IsBoolean — give the staff member a portal account. Requires Users_UPDATE on top of Staff_CREATE; person.canLogin is the older spelling of the same thing and is gated identicallytruestaff.dto.ts:245-248
joiningDatestring (ISO date)Yes@IsDateString"2026-04-15"staff.dto.ts:102-104
experienceYearsnumberNonull@IsOptional, @Type(() => Number), @IsInt, @Min(0)4staff.dto.ts:106-111
qualificationstringNo@IsOptional, @IsString, @MaxLength(200)"M.Ed."staff.dto.ts:113-117
departmentIdnumberNonull@IsOptional, @Type(() => Number), @IsInt — required together with designationId, since a designation always belongs to a department3staff.dto.ts:119-125
designationIdnumberNonull@IsOptional, @Type(() => Number), @IsInt7staff.dto.ts:127-131
employmentStatusenum EMPLOYMENT_STATUSESNo"active"@IsOptional, @IsEnumstaff.dto.ts:133-136
salaryUpdateStaffSalaryDtoNoOmit the key entirely when the actor lacks StaffSalary_UPDATE@IsOptional, @ValidateNested, @Type(() => UpdateStaffSalaryDto) — see 6.23 for the nested shape{ "basicSalary": "45000.00", "allowances": "5000.00" }staff.dto.ts:190-211

salary on POST /staff is gated on KEY PRESENCE, not on the actor's permission for its contents. StaffService.create calls StaffSalaryService.resolveSalaryForCreate(actor, dto.salary, wantsSalary), where wantsSalary is Object.hasOwn(dto, "salary") && dto.salary !== undefined — so sending "salary": {} (or any object) in the body from an actor who does not hold StaffSalary_UPDATE is refused with 403 PERMISSION_INSUFFICIENT, not silently dropped. This is deliberate: silently ignoring the key would make a rejected write indistinguishable from a successful one that simply had nothing to save, and an actor who cannot write pay must not be able to probe whether the key is even accepted by watching for a difference in behavior. Omitting the salary key entirely (not sending it at all) never triggers this check, resolves to null, and creates the staff member with no salary/bank columns set. basicSalary/allowances still travel together or not at all, exactly as on PATCH /staff/:id/salary. PATCH /staff/:id — the general staff edit endpoint — does NOT accept salary at all (absent from UpdateStaffDto, 6.22 below); the only way to change salary/bank details after creation is PATCH /staff/:id/salary, so the permission boundary for editing pay is crossed in exactly one place on the write side.

Sending designationId without departmentId is not caught by any validator in this DTO or in StaffService.create. It reaches the database as an INSERT with department_id = NULL, designation_id = <value>, which violates the staff_designation_needs_department CHECK constraint (people.ts:339-342) — a 23514 that PersonWriterService.translate has no case for, so it falls to the default: throw error as Error branch and surfaces to the caller as a bare 500 SYS_INTERNAL_ERROR, not a validation error. Always send both together, or neither.

6.22 UpdateStaffDto (body — PATCH /staff/:id)

FieldTypeRequiredDefaultValidationExampleSource
versionstringYes@IsString, @IsNotEmpty — the token from the record you loaded"1757308800000"staff.dto.ts (UpdateStaffDto)
personPersonInputDtoNoUnchanged if omitted@IsOptional, @ValidateNested, @Type(() => PersonInputDto)staff.dto.ts
joiningDatestring (ISO date)NoUnchanged if omitted@IsOptional, @IsDateStringstaff.dto.ts
experienceYearsnumberNoUnchanged if omitted; explicit null clears it@IsOptional, @Type(() => Number), @IsInt, @Min(0)staff.dto.ts:149-150
qualificationstringNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(200)staff.dto.ts:152-153
departmentIdnumberNoUnchanged if omitted; explicit null clears it@IsOptional, @Type(() => Number), @IsIntstaff.dto.ts:155-156
designationIdnumberNoUnchanged if omitted; explicit null clears it@IsOptional, @Type(() => Number), @IsIntstaff.dto.ts:158-159
employmentStatusenum EMPLOYMENT_STATUSESNoUnchanged if omitted@IsOptional, @IsEnum"on_leave"staff.dto.ts:161-163

No version field — see 6.19. The same staff_designation_needs_department gap applies here: clearing departmentId to null while leaving an existing designationId in place violates the CHECK identically and surfaces as an unmapped 500. Changing role grants is not this endpoint's jobemploymentStatus is an HR fact about the job; it never touches the staff/teacher role grant, which is only ever assigned at POST /staff and never revoked or re-evaluated on update.

6.23 UpdateStaffSalaryDto (body — PATCH /staff/:id/salary)

FieldTypeRequiredDefaultValidationExampleSource
basicSalarystring | nullNoUnchanged if omitted; explicit null clears it@IsOptional, @IsNumberString — must travel with allowances (both null, or both set)"45000.00"staff.dto.ts:173-176
allowancesstring | nullNoUnchanged if omitted; explicit null clears it@IsOptional, @IsNumberString"5000.00"staff.dto.ts:178-181
bankNamestring | nullNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(120)"Nabil Bank"staff.dto.ts:183-185
accountNumberstring | nullNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(60)staff.dto.ts:187-189
branchstring | nullNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(120)staff.dto.ts:191-193
panNumberstring | nullNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(40)staff.dto.ts:195-197
citizenshipNumberstring | nullNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(40)staff.dto.ts:199-201
ssfNumberstring | nullNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(40) — free text, no format check; SSF numbering has changed shape since the scheme opened"SSF-0041-2026"staff.dto.ts:247-251
citNumberstring | nullNoUnchanged if omitted; explicit null/"" clears it@IsOptional, @IsString, @MaxLength(40) — free text; CIT numbers vary by issuing office"CIT-778812"staff.dto.ts:253-257

PATCH /staff/:id/salary binds PatchStaffSalaryDto, not this class. PatchStaffSalaryDto extends UpdateStaffSalaryDto and adds one required field:

FieldTypeRequiredDefaultValidationExampleSource
versionstringYes@IsString, @IsNotEmpty — the token from the record you loaded"1757308800000"staff.dto.ts (PatchStaffSalaryDto)

The token lives on the subclass and not on UpdateStaffSalaryDto because CreateStaffDto.salary is typed as UpdateStaffSalaryDto. Requiring a version there would reject every staff admission carrying pay — for a row that does not exist yet — and would turn the deliberate 403 for salary: {} without StaffSalary_UPDATE into a 400 about a missing field, since validation runs before the service.

@IsNumberString accepts any numeric-looking string; it does not enforce the numeric(12,2) scale/precision or the 099999999.99 range — those are DB CHECKs (staff_basic_salary_range, staff_allowances_range) reached only after this validator passes, so an out-of-range or over-precise value produces the generic unique/check-violation path rather than a named error (see 8.35).

6.24 ListStaffQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
departmentIdnumberNoUnset@IsOptional, @Type(() => Number), @IsInt?departmentId=3staff.dto.ts:228-229
designationIdnumberNoUnset@IsOptional, @Type(() => Number), @IsInt?designationId=7staff.dto.ts:231-232
designationKindenum DESIGNATION_KINDS ("teaching", "non_teaching")NoUnset@IsOptional, @IsEnum — not a database column; the Teachers screen's own filter, translated to designations.isTeaching = true/false?designationKind=teachingstaff.dto.ts:36,234-239
employmentStatusenum EMPLOYMENT_STATUSESNoUnset@IsOptional, @IsEnum?employmentStatus=activestaff.dto.ts:241-243
genderenum GENDERSNoUnset@IsOptional, @IsEnumstaff.dto.ts:245-247
joiningDateFromstring (ISO date)NoUnset@IsOptional, @IsDateStringstaff.dto.ts:249-250
joiningDateTostring (ISO date)NoUnset@IsOptional, @IsDateStringstaff.dto.ts:252-253
recordVisibilityenum RECORD_VISIBILITIES (current | removed | all)No"current"@IsOptional, @IsIn(RECORD_VISIBILITIES)?recordVisibility=removedstaff.dto.ts
includeDeletedbooleanNoRetired, and ignored — replaced by recordVisibility@IsOptional, QueryBoolean, @IsBoolean (still validated, never read)staff.dto.ts:255-256
pagination, page, size, sort, order, searchInherited from QueryDtoNoSee 6.25Inherited — sort is validated against STAFF_SORTABLE (employeeCode, joiningDate, employmentStatus, experienceYears, fullName, createdAt, updatedAt) in the servicequery.dto.ts; staff.dto.ts:213-221

pagination cannot be turned offStaffService.findAll throws 400 PAGINATION_LIMIT_INVALID when query.pagination === false, the same error code the students and guardians lists use for the identical shape of refusal, because an unbounded staff directory is a full-table read of everyone's employment record.

6.25 QueryDto — shared base

FieldTypeRequiredDefaultValidationExampleSource
paginationbooleanNotrueQuery-string boolean transform, @IsBoolean?pagination=falsequery.dto.ts:14-29
pagenumberNo1@IsInt, @Min(1)?page=2query.dto.ts:31-36
sizenumberNo20@IsInt, @Min(1), @Max(100)rejected with 400 VALIDATION_FAILED above 100, not silently clamped; PaginationUtil.normalize's own clamp only matters for a caller that bypasses the DTO (e.g. an internal job)?size=50query.dto.ts:38-44
sortstringNo"updatedAt"@IsString — free-form at the DTO layer; each service validates it against its own allow-list (STUDENT_SORTABLE/GUARDIAN_SORTABLE/STAFF_SORTABLE) and throws 400 PEOPLE_INVALID_SORT_FIELD for anything else, rather than silently falling back the way the school module's LookupsService does?sort=fullNamequery.dto.ts:46-49
order"asc" | "desc"No"desc"@IsEnum(["asc", "desc"])?order=ascquery.dto.ts:51-54
searchstringNo— (no filter)@IsString, @MaxLength(100), trimmed; an all-whitespace value transforms to undefined and drops the filter?search=sitaquery.dto.ts:56-63

ListStudentsQueryDto additionally declares its own sortBy (not sort) as the actual allow-listed field — the inherited sort is accepted but functionally unused by StudentsService.orderBy, which reads query.sortBy. ListGuardiansQueryDto and ListStaffQueryDto use the inherited sort field directly against their own allow-lists instead.

6.26 GuardianLookupDto (body — POST /students/guardian-lookup)

FieldTypeRequiredDefaultValidationExampleSource
phonestringYes@IsString, @IsNotEmpty, @MaxLength(40)"+9779812345678"student.dto.ts:362-368

Run through the global ValidationPipe like every other body in this module — whitelist/forbidNonWhitelisted apply, and a missing or blank phone is refused with 400 VALIDATION_FAILED rather than degrading to an empty result set. See 8.2.

7. Enum Reference

EnumValueMeaningRuntime EffectSource
GENDERSmale, female, other, prefer_not_to_saySelf-reported gender.Filter on all three list endpoints; stored on users.gender.person.dto.ts:15-20
BLOOD_GROUPSA+, A-, B+, B-, AB+, AB-, O+, O-Blood group.Filter on the students list; stored on users.blood_group.person.dto.ts:16-18
MARITAL_STATUSESsingle, married, divorced, widowed, separatedMarital status.Stored on users.marital_status; no list filter uses it.person.dto.ts:19-21
DISABILITY_TYPESnone, visual, hearing, physical, intellectual, learning, speech, multiple, otherDisability classification.Stored on users.disability_type; no list filter uses it.person.dto.ts:22-25
STUDENT_RECORD_STATUSESactive, inactiveA record-level toggle — not the enrollment lifecycle.StudentDto.recordStatus; filter on the students list.student.dto.ts:24
TRANSPORT_MODESnone, school_bus, private, walking, public_transportHow the pupil travels to school.StudentDto.transportMode; filter on the students list.student.dto.ts:25-27
GUARDIAN_RELATIONSHIPSfather, mother, local_guardianThe three guardian SLOTS. Declared once, in guardians/dto/guardian.dto.ts, and imported by student.dto.ts — an earlier version declared this list twice, and narrowing it from an eleven-value list left the two copies disagreeing with only the type-checker noticing. organization is deliberately absent: it is a KIND of guardian (GUARDIAN_KINDS), orthogonal to the slot, which is what lets any of the three be one.StudentGuardianLinkDto.relationship, GuardianChildDto.relationship, UpsertStudentGuardianDto.relationship.guardian.dto.ts:45-49
GUARDIAN_KINDSperson, organizationWhat kind of guardian this is, independent of which slot it fills.GuardianDto.kind, StudentGuardianLinkDto.kind, UpsertStudentGuardianDto.kind.guardian.dto.ts:24
GUARDIAN_KINDSperson, organizationWhether the guardian is a natural person or an organisation (an orphanage trust, a hostel).GuardianDto.kind; drives whether organizationName is derived and required non-empty.guardian.dto.ts:14
EMPLOYMENT_STATUSESactive, on_leave, suspended, resigned, terminated, retiredAn HR fact about the job — independent of whether the account is banned.StaffDto.employmentStatus; filter on the staff list.staff.dto.ts:23-30
DESIGNATION_KINDSteaching, non_teachingNot a database column — a query-only vocabulary translated to designations.isTeaching. This is the Teachers screen's entire filter, since there is no Teachers entity.ListStaffQueryDto.designationKind.staff.dto.ts:36
order (on QueryDto)asc, descSort direction.Every list endpoint.query.dto.ts:51-54

8. Endpoint Reference

8.1 GET /api/students

Purpose

Returns a paginated, filterable, searchable list of students, scoped to what the caller's active role may see. Called by the student roll screen, by a guardian's own "my children" view, and by a student's own "my record" view — the same endpoint serves all three, differentiated entirely by PeopleAccessService.scopeFor.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:65-81
DTOstudent.dto.ts (ListStudentsQueryDto, StudentDto)
Servicestudents.service.ts (findAll, queryList, orderBy)
Schemapackages/db/src/schema/school/people.ts (students)
Testsstudents.service.integration.spec.ts (search, pagination-determinism, guardian-existence-filter cases)

Auth and Permissions

  • Auth: Required (JwtAuthGuard).
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Students_READ.
  • Object-level scope: PeopleAccessService.scopeForall for superadmin; role-name-first below that, so an active role named guardian or student is scoped to that restriction (own children; own record) even if it also holds Students_READ; any other role gets all only by holding Students_READ, and otherwise sees only its own profile row. See 5.
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsNo
QueryNosearch, recordStatus, gender, bloodGroup, transportMode, hasGuardians, admissionDateFrom, admissionDateTo, recordVisibility (current | removed | all, default current; replaces includeDeleted — see Section 10), includeDeleted (accepted, ignored), sortBy, order, pagination, page, size.
BodyNo
GET /api/students?search=sita&recordStatus=active&hasGuardians=true&sortBy=admissionNumber&order=asc HTTP/1.1

Response

{
  "message": "Students fetched.",
  "data": [
    {
      "id": "01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f",
      "admissionNumber": "STU-2026-0041",
      "studentId": "SID-2026-0001",
      "imeisId": null,
      "admissionDate": "2026-04-15",
      "recordStatus": "active",
      "transportMode": "school_bus",
      "interestsHobbies": null,
      "isRecordComplete": true,
      "guardianCount": 1,
      "currentClass": {
        "classId": "01922e2a-7c3a-7b1e-9d2e-1a2b3c4d5e6f",
        "gradeName": "5",
        "sectionName": "A",
        "shift": "morning",
        "name": null
      },
      "person": {
        "id": "01922e2a-6b1e-7c3a-9d2e-1a2b3c4d5e6f",
        "firstName": "Sita",
        "middleName": null,
        "lastName": "Rai",
        "fullName": "Sita Rai",
        "email": null,
        "emailVerified": false,
        "phone": null,
        "phoneVerified": false,
        "image": null,
        "canLogin": true,
        "mustChangePassword": false,
        "dateOfBirth": "2012-03-04",
        "gender": "female",
        "bloodGroup": "O+",
        "disabilityType": null,
        "maritalStatus": null,
        "ethnicityId": 4,
        "ethnicityName": "Newar",
        "motherTongueId": 2,
        "motherTongueName": "Nepal Bhasa",
        "permanentAddress": {
          "provinceId": 3,
          "provinceName": "Bagmati",
          "districtId": 27,
          "districtName": "Kathmandu",
          "municipalityId": 118,
          "municipalityName": "Kathmandu Metropolitan City",
          "municipalityType": "metropolitan",
          "wardNo": 4,
          "tole": "Baneshwor",
          "houseNo": null
        },
        "currentAddress": {
          "provinceId": null,
          "provinceName": null,
          "districtId": null,
          "districtName": null,
          "municipalityId": null,
          "municipalityName": null,
          "municipalityType": null,
          "wardNo": null,
          "tole": null,
          "houseNo": null
        },
        "bio": null,
        "banned": false,
        "banReason": null,
        "createdAt": "2026-04-15T04:15:00.000Z",
        "updatedAt": "2026-04-15T04:15:00.000Z",
        "deletedAt": null
      },
      "createdAt": "2026-04-15T04:15:00.000Z",
      "updatedAt": "2026-04-15T04:15:00.000Z",
      "deletedAt": null,
      "version": "1776123300000"
    }
  ],
  "errorCode": null,
  "count": 1,
  "currentPage": 1,
  "totalPage": 1
}

count/currentPage/totalPage are always present — the controller passes a metadata object into ResponseDto whenever query.pagination is truthy (students.controller.ts:77-79), and pagination=false is refused outright rather than accepted, so the branch that would omit them is unreachable from this route.

Side Effects

  • Cache: reads students:list:<key> first; on a hit, no database query runs. On a miss, writes the result to that key with a 120-second TTL.
  • Known staleness, accepted: the list projects a class's grade, section and shift as text, so renaming a grade, a section or a class leaves the cached roll showing the old label for up to the 120-second TTL. Enrolment writes and a change of current session both drop that cache; a rename does not, because coupling the classes module's writes to the students cache would buy a two-minute label correction at the cost of a dependency between two modules.
  • Database reads: SELECT on students INNER JOIN users, plus two correlated subqueries per row — guardianCount, and currentClass, which resolves the pupil's active enrolment in the current session down to grade, section and shift; a separate COUNT(*) when pagination is enabled (the class subquery is on the row query only, so it cannot affect the total). A search term additionally opens a transaction to pin the trigram threshold.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_ROLE_NOT_ASSIGNED / AUTH_ACTIVE_ROLE_REQUIREDNo active role, or several with none chosen.Assign/select a role.role.guard.ts:134-148
400PAGINATION_LIMIT_INVALIDpagination=false.Pagination cannot be disabled for the student roll.students.service.ts:103-116
400VALIDATION_FAILEDAn invalid query value (e.g. page=0, order=up, size=150).Fix the query string.Global ValidationPipe
400PEOPLE_INVALID_SORT_FIELDsortBy is not one of STUDENT_SORTABLE — unreachable through the DTO's own @IsIn, kept because a data-export job calls the same ordering logic directly.Choose a supported sort field.students.service.ts:284-292

Edge Cases

  • Empty search (?search=): trims to undefined and is dropped — the unfiltered (but still scoped) list returns, not zero rows.
  • search containing %/_: escaped before entering the ILIKE half of the match; the trigram half takes the raw term.
  • pagination=false is refused with 400 PAGINATION_LIMIT_INVALID, even for a superadmin — checked before the scope predicate is even built, because an unpaginated roll is every child's name, date of birth and home address in one response, and this is the largest of the three people tables.
  • size above 100: rejected with 400, not clamped.
  • A guardian or student viewing this list sees only what their scope predicate allows — never a 403, just a shorter list (or zero rows) than an administrator would see for the same query.
  • Ties on the sort column (e.g. a bulk import sharing one updatedAt) never drop or duplicate rows across pages — id is appended as a deterministic tie-breaker on every ordering.
  • hasGuardians=false returns students with zero live guardian links — a student whose only guardian was soft-deleted counts as hasGuardians=false even though a student_guardian row still exists.

Example Requests

curl -X GET "$API_URL/api/students?recordStatus=active&sortBy=admissionNumber&order=asc" \
  -H "Authorization: Bearer TOKEN"

8.2 POST /api/students/guardian-lookup

Purpose

Finds existing guardians sharing a phone number, for the admission form's create-or-select step — called before an operator types a name, so the second child of a family attaches to the same guardian row instead of duplicating a parent. A POST, deliberately: a phone number identifying a specific family belongs in a request body, not in access logs, proxy logs, or browser history from a URL.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:93-105
DTOstudent.dto.ts (GuardianLookupDto)
Servicestudent-guardians.service.ts (lookupGuardiansByPhone)
Schemapeople.ts (guardians, student_guardian)
Testsstudents.service.integration.spec.ts ("attaches a sibling to the SAME guardian row…")

Auth and Permissions

  • Auth: Required.
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Guardians_READ.
  • Object-level scope: PeopleAccessService.scopeFor(actor, "guardians", …) — a restricted actor's lookup is silently narrowed to guardians already within their own scope, never a 403.
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsNo
QueryNo
BodyYesGuardianLookupDto — see 6.26.
{
  "phone": "+977-9841002233"
}

Validated by the global ValidationPipe like every other body in this module: phone is required, must be a string, non-blank, and at most 40 characters. whitelist/forbidNonWhitelisted apply, so an unrecognized extra field in the body is rejected rather than silently accepted.

Response

{
  "message": "Guardians fetched.",
  "data": [
    {
      "guardianId": "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f",
      "fullName": "Ram Bahadur Rai",
      "phone": "+977-9841002233",
      "email": null,
      "linkedStudentCount": 1
    }
  ]
}

Returned as a plain array with no pagination metadata — capped at 25 rows (.limit(25)), never paginated.

Side Effects

  • Database reads: guardians INNER JOIN users filtered on an exact phone match, plus a correlated subquery per row for linkedStudentCount (live students only).
  • No cache, no writes, no jobs, no external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_INSUFFICIENTActive role lacks Guardians_READ.Not authorized.role.guard.ts
400VALIDATION_FAILEDphone missing, blank, not a string, or over 40 characters.Supply a phone number to search.Global ValidationPipe; student.dto.ts:362-368

Edge Cases

  • A household sharing one number returns every guardian on it, by design — the caller picks the right one rather than the API guessing, since a shared number is the normal case for a family, not an anomaly.
  • Returns a list even when it contains zero, one, or several entries; never a 404 for "no match" — an empty array is the correct answer to "nobody has this number yet".
  • The exact-match semantics mean a phone number stored with different formatting (spaces, no country code) will not match — there is no normalization on this filter beyond validation.
  • A blank or missing phone no longer silently returns an empty list — it is rejected with 400 VALIDATION_FAILED before the query ever runs, since matching on an empty string would otherwise read as "no parent on file", which is the one answer an admissions operator must not be given by accident.

Example Requests

curl -X POST "$API_URL/api/students/guardian-lookup" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+977-9841002233"}'

8.3 POST /api/students

Purpose

Admits a student: one users row, one students row, and zero or more guardian links, all in a single transaction — each guardian entry either links an existing guardian (found via 8.2) or creates a new one from scratch. Called by the admission form's submit action.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:107-114
DTOstudent.dto.ts (CreateStudentDto, UpsertStudentGuardianDto)
Servicestudents.service.ts (create); student-guardians.service.ts (writeGuardianLinks, assertGuardianSetValid, resolveGuardian)
Schemapeople.ts (students, guardians, student_guardian, students_admission_number_unique, student_single_primary_guardian, relationship_other_required)
Testsstudents.service.integration.spec.ts (admission, no-guardian admission, sibling attach, primary validation, duplicate guardian, email conflict)

Auth and Permissions

  • Auth: Required.
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Students_CREATE.
  • Object-level scope: N/A — create has no existing row to scope against.
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: None — no idempotency key; a resubmitted identical request admits a second student unless admissionNumber collides.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsNo
QueryNo
BodyYesCreateStudentDto — see 6.9.

Minimal valid request (no guardians — permitted, flags the record incomplete):

{
  "person": { "firstName": "Sita", "lastName": "Rai" },
  "admissionDate": "2026-04-15"
}

Full valid request, linking one new guardian as primary:

{
  "person": {
    "firstName": "Sita",
    "lastName": "Rai",
    "dateOfBirth": "2012-03-04",
    "gender": "female",
    "bloodGroup": "O+",
    "city": "Kathmandu",
    "state": "Bagmati"
  },
  "admissionDate": "2026-04-15",
  "transportMode": "school_bus",
  "guardians": [
    {
      "person": {
        "firstName": "Ram Bahadur",
        "lastName": "Rai",
        "phone": "+977-9841002233"
      },
      "relationship": "father",
      "isPrimary": true,
      "isLegalGuardian": true,
      "isEmergencyContact": true,
      "canPickup": true,
      "livesWith": true
    }
  ]
}

Second child of the same family, linking the existing guardian by id instead:

{
  "person": { "firstName": "Hari", "lastName": "Rai" },
  "admissionDate": "2026-04-15",
  "guardians": [
    {
      "guardianId": "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f",
      "relationship": "father",
      "isPrimary": true
    }
  ]
}

Response

Same shape as 8.1's single item, wrapped as data, with message: "Student admitted.".

The response also carries invitation. The create responses are CreatedStudentDto, CreatedGuardianDto and CreatedStaffDto — the read DTO plus one nullable field, described in 6.4d. It is null unless sign-in access was granted; { "sent": true, "to": "..." } when an invitation was enqueued; and { "sent": false, "reason": "no_email" } when access was granted to somebody with no email address on file.

Side Effects

  • Database writes, in one transaction: one INSERT into users; one INSERT into students (allocating an admission number atomically if none was supplied); for each guardian entry, either a lookup-and-reuse or an INSERT into users + guardians; one INSERT per guardian into student_guardian, all initially is_primary = false, followed by a single UPDATE setting the primary flag on the one entry marked isPrimary (a two-pass write, since the primary-uniqueness index is not deferrable).
  • Cache: every cached student list is invalidated (students:* prefix sweep).
  • When sign-in access is granted to somebody with an email address — the pupil, or a guardian entry carrying grantSignIn — the same transaction also writes an account_invite verification record and enqueues the invitation email through the notification outbox. Both commit with the person or not at all; a fire-and-forget call after the commit would lose the invitation on a restart between the two, leaving somebody who exists, believes they were invited, and has nothing scheduled.
  • No other jobs, realtime events, notifications, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDA required field missing/invalid, or an unknown field present.Fix the request body.Global ValidationPipe
400PERSON_SIGN_IN_FLAGS_CONFLICTgrantSignIn and person.canLogin were both sent with different values.Send one of them, or the same value for both.people-permissions.service.ts (resolveSignInGrant)
403AUTH_FORBIDDENgrantSignIn (or person.canLogin) is true and the actor does not hold Users_UPDATE. Also raised when a guardian entry sets grantSignIn and the actor holds neither Users_UPDATE nor Guardians_CREATE.Admit the pupil without sign-in access, or ask an administrator to grant it afterwards.people-permissions.service.ts (resolveSignInGrant), student-guardians.service.ts
400STUDENT_GUARDIAN_PRIMARY_REQUIREDOne or more guardians given, none marked isPrimary.Mark exactly one guardian as primary.student-guardians.service.ts:323-332
400STUDENT_GUARDIAN_MULTIPLE_PRIMARYMore than one guardian marked isPrimary.Mark only one as primary.student-guardians.service.ts:333-337
400STUDENT_REQUIRES_ONE_GUARDIANThe guardians array is empty, or the key is absent from the request.Add at least a father, a mother, or a local guardian.student-guardians.service.ts:373-384
400GUARDIAN_SLOT_TAKENTwo entries in the request name the same relationship slot.Each pupil may have at most one father, one mother, and one local guardian.student-guardians.service.ts:400-412
400GUARDIAN_NOT_FOUNDA guardian entry supplies neither guardianId nor person.Supply one or the other.student-guardians.service.ts:285-291
404GUARDIAN_NOT_FOUNDguardianId references no live guardian.Choose a valid guardian, or omit it to create one.student-guardians.service.ts:263-282
409STUDENT_GUARDIAN_ALREADY_LINKEDThe same guardianId appears twice in one admission's guardians array.Remove the duplicate entry.student-guardians.service.ts:219-228
409USER_EMAIL_ALREADY_EXISTSThe student's, or a new guardian's, email is already held by a live person.Use a different email, or omit it.person-writer.service.ts:139-163,177-182
409STUDENT_ADMISSION_NUMBER_TAKENA supplied admissionNumber is already in use by a live student.Choose a different number, or omit it to auto-allocate.person-writer.service.ts:183-187
409RESOURCE_ALREADY_EXISTSA race past any pre-check hits the database's own unique index directly.Refresh and retry.all-exceptions.filter.ts unique-violation fallback

If the transaction fails for any reason, nothing is written — no orphaned users row, no half-admitted student, no dangling guardian link; confirmed by the integration test asserting zero students/users rows after a rejected primary-guardian validation.

Edge Cases

  • At least one guardian is required. isRecordComplete/guardianCount still describe the live-link state after admission (a guardian later soft-deleted can bring guardianCount back to 0), but the admission request itself can no longer submit zero guardians — this overrides an earlier decision, still visible in the schema's own comments, to permit an intentionally incomplete admission with none.
  • Supplying admissionNumber explicitly is for importing an existing roll — a fresh admission should omit it and let PeopleCodeService allocate one, timezone-correct against the school's academic year. The same applies to studentId: omit it to auto-allocate, or supply one (format SID-YYYY-NNNN) when migrating a roll that already quotes one — see §6.5's comparison table.
  • Two office staff admitting simultaneously never collide on the admission number — allocation is one atomic upsert (code_counters), not a max()+1 read.
  • An organisation guardian (kind: "organization" on the link, orthogonal to relationship) derives guardians.organizationName from person.firstName — send the organisation's whole name there, with no lastName. Any of the three slots (father, mother, local_guardian) may hold an organisation.
  • Reusing guardianId links to that guardian's current data; it does not re-apply any person fields also present in the same entry (they are ignored when guardianId is set — only person is read when creating a new guardian). grantSignIn on such an entry is ignored for the same reason: the guardian already exists, and their sign-in access is changed through 8.23a.
  • Admission does not create an account. Without grantSignIn (or its older spelling person.canLogin) the pupil gets a record and no login, which is the right outcome for most of a roll. Granting one requires Users_UPDATE on top of Students_CREATE, because a login-capable row with an email address is a route to a session — POST /api/auth/password/forgot is public.
  • A grant to somebody with no email address still succeeds; the response carries invitation: { "sent": false, "reason": "no_email" }. Add an address later and call 8.10a to invite them.

Example Requests

curl -X POST "$API_URL/api/students" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"person":{"firstName":"Sita","lastName":"Rai"},"admissionDate":"2026-04-15"}'

8.4 GET /api/students/:id

Purpose

Returns one student. Called by the student detail screen, and by a guardian's or student's own record view when the id in scope is theirs.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:116-126
DTOstudent.dto.ts (StudentDto)
Servicestudents.service.ts (findOne, loadOne)
Schemapeople.ts (students)
TestsN/A — covered indirectly through create/update round-trips.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Students_READ. Object-level scope: PeopleAccessService.assertCanAccess — out-of-scope answers 404, never 403. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsYesid — UUID, enforced by ParseUUIDPipe.
QueryNo
BodyNo

Response

Same shape as one item of 8.1's data array, with message: "Student fetched.".

Side Effects

Database read only: students INNER JOIN users, plus the guardianCount and currentClass subqueries. No cache (single-item reads are not cached; only list results are).

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDid is not a valid UUID.Fix the id.ParseUUIDPipe
404STUDENT_NOT_FOUNDNo live student with this id, or the id exists but is outside the caller's scope.The student may not exist, or you cannot see it.people-access.service.ts:222-236; students.service.ts:594-599
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_ROLE_NOT_ASSIGNEDAs 8.1.As above.

Edge Cases

  • A soft-deleted student's id answers 404 here even for a caller with full scope — this endpoint never returns a deleted row; list it via ?recordVisibility=removed (or all) instead. ?includeDeleted=true no longer has this effect — see Section 10.
  • A guardian requesting another family's child gets byte-for-byte the same 404 STUDENT_NOT_FOUND as a nonexistent id — there is no way to distinguish "wrong family" from "never existed" from the response alone, by design.

Example Requests

curl -X GET "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN"

8.5 PATCH /api/students/:id

Purpose

Updates a student's identity fields or record-level fields (admission date, record status, transport mode, IMEIS id, interests). Called from the student edit screen. Cannot touch health data, guardians, or the admission number — each has its own endpoint or is immutable here.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:128-139
DTOstudent.dto.ts (UpdateStudentDto)
Servicestudents.service.ts (update)
Schemapeople.ts (students)
Testsstudents.service.integration.spec.ts ("refuses a PATCH carrying a stale version…", "leaves fields the PATCH omitted untouched")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Students_UPDATE. Object-level scope: assertCanAccess — out-of-scope is 404. Guest support: none. Rate limit: none module-specific. Idempotency: not naturally idempotent — a version token is required, and a resubmitted identical body after the first succeeds fails the second time with 409 PEOPLE_STALE_RECORD because the token has already moved.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — UUID, ParseUUIDPipe.
QueryNo
BodyYesUpdateStudentDto — see 6.10. version is mandatory; every other field is optional.

Minimal valid request:

{
  "version": "1776123300000",
  "transportMode": "public_transport"
}

Response

Same shape as 8.4, with message: "Student updated." and a new version.

Side Effects

  • Database reads: the row locked FOR UPDATE to read the current version counter (the version check), so two concurrent PATCHes serialize rather than racing.
  • Database writes: an UPDATE on users only for the fields the person object actually carried; an UPDATE on students for the record-level fields present, always touching updatedAt even when only person changed — not because it is the token (it is not; students.version is), but because it keeps the patch non-empty so the statement, and therefore the bump_row_version trigger, always fires, and leaving it alone on a person-only edit would hand every other viewer a token that still validates against a record that has, in fact, moved.
  • Database writes, when the body carries classId: an INSERT into student_class_enrollments, and an UPDATE closing the pupil's previous active enrolment as transferred — both in the same transaction as the row above, through the same enroll() the class roster uses. It also reads classes, grades, sections and academic_sessions to resolve and validate the class, and takes FOR UPDATE on the target class row (and the previous one, in ascending id order) to count occupancy.
  • Cache: every cached student list invalidated.
  • No jobs, realtime events, notifications, or external calls. An enrolment write does defer one activity record.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDInvalid field value, missing version, or an unknown field (admissionNumber, guardians, health fields) in the body.Fix the request body — those fields have their own endpoints or cannot change.Global ValidationPipe
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.Refresh the list.students.service.ts:454,594-599
409PEOPLE_STALE_RECORDThe submitted version does not match the row's current version counter.Somebody else saved first — reload and re-apply your change; never retry blindly.students.service.ts (update)
409USER_EMAIL_ALREADY_EXISTSThe new email is already held by a different live person.Choose a different email.person-writer.service.ts:139-163
404CLASS_NOT_FOUNDclassId names no class.Reload the class list.class-enrollments.service.tsenroll
409CLASS_INACTIVEThe target class has been retired.Choose a class that is still running.class-enrollments.service.tsenroll
409CLASS_AT_CAPACITYThe target class is full. The message carries the counts.Offer the operator an explicit confirmation, then retry the identical request with allowOverCapacity: true. Do not retry silently.class-enrollments.service.tsenroll
409ENROLLMENT_SESSION_NOT_CURRENTclassId names a class in another academic session.Enrol from the class screen instead.class-enrollments.service.tsenroll
409ENROLLMENT_DATE_OUTSIDE_SESSIONenrolledOn falls outside the class's academic session.Choose a date inside the year.class-enrollments.service.tsenroll
409ENROLLMENT_DATE_BEFORE_ADMISSIONenrolledOn precedes the pupil's admission date.Choose a later date.class-enrollments.service.tsenroll
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.1.As above.

The six class errors are reachable only when the body carries classId; a request that omits it cannot produce any of them. See 6.10 for the rules that govern them.

Edge Cases

  • A stale version never auto-merges or auto-retries. The correct client behavior is to reload the record, show the operator the fresh state, and let them re-apply their edit — the token is opaque and exists exactly to force that reload rather than a silent overwrite.
  • Sending version with no other field is technically valid and is a no-op except that it still emits an UPDATE, so the trigger still increments version — a client polling for "has anything changed" using this endpoint as a heartbeat would get a false positive.
  • imeisId/transportMode/interestsHobbies accept an explicit null (or, after trimming, an empty string) to clear the field — omitting the key entirely leaves it untouched. These are different client actions and must not be conflated.
  • The row lock (FOR UPDATE) held during the version check means a second concurrent PATCH on the same student waits for the first to commit or roll back rather than racing to read a possibly-stale value — it does not itself fail; it is the version comparison, evaluated after the lock is acquired, that produces the 409.

Example Requests

curl -X PATCH "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version":"1776123300000","transportMode":"public_transport"}'

8.6 DELETE /api/students/:id

Purpose

Soft-deletes a student. Called from the student roll's remove action — typically for a genuine data-entry error, since a normal departure is better recorded as recordStatus: "inactive" and this action removes the record from every default list view.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:141-150
DTONone — no body.
Servicestudents.service.ts (remove); people-deletion.service.ts (softDeleteProfile)
Schemapeople.ts (students.deleted_at, students_admission_number_unique partial index)
Testsstudents.service.integration.spec.ts ("soft-deletes the student, hides them from the list, and restores them"; "does not remove the person when they still hold another live profile")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Students_DELETE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a second DELETE on an already-deleted id returns 404, not a repeated success.

Request

id in the path only, no body.

Response

{
  "message": "Student removed.",
  "data": null,
  "errorCode": null
}

Side Effects

  • Database writes, in one transaction: students.deleted_at/updated_at set on the profile row. Only if the underlying person now holds no other live profile (checked by counting live students/guardians/staff rows for that userId): users.deleted_at is also set, every account credential row for that person is deleted, and every session is revoked. A person who is both a student and a guardian of a sibling keeps their users row and their guardian profile intact.
  • Releases admissionNumber for reuse — the unique index is partial on deleted_at IS NULL, so a future allocation (or an explicit admissionNumber on a new admission) can legally take the same string.
  • Cache: every cached student list invalidated.
  • No jobs, realtime events, notifications, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.Already removed, never existed, or not visible to you.people-deletion.service.ts:247-278
409USER_CANNOT_DELETE_SELFThe target's underlying users.id equals the caller's own id.You cannot delete your own account.people-deletion.service.ts:62-67
403USER_LAST_SUPERADMIN_PROTECTEDDeleting this person's last profile would also delete their users row, and they are the last sign-in-capable superadmin.This is the last superadmin account and cannot be removed.actor-authority.service.ts:278-316
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.1.As above.

A student is vanishingly unlikely to be a superadmin in practice, but the check runs unconditionally because softDeleteProfile is the same code path for all three profile kinds.

Edge Cases

  • Deleting a student who is also a guardian (rare, but representable — an eighteen-year-old sibling caring for a younger one) removes only the students row; the person's guardians profile, login, and sessions are untouched.
  • Deleting the student who is a guardian's only linked child does not delete the guardian — student_guardian rows are removed by the ON DELETE cascade FK from students, not by this endpoint, and the guardian row itself is independent.
  • Restoring afterward is not automatic and not guaranteed to succeed — see 8.7.

Example Requests

curl -X DELETE "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN"

8.7 POST /api/students/:id/restore

Purpose

Brings a soft-deleted student back, and their person with it if the person was deleted as a consequence. Called from the deleted-students screen's restore action.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:152-162
DTONone — no body.
Servicestudents.service.ts (restore); people-deletion.service.ts (restoreProfile)
Schemapeople.ts (students_admission_number_unique); identity.ts (users_email_unique)
Testsstudents.service.integration.spec.ts ("soft-deletes… and restores them"; "refuses to restore a student whose admission number was reissued")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Students_UPDATE (not a separate Students_RESTORE — deliberately: see 13.5). Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: restoring an already-live student is a silent no-op success, not an error — restoreProfile returns immediately when deletedAt is already null.

Request

id in the path only, no body.

Response

Same shape as 8.4, with message: "Student restored.".

Side Effects

  • Database reads: the profile row locked FOR UPDATE; a check that the admission number is still free among live students; a check that the person's email is still free among live users.
  • Database writes: students.deleted_at cleared; users.deleted_at cleared (harmless if it was already null because another live profile kept the person alive).
  • Cache: every cached student list invalidated.
  • No jobs, realtime events, notifications, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STUDENT_NOT_FOUNDNo student row with this id at all (live or deleted), or out of scope.The record does not exist or is not visible to you.people-deletion.service.ts:140,228-245
409STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICTThe admission number this student held has been reissued to a different, currently-live student.Give this record a new admission number before restoring — not directly supported by this endpoint; recreate or contact support.people-deletion.service.ts:158-181
409USER_RESTORE_EMAIL_CONFLICTThe person's email has been taken by a different live account since the deletion.Change the conflicting account's email, or this person's, before restoring.people-deletion.service.ts:204-226
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.1.As above.

Edge Cases

  • Calling this on a student who was never deleted returns 200 with the current (unchanged) record — not an error, and not a signal that anything happened.
  • If the admission number is genuinely blocked, this endpoint has no way to accept a replacement number in the same call — the conflict must be resolved (typically by changing the other record's number) before retrying.
  • Restoring a student whose guardian was also deleted does not restore the guardian or the student_guardian link automatically — each profile's deletion and restoration is independent.

Example Requests

curl -X POST "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/restore" \
  -H "Authorization: Bearer TOKEN"

8.8 POST /api/students/:id/ban

Purpose

Suspends the student's ability to sign in, without touching the roll record. Called from the student detail screen's suspend action — for example, unpaid fees pending readmission.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:172-182
DTOdto/account-action.dto.ts (BanAccountDto)
Servicestudents.service.ts (ban); people-account.service.ts (ban)
Schemaidentity.ts (users.banned, banReason, bannedAt, bannedBy)
Testsstudents.service.integration.spec.ts ("suspends an account with a reason and records who did it"; "refuses a suspension with no reason")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Students_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — banning an already-banned student updates the reason/timestamp/actor again with no error.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — UUID, ParseUUIDPipe.
BodyYesBanAccountDto — see 6.3.
{ "reason": "Fees outstanding since Baisakh; readmission pending." }

Response

{
  "message": "Account suspended.",
  "data": null,
  "errorCode": null
}

Side Effects

  • Database writes: users.banned = true, banReason, bannedAt, bannedBy set, inside a transaction that also runs the last-superadmin check.
  • Every session the person holds is deleted immediately after the transaction commits — a refresh token stays redeemable otherwise, since JwtStrategy re-checks banned only on the access-token path.
  • Cache: every cached student list invalidated (the response's person.banned field changes).
  • No jobs, realtime events, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDreason missing or over 500 characters.Give a reason.Global ValidationPipe
400USER_BAN_REASON_REQUIREDreason is present but blank after trimming (e.g. " ").Give a real reason.people-account.service.ts:70-79
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.As above.people-account.service.ts:215-247
409USER_CANNOT_DELETE_SELFThe caller is banning their own account.You cannot suspend yourself.people-account.service.ts:225-232 (assertNotSelf) — reuses the delete-self error code
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not (self-targeting is exempt — that path is USER_CANNOT_DELETE_SELF instead).Only another superadmin can suspend this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:86
403USER_LAST_SUPERADMIN_PROTECTEDThe target is the last sign-in-capable superadmin.This is the last superadmin account.actor-authority.service.ts:315-353
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.1.As above.

A student cannot ban themselves through this check even though a student rarely holds Students_UPDATE in practice — the guard runs before scope is even relevant, closing the path structurally rather than relying on the permission catalogue alone. An office clerk cannot suspend a superadmin's account either, even one holding the entity's _UPDATE permission legitimately — assertMayActOnAccount reads the target's full role set (not the target's active role) before the last-superadmin count ever runs, because with two live superadmins on file the count alone would happily let a clerk suspend either of them.

Edge Cases

  • Re-banning an already-banned student with a new reason overwrites the old one — there is no ban history kept on the row itself, only the current state.
  • USER_CANNOT_DELETE_SELF is reused here rather than a ban-specific code — a deliberate sharing of vocabulary between "you cannot remove yourself" and "you cannot suspend yourself", both closing off the same kind of self-inflicted lockout.

Example Requests

curl -X POST "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/ban" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Fees outstanding since Baisakh; readmission pending."}'

8.9 POST /api/students/:id/unban

Purpose

Lifts a suspension. Called from the same detail screen once the reason (e.g. fees) is resolved.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:184-193
DTONone — no body.
Servicestudents.service.ts (unban); people-account.service.ts (unban)
Schemaidentity.ts (users.banned, banReason, bannedAt, bannedBy)
Testsstudents.service.integration.spec.ts ("clears the reason when the account is restored")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Students_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — unbanning an already-unbanned student is a no-op success.

Request

id in the path only, no body.

Response

{
  "message": "Account restored.",
  "data": null,
  "errorCode": null
}

Side Effects

Database writes: users.banned = false, banReason, bannedAt, bannedBy all cleared to nullnot just the flag, so a subsequent read never shows a stale reason on an active account. No session sweep on this path (there is nothing to revoke — an unban only widens access). Cache invalidated identically to ban.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.As above.people-account.service.ts:215-247
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can restore this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:118
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.1.As above.

No last-superadmin check on this path — unbanning only ever grants access back, never removes it. It still runs the same assertMayActOnAccount check as ban, guarding the milder half of the same authority: a suspension a superadmin imposed cannot be lifted by a clerk acting on that superadmin's behalf, and reinstating an account carries the same "who may touch this" question as suspending one.

Edge Cases

  • Unbanning an account that was never banned succeeds with no visible effect — all four fields were already at their cleared defaults.
  • No self-ban restriction exists for unban — a student unbanning themselves would only be reachable if they already held Students_UPDATE, which no seeded role grants.
  • Unbanning is refused for the same reason banning is, when the target is a superadmin and the caller is not — assertMayActOnAccount reads the target's full role set, not their active role, so this holds even if the superadmin were, in this session, acting as a guardian.

Example Requests

curl -X POST "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/unban" \
  -H "Authorization: Bearer TOKEN"

8.10 POST /api/students/:id/password-reset

Purpose

Emails a password-reset link to the student (or, more commonly in practice, to whichever contact address is on file). Called from the detail screen's "send reset link" action — never sets a password directly, so an administrator never learns what the new password is.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:195-209
DTOdto/account-action.dto.ts (PasswordResetSentDto, response)
Servicestudents.service.ts (sendPasswordResetLink); people-account.service.ts (sendPasswordResetLink)
Schemaidentity.ts (users.email, canLogin, banned)
TestsCovered on the staff variant — staff.service.spec.ts ("issues a reset token and emails the link", "refuses a reset link for somebody with no sign-in access"); identical code path.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Students_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific (the underlying token issuance has its own lifecycle, not modeled here). Idempotency: not idempotent in the strict sense — each call issues a fresh single-use token and sends a new email; an old, unused link is not necessarily invalidated by a new one being issued (see the auth module's token lifecycle for that guarantee).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsYesid — UUID, ParseUUIDPipe.
BodyNo— (the request's ip/user-agent are read server-side from the HTTP request itself, not the body).

Response

{
  "message": "Password reset link sent.",
  "data": { "sentTo": "sita.parent@example.com" },
  "errorCode": null
}

Side Effects

  • Database reads: the person's email, canLogin, banned state.
  • Issues a single-use, expiring password-reset verification token, recording the caller's IP and user agent as context.
  • Sends the reset email (fail-soft at the email layer — sendPasswordResetEmailSafe).
  • Logs an activity line naming the actor, the kind, and the profile id.
  • No cache invalidation (this endpoint changes nothing on the student/guardian/staff response itself).

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.As above.people-account.service.ts:152
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can trigger a reset link for this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:157
409USER_EMAIL_REQUIREDThe person has no email on file.Add an email address first.people-account.service.ts:154-160
409USER_LOGIN_DISABLEDcanLogin is false.This person cannot sign in; a link would not work.people-account.service.ts:161-169
409AUTH_ACCOUNT_BANNEDThe account is currently suspended.Restore the account first.people-account.service.ts:170-176
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.1.As above.

A reset link is a route to somebody's credentials, so it is guarded the same way as ban and unban — a clerk who could trigger one on a superadmin's account could invalidate their current password at will, which is denial of access by another name, checked before the email/login/ban state is even loaded.

Unlike the self-service "forgot password" endpoint, this one does not stay silent about a missing address. The self-service flow's silence prevents an anonymous caller enumerating accounts; here the caller is an authenticated administrator already looking at the record, and silence would only mean the button appeared to work while nothing was sent.

Edge Cases

  • A very young pupil with no email and canLogin: false cannot receive a reset link at all through this endpoint — both refusals would fire, USER_EMAIL_REQUIRED first.
  • Calling this repeatedly in quick succession issues a new token each time; nothing in this endpoint itself throttles it.

Example Requests

curl -X POST "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/password-reset" \
  -H "Authorization: Bearer TOKEN"

8.10a POST /api/students/:id/sign-in

Purpose

Gives this pupil a portal account, and by default emails them the invitation that lets them choose a password. Called from the detail screen's sign-in toggle.

This is the only route that turns sign-in access on after creation. PATCH /api/students/:id does not accept canLogin at all.

Source Evidence

EvidencePath
Controllerstudents.controller.ts (grantSignIn)
DTOsign-in-access.dto.ts (GrantSignInDtoSignInAccessDto)
Serviceshared/people-account.service.ts (setSignIn)
Schemaidentity.ts (users.can_login), auth.ts (verification)
  • Auth: JWT.
  • Permission: Users_UPDATE — not Students_UPDATE. Granting sign-in is an identity change rather than a record edit: a login-capable row with an email address is a route into a session, because POST /api/auth/password/forgot is public.
  • Idempotency: safe to repeat, but not a no-op. Granting to somebody who already has access skips the state change and still decides the invitation afresh — that is what makes the two documented recovery paths work, since both invite: false and no_email end with the operator calling this route again while the state is already correct.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — the pupil's profile id (UUID; ParseUUIDPipe).
BodyNoGrantSignInDto — see 6.4a. An empty body means invite: true.
{ "invite": true }

Response

{
  "message": "Sign-in access granted.",
  "data": {
    "canLogin": true,
    "invitation": { "sent": true, "to": "sita.rai@example.com" }
  },
  "errorCode": null
}

canLogin is the state after the change. invitation is described in 6.4bsent: true means the invitation was enqueued, not that it arrived.

Side Effects

  • UPDATE users SET can_login = true guarded by the value that was read a moment earlier: WHERE id = :id AND can_login = :observed AND deleted_at IS NULL. A concurrent change, or a soft delete between the read and the write, makes this a zero-row result and a 409 rather than a silent overwrite of somebody else's decision. It is skipped when the person already has access.
  • When invite is not false, the person has an email address, and the account is not suspended: an account_invite verification record valid for 7 days, and an invitation email enqueued through the notification outbox.
  • The state change and the invitation share one transaction. If the invitation cannot be scheduled, the grant rolls back with it — answering sent: true for an invitation nobody will receive would tell the operator something false about a person now holding credentials they will never hear about.
  • No session changes — granting access creates nothing to sign in with until the person sets a password.
  • No cache invalidation — can_login is not part of any cached student projection.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STUDENT_NOT_FOUNDNo live pupil with that id, or it is outside the actor's scope.Not found.people-account.service.ts (resolveUserId)
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Ask another superadmin to do this.actor-authority.service.ts (assertMayActOnAccount)
409PERSON_SIGN_IN_STATE_CHANGEDSomebody else changed this person's sign-in access between the read and the write.Re-read the record and try again.people-account.service.ts (setSignIn)
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTNo valid JWT, or the actor lacks Users_UPDATE.Sign in, or ask an administrator.JwtAuthGuard, RoleGuard

A missing email address is not an error here, and neither is a suspended account. The grant succeeds in both cases and the response says why the invitation was skipped — reason: "no_email" or reason: "banned". The account is legitimate either way, and refusing the grant over it would be the worse outcome. Fix the underlying condition and call this route again; it invites without needing the sign-in state to change.

Edge Cases

  • Granting to somebody who already has access still sends the invitation. The state change is skipped; the invitation is decided on its own. This is deliberate — invite: false promises "prepare an account and invite later" and no_email promises "add an address and call again", and both bring the operator back to this route with the state already correct. Returning early would answer 200 having sent nothing, indistinguishable from success, with no other route that would ever send that invitation.
  • invite: false prepares the account silently. Call the route again with invite: true — or once an email address exists — to send the invitation then.
  • The invitation lasts 7 days, not the 15 minutes an OTP gets. It redeems by link only: the one-time code is never printed in an invitation email, and POST /api/auth/password/reset matches OTPs against password resets alone.
  • A suspended (banned) person can still be granted sign-in access, but is not invited: the response carries reason: "banned". The two flags are independent — banned is a statement about conduct with a recorded reason, can_login a statement about whether a portal account exists at all — and an invitation into an account the login guard will refuse produces a support call, not an activated account. Lift the suspension and call this route again to invite them.
  • can_login governs authentication and nothing else. It never affects notification delivery: somebody with no portal account still receives every notification addressed to them.

Example Requests

curl -X POST "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/sign-in" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"invite":true}'

8.10b DELETE /api/students/:id/sign-in

Purpose

Takes this pupil's portal account away and ends every session they hold.

Not the same as suspending. A ban records a reason and is a statement about conduct; this simply says the person no longer has an account. Their record, and every notification addressed to it, is untouched.

Source Evidence

EvidencePath
Controllerstudents.controller.ts (revokeSignIn)
DTOsign-in-access.dto.ts (SignInAccessDto)
Serviceshared/people-account.service.ts (setSignIn)
Schemaidentity.ts (users.can_login), auth.ts (session)
  • Auth: JWT.
  • Permission: Users_UPDATE, for the same reason as the grant.
  • Body: none — there is nothing to choose when revoking.

Response

{
  "message": "Sign-in access revoked.",
  "data": {
    "canLogin": false,
    "invitation": { "sent": false, "reason": "revoked" }
  },
  "errorCode": null
}

invitation is always present, and on a revoke is always sent: false with reason: "revoked" — there is nothing to invite anybody to.

Side Effects

  • UPDATE users SET can_login = false, guarded by the observed value exactly as the grant is, and skipped when the person has no access to begin with.
  • Every session for that user is deleted. JwtStrategy re-reads can_login on each request, so the revoke takes effect on the next call either way — but leaving the rows behind keeps a refresh token redeemable, and deleting them removes that possibility rather than relying on every future caller remembering to check.
  • No invitation, no email, no notification.
  • No cache invalidation — can_login is not part of any cached student projection.

Error Cases

Identical to 8.10a: 404 STUDENT_NOT_FOUND, 403 USER_SUPERADMIN_PROTECTED, 409 PERSON_SIGN_IN_STATE_CHANGED, and the standard 401/403.

Edge Cases

  • Revoking from somebody who has no access does nothing — no write, no session sweep, and 200 with canLogin: false. A revoke is the one direction allowed to short-circuit on the state already holding, because unlike a grant there is no second question left to answer.
  • Any unredeemed invitation the person holds is left in place. It stops being useful the moment can_login is false, because a password set through it would not let them sign in; it expires on its own within 7 days of being issued.
  • Revoking does not delete, suspend, or otherwise change the pupil's record. Sign-in access can be granted again later through 8.10a.

Example Requests

curl -X DELETE "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/sign-in" \
  -H "Authorization: Bearer TOKEN"

8.11 GET /api/students/:id/guardians

Purpose

Lists a student's guardian links, primary first. Called by the student detail screen's guardians panel.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:211-221
DTOstudent.dto.ts (StudentGuardianLinkDto)
Servicestudent-guardians.service.ts (findGuardians, loadGuardians)
Schemapeople.ts (student_guardian)
TestsCovered via the admission and guardian-management integration tests.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: both Students_READ and Guardians_READ. Object-level scope: assertCanAccess(actor, "students", id, …) only — the student must be in scope; the guardians returned are not separately scope-checked one by one, since they are the student's own declared links. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

id in the path only, no query, no body.

Response

{
  "message": "Guardians fetched.",
  "data": [
    {
      "guardianId": "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f",
      "fullName": "Ram Bahadur Rai",
      "phone": "+977-9841002233",
      "relationship": "father",
      "kind": "person",
      "organizationName": null,
      "isPrimary": true,
      "isLegalGuardian": true,
      "isEmergencyContact": true,
      "canPickup": true,
      "livesWith": true
    }
  ],
  "errorCode": null
}

Plain array, no pagination — a student's guardian set is small by construction.

Side Effects

Database read only: student_guardian INNER JOIN guardians INNER JOIN users, filtered to live guardians and live guardian-persons, ordered primary-first. No cache.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope for the students permission (checked, not for guardians).As above.student-guardians.service.ts:59-61
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTMissing either Students_READ or Guardians_READ.Not authorized.role.guard.ts

Edge Cases

  • A guardian who was soft-deleted, or whose own users row was soft-deleted, is silently excluded from this list — the link row still exists in student_guardian, but is filtered as not-live from both directions.
  • A student with zero live guardian links returns an empty array, matching isRecordComplete: false on the student response.

Example Requests

curl -X GET "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/guardians" \
  -H "Authorization: Bearer TOKEN"

8.12 PUT /api/students/:id/guardians

Purpose

Replaces a student's entire guardian set in one call. Called from the student detail screen's guardian-management panel whenever the set of guardians, or who is primary, changes. Deliberately a full replace rather than independent add/remove endpoints — see the source comment cited below.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:223-242
DTOstudent.dto.ts (SetStudentGuardiansDto, UpsertStudentGuardianDto)
Servicestudent-guardians.service.ts (setGuardians, writeGuardianLinks)
Schemapeople.ts (student_single_primary_guardian, relationship_other_required)
Testsstudents.service.integration.spec.ts ("moves the primary flag between guardians in one transaction", "refuses the same guardian twice on one student")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: both Students_UPDATE and Guardians_UPDATE. Object-level scope: assertCanAccess(actor, "students", id, …). Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — resubmitting the identical set produces the identical end state (with every link row's updatedAt bumped).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — UUID, ParseUUIDPipe.
BodyYesSetStudentGuardiansDto — see 6.12. An empty guardians array removes every guardian.
{
  "guardians": [
    {
      "guardianId": "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f",
      "relationship": "father",
      "isPrimary": true,
      "isLegalGuardian": true,
      "canPickup": true,
      "livesWith": true
    },
    {
      "person": { "firstName": "Devi Kumari", "lastName": "Thapa", "phone": "+977-9812345678" },
      "relationship": "aunt",
      "isPrimary": false,
      "isEmergencyContact": true,
      "canPickup": true
    }
  ]
}

Response

Same shape as 8.11, with message: "Guardians updated.".

Side Effects

  • Database writes, in one transaction: every existing student_guardian row for this student is deleted, then every entry in the new set is inserted with is_primary = false, then a single follow-up UPDATE sets is_primary = true on the one entry marked primary — the same two-pass write POST /students uses, for the same non-deferrable-index reason.
  • A new guardian entry (person given, no guardianId) creates a fresh users + guardians row exactly as at admission.
  • Cache: every cached student list invalidated (the guardianCount/isRecordComplete projection changes).
  • No jobs, realtime events, notifications, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDInvalid entry shape, or an unknown field.Fix the request body.Global ValidationPipe
400STUDENT_GUARDIAN_PRIMARY_REQUIREDOne or more guardians given, none marked primary.Mark exactly one as primary.student-guardians.service.ts:323-332
400STUDENT_GUARDIAN_MULTIPLE_PRIMARYMore than one marked primary.Mark only one.student-guardians.service.ts:333-337
400STUDENT_REQUIRES_ONE_GUARDIAN{ "guardians": [] } — this route's whole job is the guardian set, so an empty array is refused rather than silently deleting every existing link.Add at least one guardian before submitting.student-guardians.service.ts:373-384
400GUARDIAN_SLOT_TAKENTwo entries name the same relationship slot.Each pupil may have at most one father, one mother, and one local guardian.student-guardians.service.ts:400-412
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.As above.student-guardians.service.ts:174,350-355
404GUARDIAN_NOT_FOUNDA guardianId references no live guardian, or neither guardianId nor person was given.Choose a valid guardian, or supply person.student-guardians.service.ts:276-291
409STUDENT_GUARDIAN_ALREADY_LINKEDThe same guardianId appears twice in the submitted set.Remove the duplicate entry.student-guardians.service.ts:219-228
409USER_EMAIL_ALREADY_EXISTSA newly-created guardian's email is already held.Choose a different email.person-writer.service.ts:139-163
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTMissing either Students_UPDATE or Guardians_UPDATE.Not authorized.role.guard.ts

Edge Cases

  • Sending guardians: [] removes every guardian from the student and flips isRecordComplete back to false — there is no confirmation step at the API layer; the client should confirm before sending an empty set.
  • Reassigning the primary from guardian A to guardian B, in one call, is exactly the scenario this replace-the-whole-set design exists for: doing it as two independent calls (unset A, set B) would pass through a state with two primaries or zero, and the non-deferrable partial unique index would reject whichever statement hit it.
  • Guardians omitted from the new set that were previously linked are unlinked, not deleted as people — their guardians/users rows survive; only the student_guardian row is removed. If that guardian now has zero live children, their record is unaffected (still restorable, still listable) unless separately deleted.

Example Requests

curl -X PUT "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/guardians" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"guardians":[{"guardianId":"01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f","relationship":"father","isPrimary":true}]}'

8.13 GET /api/students/:id/medical

Purpose

Returns a student's health record — conditions, allergies, special needs. A separate, permission-gated endpoint because this is health data about children, structurally kept out of the general student projection so a teacher's legitimate Students_READ never doubles as a diagnosis read.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:244-254
DTOstudent.dto.ts (StudentMedicalDto)
Servicestudent-medical.service.ts (findMedical)
Schemapeople.ts (students.medical_conditions, allergies, special_needs)
Testsstudents.service.integration.spec.ts ("keeps health data out of the student response and behind its own endpoint")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: StudentMedical_READ (distinct from Students_READ). Object-level scope: assertCanAccess(actor, "students", id, …) — the same student-scope check as every other student route; the field gate is the permission, not a second scope. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

id in the path only, no query, no body.

Response

{
  "message": "Medical record fetched.",
  "data": {
    "medicalConditions": "Asthma",
    "allergies": "Peanuts",
    "specialNeeds": null
  },
  "errorCode": null
}

Side Effects

Database read only: students filtered to the three health columns. No cache.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.As above.student-medical.service.ts:48-62
403PERMISSION_INSUFFICIENTActive role lacks StudentMedical_READ, even if it holds plain Students_READ.Not authorized to view health data.role.guard.ts
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts

Edge Cases

  • Holding Students_READ alone — enough to see the student's name, admission number, and guardians — grants no access to this endpoint; the two permissions are independent, and a colleague who forgets to check would get a clean 403, not a blank/nulled field that could be misread as "nothing recorded".
  • A student with no health information on file returns all three fields as null, indistinguishable from "not yet asked" — there is no separate "no known conditions, confirmed" flag.

Example Requests

curl -X GET "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/medical" \
  -H "Authorization: Bearer TOKEN"

8.14 PATCH /api/students/:id/medical

Purpose

Updates a student's health record. Called from the same permission-gated panel as 8.13.

Source Evidence

EvidencePath
Controllerstudents.controller.ts:256-267
DTOstudent.dto.ts (UpdateStudentMedicalDto)
Servicestudent-medical.service.ts (updateMedical)
Schemapeople.ts (students.medical_conditions, allergies, special_needs)
Testsstudents.service.integration.spec.ts (medical update assertions in the same case as 8.13)

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: StudentMedical_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — no version field, but resubmitting the same body reapplies the same values.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — UUID, ParseUUIDPipe.
BodyYesUpdateStudentMedicalDto, every field optional — see 6.11.
{
  "allergies": "Peanuts, shellfish"
}

Response

Same shape as 8.13, with message: "Medical record updated.".

Side Effects

Database write: an UPDATE on students restricted to the three health columns present in the body (each empty-string-or-null clears to null), plus updatedAt. Cache: every cached student list invalidated (defensive — the list projection does not actually include these fields, but the invalidation contract is "any write to this student row").

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDAn unknown field, or a value over 2000 characters.Fix the request body.Global ValidationPipe
404STUDENT_NOT_FOUNDNo live student with this id, or out of scope.As above.student-medical.service.ts:93
403PERMISSION_INSUFFICIENTActive role lacks StudentMedical_UPDATE.Not authorized.role.guard.ts
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts

Edge Cases

  • No version/staleness protection exists on this endpoint at all, unlike the main student PATCH — two concurrent edits to a health record silently last-write-wins.
  • Clearing allergies to null this way is distinguishable from never having recorded it only by whoever remembers doing so — there is no audit trail on the field itself.
  • This endpoint touches only the three health columns — every other student field is untouched even if this call runs concurrently with a main PATCH /students/:id.

Example Requests

curl -X PATCH "$API_URL/api/students/01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f/medical" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"allergies":"Peanuts, shellfish"}'

8.15 GET /api/guardians

Purpose

Returns a paginated, filterable, searchable list of guardians, scoped to what the caller's active role may see. Called by the guardians directory screen.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:49-71
DTOguardian.dto.ts (ListGuardiansQueryDto, GuardianDto)
Serviceguardians.service.ts (findAll, queryList, resolveSort)
Schemapeople.ts (guardians)
Testsguardians.service.spec.ts ("rejects pagination=false", "rejects an unknown sort field")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_READ. Object-level scope: PeopleAccessService.scopeFor(actor, "guardians", …). Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsNo
QueryNosearch, kind, phone, hasChildren, recordVisibility (current | removed | all, default current; replaces includeDeleted), includeDeleted (accepted, ignored), sort (fullName/createdAt/updatedAt), order, page, size. pagination=false is refused.
BodyNo
GET /api/guardians?search=rai&kind=person&hasChildren=true&sort=fullName&order=asc HTTP/1.1

Response

{
  "message": "Guardians fetched.",
  "data": [
    {
      "id": "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f",
      "kind": "person",
      "organizationName": null,
      "occupation": "Farmer",
      "person": {
        "id": "01922e2c-6b1e-7c3a-9d2e-1a2b3c4d5e6f",
        "firstName": "Ram Bahadur",
        "middleName": null,
        "lastName": "Rai",
        "fullName": "Ram Bahadur Rai",
        "email": null,
        "emailVerified": false,
        "phone": "+977-9841002233",
        "phoneVerified": false,
        "image": null,
        "canLogin": true,
        "mustChangePassword": false,
        "dateOfBirth": null,
        "gender": "male",
        "bloodGroup": null,
        "disabilityType": null,
        "maritalStatus": "married",
        "ethnicityId": null,
        "ethnicityName": null,
        "motherTongueId": null,
        "motherTongueName": null,
        "permanentAddress": {
          "provinceId": 3,
          "provinceName": "Bagmati",
          "districtId": 27,
          "districtName": "Kathmandu",
          "municipalityId": 118,
          "municipalityName": "Kathmandu Metropolitan City",
          "municipalityType": "metropolitan",
          "wardNo": 4,
          "tole": "Baneshwor",
          "houseNo": null
        },
        "currentAddress": {
          "provinceId": null,
          "provinceName": null,
          "districtId": null,
          "districtName": null,
          "municipalityId": null,
          "municipalityName": null,
          "municipalityType": null,
          "wardNo": null,
          "tole": null,
          "houseNo": null
        },
        "bio": null,
        "banned": false,
        "banReason": null,
        "createdAt": "2026-04-15T04:10:00.000Z",
        "updatedAt": "2026-04-15T04:10:00.000Z",
        "deletedAt": null
      },
      "childCount": 1,
      "createdAt": "2026-04-15T04:10:00.000Z",
      "updatedAt": "2026-04-15T04:10:00.000Z",
      "deletedAt": null
    }
  ],
  "errorCode": null,
  "count": 1,
  "currentPage": 1,
  "totalPage": 1
}

count/currentPage/totalPage are always present here — unlike students and staff, this list cannot be unpaginated, so ResponseDto's pagination branch always fires.

Side Effects

  • Cache: reads guardians:list:<key> first; writes on a miss with a 120-second TTL.
  • Database reads: guardians INNER JOIN users, plus a correlated subquery per row for childCount; a separate COUNT(*) always runs (pagination is mandatory). A search term opens a transaction to pin the trigram threshold.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_ROLE_NOT_ASSIGNED / AUTH_ACTIVE_ROLE_REQUIREDNo active role, or unresolved.Assign/select a role.role.guard.ts
400PAGINATION_LIMIT_INVALIDpagination=false.pagination cannot be turned off for this endpoint.guardians.service.ts:136-141
400VALIDATION_FAILEDAn invalid enum, or size over 100.Fix the query string.Global ValidationPipe
400PEOPLE_INVALID_SORT_FIELDsort is not fullName/createdAt/updatedAt.Choose a supported sort field.guardians.service.ts:277-284

Edge Cases

  • pagination=false is always refused, even for a superadmin — the guard is unconditional in GuardiansService.findAll, because the restricted-scope predicate (a subquery per row for a non-privileged caller) makes an unpaginated read over the whole roll the query this endpoint must never run, regardless of who is asking.
  • phone is an exact match, not a partial search — ?phone=98 will not find +977-9841002233; use search for partial name matching or POST /students/guardian-lookup for the household-lookup use case.
  • hasChildren=false returns guardians with zero live linked students — a guardian whose only child was soft-deleted counts as hasChildren=false.
  • An organisation guardian's search match comes from either its trigram/ILIKE match on person.firstName (which holds the org's whole name) or a literal ILIKE match on guardians.organization_name directly — both are checked.

Example Requests

curl -X GET "$API_URL/api/guardians?kind=person&hasChildren=true&size=50" \
  -H "Authorization: Bearer TOKEN"

8.16 GET /api/guardians/:id/students

Purpose

Lists the students linked to one guardian, primary-first — the inverse view of 8.11. Called by the guardian detail screen's children panel.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:73-86
DTOguardian.dto.ts (GuardianChildDto)
Serviceguardians.service.ts (findChildren, loadChildren)
Schemapeople.ts (student_guardian)
TestsN/A — exercised indirectly via the sibling/admission integration tests.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_READ. Object-level scope: assertCanAccess(actor, "guardians", id, …). Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsYesidno ParseUUIDPipe; see 5's malformed-id caveat.
QueryNo
BodyNo

Response

{
  "message": "Guardian's students fetched.",
  "data": [
    {
      "studentId": "01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f",
      "admissionNumber": "STU-2026-0041",
      "fullName": "Sita Rai",
      "relationship": "father",
      "kind": "person",
      "organizationName": null,
      "isPrimary": true,
      "isLegalGuardian": true,
      "isEmergencyContact": true,
      "canPickup": true,
      "livesWith": true
    }
  ],
  "errorCode": null
}

Plain array, no pagination.

Side Effects

Database read only: student_guardian INNER JOIN students INNER JOIN users, filtered to live students and live student-persons, ordered primary-first. No cache.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404GUARDIAN_NOT_FOUNDNo live guardian with this id, or out of scope.As above.people-access.service.ts:222-236
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled — validate the id shape client-side.Postgres 22P02, unmapped by AllExceptionsFilter
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

Edge Cases

  • A student soft-deleted since the link was made is excluded, exactly as on the student-side view — the link row persists, but neither side's list surfaces it.
  • A guardian with zero live children returns an empty array — this is the normal state right after POST /guardians creates a standalone guardian record before any child is linked.

Example Requests

curl -X GET "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f/students" \
  -H "Authorization: Bearer TOKEN"

8.17 GET /api/guardians/:id

Purpose

Returns one guardian. Called by the guardian detail screen, and by a guardian's own "my profile" view.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:88-97
DTOguardian.dto.ts (GuardianDto)
Serviceguardians.service.ts (findOne, loadOne)
Schemapeople.ts (guardians)
Testsguardians.service.spec.ts ("lets a guardian see only themselves, and 404s (never 403) on someone else's record")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_READ. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsYesidno ParseUUIDPipe.
QueryNo
BodyNo

Response

Same shape as one item of 8.15's data array, with message: "Guardian fetched.".

Side Effects

Database read only: guardians INNER JOIN users, plus the childCount subquery. No cache (single-item reads are not cached).

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404GUARDIAN_NOT_FOUNDNo live guardian with this id, or out of scope — confirmed by test to be byte-for-byte identical between the two cases.The record may not exist or is not visible to you.guardians.service.ts:591-596; people-access.service.ts:222-236
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled — see 5.Postgres 22P02
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

Edge Cases

  • A soft-deleted guardian's id answers 404 here even for a caller with full scope.
  • A guardian requesting another family's guardian record (e.g. guessing a sequential-looking id) gets exactly the same 404 a nonexistent id would — no signal distinguishes the two, by design.

Example Requests

curl -X GET "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN"

8.18 POST /api/guardians

Purpose

Creates a standalone guardian and grants the guardian role in the same transaction — without the grant, the person redeems their invite, signs in, and lands on a shell with nothing on it. Called from the guardian directory's "add guardian" action, independent of any admission (the admission flow's own guardian creation goes through CreateStudentDto.guardians instead).

Source Evidence

EvidencePath
Controllerguardians.controller.ts:99-112
DTOguardian.dto.ts (CreateGuardianDto)
Serviceguardians.service.ts (create, grantGuardianRole)
Schemapeople.ts (guardian_org_has_name); identity.ts (role, user_role)
Testsguardians.service.spec.ts ("rejects an organisation with a blank name…", "accepts an organisation with a real name…", "grants the guardian role in the same transaction")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_CREATE. Object-level scope: N/A — create has no existing row to scope against. Guest support: none. Rate limit: none module-specific. Idempotency: None — no idempotency key.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
BodyYesCreateGuardianDto — see 6.16.

Minimal valid request:

{ "person": { "firstName": "Devi Kumari", "lastName": "Thapa", "phone": "+977-9812345678" } }

Organisation guardian:

{
  "person": { "firstName": "Rai Foundation Orphanage Trust" },
  "kind": "organization",
  "occupation": null
}

Response

{
  "message": "Guardian created.",
  "data": {
    "id": "01922e2d-4d5e-7c3a-9d2e-1a2b3c4d5e6f",
    "kind": "person",
    "organizationName": null,
    "occupation": null,
    "person": { "...": "PersonDto, see 6.1" },
    "childCount": 0,
    "createdAt": "2026-04-15T05:00:00.000Z",
    "updatedAt": "2026-04-15T05:00:00.000Z",
    "deletedAt": null
  },
  "errorCode": null
}

Returned with HTTP 201.

The response also carries invitation. The create responses are CreatedStudentDto, CreatedGuardianDto and CreatedStaffDto — the read DTO plus one nullable field, described in 6.4d. It is null unless sign-in access was granted; { "sent": true, "to": "..." } when an invitation was enqueued; and { "sent": false, "reason": "no_email" } when access was granted to somebody with no email address on file.

Side Effects

  • Database writes, in one transaction: one INSERT into users; one INSERT into guardians; a lookup of the seeded guardian role by name, then one INSERT into user_role granting it.
  • When grantSignIn (or person.canLogin) is true and the guardian has an email address, the same transaction also writes an account_invite verification record and enqueues the invitation email through the notification outbox — committed with the guardian or not at all.
  • Cache: every cached guardian list invalidated.
  • No other jobs, realtime events, notifications, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDA required field missing/invalid, or an unknown field.Fix the request body.Global ValidationPipe
400PERSON_SIGN_IN_FLAGS_CONFLICTgrantSignIn and person.canLogin were both sent with different values.Send one of them, or the same value for both.people-permissions.service.ts (resolveSignInGrant)
403AUTH_FORBIDDENgrantSignIn (or person.canLogin) is true and the actor does not hold Users_UPDATE.Create the guardian without sign-in access, or ask an administrator to grant it afterwards.people-permissions.service.ts (resolveSignInGrant)
409GUARDIAN_ORGANIZATION_NAME_REQUIREDkind: "organization" with a blank/whitespace-only person.firstName.An organisation guardian needs a real name.person-writer.service.ts:203-207 (via the guardian_org_has_name CHECK)
409USER_EMAIL_ALREADY_EXISTSThe email is already held by a live person.Choose a different email.person-writer.service.ts:139-163,177-182
500ROLE_NOT_FOUND (as an unhandled 500, not a clean domain error — see below)The seeded guardian role is missing from the database.Should not occur against a normally-seeded database; contact an operator.guardians.service.ts:433-447
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

The ROLE_NOT_FOUND throw is an InternalServerErrorException, so it surfaces as HTTP 500 even though it carries a specific errorCode — this is deliberate: creating a guardian who can never act as one is worse than a generic failure, but it is not a client-fixable 400/409 either.

Edge Cases

  • The transaction is all-or-nothing: if the role grant fails after the users/guardians rows are written, nothing commits — there is no partially-created guardian left behind.
  • occupation accepts null explicitly and an absent key identically on create (both mean "not recorded") — the omitted-vs-nulled distinction only matters on update.
  • An organisation's person.lastName, dateOfBirth, gender, etc. are all still accepted by PersonInputDto even though none of them make sense for an organisation — nothing in this DTO or service rejects them; they are simply stored as given.
  • Creating a guardian does not create an account. The guardian role grant and sign-in access are separate things: the role says what this person may do once signed in, can_login says whether they may sign in at all. A guardian created without grantSignIn holds the role and no login, and can be given one later through 8.23a.

Example Requests

curl -X POST "$API_URL/api/guardians" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"person":{"firstName":"Devi Kumari","lastName":"Thapa","phone":"+977-9812345678"}}'

8.19 PATCH /api/guardians/:id

Purpose

Updates a guardian's identity, kind, or occupation. Called from the guardian edit screen.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:114-127
DTOguardian.dto.ts (UpdateGuardianDto)
Serviceguardians.service.ts (update)
Schemapeople.ts (guardian_org_has_name)
TestsCovered indirectly; the organisation-name derivation is exercised by the create-path tests and mirrored in update.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: guarded by the required version token — a repeat of the same body with the same token is refused with 409 PEOPLE_STALE_RECORD, because the first one advanced the counter.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesidno ParseUUIDPipe.
BodyYesUpdateGuardianDto, every field optional — see 6.17. No version field.
{ "occupation": "Retired farmer" }

Response

Same shape as 8.17, with message: "Guardian updated.".

Side Effects

Database writes: an UPDATE on users for the fields person actually carried; an UPDATE on guardians for kind/organizationName (always recomputed) and occupation (only if present), plus updatedAt. Cache: every cached guardian list invalidated.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDInvalid field value, or an unknown field.Fix the request body.Global ValidationPipe
404GUARDIAN_NOT_FOUNDNo live guardian with this id, or out of scope.As above.guardians.service.ts:477
409GUARDIAN_ORGANIZATION_NAME_REQUIREDThe resulting kind/firstName pair would leave an organisation with a blank name.Give the organisation a real name.person-writer.service.ts:203-207
409USER_EMAIL_ALREADY_EXISTSThe new email is already held by a different live person.Choose a different email.person-writer.service.ts:139-163
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled — see 5.Postgres 22P02
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

Edge Cases

  • This endpoint has no stale-write protection. Two administrators editing the same guardian concurrently both succeed; the second write silently overwrites the first with no 409 — contrast with PATCH /students/:id's mandatory version.
  • Switching kind from "person" to "organization" without also sending a person.firstName reuses the row's current firstName as the organisation name — it does not require the caller to resend it, but the caller should verify the existing first name reads sensibly as an organisation name before flipping the kind.
  • Switching kind from "organization" back to "person" clears organizationName to null on the same write.

Example Requests

curl -X PATCH "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"occupation":"Retired farmer"}'

8.20 DELETE /api/guardians/:id

Purpose

Soft-deletes a guardian. Refused while the guardian still has a live linked student — unlink them first via 8.12.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:129-139
DTONone — no body.
Serviceguardians.service.ts (remove); people-deletion.service.ts (softDeleteProfile)
Schemapeople.ts (student_guardian, guardians.deleted_at)
Testsguardians.service.spec.ts ("refuses to delete a guardian with a live linked student")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_DELETE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a second DELETE on an already-deleted id returns 404.

Request

id in the path only (no ParseUUIDPipe), no body.

Response

{
  "message": "Guardian deleted.",
  "data": null,
  "errorCode": null
}

Side Effects

Database writes, in one transaction: a pre-check for any live student_guardian row referencing this guardian; if none, guardians.deleted_at/updated_at set, and — only if the person now holds no other live profile — users.deleted_at set, credentials deleted, sessions revoked. Cache: every cached guardian list invalidated.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404GUARDIAN_NOT_FOUNDNo live guardian with this id, or out of scope.As above.people-deletion.service.ts:247-278
409GUARDIAN_HAS_LINKED_STUDENTSAt least one live student is still linked to this guardian.Unlink every student first (PUT /students/:id/guardians).people-deletion.service.ts:72-91
409USER_CANNOT_DELETE_SELFThe target's users.id equals the caller's own id.You cannot delete your own account.people-deletion.service.ts:62-67
403USER_LAST_SUPERADMIN_PROTECTEDDeleting this guardian's last profile would remove the last sign-in-capable superadmin.This is the last superadmin account.actor-authority.service.ts:278-316
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled — see 5.Postgres 22P02
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

Edge Cases

  • The linked-student check considers only live students — a guardian whose only linked child was already soft-deleted can be deleted; the student_guardian row, at that point pointing at a non-live student, does not block it.
  • A guardian is checked for links from their side of student_guardian only — this is symmetric with, but a separate query from, the analogous check PeopleDeletionService does not run for students (a student's deletion never checks the guardian side, since a student going away is not blocked by anything about their guardians).

Example Requests

curl -X DELETE "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN"

8.21 POST /api/guardians/:id/ban

Purpose

Suspends the guardian's sign-in without touching their record. Called from the guardian detail screen's suspend action.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:141-156
DTOdto/account-action.dto.ts (BanAccountDto)
Serviceguardians.service.ts (ban); people-account.service.ts (ban)
Schemaidentity.ts (users.banned, banReason, bannedAt, bannedBy)
TestsSame shared-service behavior as students.service.integration.spec.ts's ban cases.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesidno ParseUUIDPipe.
BodyYesBanAccountDto.
{ "reason": "Repeated abusive contact with front-office staff." }

Response

{ "message": "Account suspended.", "data": null, "errorCode": null }

Side Effects

Identical to 8.8: users.banned/banReason/bannedAt/bannedBy set inside a transaction with the last-superadmin check; every session for the person deleted; every cached guardian list invalidated.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDreason missing or over 500 characters.Give a reason.Global ValidationPipe
400USER_BAN_REASON_REQUIREDreason blank after trimming.Give a real reason.people-account.service.ts:70-79
404GUARDIAN_NOT_FOUNDNo live guardian with this id, or out of scope.As above.people-account.service.ts:215-247
409USER_CANNOT_DELETE_SELFThe caller is banning their own account.You cannot suspend yourself.people-account.service.ts:225-232 (assertNotSelf)
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can suspend this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:86
403USER_LAST_SUPERADMIN_PROTECTEDThe target is the last sign-in-capable superadmin.This is the last superadmin account.actor-authority.service.ts:315-353
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled.Postgres 22P02
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

Edge Cases

  • A guardian holding the superadmin role cannot be suspended by anyone but another superadmin — the same protection 8.8 documents, since PeopleAccountService.ban is one shared implementation across all three entities.
  • Banning a guardian never cascades to their children — a suspended parent's students stay exactly as they were; isRecordComplete/guardianCount on the student side is unaffected, since those count live guardian links, not sign-in-capable ones.
  • A guardian barred from the parent portal is still the school's on-file emergency contact and pickup authorization — the ban is purely a sign-in refusal, not a removal from any child's record.

Example Requests

curl -X POST "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f/ban" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Repeated abusive contact with front-office staff."}'

8.22 POST /api/guardians/:id/unban

Purpose

Lifts a suspension. Called from the guardian detail screen.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:158-164
DTONone — no body.
Serviceguardians.service.ts (unban); people-account.service.ts (unban)
Schemaidentity.ts (users.banned, banReason, bannedAt, bannedBy)
TestsSame shared-service behavior as the student/staff unban cases.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent.

Request

id in the path only (no ParseUUIDPipe), no body.

Response

{ "message": "Account restored.", "data": null, "errorCode": null }

Side Effects

users.banned/banReason/bannedAt/bannedBy all cleared. No session sweep. Cache invalidated identically to ban.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404GUARDIAN_NOT_FOUNDNo live guardian with this id, or out of scope.As above.people-account.service.ts:215-247
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can restore this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:118
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled.Postgres 22P02
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

Edge Cases

Identical to 8.9 — no last-superadmin check, no-op on an already-unbanned account, and the same superadmin-acting-on-superadmin protection as ban.

Example Requests

curl -X POST "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f/unban" \
  -H "Authorization: Bearer TOKEN"

8.23 POST /api/guardians/:id/password-reset

Purpose

Emails a password-reset link to the guardian. Called from the guardian detail screen.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:166-184
DTOdto/account-action.dto.ts (PasswordResetSentDto, response)
Serviceguardians.service.ts (sendPasswordResetLink); people-account.service.ts (sendPasswordResetLink)
Schemaidentity.ts (users.email, canLogin, banned)
TestsSame shared-service behavior as staff.service.spec.ts's reset-link cases.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: issues a fresh token each call.

Request

id in the path only (no ParseUUIDPipe), no body.

Response

{ "message": "Password reset link sent.", "data": { "sentTo": "ram.rai@example.com" }, "errorCode": null }

Side Effects

Identical mechanics to 8.10: a single-use expiring token issued and recorded with the caller's IP/user agent, the email sent fail-soft, an activity line logged. No cache invalidation.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404GUARDIAN_NOT_FOUNDNo live guardian with this id, or out of scope.As above.people-account.service.ts:152
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can trigger a reset link for this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:157
409USER_EMAIL_REQUIREDNo email on file.Add an email first.people-account.service.ts:154-160
409USER_LOGIN_DISABLEDcanLogin is false.This person cannot sign in.people-account.service.ts:161-169
409AUTH_ACCOUNT_BANNEDThe account is currently suspended.Restore the account first.people-account.service.ts:170-176
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled.Postgres 22P02
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.15.As above.

Edge Cases

Identical to 8.10 — a banned guardian must be unbanned first; a guardian with canLogin: false (rare in practice — most guardians are created with sign-in enabled) cannot be reached this way at all.

Example Requests

curl -X POST "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f/password-reset" \
  -H "Authorization: Bearer TOKEN"

8.23a POST /api/guardians/:id/sign-in

Purpose

Gives this guardian a portal account, and by default emails them the invitation that lets them choose a password. Called from the detail screen's sign-in toggle.

This is the only route that turns sign-in access on after creation. PATCH /api/guardians/:id does not accept canLogin at all.

Source Evidence

EvidencePath
Controllerguardians.controller.ts (grantSignIn)
DTOsign-in-access.dto.ts (GrantSignInDtoSignInAccessDto)
Serviceshared/people-account.service.ts (setSignIn)
Schemaidentity.ts (users.can_login), auth.ts (verification)
  • Auth: JWT.
  • Permission: Users_UPDATE — not Guardians_UPDATE. Granting sign-in is an identity change rather than a record edit: a login-capable row with an email address is a route into a session, because POST /api/auth/password/forgot is public.
  • Idempotency: safe to repeat, but not a no-op. Granting to somebody who already has access skips the state change and still decides the invitation afresh — that is what makes the two documented recovery paths work, since both invite: false and no_email end with the operator calling this route again while the state is already correct.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — the guardian's profile id (UUID; ParseUUIDPipe).
BodyNoGrantSignInDto — see 6.4a. An empty body means invite: true.
{ "invite": true }

Response

{
  "message": "Sign-in access granted.",
  "data": {
    "canLogin": true,
    "invitation": { "sent": true, "to": "ram.rai@example.com" }
  },
  "errorCode": null
}

canLogin is the state after the change. invitation is described in 6.4bsent: true means the invitation was enqueued, not that it arrived.

Side Effects

  • UPDATE users SET can_login = true guarded by the value that was read a moment earlier: WHERE id = :id AND can_login = :observed AND deleted_at IS NULL. A concurrent change, or a soft delete between the read and the write, makes this a zero-row result and a 409 rather than a silent overwrite of somebody else's decision. It is skipped when the person already has access.
  • When invite is not false, the person has an email address, and the account is not suspended: an account_invite verification record valid for 7 days, and an invitation email enqueued through the notification outbox.
  • The state change and the invitation share one transaction. If the invitation cannot be scheduled, the grant rolls back with it — answering sent: true for an invitation nobody will receive would tell the operator something false about a person now holding credentials they will never hear about.
  • No session changes — granting access creates nothing to sign in with until the person sets a password.
  • No cache invalidation — can_login is not part of any cached guardian projection.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404GUARDIAN_NOT_FOUNDNo live guardian with that id, or it is outside the actor's scope.Not found.people-account.service.ts (resolveUserId)
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Ask another superadmin to do this.actor-authority.service.ts (assertMayActOnAccount)
409PERSON_SIGN_IN_STATE_CHANGEDSomebody else changed this person's sign-in access between the read and the write.Re-read the record and try again.people-account.service.ts (setSignIn)
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTNo valid JWT, or the actor lacks Users_UPDATE.Sign in, or ask an administrator.JwtAuthGuard, RoleGuard

A missing email address is not an error here, and neither is a suspended account. The grant succeeds in both cases and the response says why the invitation was skipped — reason: "no_email" or reason: "banned". The account is legitimate either way, and refusing the grant over it would be the worse outcome. Fix the underlying condition and call this route again; it invites without needing the sign-in state to change.

Edge Cases

  • Granting to somebody who already has access still sends the invitation. The state change is skipped; the invitation is decided on its own. This is deliberate — invite: false promises "prepare an account and invite later" and no_email promises "add an address and call again", and both bring the operator back to this route with the state already correct. Returning early would answer 200 having sent nothing, indistinguishable from success, with no other route that would ever send that invitation.
  • invite: false prepares the account silently. Call the route again with invite: true — or once an email address exists — to send the invitation then.
  • The invitation lasts 7 days, not the 15 minutes an OTP gets. It redeems by link only: the one-time code is never printed in an invitation email, and POST /api/auth/password/reset matches OTPs against password resets alone.
  • A suspended (banned) person can still be granted sign-in access, but is not invited: the response carries reason: "banned". The two flags are independent — banned is a statement about conduct with a recorded reason, can_login a statement about whether a portal account exists at all — and an invitation into an account the login guard will refuse produces a support call, not an activated account. Lift the suspension and call this route again to invite them.
  • can_login governs authentication and nothing else. It never affects notification delivery: somebody with no portal account still receives every notification addressed to them.

Example Requests

curl -X POST "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f/sign-in" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"invite":true}'

8.23b DELETE /api/guardians/:id/sign-in

Purpose

Takes this guardian's portal account away and ends every session they hold.

Not the same as suspending. A ban records a reason and is a statement about conduct; this simply says the person no longer has an account. Their record, and every notification addressed to it, is untouched.

Source Evidence

EvidencePath
Controllerguardians.controller.ts (revokeSignIn)
DTOsign-in-access.dto.ts (SignInAccessDto)
Serviceshared/people-account.service.ts (setSignIn)
Schemaidentity.ts (users.can_login), auth.ts (session)
  • Auth: JWT.
  • Permission: Users_UPDATE, for the same reason as the grant.
  • Body: none — there is nothing to choose when revoking.

Response

{
  "message": "Sign-in access revoked.",
  "data": {
    "canLogin": false,
    "invitation": { "sent": false, "reason": "revoked" }
  },
  "errorCode": null
}

invitation is always present, and on a revoke is always sent: false with reason: "revoked" — there is nothing to invite anybody to.

Side Effects

  • UPDATE users SET can_login = false, guarded by the observed value exactly as the grant is, and skipped when the person has no access to begin with.
  • Every session for that user is deleted. JwtStrategy re-reads can_login on each request, so the revoke takes effect on the next call either way — but leaving the rows behind keeps a refresh token redeemable, and deleting them removes that possibility rather than relying on every future caller remembering to check.
  • No invitation, no email, no notification.
  • No cache invalidation — can_login is not part of any cached guardian projection.

Error Cases

Identical to 8.23a: 404 GUARDIAN_NOT_FOUND, 403 USER_SUPERADMIN_PROTECTED, 409 PERSON_SIGN_IN_STATE_CHANGED, and the standard 401/403.

Edge Cases

  • Revoking from somebody who has no access does nothing — no write, no session sweep, and 200 with canLogin: false. A revoke is the one direction allowed to short-circuit on the state already holding, because unlike a grant there is no second question left to answer.
  • Any unredeemed invitation the person holds is left in place. It stops being useful the moment can_login is false, because a password set through it would not let them sign in; it expires on its own within 7 days of being issued.
  • Revoking does not delete, suspend, or otherwise change the guardian's record. Sign-in access can be granted again later through 8.23a.

Example Requests

curl -X DELETE "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f/sign-in" \
  -H "Authorization: Bearer TOKEN"

8.24 POST /api/guardians/:id/restore

Purpose

Brings a soft-deleted guardian back. Called from the deleted-guardians screen.

Source Evidence

EvidencePath
Controllerguardians.controller.ts:186-195
DTONone — no body.
Serviceguardians.service.ts (restore); people-deletion.service.ts (restoreProfile)
Schemaidentity.ts (users_email_unique)
TestsShared restoreProfile mechanics exercised on the student side; guardian-specific behavior (no code-conflict check) verified by reading assertCodeStillFree, which only branches for student/staff.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Guardians_RESTORE — the only one of the three profile kinds' restore routes that does not reuse its plain _UPDATE permission; students restore under Students_UPDATE and staff restore under Staff_RESTORE (see 13.5 for why this is not a slip). Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: restoring an already-live guardian is a silent no-op success.

Request

id in the path only (no ParseUUIDPipe), no body.

Response

Same shape as 8.17, with message: "Guardian restored.".

Side Effects

Database reads: the profile row locked FOR UPDATE; a check that the person's email is still free among live users (no admission-number/employee-code-style check — guardians carry no such allocated code). Database writes: guardians.deleted_at cleared; users.deleted_at cleared if it was set by this profile's own deletion. Cache: every cached guardian list invalidated.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404GUARDIAN_NOT_FOUNDNo guardian row with this id at all (live or deleted), or out of scope.The record does not exist or is not visible to you.people-deletion.service.ts:140,228-245
409USER_RESTORE_EMAIL_CONFLICTThe person's email has been taken by a different live account since deletion.Change the conflicting account's email, or this person's, before restoring.people-deletion.service.ts:204-226
403PERMISSION_INSUFFICIENTActive role holds Guardians_UPDATE but not Guardians_RESTORE.Not authorized to restore — a distinct grant from ordinary update.role.guard.ts
500SYS_INTERNAL_ERRORid is not a syntactically valid UUID.Unhandled.Postgres 22P02
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts

Edge Cases

  • Calling this on a guardian who was never deleted returns 200 with the current record unchanged — not an error.
  • A guardian's admission-style code conflict (the equivalent of STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICT) cannot happen — guardians have no unique code to reissue, so this is the one restore endpoint with strictly fewer conflict paths than its student/staff equivalents.
  • Restoring a guardian does not automatically re-link any student whose student_guardian row was removed by a cascading delete when the guardian's users row was hard-deleted in some other flow — there is no such flow in this module (deletion here is always soft), but a consumer relying on restoreProfile to also restore relationships would be wrong to assume it.

Example Requests

curl -X POST "$API_URL/api/guardians/01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f/restore" \
  -H "Authorization: Bearer TOKEN"

8.25 GET /api/staff

Purpose

Returns a paginated, filterable, searchable staff directory, scoped to what the caller's active role may see. This is the exact query the Teachers screen runs with designationKind=teaching, since there is no separate Teachers entity or endpoint.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:61-77
DTOstaff.dto.ts (ListStaffQueryDto, StaffDto)
Servicestaff.service.ts (findAll, queryList, orderBy)
Schemapeople.ts (staff), lookups.ts (departments, designations)
Testsstaff.service.spec.ts ("rejects pagination=false on the staff directory", "refuses to sort by a column the response withholds", "filters the directory by department, designation kind and search term", "restricts a non-permissioned actor's list to their own staff record")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_READ. Object-level scope: PeopleAccessService.scopeFor(actor, "staff", …) — a non-privileged caller (no active role, or a role without Staff_READ) is restricted to their own staff row only; there is no guardian/student-style broader restricted view for staff. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsNo
QueryNosearch, departmentId, designationId, designationKind, employmentStatus, gender, joiningDateFrom, joiningDateTo, recordVisibility (current | removed | all, default current; replaces includeDeleted), includeDeleted (accepted, ignored), sort, order, page, size. pagination=false is refused.
BodyNo
GET /api/staff?departmentId=3&designationKind=teaching&employmentStatus=active&sort=fullName&order=asc HTTP/1.1

Response

{
  "message": "Staff fetched.",
  "data": [
    {
      "id": "01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f",
      "employeeCode": "EMP-2026-0007",
      "joiningDate": "2026-04-15",
      "experienceYears": 4,
      "qualification": "M.Ed.",
      "department": { "id": 3, "name": "Science" },
      "designation": { "id": 7, "name": "Senior Teacher", "isTeaching": true },
      "employmentStatus": "active",
      "person": { "...": "PersonDto, see 6.1" },
      "createdAt": "2026-04-15T05:30:00.000Z",
      "updatedAt": "2026-04-15T05:30:00.000Z",
      "deletedAt": null
    }
  ],
  "errorCode": null,
  "count": 1,
  "currentPage": 1,
  "totalPage": 1
}

count/currentPage/totalPage are always present — this list can never be unpaginated.

Side Effects

  • Cache: reads staff:list:<key> first (the key carries a viewTag, though StaffDto never actually varies by permission today — kept for cache-key-contract consistency across the whole people domain); writes on a miss with a 120-second TTL.
  • Database reads: staff INNER JOIN users LEFT JOIN departments LEFT JOIN designations, plus a separate COUNT(*) (always, since pagination cannot be disabled). A search term opens a transaction to pin the trigram threshold.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_ROLE_NOT_ASSIGNED / AUTH_ACTIVE_ROLE_REQUIREDNo active role, or unresolved.Assign/select a role.role.guard.ts
400PAGINATION_LIMIT_INVALIDpagination=false.Pagination cannot be disabled for the staff directory.staff.service.ts:112-117
400VALIDATION_FAILEDAn invalid enum, or size over 100.Fix the query string.Global ValidationPipe
400PEOPLE_INVALID_SORT_FIELDsort is outside STAFF_SORTABLE.Choose a supported sort field — note basicSalary is deliberately not in the allow-list.staff.service.ts:286-292

Edge Cases

  • pagination=false is refused with the same error code (PAGINATION_LIMIT_INVALID) as the students and guardians lists' equivalent refusal — all three are the same shape of guard against an unbounded full-table read, so a client can branch on this one code for any of them.
  • designationKind=teaching translates to designations.isTeaching = true; a staff row with no designation at all (designationId unset) is excluded from both teaching and non_teaching filters, since the underlying LEFT JOIN produces NULL for isTeaching and neither = true nor = false matches NULL.
  • A caller restricted to their own row (holds no Staff_READ-granting role) still receives the full StaffDto shape for that one row — the scope restricts which rows, never which fields; salary/bank fields are withheld by a completely separate mechanism (8.34).

Example Requests

curl -X GET "$API_URL/api/staff?designationKind=teaching&employmentStatus=active" \
  -H "Authorization: Bearer TOKEN"

8.26 POST /api/staff

Purpose

Admits a staff member: one users row, one staff row, and the role grants that come with the job — staff always, and teacher too when the chosen designation is a teaching one — all in one transaction. Called from the HR/admissions "add staff" action.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:79-86
DTOstaff.dto.ts (CreateStaffDto)
Servicestaff.service.ts (create, grantStaffRoles)
Schemapeople.ts (staff_employee_code_unique, staff_designation_in_department_fk, staff_designation_needs_department)
Testsstaff.service.spec.ts ("admits a staff member, allocates an employee code, and grants the staff and teacher roles", "grants only the staff role for a non-teaching designation", "refuses a designation that does not belong to the chosen department", "maps a real employee-code collision to a typed conflict")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_CREATE. Object-level scope: N/A. Guest support: none. Rate limit: none module-specific. Idempotency: None — no idempotency key.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
BodyYesCreateStaffDto — see 6.21.

Minimal valid request:

{
  "person": { "firstName": "Anita", "lastName": "Sharma" },
  "joiningDate": "2026-04-15"
}

Full valid request, admitting a teacher:

{
  "person": {
    "firstName": "Anita",
    "lastName": "Sharma",
    "phone": "+977-9803456789",
    "email": "anita.sharma@example.com",
    "gender": "female"
  },
  "joiningDate": "2026-04-15",
  "experienceYears": 4,
  "qualification": "M.Ed.",
  "departmentId": 3,
  "designationId": 7,
  "employmentStatus": "active",
  "salary": {
    "basicSalary": "45000.00",
    "allowances": "5000.00"
  }
}

salary is optional and requires StaffSalary_UPDATE. Sending the key at all without that permission — even "salary": {} — is refused with 403 PERMISSION_INSUFFICIENT; omitting the key entirely is always accepted. See 6.21 for the full gating rule.

Response

Same shape as one item of 8.25's data array, with message: "Staff member created.". Salary/bank fields are never part of this response regardless of whether salary was sent — they are withheld from StaffDto entirely and read back only via GET /api/staff/:id/salary (8.34).

The response also carries invitation. The create responses are CreatedStudentDto, CreatedGuardianDto and CreatedStaffDto — the read DTO plus one nullable field, described in 6.4d. It is null unless sign-in access was granted; { "sent": true, "to": "..." } when an invitation was enqueued; and { "sent": false, "reason": "no_email" } when access was granted to somebody with no email address on file.

Side Effects

  • Database writes, in one transaction: one INSERT into users; one atomic allocation of employeeCode (code_counters, timezone-correct year); a permission check and, if salary was sent and holds a value, validation of the basicSalary/allowances pair (StaffSalaryService.resolveSalaryForCreate); one INSERT into staff carrying the resolved salary columns alongside the rest; a lookup of the chosen designationId's isTeaching flag (when supplied) to decide whether to also grant teacher; one batched INSERT ... ON CONFLICT DO NOTHING into user_role for staff (and teacher if applicable).
  • When grantSignIn (or person.canLogin) is true and the staff member has an email address, the same transaction also writes an account_invite verification record and enqueues the invitation email through the notification outbox — committed with the staff member or not at all.
  • Cache: every cached staff list invalidated.
  • No other jobs, realtime events, notifications, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDA required field missing/invalid, or an unknown field.Fix the request body.Global ValidationPipe
400PERSON_SIGN_IN_FLAGS_CONFLICTgrantSignIn and person.canLogin were both sent with different values.Send one of them, or the same value for both.people-permissions.service.ts (resolveSignInGrant)
403AUTH_FORBIDDENgrantSignIn (or person.canLogin) is true and the actor does not hold Users_UPDATE.Create the staff member without sign-in access, or ask an administrator to grant it afterwards.people-permissions.service.ts (resolveSignInGrant)
409STAFF_DESIGNATION_NOT_IN_DEPARTMENTdesignationId exists but belongs to a different department than departmentId.Choose a designation that belongs to the chosen department.person-writer.service.ts:208-212 (via staff_designation_in_department_fk)
500SYS_INTERNAL_ERROR (unmapped 23514)designationId given with departmentId omitted/null.Unhandled — always send both together; see 6.21.people.ts:339-342 (staff_designation_needs_department), unmapped in person-writer.service.ts
409USER_EMAIL_ALREADY_EXISTSThe email is already held by a live person.Choose a different email.person-writer.service.ts:139-163,177-182
409STAFF_EMPLOYEE_CODE_TAKENA race collides on the allocated (or, in principle, a manually-supplied) employee code.Retry — this should be rare given atomic allocation.person-writer.service.ts:188-192
403PERMISSION_INSUFFICIENTThe salary key is present in the body (any value, including {}) and the actor does not hold StaffSalary_UPDATE. Checked before any database write.Create the staff member without a salary block, or ask an administrator.staff-salary.service.ts (resolveSalaryForCreate)
400VALIDATION_FAILEDInside salary: basicSalary set without allowances or vice versa.basicSalary and allowances must be set together.staff-salary.service.ts (resolveSalaryForCreate)
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.25.As above.

Edge Cases

  • departmentId alone, with no designationId, is fully supported — a staff member assigned to a department but not yet given a specific title. The reverse (designationId alone) is the unmapped-500 trap documented above.
  • salary is checked for key presence, not for whether it contains anything. "salary": {} from an actor without StaffSalary_UPDATE still 403s — the check runs on whether the key exists on the parsed body at all (Object.hasOwn(dto, "salary") && dto.salary !== undefined), before looking at what, if anything, is inside it.
  • A retired (isActive: false) designation is still accepted here — nothing in StaffService.create checks a designation's active flag, only that the department/designation pair is valid.
  • Two office staff admitting simultaneously never collide on the employee code — same atomic-counter mechanism as admission numbers.
  • Creating a staff member does not create an account, and the staff/teacher role grants do not imply one. Roles say what somebody may do once signed in; can_login says whether they may sign in at all. A caretaker gets a payroll record and no login; a teacher who needs the portal is created with grantSignIn: true by an actor holding Users_UPDATE, or granted access afterwards through 8.33a.
  • Granting teacher is a one-time decision made from the designation given at creation. Changing designationId later via PATCH /staff/:id to point at a teaching designation does not retroactively grant teachergrantStaffRoles is only ever called from create.

Example Requests

curl -X POST "$API_URL/api/staff" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"person":{"firstName":"Anita","lastName":"Sharma"},"joiningDate":"2026-04-15","departmentId":3,"designationId":7}'

8.27 GET /api/staff/:id

Purpose

Returns one staff member. Called by the staff detail screen, and by a staff member's own profile view.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:86-98
DTOstaff.dto.ts (StaffDto)
Servicestaff.service.ts (findOne, loadOne)
Schemapeople.ts (staff)
TestsN/A — covered indirectly through create/update round-trips.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_READ. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsYesid — UUID, ParseUUIDPipe.
QueryNo
BodyNo

Response

Same shape as one item of 8.25's data array, with message: "Staff member fetched.".

Side Effects

Database read only: staff INNER JOIN users LEFT JOIN departments LEFT JOIN designations. No cache.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDid is not a valid UUID.Fix the id.ParseUUIDPipe
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope.The record may not exist or is not visible to you.people-access.service.ts:222-236; staff.service.ts:583-588
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.25.As above.

Edge Cases

  • A soft-deleted staff member's id answers 404 here even for a caller with full scope.
  • A staff member with neither department nor designation set returns department: null, designation: null — a fully valid, if administratively incomplete, state.

Example Requests

curl -X GET "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN"

8.28 PATCH /api/staff/:id

Purpose

Updates a staff member's identity, employment, department/designation assignment, or employment status. Called from the staff edit screen.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:98-111
DTOstaff.dto.ts (UpdateStaffDto)
Servicestaff.service.ts (update)
Schemapeople.ts (staff_designation_in_department_fk, staff_designation_needs_department)
TestsCovered indirectly; the designation/department coherence checks are exercised on create and apply identically here.

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: guarded by the required version token — a repeat of the same body with the same token is refused with 409 PEOPLE_STALE_RECORD.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — UUID, ParseUUIDPipe.
BodyYesUpdateStaffDtoversion is REQUIRED, every other field optional. See 6.22.
{ "employmentStatus": "on_leave" }

Response

Same shape as 8.27, with message: "Staff member updated.".

The returned version is the token this write produced, read back inside the same transaction while the row lock is still held. A client saving pay in a following request must send that value, not the one it loaded the page with — both routes write the same row and both advance the token.

The staleness check runs after the row is locked and after liveness, so a removed staff member answers 404, never 409. Note that the principal-uniqueness assert runs earlier in the transaction: a stale request that also changes designationId can surface STAFF_PRINCIPAL_ALREADY_ASSIGNED rather than PEOPLE_STALE_RECORD.

Side Effects

Database writes: an UPDATE on users for the fields person carried; an UPDATE on staff for the fields present (each nullable field clears to null when the key is present with an explicit null/omitted-per-Object.hasOwn semantics — see 6.22), plus updatedAt. Cache: every cached staff list invalidated. No role re-evaluation — see the edge case below.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDInvalid field value, or an unknown field.Fix the request body.Global ValidationPipe
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope.As above.staff.service.ts
409PEOPLE_STALE_RECORDThe version sent does not match the row's current token — somebody else wrote this staff member first.Reload and re-apply the change.staff.service.ts
409STAFF_DESIGNATION_NOT_IN_DEPARTMENTThe resulting designationId/departmentId pair mismatches.Choose a designation that belongs to the chosen department.person-writer.service.ts
500SYS_INTERNAL_ERROR (unmapped 23514)Clearing departmentId to null while designationId remains set (or vice versa into an invalid pair).Unhandled — clear both together, or leave both alone.people.ts:339-342, unmapped
409USER_EMAIL_ALREADY_EXISTSThe new email is already held by a different live person.Choose a different email.person-writer.service.ts:139-163
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.25.As above.

Edge Cases

  • Stale-write protection is the version token, checked under the row's FOR UPDATE lock after the liveness check, so a removed staff member answers 404 and never 409. The token returned by this endpoint is read back inside the same transaction, so it is the one this write produced rather than whatever a concurrent writer left behind — which matters because the staff form saves pay in a second request that must carry it.
  • Changing designationId here to point at a teaching designation does not grant the teacher role, and changing it away from one does not revoke it — role grants are decided once, at POST /staff, and this endpoint never touches user_role. A staff member's role set and their current designation can drift out of sync as a result, and any consumer inferring "is this person a teacher" from the role grant rather than from designation.isTeaching on a fresh read will be wrong after such a change.
  • employmentStatus and account suspension (POST /:id/ban) are fully independent — setting employmentStatus: "terminated" here does not ban the account, and banning does not change employmentStatus. Both must be set explicitly if both should change.

Example Requests

curl -X PATCH "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"employmentStatus":"on_leave"}'

8.29 DELETE /api/staff/:id

Purpose

Soft-deletes a staff member. Called from the staff directory's remove action.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:111-122
DTONone — no body.
Servicestaff.service.ts (remove); people-deletion.service.ts (softDeleteProfile)
Schemapeople.ts (staff.deleted_at, staff_employee_code_unique)
Testsstaff.service.spec.ts ("soft-deletes a staff member, hides them from the list, and restores them")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_DELETE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a second DELETE returns 404.

Request

id in the path only, no body.

Response

{ "message": "Staff member removed.", "data": null, "errorCode": null }

Side Effects

Database writes, in one transaction: staff.deleted_at/updated_at set; only if the person now holds no other live profile, users.deleted_at set, credentials deleted, sessions revoked. Releases employeeCode for reuse (partial unique index on deleted_at IS NULL). Cache: every cached staff list invalidated. The staff/teacher role grants are not revoked — see the edge case below.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope.Already removed, never existed, or not visible to you.people-deletion.service.ts:247-278
409USER_CANNOT_DELETE_SELFThe target's users.id equals the caller's own id.You cannot delete your own account.people-deletion.service.ts:62-67
403USER_LAST_SUPERADMIN_PROTECTEDDeleting this person's last profile would remove the last sign-in-capable superadmin.This is the last superadmin account.actor-authority.service.ts:278-316
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.25.As above.

Edge Cases

  • Deleting a staff member leaves their staff/teacher role grants in place on user_rolesoftDeleteProfile only ever touches students/guardians/staff and, conditionally, users; it never touches user_role. If the person's users row also gets soft-deleted (no other live profile), the role grant becomes moot in practice since they can no longer sign in — but if the same person is also a live guardian, they retain functioning staff/teacher permissions on a role that no longer corresponds to an active employment record, until an administrator manually revokes the role.
  • If this staff member is a department head or similar reference held elsewhere, this module does not check for or block on that — staff.department_id/designation_id are the referencing side of those foreign keys, not the referenced side, so no other row's FK is affected by a staff row's own deletion.

Example Requests

curl -X DELETE "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN"

8.30 POST /api/staff/:id/restore

Purpose

Brings a soft-deleted staff member back. Called from the deleted-staff screen.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:122-134
DTONone — no body.
Servicestaff.service.ts (restore); people-deletion.service.ts (restoreProfile)
Schemapeople.ts (staff_employee_code_unique); identity.ts (users_email_unique)
Testsstaff.service.spec.ts ("refuses to restore a staff member whose employee code was reissued")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_RESTORE — its own permission, distinct from Staff_UPDATE (contrast students, which reuses Students_UPDATE; see 13.5). Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: restoring an already-live staff member is a silent no-op success.

Request

id in the path only, no body.

Response

Same shape as 8.27, with message: "Staff member restored.".

Side Effects

Database reads: the profile row locked FOR UPDATE; a check that the employee code is still free among live staff; a check that the email is still free among live users. Database writes: staff.deleted_at cleared; users.deleted_at cleared if applicable. Cache: every cached staff list invalidated.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STAFF_NOT_FOUNDNo staff row with this id at all (live or deleted), or out of scope.The record does not exist or is not visible to you.people-deletion.service.ts:140,228-245
409STAFF_RESTORE_EMPLOYEE_CODE_CONFLICTThe employee code this staff member held has been reissued to a different, currently-live staff member.Give this record a new employee code before restoring.people-deletion.service.ts:183-201
409USER_RESTORE_EMAIL_CONFLICTThe email has been taken by a different live account since deletion.Change the conflicting account's email, or this person's, before restoring.people-deletion.service.ts:204-226
403PERMISSION_INSUFFICIENTActive role holds Staff_UPDATE but not Staff_RESTORE.Not authorized to restore.role.guard.ts
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts

Edge Cases

  • Restoring a staff member does not re-grant teacher even if their designation is a teaching one — the role grant only ever happens on POST /staff, never on restore. A restored teacher may need the role re-added manually if it was ever removed.
  • Calling this on a staff member who was never deleted is a no-op success, exactly as on students and guardians.

Example Requests

curl -X POST "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/restore" \
  -H "Authorization: Bearer TOKEN"

8.31 POST /api/staff/:id/ban

Purpose

Suspends the staff member's sign-in without touching their employment record. Called from the staff detail screen's suspend action.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:141-153
DTOdto/account-action.dto.ts (BanAccountDto)
Servicestaff.service.ts (ban); people-account.service.ts (ban)
Schemaidentity.ts (users.banned, banReason, bannedAt, bannedBy); people.ts (staff.employment_status, kept independent)
Testsstaff.service.spec.ts ("suspends an account with a reason, leaving employment status alone", "refuses a suspension with no reason")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — UUID, ParseUUIDPipe.
BodyYesBanAccountDto.
{ "reason": "Under investigation; access suspended pending review." }

Response

{ "message": "Account suspended.", "data": null, "errorCode": null }

Side Effects

Identical mechanics to 8.8/8.21: users.banned/banReason/bannedAt/bannedBy set inside a transaction with the last-superadmin check; every session deleted; every cached staff list invalidated. staff.employment_status is untouched — confirmed by test.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDreason missing or over 500 characters.Give a reason.Global ValidationPipe
400USER_BAN_REASON_REQUIREDreason blank after trimming.Give a real reason.people-account.service.ts:70-79
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope.As above.people-account.service.ts:215-247
409USER_CANNOT_DELETE_SELFThe caller is banning their own account.You cannot suspend yourself.people-account.service.ts:225-232 (assertNotSelf)
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can suspend this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:86
403USER_LAST_SUPERADMIN_PROTECTEDThe target is the last sign-in-capable superadmin.This is the last superadmin account.actor-authority.service.ts:315-353
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.25.As above.

Edge Cases

  • A staff member can be simultaneously banned (cannot sign in) and employmentStatus: "active" (still employed on paper), or unbanned and employmentStatus: "terminated" (employment ended but the account, until separately handled, is not suspended) — the two are deliberately independent axes, one an HR fact about the job and the other a sign-in refusal about the account, and neither endpoint changes the other.
  • The superadmin most likely to trip USER_LAST_SUPERADMIN_PROTECTED in practice is a staff member holding that role — this check exists precisely because a staff account is where an organisation's superadmin access commonly lives.

Example Requests

curl -X POST "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/ban" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Under investigation; access suspended pending review."}'

8.32 POST /api/staff/:id/unban

Purpose

Lifts a suspension. Called from the staff detail screen.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:154-165
DTONone — no body.
Servicestaff.service.ts (unban); people-account.service.ts (unban)
Schemaidentity.ts (users.banned, banReason, bannedAt, bannedBy)
Testsstaff.service.spec.ts ("clears the reason when the account is restored")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent.

Request

id in the path only, no body.

Response

{ "message": "Account restored.", "data": null, "errorCode": null }

Side Effects

users.banned/banReason/bannedAt/bannedBy all cleared. No session sweep. Cache invalidated identically to ban.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope.As above.people-account.service.ts:215-247
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can restore this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:118
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.25.As above.

Edge Cases

Identical to 8.9/8.22 — no last-superadmin check; no-op on an already-unbanned account; employmentStatus untouched; the same superadmin-acting-on-superadmin protection as ban.

Example Requests

curl -X POST "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/unban" \
  -H "Authorization: Bearer TOKEN"

8.33 POST /api/staff/:id/password-reset

Purpose

Emails a password-reset link to the staff member. Called from the staff detail screen.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:165-181
DTOdto/account-action.dto.ts (PasswordResetSentDto, response)
Servicestaff.service.ts (sendPasswordResetLink); people-account.service.ts (sendPasswordResetLink)
Schemaidentity.ts (users.email, canLogin, banned)
Testsstaff.service.spec.ts ("issues a reset token and emails the link", "refuses a reset link for somebody with no sign-in access")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Staff_UPDATE. Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: issues a fresh token each call.

Request

id in the path only (UUID, ParseUUIDPipe), no body.

Response

{ "message": "Password reset link sent.", "data": { "sentTo": "anita.sharma@example.com" }, "errorCode": null }

Side Effects

Identical mechanics to 8.10/8.23: a single-use expiring token issued and recorded with the caller's IP/user agent, the email sent fail-soft, an activity line logged. No cache invalidation.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope.As above.people-account.service.ts:152
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Only another superadmin can trigger a reset link for this account.actor-authority.service.ts:143-158 (assertMayActOnAccount), called from people-account.service.ts:157
409USER_EMAIL_REQUIREDNo email on file.Add an email first.people-account.service.ts:154-160
409USER_LOGIN_DISABLEDcanLogin is false.This person cannot sign in.people-account.service.ts:161-169
409AUTH_ACCOUNT_BANNEDThe account is currently suspended.Restore the account first.people-account.service.ts:170-176
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs 8.25.As above.

Edge Cases

Identical to 8.10/8.23 — a banned staff member must be unbanned first; the same superadmin-acting-on-superadmin protection applies here too.

Example Requests

curl -X POST "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/password-reset" \
  -H "Authorization: Bearer TOKEN"

8.33a POST /api/staff/:id/sign-in

Purpose

Gives this staff member a portal account, and by default emails them the invitation that lets them choose a password. Called from the detail screen's sign-in toggle.

This is the only route that turns sign-in access on after creation. PATCH /api/staff/:id does not accept canLogin at all.

Source Evidence

EvidencePath
Controllerstaff.controller.ts (grantSignIn)
DTOsign-in-access.dto.ts (GrantSignInDtoSignInAccessDto)
Serviceshared/people-account.service.ts (setSignIn)
Schemaidentity.ts (users.can_login), auth.ts (verification)
  • Auth: JWT.
  • Permission: Users_UPDATE — not Staff_UPDATE. Granting sign-in is an identity change rather than a record edit: a login-capable row with an email address is a route into a session, because POST /api/auth/password/forgot is public.
  • Idempotency: safe to repeat, but not a no-op. Granting to somebody who already has access skips the state change and still decides the invitation afresh — that is what makes the two documented recovery paths work, since both invite: false and no_email end with the operator calling this route again while the state is already correct.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — the staff member's profile id (UUID; ParseUUIDPipe).
BodyNoGrantSignInDto — see 6.4a. An empty body means invite: true.
{ "invite": true }

Response

{
  "message": "Sign-in access granted.",
  "data": {
    "canLogin": true,
    "invitation": { "sent": true, "to": "bina.thapa@example.com" }
  },
  "errorCode": null
}

canLogin is the state after the change. invitation is described in 6.4bsent: true means the invitation was enqueued, not that it arrived.

Side Effects

  • UPDATE users SET can_login = true guarded by the value that was read a moment earlier: WHERE id = :id AND can_login = :observed AND deleted_at IS NULL. A concurrent change, or a soft delete between the read and the write, makes this a zero-row result and a 409 rather than a silent overwrite of somebody else's decision. It is skipped when the person already has access.
  • When invite is not false, the person has an email address, and the account is not suspended: an account_invite verification record valid for 7 days, and an invitation email enqueued through the notification outbox.
  • The state change and the invitation share one transaction. If the invitation cannot be scheduled, the grant rolls back with it — answering sent: true for an invitation nobody will receive would tell the operator something false about a person now holding credentials they will never hear about.
  • No session changes — granting access creates nothing to sign in with until the person sets a password.
  • No cache invalidation — can_login is not part of any cached staff projection.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STAFF_NOT_FOUNDNo live staff member with that id, or it is outside the actor's scope.Not found.people-account.service.ts (resolveUserId)
403USER_SUPERADMIN_PROTECTEDThe target holds the superadmin role and the caller does not.Ask another superadmin to do this.actor-authority.service.ts (assertMayActOnAccount)
409PERSON_SIGN_IN_STATE_CHANGEDSomebody else changed this person's sign-in access between the read and the write.Re-read the record and try again.people-account.service.ts (setSignIn)
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTNo valid JWT, or the actor lacks Users_UPDATE.Sign in, or ask an administrator.JwtAuthGuard, RoleGuard

A missing email address is not an error here, and neither is a suspended account. The grant succeeds in both cases and the response says why the invitation was skipped — reason: "no_email" or reason: "banned". The account is legitimate either way, and refusing the grant over it would be the worse outcome. Fix the underlying condition and call this route again; it invites without needing the sign-in state to change.

Edge Cases

  • Granting to somebody who already has access still sends the invitation. The state change is skipped; the invitation is decided on its own. This is deliberate — invite: false promises "prepare an account and invite later" and no_email promises "add an address and call again", and both bring the operator back to this route with the state already correct. Returning early would answer 200 having sent nothing, indistinguishable from success, with no other route that would ever send that invitation.
  • invite: false prepares the account silently. Call the route again with invite: true — or once an email address exists — to send the invitation then.
  • The invitation lasts 7 days, not the 15 minutes an OTP gets. It redeems by link only: the one-time code is never printed in an invitation email, and POST /api/auth/password/reset matches OTPs against password resets alone.
  • A suspended (banned) person can still be granted sign-in access, but is not invited: the response carries reason: "banned". The two flags are independent — banned is a statement about conduct with a recorded reason, can_login a statement about whether a portal account exists at all — and an invitation into an account the login guard will refuse produces a support call, not an activated account. Lift the suspension and call this route again to invite them.
  • can_login governs authentication and nothing else. It never affects notification delivery: somebody with no portal account still receives every notification addressed to them.

Example Requests

curl -X POST "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/sign-in" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"invite":true}'

8.33b DELETE /api/staff/:id/sign-in

Purpose

Takes this staff member's portal account away and ends every session they hold.

Not the same as suspending. A ban records a reason and is a statement about conduct; this simply says the person no longer has an account. Their record, and every notification addressed to it, is untouched.

Source Evidence

EvidencePath
Controllerstaff.controller.ts (revokeSignIn)
DTOsign-in-access.dto.ts (SignInAccessDto)
Serviceshared/people-account.service.ts (setSignIn)
Schemaidentity.ts (users.can_login), auth.ts (session)
  • Auth: JWT.
  • Permission: Users_UPDATE, for the same reason as the grant.
  • Body: none — there is nothing to choose when revoking.

Response

{
  "message": "Sign-in access revoked.",
  "data": {
    "canLogin": false,
    "invitation": { "sent": false, "reason": "revoked" }
  },
  "errorCode": null
}

invitation is always present, and on a revoke is always sent: false with reason: "revoked" — there is nothing to invite anybody to.

Side Effects

  • UPDATE users SET can_login = false, guarded by the observed value exactly as the grant is, and skipped when the person has no access to begin with.
  • Every session for that user is deleted. JwtStrategy re-reads can_login on each request, so the revoke takes effect on the next call either way — but leaving the rows behind keeps a refresh token redeemable, and deleting them removes that possibility rather than relying on every future caller remembering to check.
  • No invitation, no email, no notification.
  • No cache invalidation — can_login is not part of any cached staff projection.

Error Cases

Identical to 8.33a: 404 STAFF_NOT_FOUND, 403 USER_SUPERADMIN_PROTECTED, 409 PERSON_SIGN_IN_STATE_CHANGED, and the standard 401/403.

Edge Cases

  • Revoking from somebody who has no access does nothing — no write, no session sweep, and 200 with canLogin: false. A revoke is the one direction allowed to short-circuit on the state already holding, because unlike a grant there is no second question left to answer.
  • Any unredeemed invitation the person holds is left in place. It stops being useful the moment can_login is false, because a password set through it would not let them sign in; it expires on its own within 7 days of being issued.
  • Revoking does not delete, suspend, or otherwise change the staff member's record. Sign-in access can be granted again later through 8.33a.

Example Requests

curl -X DELETE "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/sign-in" \
  -H "Authorization: Bearer TOKEN"

8.34 GET /api/staff/:id/salary

Purpose

Returns a staff member's salary and bank details. A separate, permission-gated endpoint because this is money and personal financial data, structurally kept out of the general staff projection so a colleague's legitimate Staff_READ never doubles as a payroll read.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:181-193
DTOstaff.dto.ts (StaffSalaryDto)
Servicestaff-salary.service.ts (find)
Schemapeople.ts (staff.basic_salary, allowances, total_salary, bank_name, account_number, branch, pan_number, citizenship_number, ssf_number, cit_number)
Testsstaff.service.spec.ts ("keeps salary out of the staff response, omits it without the permission, and returns it with the permission")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: StaffSalary_READ (distinct from Staff_READ), checked twice — once by RoleGuard at the route, and again inside StaffSalaryService.find via PeoplePermissionsService.can. Object-level scope: assertCanAccess(actor, "staff", id, …) — the general staff-scope check, run before the field-permission check. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

id in the path only (UUID, ParseUUIDPipe), no query, no body.

Response

{
  "message": "Salary fetched.",
  "data": {
    "basicSalary": "45000.00",
    "allowances": "5000.00",
    "totalSalary": "50000.00",
    "bankName": "Nabil Bank",
    "accountNumber": "01234567890",
    "branch": "New Baneshwor",
    "panNumber": "301234567",
    "citizenshipNumber": "27-01-70-12345",
    "ssfNumber": "SSF-0041-2026",
    "citNumber": "CIT-778812"
  },
  "errorCode": null
}

Side Effects

Database read only: staff filtered to the ten salary/bank/statutory columns. No cache.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope (checked before the field permission).As above.staff-salary.service.ts:53-56,150-155
403PERMISSION_INSUFFICIENTThe route-level StaffSalary_READ check already passed RoleGuard, but the in-service PeoplePermissionsService.can check fails — reachable only if the two checks could ever disagree (e.g. a role's permissions changed between the guard's check and the service's, within the same request window is not possible in practice, but the check exists for any future code path that reaches this method without going through the guarded route).Not authorized to view salary information.staff-salary.service.ts:57-59,157-162
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts

Edge Cases

  • Holding Staff_READ alone grants no access here; the two permissions are entirely independent, and there is no partial view (e.g. bank details without salary figures).
  • A staff member with no salary recorded returns every field as null, including totalSalary — the generated column evaluates to NULL when either input is NULL, never to 0, so a payroll sum over unrecorded staff correctly excludes rather than zeroes them.
  • Every monetary field is a string. Deserializing "45000.00" to a JavaScript number and back for any subsequent PATCH risks trailing-zero or precision drift that a strict-string round-trip avoids.

Example Requests

curl -X GET "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/salary" \
  -H "Authorization: Bearer TOKEN"

8.35 PATCH /api/staff/:id/salary

Purpose

Updates a staff member's salary and bank details. Called from the same permission-gated panel as 8.34.

Source Evidence

EvidencePath
Controllerstaff.controller.ts:193-206
DTOstaff.dto.ts (UpdateStaffSalaryDto)
Servicestaff-salary.service.ts (update)
Schemapeople.ts (staff_salary_pair_coherent, staff_basic_salary_range, staff_allowances_range)
Testsstaff.service.spec.ts ("refuses a salary update that breaks the basicSalary/allowances pair")

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: StaffSalary_UPDATE, checked twice (route and in-service, as 8.34). Object-level scope: assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — resubmitting the same body reapplies the same values.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYesid — UUID, ParseUUIDPipe.
BodyYesPatchStaffSalaryDtoversion is REQUIRED, every other field optional. See 6.23. basicSalary/allowances must travel together or not at all.

Minimal valid request (bank details only, salary untouched):

{ "bankName": "Nabil Bank", "accountNumber": "01234567890" }

Full valid request:

{
  "basicSalary": "45000.00",
  "allowances": "5000.00",
  "bankName": "Nabil Bank",
  "accountNumber": "01234567890",
  "branch": "New Baneshwor",
  "panNumber": "301234567",
  "citizenshipNumber": "27-01-70-12345",
  "ssfNumber": "SSF-0041-2026",
  "citNumber": "CIT-778812"
}

Every request body must also carry version. Clearing salary entirely:

{ "version": "1757308800000", "basicSalary": null, "allowances": null }

Response

Same shape as 8.34, with message: "Salary updated.", and a fresh version reflecting this write.

Side Effects

  • Database reads: the staff row is locked FOR UPDATE at the start of one transaction, and its liveness, concurrency token and current basicSalary/allowances are read from that locked row. The pair check therefore sees the values the write is about to overwrite, not a snapshot taken before another writer committed.
  • Database writes: an UPDATE on staff restricted to the salary/bank/statutory columns present in the body, plus updatedAt, in the same transaction.
  • Cache: every cached staff list invalidated — defensive, since the list projection never includes these fields, but the invalidation contract is "any write to this staff row". This runs after the transaction commits: it is a Redis pattern scan, and holding a payroll row lock across a network round trip to another service would serialize every write to that staff member behind it.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDAn unknown field; a non-numeric-looking string for basicSalary/allowances; or the application-level pair check — setting one of basicSalary/allowances to a real value while the other resolves to null (whether by this request or by the existing row).basicSalary and allowances must be set together, or cleared together.staff-salary.service.ts:110-133
404STAFF_NOT_FOUNDNo live staff row with this id, or out of scope, or the row was soft-deleted before the lock was acquired.As above.staff-salary.service.ts
409PEOPLE_STALE_RECORDThe version sent does not match the row's current token.Reload and re-apply the change.staff-salary.service.ts
403PERMISSION_INSUFFICIENTSee 8.34's equivalent case.Not authorized.staff-salary.service.ts:84-86
500SYS_INTERNAL_ERROR (unmapped)A value passes @IsNumberString but violates staff_basic_salary_range/staff_allowances_range at the database — outside 099999999.99, or more than two decimal places for the column's numeric(12,2) scale.Unhandled — validate range/scale client-side; the DTO only checks "is this a numeric-looking string".people.ts:347-354, not mapped in person-writer.service.ts's translate (that mapper is not even in this call path — StaffSalaryService.update writes directly, uncaught)
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts

Edge Cases

  • Order inside the transaction is liveness, then staleness, then the pair check. A soft-deleted row answers 404 rather than 409, so a caller is never told a removed record merely moved on; and a stale caller is refused before its view of the basicSalary/allowances pair is validated, because that view describes a row that has already changed.
  • One token covers both staff write routes. PATCH /staff/:id and this endpoint write the same staff row and both advance the same staff.version counter. A client that calls the general edit and then this one in the same flow must send the version the FIRST call returned; sending the page-load token here fails with 409 every time, with no other operator involved.
  • The application-level pair check runs before the write, so a caller gets the named 400 VALIDATION_FAILED rather than the database's staff_salary_pair_coherent 23514 for the common case of breaking the pair — but the DB CHECK is still the backstop for any write that bypasses this service.
  • Sending only basicSalary when allowances is currently null (or vice versa) is exactly the case the pair check exists to catch — a staff member's very first salary entry must set both in the same call.
  • totalSalary is never accepted in the body — UpdateStaffSalaryDto has no such field; sending it produces a plain 400 from forbidNonWhitelisted, not a domain error.
  • Clearing both to null (e.g. reversing an accidental entry) is fully supported and passes the pair check trivially (null === null).

Example Requests

curl -X PATCH "$API_URL/api/staff/01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f/salary" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"basicSalary":"45000.00","allowances":"5000.00"}'

9. Flow Diagrams

9.1 Route Ownership

9.2 Request Sequence — admission with a shared guardian

9.3 Request Sequence — concurrent PATCH with a stale version

9.4 Activity Diagram — the shared account-action flows (ban / unban / password-reset)

The superadmin check (ActorAuthorityService.assertMayActOnAccount) is one gate shared by all three actions: self-targeting always passes it — the specific self-actions that are dangerous are refused by name, as shown on the ban branch — and it otherwise refuses only a non-superadmin acting on a superadmin, reading the target's full role set rather than their currently active role.

9.5 Error Decision Tree — every mutation endpoint family

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/studentspage/size onlypagination=false is refused (PAGINATION_LIMIT_INVALID)20100admissionNumber, admissionDate, fullName, createdAt, else updatedAt (via sortBy, not sort)search, recordStatus, gender, bloodGroup, transportMode, hasGuardians, admissionDateFrom/admissionDateTo, recordVisibility (includeDeleted accepted, ignored)N/A — always paginated.
GET /api/guardianspage/size onlypagination=false is refused20100fullName, createdAt, else updatedAt (via inherited sort)search, kind, phone (exact), hasChildren, recordVisibility (includeDeleted accepted, ignored)N/A — always paginated.
GET /api/staffpage/size onlypagination=false is refused (PAGINATION_LIMIT_INVALID, same code as guardians)20100employeeCode, joiningDate, employmentStatus, experienceYears, fullName, createdAt, else updatedAt (via inherited sort)search, departmentId, designationId, designationKind, employmentStatus, gender, joiningDateFrom/joiningDateTo, recordVisibility (includeDeleted accepted, ignored)N/A — always paginated.

recordVisibility replaced includeDeleted on all three list endpoints above. The retired boolean is still declared on each query DTO — validated, and accepted on the wire — but is never read by any service; the global ValidationPipe's forbidNonWhitelisted is why it stays on the DTO for one release rather than being deleted outright, exactly as with the school-profile fields documented in the school module's docs. The old boolean never behaved as its name suggested: includeDeleted=true returned every record, active and removed together, never the removed ones on their own — so a caller that actually wants only the removed records must move to recordVisibility=removed; there is no way to get that result from the retired flag, on any version of this API. recordVisibility accepts current (default — what the office normally works with), removed (deleted records only), or all (both) — a separate axis from recordStatus/employmentStatus (a fact about the person) rather than about the record. Source: apps/api/src/common/dto/record-visibility.ts.

  • Shared pagination utility: apps/api/src/common/utils/pagination.util.ts (PaginationUtil), used identically by all three; its own clamp to 100 is superseded in practice by QueryDto's @Max(100), which rejects rather than clamps for any caller going through the DTO.
  • Broad-search detection: none — search always runs as trigram similarity (threshold 0.3) OR-ed with an escaped ILIKE %term% on the relevant secondary field (admissionNumber for students, organizationName for guardians, employeeCode for staff), regardless of term length.
  • Relevance scoring: when a search term is present, results are ordered by similarity(fullName, term) descending, ahead of whatever sort/sortBy was requested — the requested sort still applies when no search term is given.
  • Cache behavior per query: every list result caches for 120 seconds, keyed on the actor's scope tag, view tag, and every filter/sort/page parameter — two callers with identical permissions and identical query strings share a cache entry; two callers differing in scope or in a field-gated permission (StaffSalary_READ/StudentMedical_READ) never do, by construction of the cache key.
  • Empty result behavior: { "data": [], "count": 0, "currentPage": 1, "totalPage": 0 } (or totalPage: 1 with count: 0 depending on rounding — Math.ceil(0/size) is 0), never an error.
  • Every list is tie-broken by the primary key ascending after the requested sort column, so rows sharing an identical updatedAt (a bulk import, for example) never silently drop or duplicate across pages under offset pagination — verified by the integration test "paginates deterministically when every row shares an updated_at".
  • sortBy/sort validation is strict across this whole module, unlike the school module's lookups: an unrecognized value is refused with 400 PEOPLE_INVALID_SORT_FIELD, never silently redirected to a default column.

11. Caching, Jobs, and External Integrations

IntegrationUsed?DetailsSource
Redis cache (list results)YesKeys students:list:*, guardians:list:*, staff:list:*; 120-second TTL; cache-aside (getSoft on read, setSoft on miss); every write to the corresponding profile kind does a full delPatternSoft prefix sweep (students:*, guardians:*, staff:*) rather than a targeted key, because the key space itself (scope tag × view tag × every filter/sort/page combination) is not enumerable from the write site. Fail-soft on every path — a Redis outage degrades to always querying the database, never surfaces as an error to the client.students.service.ts:92-93,610-612; guardians.service.ts:114-115,598-603; staff.service.ts:90-91,590-598; staff-salary.service.ts:146
BullMQNoNo queue import in any service in this module.
RealtimeNoNo Socket.IO/SSE emission in any service in this module.
External APINoNo outbound HTTP call in any service — email delivery goes through AuthEmailService, which is this module's only outbound side effect and is itself fail-soft (sendPasswordResetEmailSafe).people-account.service.ts:189

Consumer implication: unlike the school module's school-profile endpoint, no read in this module ever serves data older than the database — the cache here exists purely to absorb read load on identical, repeated list queries within a 120-second window, and every write invalidates broadly enough that a client re-fetching immediately after its own write always sees the fresh state. The one caveat is a different actor's concurrent write: their invalidation runs against the same prefix, so it also clears an in-flight cache entry this actor's own request might have just populated — the next read simply re-queries, at worst adding one avoidable database round trip, never staleness.

13. Mandatory Deep API Documentation Pack

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsCacheDB TouchesErrorsTestsDocumented?
GET /api/studentsStudentsController.findAllListStudentsQueryDtoStudentDto[]StudentsService.findAllJWT, RoleStudents_READRead/write students:list:*students, users, student_class_enrollments, classes, grades, sections, academic_sessions (all read)401,403,400Integration specYes
POST /api/students/guardian-lookupStudentsController.guardianLookupGuardianLookupDto → arrayStudentGuardiansService.lookupGuardiansByPhoneJWT, RoleGuardians_READN/Aguardians, users, student_guardian400,401,403Integration specYes
POST /api/studentsStudentsController.createCreateStudentDtoCreatedStudentDtoStudentsService.createJWT, RoleStudents_CREATEInvalidate students:*users, students, guardians, student_guardian, code_counters; plus student_class_enrollments (write) and classes/grades/sections/academic_sessions (read) when classId is sent400,404,409Integration specYes
GET /api/students/:idStudentsController.findOneNone → StudentDtoStudentsService.findOneJWT, RoleStudents_READN/Astudents, users, student_class_enrollments, classes, grades, sections, academic_sessions (all read)400,401,403,404N/AYes
PATCH /api/students/:idStudentsController.updateUpdateStudentDtoStudentDtoStudentsService.updateJWT, RoleStudents_UPDATEInvalidate students:*users, students; plus student_class_enrollments (write) and classes/grades/sections/academic_sessions (read) when classId is sent400,404,409Integration specYes
DELETE /api/students/:idStudentsController.removeNone → nullStudentsService.removeJWT, RoleStudents_DELETEInvalidate students:*students, users, account404,409,403Integration specYes
POST /api/students/:id/restoreStudentsController.restoreNone → StudentDtoStudentsService.restoreJWT, RoleStudents_UPDATEInvalidate students:*students, users404,409Integration specYes
POST /api/students/:id/banStudentsController.banBanAccountDtonullStudentsService.banJWT, RoleStudents_UPDATEInvalidate students:*users400,404,409,403Integration specYes
POST /api/students/:id/unbanStudentsController.unbanNone → nullStudentsService.unbanJWT, RoleStudents_UPDATEInvalidate students:*users404,403Integration specYes
POST /api/students/:id/password-resetStudentsController.sendPasswordResetLinkNone → PasswordResetSentDtoStudentsService.sendPasswordResetLinkJWT, RoleStudents_UPDATEN/Ausers (read)404,409,403Shared specYes
POST /api/students/:id/sign-inStudentsController.grantSignInGrantSignInDtoSignInAccessDtoPeopleAccountService.setSignInJWT, RoleUsers_UPDATEN/Ausers (guarded update), verification (write), notification outbox401,403,404,409Integration specYes
DELETE /api/students/:id/sign-inStudentsController.revokeSignInNone → SignInAccessDtoPeopleAccountService.setSignInJWT, RoleUsers_UPDATEN/Ausers (guarded update), session (deleted)401,403,404,409Integration specYes
GET /api/students/:id/guardiansStudentsController.findGuardiansNone → StudentGuardianLinkDto[]StudentGuardiansService.findGuardiansJWT, RoleStudents_READ+Guardians_READN/Astudent_guardian, guardians, users404N/AYes
PUT /api/students/:id/guardiansStudentsController.setGuardiansSetStudentGuardiansDtoStudentGuardianLinkDto[]StudentGuardiansService.setGuardiansJWT, RoleStudents_UPDATE+Guardians_UPDATEInvalidate students:*student_guardian, guardians, users400,404,409Integration specYes
GET /api/students/:id/medicalStudentsController.findMedicalNone → StudentMedicalDtoStudentMedicalService.findMedicalJWT, RoleStudentMedical_READN/Astudents404,403Integration specYes
PATCH /api/students/:id/medicalStudentsController.updateMedicalUpdateStudentMedicalDtoStudentMedicalDtoStudentMedicalService.updateMedicalJWT, RoleStudentMedical_UPDATEInvalidate students:*students400,404,403Integration specYes
GET /api/guardiansGuardiansController.findAllListGuardiansQueryDtoGuardianDto[]GuardiansService.findAllJWT, RoleGuardians_READRead/write guardians:list:*guardians, users400,401,403Unit specYes
GET /api/guardians/:id/studentsGuardiansController.findStudentsNone → GuardianChildDto[]GuardiansService.findChildrenJWT, RoleGuardians_READN/Astudent_guardian, students, users404,500*N/AYes
GET /api/guardians/:idGuardiansController.findOneNone → GuardianDtoGuardiansService.findOneJWT, RoleGuardians_READN/Aguardians, users404,500*Unit specYes
POST /api/guardiansGuardiansController.createCreateGuardianDtoCreatedGuardianDto (201)GuardiansService.createJWT, RoleGuardians_CREATEInvalidate guardians:*users, guardians, role, user_role400,409,500Unit specYes
PATCH /api/guardians/:idGuardiansController.updateUpdateGuardianDtoGuardianDtoGuardiansService.updateJWT, RoleGuardians_UPDATEInvalidate guardians:*users, guardians400,404,409,500*N/AYes
DELETE /api/guardians/:idGuardiansController.removeNone → nullGuardiansService.removeJWT, RoleGuardians_DELETEInvalidate guardians:*guardians, users, account404,409,403,500*Unit specYes
POST /api/guardians/:id/banGuardiansController.banBanAccountDtonullGuardiansService.banJWT, RoleGuardians_UPDATEInvalidate guardians:*users400,404,409,403,500*Shared specYes
POST /api/guardians/:id/unbanGuardiansController.unbanNone → nullGuardiansService.unbanJWT, RoleGuardians_UPDATEInvalidate guardians:*users404,403,500*Shared specYes
POST /api/guardians/:id/password-resetGuardiansController.sendPasswordResetLinkNone → PasswordResetSentDtoGuardiansService.sendPasswordResetLinkJWT, RoleGuardians_UPDATEN/Ausers (read)404,409,403,500*Shared specYes
POST /api/guardians/:id/sign-inGuardiansController.grantSignInGrantSignInDtoSignInAccessDtoPeopleAccountService.setSignInJWT, RoleUsers_UPDATEN/Ausers (guarded update), verification (write), notification outbox401,403,404,409Integration specYes
DELETE /api/guardians/:id/sign-inGuardiansController.revokeSignInNone → SignInAccessDtoPeopleAccountService.setSignInJWT, RoleUsers_UPDATEN/Ausers (guarded update), session (deleted)401,403,404,409Integration specYes
POST /api/guardians/:id/restoreGuardiansController.restoreNone → GuardianDtoGuardiansService.restoreJWT, RoleGuardians_RESTOREInvalidate guardians:*guardians, users404,409,500*N/AYes
GET /api/staffStaffController.findAllListStaffQueryDtoStaffDto[]StaffService.findAllJWT, RoleStaff_READRead/write staff:list:*staff, users, departments, designations400,401,403Unit specYes
POST /api/staffStaffController.createCreateStaffDtoCreatedStaffDtoStaffService.createJWT, RoleStaff_CREATEInvalidate staff:*users, staff, code_counters, designations, role, user_role400,409,500Unit specYes
GET /api/staff/:idStaffController.findOneNone → StaffDtoStaffService.findOneJWT, RoleStaff_READN/Astaff, users, departments, designations400,404N/AYes
PATCH /api/staff/:idStaffController.updateUpdateStaffDtoStaffDtoStaffService.updateJWT, RoleStaff_UPDATEInvalidate staff:*users, staff400,404,409,500N/AYes
DELETE /api/staff/:idStaffController.removeNone → nullStaffService.removeJWT, RoleStaff_DELETEInvalidate staff:*staff, users, account404,409,403Unit specYes
POST /api/staff/:id/restoreStaffController.restoreNone → StaffDtoStaffService.restoreJWT, RoleStaff_RESTOREInvalidate staff:*staff, users404,409,403Unit specYes
POST /api/staff/:id/banStaffController.banBanAccountDtonullStaffService.banJWT, RoleStaff_UPDATEInvalidate staff:*users400,404,409,403Unit specYes
POST /api/staff/:id/unbanStaffController.unbanNone → nullStaffService.unbanJWT, RoleStaff_UPDATEInvalidate staff:*users404,403Unit specYes
POST /api/staff/:id/password-resetStaffController.sendPasswordResetLinkNone → PasswordResetSentDtoStaffService.sendPasswordResetLinkJWT, RoleStaff_UPDATEN/Ausers (read)404,409,403Unit specYes
POST /api/staff/:id/sign-inStaffController.grantSignInGrantSignInDtoSignInAccessDtoPeopleAccountService.setSignInJWT, RoleUsers_UPDATEN/Ausers (guarded update), verification (write), notification outbox401,403,404,409Integration specYes
DELETE /api/staff/:id/sign-inStaffController.revokeSignInNone → SignInAccessDtoPeopleAccountService.setSignInJWT, RoleUsers_UPDATEN/Ausers (guarded update), session (deleted)401,403,404,409Integration specYes
GET /api/staff/:id/salaryStaffController.findSalaryNone → StaffSalaryDtoStaffSalaryService.findJWT, RoleStaffSalary_READN/Astaff404,403Unit specYes
PATCH /api/staff/:id/salaryStaffController.updateSalaryUpdateStaffSalaryDtoStaffSalaryDtoStaffSalaryService.updateJWT, RoleStaffSalary_UPDATEInvalidate staff:*staff400,404,403,500Unit specYes

500* marks the eight GuardiansController routes where a malformed (non-UUID) :id produces an unmapped 500 SYS_INTERNAL_ERROR rather than a clean 400, because none of them apply ParseUUIDPipe — see 5.

13.2 Request/Response Exhaustiveness

Covered per-endpoint in 8 — every endpoint includes a request example (minimal and/or full where the DTO has optional fields), a success response, and its representative error set. There is no public or guest-accessible variant of any endpoint in this module (every route requires authentication), so that example type is not applicable here. Every list endpoint's empty-result shape is documented in 10.

13.3 API Diagram Pack

Covered in 9: route ownership, an admission sequence showing the guardian-lookup-then-link flow, a concurrent-PATCH sequence illustrating the version conflict, the shared account-action activity diagram, and the module-wide error decision tree. A dedicated data contract map for the two-body write path every profile create shares:

Cache flow, shared by all three list endpoints:

13.4 Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin panelAll 41 routes are authenticated-and-permissioned; the six :id/sign-in routes need Users_UPDATE, so hide the sign-in toggle from an actor who lacks it rather than letting them discover the 403; version is mandatory on PATCH /students/:id but absent from guardian/staff PATCH; Guardians_RESTORE/Staff_RESTORE are distinct grants from their _UPDATE counterparts, while student restore reuses Students_UPDATE.Map errorCode to a specific message per the tables in 8; on 409 PEOPLE_STALE_RECORD, reload and re-render the form rather than retrying the same body; on the two unmapped 500s (staff designation/department pairing, staff salary range), validate client-side since the server gives no named code to branch on.Stable, except the two unmapped-500 gaps noted throughout, which are candidates for a future named error code.
QAReproduce the primary-guardian race and the staff_designation_needs_department gap directly; seed a soft-deleted guardian still referenced by a soft-deleted staff/student row to exercise restore conflicts; exercise the GuardiansController malformed-UUID path (GET /guardians/not-a-uuid) to confirm the 500, since it is easy to assume ParseUUIDPipe is applied uniformly.Fixtures should include at least one organisation guardian, one staff member with a teaching designation, and one record with canLogin: false to exercise the password-reset refusal paths.Stable.
Internal service (a future classes/timetable module)Resolve staff/guardian/student by their public UUID, never by users.id directly from another module unless already holding it; read StaffDto.designation.isTeaching fresh rather than inferring "is a teacher" from the teacher role grant, which can drift after a designation change.N/A — no internal HTTP calls exist into this module today; a future consumer should treat PeopleAccessService's scope model as the pattern to follow, not something to reimplement.Stable for the DTOs cited; the internal service layer (PeopleAccessService, PersonWriterService) is exported from PeopleModule and is the intended internal integration point, not raw SQL against these tables.
Web/mobile frontendThere is no mobile-facing route in this module — every one of the 41 routes is admin-only. A mobile app needing a directory-style view of staff for a different purpose should not call these routes directly.N/AN/A — not exposed to that surface.

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Optimistic concurrency on students onlyversion required on PATCH /students/:id; absent from guardians and staffA single shared concurrency mechanism across all three profile kindsStudents are the highest-contention edit surface in a school office (multiple staff editing the same roll during admission season); guardians and staff are edited far less concurrently in practice. This is a real, code-verified asymmetry, not an oversight — but it does mean a guardian/staff edit can silently lose a concurrent write with no signal to either editor.A guardian or staff PATCH racing another loses data silently.None implemented today; extending version to GuardianDto/StaffDto and their update DTOs is the same mechanism already proven on students.
Restore permission varies per entityStudents restore under Students_UPDATE; guardians under Guardians_RESTORE; staff under Staff_RESTOREOne consistent permission scheme for all three restoresVerified directly against all three controllers — this is not a documentation slip. The catalogue seeds a _RESTORE action for every module including Students, so a role granted only Students_UPDATE (not Students_RESTORE) can still restore a student, while the equivalent guardian/staff role would need the separate _RESTORE grant.An administrator's role built by copying "the update permission" for students, then assumed to generalize, under-grants for guardians/staff.Document the asymmetry explicitly here and in the role-management screen's help text; not something an API consumer can detect from the response shape alone.
None of the three people lists can be unpaginated?pagination=false is refused with 400 PAGINATION_LIMIT_INVALID on /students, /guardians, and /staff alikeAllow an unpaginated read on at least the students list, whose scope predicate is a comparatively cheap EXISTS check rather than the per-row correlated subquery guardians/staff pay for a restricted callerAn unpaginated roll is every child's, parent's, or employee's name, date of birth, address and contact details in one response regardless of which of the three tables is asked, and the student roll is the largest of the three — the risk the refusal exists to close is greater there, not smaller, so all three refuse uniformly rather than only the two with the more expensive query plan.A client relying on any of the three lists to return its entire contents in one unpaginated call must page through results instead.Documented per-endpoint in 10; one error code covers all three refusals, so a client need only branch on PAGINATION_LIMIT_INVALID once.
ParseUUIDPipe on students/staff :id, absent on guardiansA malformed id cleanly 400s on students/staff, 500s on guardiansApply ParseUUIDPipe uniformlyNo rationale is recorded in the source for the omission — it reads as an inconsistency between two controllers that otherwise share every other pattern (thin controllers, the same guard chain, the same shared services), rather than a deliberate design choice.A client that only tested against StudentsController's clean 400 behavior will be surprised by a bare 500 from GuardiansController on the same class of bad input.Documented explicitly per guardian endpoint in 8; the fix (adding ParseUUIDPipe to GuardiansController) is a one-line, backward-compatible change for a future pass — every currently-valid request is unaffected.
Field-gated groups (StaffSalary, StudentMedical) as separate endpoints, not conditional response fieldsA colleague without the gated permission gets a clean 403 on the dedicated endpoint; the general endpoint never includes the field at all, for anyoneConditional fields on StudentDto/StaffDto present only for permitted callersA response whose shape depends on the caller's permissions is a shape every consumer must branch on, and the one that forgets renders a blank where a permission refusal was meant — a materially worse failure mode for health/financial data than a clean 403 on a dedicated route.A consumer must know to call the second endpoint at all, rather than discovering the field's absence from the first.Documented in the module summary, the concepts table, and per-DTO omission notes throughout 6.
Two staff CHECK constraints with no mapped error codestaff_designation_needs_department and the two salary-range CHECKs surface as bare 500sAdd explicit pre-write validation, or map the constraint name in PersonWriterService.translateVerified by grepping both the translator and the service for the constraint names — genuinely absent, not merely undocumented. Recorded here as a real gap rather than smoothed over.A consumer sending designationId alone, or an out-of-range/over-precision salary figure, gets an opaque 500 with no actionable error code.Client-side validation (require departmentId whenever designationId is set; enforce the 099999999.99 range and two-decimal scale before submitting) is the only mitigation available today.
Guardian phone is a lookup field, never a unique identifierGET /guardians?phone=... may return several people; there is no /guardians/search routeEnforce phone uniqueness, or add a dedicated search routeA household sharing one number is the normal case for this domain, not an anomaly to engineer around; a dedicated /guardians/search route beside :id risks Nest matching order shadowing one route with the other.A consumer expecting phone to behave like an identifier (as it might in a consumer product) must instead handle a list and let the human pick.Documented in the concepts table and in 8.2/8.15's edge cases.

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
Adding ParseUUIDPipe to every GuardiansController :id routeAdmin panel (guardian screens only)A malformed id that previously produced a 500 would instead produce a clean 400 VALIDATION_FAILEDNone — no schema changeNoPurely additive from a correct client's perspective; only a client that was somehow relying on the 500 behavior (unlikely) would need to change.
Mapping staff_designation_needs_department to a named error codeAdmin panel (staff create/update)A previously-opaque 500 becomes a named 400/409NoneNoStrictly an improvement — no existing correct request is affected, since the constraint already blocks the write either way.
Extending the teacher/staff role re-evaluation to PATCH /staff/:idAdmin panel (staff edit screen)StaffService.update would need to call the same role-grant logic create uses whenever designationId changesNone — no schema change, user_role already supports the grantNoAdditive; a staff member whose designation was already correctly teaching/non-teaching sees no change, and only a staff member whose designation changed after creation would gain (or need to have manually removed) the teacher role automatically.

14. Zero-Omission API Checklist

  • Every controller route is documented (41 of 41 — 16 students, 12 guardians, 13 staff).
  • The /api global prefix is documented alongside each controller-local path.
  • Every DTO field, nested field, enum, default, transform, and validator is documented (§6-§7), including fields that round-trip via a read-only, server-resolved companion (ethnicityId/ethnicityName, motherTongueId/motherTongueName) and DTOs with no cross-field validator despite a DB-level coherence requirement.
  • Every response field, nullable field, generated field, and omitted raw entity field is documented — the StudentMedical/StaffSalary field-gate omissions are called out explicitly per DTO, not left implicit.
  • Every auth, guard, permission, role, and identity branch is documented (§5), including the per-entity restore-permission asymmetry and the guardians ParseUUIDPipe gap.
  • Every success, validation, auth, permission, not-found, conflict, and unmapped-error branch is documented per endpoint (§8) — including the two genuinely unmapped 500 paths (staff_designation_needs_department, the salary range/scale CHECKs), verified by grepping the translator rather than assumed.
  • Every database read/write, cache hit/miss/write/invalidation is documented; no queue, realtime, or external-call surface exists in this module and that absence is stated, not omitted.
  • Every route has examples for at least a minimal or full request and a success response; representative failures are tabulated per endpoint.
  • Every endpoint family has route, sequence, activity, and error diagrams (§9).
  • Every tradeoff and compatibility risk is documented (§13.5-§13.6), including inconsistencies found in the code rather than assumed away.
  • The API doc links to backend and features/flows docs (below).

14b. The consumer portal — guardian and student surfaces

Three guardian routes and one student route sit under /api/mobile, alongside the admin surface this document otherwise describes. They read the same tables through their own, narrower DTOs.

MethodPathAudienceReturns
GET/api/mobile/guardian/childrenguardianpaginated PortalChildDto
GET/api/mobile/guardian/children/{id}guardianone PortalChildDto
GET/api/mobile/guardian/children/{id}/enrollmentsguardianpaginated EnrollmentDto
GET/api/mobile/student/enrollmentsstudentpaginated EnrollmentDto

{id} is students.id. That table has no public_id column — its primary key is itself a uuid v7, which is what every other people route addresses a pupil by.

Authorization — no permission decorator, and that is the design

None of these handlers declares @Permissions(), because GUARDIAN_PERMISSIONS and STUDENT_PERMISSIONS are both empty by design: a guardian's and a pupil's access to their own records runs through object-level scope, not a module permission. A permissioned handler would refuse them before any scoping ran.

Each handler instead asserts its audience as the first thing it does, and each is listed in RoleGuard's no-permission allowlist together with the CI mirror that keeps the two in step.

Acting role/guardian/children/student/enrollments
guardian200, own children only403 PERMISSION_INSUFFICIENT
student403 PERMISSION_INSUFFICIENT200, own enrolments only
staff, teacher, superadmin403 PERMISSION_INSUFFICIENT403 PERMISSION_INSUFFICIENT
no role selected403 AUTH_ACTIVE_ROLE_REQUIRED403 AUTH_ACTIVE_ROLE_REQUIRED

The third row is the one worth understanding. staff and teacher carry scope_kind = 'all' and hold Students_READ, so the shared people scope resolver returns "every row" for them. A portal route that delegated its row scoping to that resolver would serve the entire pupil roll from a path called /guardian/children. These routes therefore build their own predicates and refuse an all-scoped caller outright.

Row scoping and the 404 convention

A child that exists but is not the caller's answers 404 STUDENT_NOT_FOUND, never 403 — the same rule the admin routes follow, and for the same reason: a 403 confirms the record exists, which turns the id space into an enumeration oracle against a roll of children.

The child is resolved THROUGH the scoped predicate. On the enrolments route this matters more than it looks: the URL id and the internal id are the same uuid, so the resolution step has no data dependency and reads like dead code. Removing it would compile, return an identical body for a legitimate caller, and hand any guardian any child's class history.

Three soft-delete filters apply to the children queries — guardians.deleted_at, students.deleted_at and users.deleted_at. The third is not redundant: the self-profile read filters it, so omitting it here would make /portal/me and /guardian/children disagree about the caller's own children within a single session.

Fields these routes never return

PortalChildDto carries id, admission number, student id, admission date, record status, full name, and the caller's own link to the child (relationship, canPickup, isPrimary). It carries no medical field — medicalConditions, allergies and specialNeeds are gated by StudentMedical_READ and are absent by construction, not by omission.

Pagination

pagination=false is refused with 400 PAGINATION_LIMIT_INVALID, matching the people tables. A guardian's own list is small, so this is not about volume: the readers these routes reuse fall back to an unpaginated hard cap, and an unpaginated roll of children is exactly what the admin routes refuse.

15. Integration Checklist

  • Every route from all three controllers is documented.
  • Every DTO field is documented.
  • Every enum value is documented.
  • Every response envelope is documented, including the pagination metadata, which is always present on all three list endpoints since none of them can be unpaginated.
  • Every error code this module can produce, including the shared global ones (VALIDATION_FAILED, RESOURCE_ALREADY_EXISTS, AUTH_UNAUTHENTICATED, PERMISSION_INSUFFICIENT, etc.), is documented.
  • Every auth guard and permission is documented, including the two-layer (route permission + object-level scope + field-gate) model unique to this module.
  • The one real cache path (list results, all three entities) is documented; the absence of jobs, realtime events, and external calls is documented explicitly rather than left silent.
  • Every diagram matches the current code — verified against the exact controller/service files cited throughout.
  • This doc links to backend and features/flows docs.

See Also

On this page

People - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 PersonDto (response — the identity block on every profile)6.2 PersonInputDto (request — nested under every create/update body's person field)6.1a AddressDto and AddressInputDto (a Nepali address)6.3 BanAccountDto (body — every POST /:id/ban)6.4 PasswordResetSentDto (response — every POST /:id/password-reset)6.4a GrantSignInDto (body — every POST /:id/sign-in)6.4b InvitationOutcomeDto (response — inside SignInAccessDto and every create response)6.4c SignInAccessDto (response — every POST/DELETE /:id/sign-in)6.4d CreatedStudentDto / CreatedGuardianDto / CreatedStaffDto (responses — the three POST creates)6.5 StudentDto (response)6.6 StudentGuardianLinkDto (response — one entry on GET/PUT /students/:id/guardians)6.7 StudentMedicalDto (response and, structurally, the update shape — behind StudentMedical_READ/_UPDATE, never part of StudentDto)6.8 UpsertStudentGuardianDto (body — one entry inside CreateStudentDto.guardians and SetStudentGuardiansDto.guardians)6.9 CreateStudentDto (body — POST /students)6.10 UpdateStudentDto (body — PATCH /students/:id)6.11 UpdateStudentMedicalDto (body — PATCH /students/:id/medical)6.12 SetStudentGuardiansDto (body — PUT /students/:id/guardians)6.13 ListStudentsQueryDto (query, extends QueryDto)6.14 GuardianDto (response)6.15 GuardianChildDto (response — one entry on GET /guardians/:id/students)6.16 CreateGuardianDto (body — POST /guardians)6.17 UpdateGuardianDto (body — PATCH /guardians/:id)6.18 ListGuardiansQueryDto (query, extends QueryDto)6.19 StaffDto (response), with StaffDepartmentRefDto / StaffDesignationRefDto6.20 StaffSalaryDto (response and, structurally, the update shape — behind StaffSalary_READ/_UPDATE, never part of StaffDto)6.21 CreateStaffDto (body — POST /staff)6.22 UpdateStaffDto (body — PATCH /staff/:id)6.23 UpdateStaffSalaryDto (body — PATCH /staff/:id/salary)6.24 ListStaffQueryDto (query, extends QueryDto)6.25 QueryDto — shared base6.26 GuardianLookupDto (body — POST /students/guardian-lookup)7. Enum Reference8. Endpoint Reference8.1 GET /api/studentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.2 POST /api/students/guardian-lookupPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.3 POST /api/studentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.4 GET /api/students/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.5 PATCH /api/students/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.6 DELETE /api/students/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.7 POST /api/students/:id/restorePurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.8 POST /api/students/:id/banPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.9 POST /api/students/:id/unbanPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.10 POST /api/students/:id/password-resetPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.10a POST /api/students/:id/sign-inPurposeSource EvidenceRequestResponseSide EffectsError CasesEdge CasesExample Requests8.10b DELETE /api/students/:id/sign-inPurposeSource EvidenceResponseSide EffectsError CasesEdge CasesExample Requests8.11 GET /api/students/:id/guardiansPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.12 PUT /api/students/:id/guardiansPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.13 GET /api/students/:id/medicalPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.14 PATCH /api/students/:id/medicalPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.15 GET /api/guardiansPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.16 GET /api/guardians/:id/studentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.17 GET /api/guardians/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.18 POST /api/guardiansPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.19 PATCH /api/guardians/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.20 DELETE /api/guardians/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.21 POST /api/guardians/:id/banPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.22 POST /api/guardians/:id/unbanPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.23 POST /api/guardians/:id/password-resetPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.23a POST /api/guardians/:id/sign-inPurposeSource EvidenceRequestResponseSide EffectsError CasesEdge CasesExample Requests8.23b DELETE /api/guardians/:id/sign-inPurposeSource EvidenceResponseSide EffectsError CasesEdge CasesExample Requests8.24 POST /api/guardians/:id/restorePurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.25 GET /api/staffPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.26 POST /api/staffPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.27 GET /api/staff/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.28 PATCH /api/staff/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.29 DELETE /api/staff/:idPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.30 POST /api/staff/:id/restorePurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.31 POST /api/staff/:id/banPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.32 POST /api/staff/:id/unbanPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.33 POST /api/staff/:id/password-resetPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.33a POST /api/staff/:id/sign-inPurposeSource EvidenceRequestResponseSide EffectsError CasesEdge CasesExample Requests8.33b DELETE /api/staff/:id/sign-inPurposeSource EvidenceResponseSide EffectsError CasesEdge CasesExample Requests8.34 GET /api/staff/:id/salaryPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.35 PATCH /api/staff/:id/salaryPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence — admission with a shared guardian9.3 Request Sequence — concurrent PATCH with a stale version9.4 Activity Diagram — the shared account-action flows (ban / unban / password-reset)9.5 Error Decision Tree — every mutation endpoint family10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist14b. The consumer portal — guardian and student surfacesAuthorization — no permission decorator, and that is the designRow scoping and the 404 conventionFields these routes never returnPagination15. Integration ChecklistSee Also