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
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/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.ts | Every route, method, guard, permission decorator, and param pipe — grepped directly against @Get|@Post|@Patch|@Put|@Delete|@Permissions. |
| DTOs | apps/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.ts | Every field, type, optionality, default, validator, and sort allow-list. |
| Services | students/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.ts | Behavior, transactions, cache invalidation, response mapping, error mapping. |
| Schema | packages/db/src/schema/school/people.ts | Tables, enums, unique indexes, CHECK constraints, generated columns, foreign keys. |
| Shared query base | apps/api/src/common/dto/query.dto.ts | Inherited pagination, page, size, sort, order, search fields and their defaults. |
| Response envelope | apps/api/src/common/dto/response-dto.ts | Exact success envelope shape, including when pagination fields are present. |
| Error envelope | apps/api/src/common/filters/all-exceptions.filter.ts | Exact error envelope shape, the unique-violation fallback, and the default-error-code-by-status table. |
| Errors | apps/api/src/common/types/error-codes.ts | Every error code this module's routes can produce, including the shared global ones. |
| Auth | apps/api/src/modules/auth/guards/jwt-auth.guard.ts, apps/api/src/common/authorization/role.guard.ts | Guard chain, identity shape, the active-role rule, the superadmin flag bypass. |
| Permissions | packages/db/src/authorization/permission-catalog.ts | The five people-domain permission modules and the five actions each carries. |
| Object-level access | apps/api/src/modules/people/shared/people-access.service.ts | Scope resolution per actor/entity, the 404-not-403 rule, cache-tag derivation. |
| Account actions | apps/api/src/modules/people/shared/people-account.service.ts | Ban/unban/password-reset-link behavior and every error each can throw. |
| Deletion/restore | apps/api/src/modules/people/shared/people-deletion.service.ts | Profile-scoped soft delete, person-level cascade rule, restore's code/email re-checks. |
| Code allocation | apps/api/src/modules/people/shared/people-code.service.ts | Atomic admission/employee number allocation, timezone-correct year. |
| Person writer | apps/api/src/modules/people/shared/person-writer.service.ts | Shared identity write path, omitted-vs-nulled rule, constraint-to-error-code mapping. |
| Pagination | apps/api/src/common/utils/pagination.util.ts | Default/max size, offset math, why the DTO's own @Max(100) is what actually rejects an oversized page. |
| Search | apps/api/src/modules/people/shared/trigram-search.ts, packages/db/src/search/search.constants.ts | Trigram threshold (0.3), why it must be pinned per-transaction, the ILIKE+trigram OR. |
| Authorization internals | apps/api/src/common/authorization/actor-authority.service.ts | The 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 wiring | apps/api/src/modules/people/people.module.ts, guardians/guardians.module.ts | Why GuardiansModule is nested, what each shared service's importing module supplies it. |
| Tests | students/__tests__/students.service.integration.spec.ts, guardians/guardians.service.spec.ts, staff/staff.service.spec.ts | Confirmed edge-case behavior, cited per section below. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | PeopleModule (students, staff), GuardiansModule (nested inside it) |
| Module slug | people |
| Primary actors | Superadmin (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 surfaces | Admin 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 model | JwtAuthGuard + RoleGuard, class-level on all three controllers, plus a second, object-level access check (PeopleAccessService) inside every handler that names an :id |
| Persistence | PostgreSQL (students, guardians, student_guardian, staff, users, code_counters); Redis (list-result caching only — see 11) |
| Runtime source of truth | students/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 docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Person | The 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-296 | person on every response DTO; person/PersonInputDto on every write DTO. |
| Profile | The 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.ts | Every route in this module operates on exactly one profile kind. |
| Admission number | A 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-127 | StudentDto.admissionNumber; the students_admission_number_unique partial index. |
| Employee code | A 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-322 | StaffDto.employeeCode. |
version | An 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 0012 | StudentDto/UpdateStudentDto, StaffDto/UpdateStaffDto, StaffSalaryDto/PatchStaffSalaryDto, GuardianDto/UpdateGuardianDto — all .version. |
isRecordComplete | Computed 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,585 | StudentDto.isRecordComplete. |
| Primary guardian | The 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 kind | person 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-554 | ListStaffQueryDto.designationKind; the teacher role grant on POST /staff. |
| Active role | The 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-148 | Every 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 invitation | The 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 group | A 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.ts | StudentMedicalDto, StaffSalaryDto. |
| Guardian lookup | The 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-146 | POST /students/guardian-lookup. |
| Trigram search | Name 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:18 | The search filter on all three list endpoints. |
record_status | A 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-72 | StudentDto.recordStatus; ListStudentsQueryDto.recordStatus. |
| Ethnicity / mother tongue | Nepal'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
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/students | Admin/staff/guardian/student (scoped) | JwtAuthGuard, RoleGuard | Students_READ | StudentsController | List/search students, paginated, scope-restricted. |
| Admin | POST | /api/students/guardian-lookup | Admin | JwtAuthGuard, RoleGuard | Guardians_READ | StudentsController | Find existing guardians by exact phone match. |
| Admin | POST | /api/students | Admin | JwtAuthGuard, RoleGuard | Students_CREATE | StudentsController | Admit a student, optionally linking or creating guardians. |
| Admin | GET | /api/students/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Students_READ | StudentsController | Get one student. |
| Admin | PATCH | /api/students/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Students_UPDATE | StudentsController | Update a student's identity or record fields. |
| Admin | DELETE | /api/students/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Students_DELETE | StudentsController | Soft-delete a student. |
| Admin | POST | /api/students/:id/restore | Admin/scoped | JwtAuthGuard, RoleGuard | Students_UPDATE | StudentsController | Restore a soft-deleted student. |
| Admin | POST | /api/students/:id/ban | Admin/scoped | JwtAuthGuard, RoleGuard | Students_UPDATE | StudentsController | Suspend the student's sign-in. |
| Admin | POST | /api/students/:id/unban | Admin/scoped | JwtAuthGuard, RoleGuard | Students_UPDATE | StudentsController | Lift a suspension. |
| Admin | POST | /api/students/:id/password-reset | Admin/scoped | JwtAuthGuard, RoleGuard | Students_UPDATE | StudentsController | Email a password-reset link. |
| Admin | POST | /api/students/:id/sign-in | Admin/scoped | JwtAuthGuard, RoleGuard | Users_UPDATE | StudentsController | Grant sign-in access and, by default, send the invitation. |
| Admin | DELETE | /api/students/:id/sign-in | Admin/scoped | JwtAuthGuard, RoleGuard | Users_UPDATE | StudentsController | Revoke sign-in access and delete every live session. |
| Admin | GET | /api/students/:id/guardians | Admin/scoped | JwtAuthGuard, RoleGuard | Students_READ and Guardians_READ | StudentsController | List a student's guardian links. |
| Admin | PUT | /api/students/:id/guardians | Admin/scoped | JwtAuthGuard, RoleGuard | Students_UPDATE and Guardians_UPDATE | StudentsController | Replace a student's whole guardian set. |
| Admin | GET | /api/students/:id/medical | Admin/scoped | JwtAuthGuard, RoleGuard | StudentMedical_READ | StudentsController | Get a student's health record. |
| Admin | PATCH | /api/students/:id/medical | Admin/scoped | JwtAuthGuard, RoleGuard | StudentMedical_UPDATE | StudentsController | Update a student's health record. |
| Admin | GET | /api/guardians | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_READ | GuardiansController | List/search guardians, paginated (cannot be turned off). |
| Admin | GET | /api/guardians/:id/students | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_READ | GuardiansController | List a guardian's linked students. |
| Admin | GET | /api/guardians/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_READ | GuardiansController | Get a guardian. |
| Admin | POST | /api/guardians | Admin | JwtAuthGuard, RoleGuard | Guardians_CREATE | GuardiansController | Create a guardian and grant the guardian role. |
| Admin | PATCH | /api/guardians/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_UPDATE | GuardiansController | Update a guardian. No version/staleness check. |
| Admin | DELETE | /api/guardians/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_DELETE | GuardiansController | Soft-delete a guardian; refused while linked to a live student. |
| Admin | POST | /api/guardians/:id/ban | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_UPDATE | GuardiansController | Suspend the guardian's sign-in. |
| Admin | POST | /api/guardians/:id/unban | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_UPDATE | GuardiansController | Lift a suspension. |
| Admin | POST | /api/guardians/:id/password-reset | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_UPDATE | GuardiansController | Email a password-reset link. |
| Admin | POST | /api/guardians/:id/sign-in | Admin/scoped | JwtAuthGuard, RoleGuard | Users_UPDATE | GuardiansController | Grant sign-in access and, by default, send the invitation. |
| Admin | DELETE | /api/guardians/:id/sign-in | Admin/scoped | JwtAuthGuard, RoleGuard | Users_UPDATE | GuardiansController | Revoke sign-in access and delete every live session. |
| Admin | POST | /api/guardians/:id/restore | Admin/scoped | JwtAuthGuard, RoleGuard | Guardians_RESTORE | GuardiansController | Restore a soft-deleted guardian. |
| Admin | GET | /api/staff | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_READ | StaffController | List/search staff, paginated (cannot be turned off). |
| Admin | POST | /api/staff | Admin | JwtAuthGuard, RoleGuard | Staff_CREATE | StaffController | Admit a staff member, granting staff (and teacher if applicable). |
| Admin | GET | /api/staff/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_READ | StaffController | Get one staff member. |
| Admin | PATCH | /api/staff/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_UPDATE | StaffController | Update a staff member. No version/staleness check. |
| Admin | DELETE | /api/staff/:id | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_DELETE | StaffController | Soft-delete a staff member. |
| Admin | POST | /api/staff/:id/restore | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_RESTORE | StaffController | Restore a soft-deleted staff member. |
| Admin | POST | /api/staff/:id/ban | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_UPDATE | StaffController | Suspend the staff member's sign-in. |
| Admin | POST | /api/staff/:id/unban | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_UPDATE | StaffController | Lift a suspension. |
| Admin | POST | /api/staff/:id/password-reset | Admin/scoped | JwtAuthGuard, RoleGuard | Staff_UPDATE | StaffController | Email a password-reset link. |
| Admin | POST | /api/staff/:id/sign-in | Admin/scoped | JwtAuthGuard, RoleGuard | Users_UPDATE | StaffController | Grant sign-in access and, by default, send the invitation. |
| Admin | DELETE | /api/staff/:id/sign-in | Admin/scoped | JwtAuthGuard, RoleGuard | Users_UPDATE | StaffController | Revoke sign-in access and delete every live session. |
| Admin | GET | /api/staff/:id/salary | Admin/scoped | JwtAuthGuard, RoleGuard | StaffSalary_READ | StaffController | Get a staff member's salary and bank details. |
| Admin | PATCH | /api/staff/:id/salary | Admin/scoped | JwtAuthGuard, RoleGuard | StaffSalary_UPDATE | StaffController | Update 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
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| All 41 routes | @UseGuards(JwtAuthGuard, RoleGuard) at the controller class level | req.user populated by the JWT strategy; activeRole resolved from it | One of the codes in 4 | No | No 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 withis_superadmin = truebypasses the permission list entirely, keyed on the boolean flag rather than the role'sname(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 refused403 PERMISSION_ROLE_NOT_ASSIGNEDor403 AUTH_ACTIVE_ROLE_REQUIREDrespectively, 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 namedguardianorstudentis scoped to that role's restriction (guardiansees their own children and those children's co-guardians;studentsees their own record and their own guardians) before the module permission is ever consulted — holdingStudents_READ/Guardians_READon top of aguardian/studentrole does not widen it toall. Only for a role named anything else does holding the module permission grantall; 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-reportcount.
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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | string (UUID) | Yes | Server-generated | N/A — the users row's own id | "01922e2a-6b1e-7c3a-9d2e-1a2b3c4d5e6f" | person.dto.ts:49 |
firstName | string | Yes | — | — | "Sita" | person.dto.ts:50 |
middleName | string | null | Yes (nullable) | null | — | null | person.dto.ts:51 |
lastName | string | null | Yes (nullable) | null | — | "Rai" | person.dto.ts:52 |
fullName | string | null | Yes | Database-generated from the three name parts | Never assembled in application code — cannot disagree with them | "Sita Rai" | person.dto.ts:53-57 |
email | string | null | Yes (nullable) | null | — | null | person.dto.ts:58 |
emailVerified | boolean | Yes | false | — | false | person.dto.ts:59 |
phone | string | null | Yes (nullable) | null | — | "+977-9841002233" | person.dto.ts:60 |
phoneVerified | boolean | Yes | false | — | false | person.dto.ts:61 |
image | string | null | Yes (nullable) | null | — | null | person.dto.ts:62 |
canLogin | boolean | Yes | false unless sign-in was explicitly granted on create | Whether 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 them | false | person.dto.ts:88-92 |
mustChangePassword | boolean | Yes | — | — | false | person.dto.ts:68 |
dateOfBirth | string | null (ISO date) | Yes (nullable) | null | — | "2012-03-04" | person.dto.ts:69 |
gender | string | null, enum GENDERS | Yes (nullable) | null | — | "female" | person.dto.ts:70-71 |
bloodGroup | string | null, enum BLOOD_GROUPS | Yes (nullable) | null | — | "O+" | person.dto.ts:72-73 |
disabilityType | string | null, enum DISABILITY_TYPES | Yes (nullable) | null | — | "none" | person.dto.ts:74-75 |
maritalStatus | string | null, enum MARITAL_STATUSES | Yes (nullable) | null | — | null | person.dto.ts:76-77 |
ethnicityId | number | null | Yes (nullable) | null | The caste/ethnic group, as an id from GET /api/lookups/ethnicities | 4 | person.dto.ts:101-106 |
ethnicityName | string | null | Yes (nullable) | null | Read-only — resolved server-side from ethnicityId via a correlated subquery, never accepted on write | "Newar" | person.dto.ts:107-109 |
motherTongueId | number | null | Yes (nullable) | null | The language spoken at home, as an id from GET /api/lookups/mother-tongues | 2 | person.dto.ts:110-115 |
motherTongueName | string | null | Yes (nullable) | null | Read-only, resolved the same way as ethnicityName | "Nepal Bhasa" | person.dto.ts:116-117 |
permanentAddress | AddressDto | Yes | All fields null | The pupil's permanent Nepali address — province, district, municipality, ward, tole, house number, plus each id's resolved name | See §6.1a | address.dto.ts |
currentAddress | AddressDto | Yes | All fields null | The present address, independent of the permanent one. All-null means not recorded — there is no "same as permanent" flag | See §6.1a | address.dto.ts |
bio | string | null | Yes (nullable) | null | — | null | person.dto.ts:83 |
banned | boolean | Yes | false | — | false | person.dto.ts:84 |
banReason | string | null | Yes (nullable) | null | — | null | person.dto.ts:85 |
createdAt | Date | Yes | Server-generated | — | "2026-04-15T04:15:00.000Z" | person.dto.ts:86 |
updatedAt | Date | Yes | Server-generated | — | "2026-04-15T04:15:00.000Z" | person.dto.ts:87 |
deletedAt | Date | null | Yes (nullable) | null | — | null | person.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 name — ethnicityName/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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
firstName | string | Yes | — | @IsString, @MinLength(1), @MaxLength(120) | "Sita" | person.dto.ts:93-97 |
middleName | string | No | — | @IsOptional, @IsString, @MaxLength(120) | — | person.dto.ts:99-103 |
lastName | string | No | — | @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 |
email | string | No | — | @IsOptional, @IsEmail | — | person.dto.ts:114-117 |
phone | string | No | — | @IsOptional, @IsString, @MaxLength(40) | "+977-9841002233" | person.dto.ts:119-123 |
dateOfBirth | string (ISO date) | No | — | @IsOptional, @IsDateString | "2012-03-04" | person.dto.ts:125-128 |
gender | enum GENDERS | No | — | @IsOptional, @IsEnum(GENDERS) | "female" | person.dto.ts:130-133 |
bloodGroup | enum BLOOD_GROUPS | No | — | @IsOptional, @IsEnum(BLOOD_GROUPS) | "O+" | person.dto.ts:135-138 |
disabilityType | enum DISABILITY_TYPES | No | — | @IsOptional, @IsEnum(DISABILITY_TYPES) | "none" | person.dto.ts:140-143 |
maritalStatus | enum MARITAL_STATUSES | No | — | @IsOptional, @IsEnum(MARITAL_STATUSES) | — | person.dto.ts:145-148 |
ethnicityId | number | No | — | @IsOptional, @Type(() => Number), @IsInt | — | person.dto.ts:150-154 |
motherTongueId | number | No | — | @IsOptional, @Type(() => Number), @IsInt | — | person.dto.ts:156-160 |
permanentAddress | AddressInputDto | No | Untouched 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 rule | — | address.dto.ts |
currentAddress | AddressInputDto | No | Same as permanentAddress | Same | — | address.dto.ts |
bio | string | No | — | @IsOptional, @IsString, @MaxLength(2000) | — | person.dto.ts:172-173 |
image | string | No | — | @IsOptional, @IsString, @MaxLength(500) | — | person.dto.ts:174-175 |
canLogin | boolean | No | false | The older spelling of grantSignIn. Accepted only on create, where it is gated on Users_UPDATE exactly as grantSignIn is; ignored entirely by PATCH | true | person.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):
| Field | Type | Notes | Source |
|---|---|---|---|
provinceId | number | null | From GET /api/lookups/provinces. | address.dto.ts |
provinceName | string | null | Resolved server-side, read-only. | address.dto.ts |
districtId | number | null | From GET /api/lookups/districts?provinceId=. | address.dto.ts |
districtName | string | null | Resolved, read-only. | address.dto.ts |
municipalityId | number | null | From GET /api/lookups/municipalities?districtId=. | address.dto.ts |
municipalityName | string | null | Resolved, read-only. | address.dto.ts |
municipalityType | string | null | Resolved, read-only — one of MUNICIPALITY_TYPES. | address.dto.ts |
wardNo | number | null | 1-35. | address.dto.ts |
tole | string | null | Street or locality. | address.dto.ts |
houseNo | string | null | address.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):
| Field | Type | Validation | Notes |
|---|---|---|---|
provinceId | number | null | @IsOptional, @Type(() => Number), @IsInt | Required whenever a district is given — a district without its province leaves the composite foreign key unenforced, because it is MATCH SIMPLE. |
districtId | number | null | Same | Must belong to provinceId or the write is refused with ADDRESS_HIERARCHY_INVALID. |
municipalityId | number | null | Same | Must belong to districtId. |
wardNo | number | 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. |
tole | string | null | @IsOptional, @IsString, @MaxLength(200) | |
houseNo | string | 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
reason | string | Yes | — | @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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
sentTo | string | Yes | The person's own email, echoed back | N/A — response-only | "sita.parent@example.com" | account-action.dto.ts:16-21 |
6.4a GrantSignInDto (body — every POST /:id/sign-in)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
invite | boolean | No | true | @IsOptional, @IsBoolean — send the account invitation as well as granting access | false | sign-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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
sent | boolean | Yes | — | Whether an invitation was enqueued | true | sign-in-access.dto.ts:32 |
to | string | No | Absent when nothing was sent | The address it went to | "sita.parent@example.com" | sign-in-access.dto.ts:35 |
reason | "not_requested" | "no_email" | "banned" | "revoked" | No | Present whenever sent is false | Why 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:
reason | Meaning | What to do |
|---|---|---|
not_requested | The caller sent invite: false. | Nothing, unless the operator changes their mind — call the grant route again with invite: true. |
no_email | The 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. |
banned | The account is suspended, so a sign-in reached through the invitation would be refused anyway. | Lift the suspension, then call the grant route again. |
revoked | Sign-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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
canLogin | boolean | Yes | — | The state after the change | true | sign-in-access.dto.ts:50 |
invitation | InvitationOutcomeDto | Yes | — | Always present; sent: false on a revoke, and on a grant that sent nothing | — | sign-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:
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
invitation | InvitationOutcomeDto | null | Yes (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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | string (UUID) | Yes | Server-generated | — | "01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f" | student.dto.ts:49 |
admissionNumber | string | Yes | Auto-allocated unless supplied on create | — | "STU-2026-0041" | student.dto.ts:50 |
studentId | string | Yes | Always system-allocated | Never accepted on any write — see the comparison table below | "SID-2026-0001" | student.dto.ts |
imeisId | string | null | Yes (nullable) | null | — | null | student.dto.ts:51 |
admissionDate | string (ISO date) | Yes | — | — | "2026-04-15" | student.dto.ts:52 |
recordStatus | enum STUDENT_RECORD_STATUSES | Yes | "active" | — | "active" | student.dto.ts:53-54 |
transportMode | string | null, enum TRANSPORT_MODES | Yes (nullable) | null | — | "school_bus" | student.dto.ts:55-56 |
interestsHobbies | string | null | Yes (nullable) | null | — | null | student.dto.ts:57-58 |
isRecordComplete | boolean | Yes | Computed, never stored | true when at least one guardian link is live end-to-end | true | student.dto.ts:59-63 |
guardianCount | number | Yes | Computed, never stored | Count of live guardian links only | 1 | student.dto.ts:64 |
currentClass | StudentClassSummaryDto | null | Yes | Computed, never stored | The 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 states | see below | student.dto.ts — StudentClassSummaryDto |
person | PersonDto | Yes | — | See 6.1 | — | student.dto.ts:65 |
createdAt | Date | Yes | Server-generated | — | — | student.dto.ts:66 |
updatedAt | Date | Yes | Server-generated | — | — | student.dto.ts:67 |
deletedAt | Date | null | Yes (nullable) | null | — | null | student.dto.ts:68 |
version | string | Yes | String(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.
admissionNumber | studentId | |
|---|---|---|
| What it belongs to | The ADMISSION — reissued when a school migrates a roll or re-admits under a new number | The PUPIL — allocated once, permanent |
| Unique index | Partial: students_admission_number_unique on (admission_number) WHERE deleted_at IS NULL | Full: 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 else | Never — the full index guards every row, including soft-deleted ones |
Allocation scope (code_counters) | student, prefix STU | student_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 roll | Never. 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. |
| Source | packages/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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
guardianId | string (UUID) | Yes | — | — | "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f" | student.dto.ts:34 |
fullName | string | null | Yes | — | Denormalized from the guardian's users row | "Ram Bahadur Rai" | student.dto.ts:35 |
phone | string | null | Yes (nullable) | null | — | "+977-9841002233" | student.dto.ts:36 |
relationship | enum GUARDIAN_RELATIONSHIPS | Yes | — | The 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 |
kind | enum GUARDIAN_KINDS | Yes | — | Orthogonal to the slot: a father, a mother, OR a local guardian may be an organisation | "person" | student.dto.ts:50-54 |
organizationName | string | null | Yes (nullable) | null | Set only for an organisation guardian, derived server-side from person.firstName — never entered separately | null | student.dto.ts:56-57 |
isPrimary | boolean | Yes | false | At most one true per student, DB-enforced | true | student.dto.ts:58 |
isLegalGuardian | boolean | Yes | false | — | false | student.dto.ts:42 |
isEmergencyContact | boolean | Yes | false | — | false | student.dto.ts:43 |
canPickup | boolean | Yes | false | — | true | student.dto.ts:44 |
livesWith | boolean | Yes | false | — | true | student.dto.ts:45 |
6.7 StudentMedicalDto (response and, structurally, the update shape — behind StudentMedical_READ/_UPDATE, never part of StudentDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
medicalConditions | string | null | Yes (nullable) | null | — | "Asthma" | student.dto.ts:78-79 |
allergies | string | null | Yes (nullable) | null | — | "Peanuts" | student.dto.ts:80 |
specialNeeds | string | null | Yes (nullable) | null | — | null | student.dto.ts:81 |
6.8 UpsertStudentGuardianDto (body — one entry inside CreateStudentDto.guardians and SetStudentGuardiansDto.guardians)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
guardianId | string (UUID) | No | — | @IsOptional, @IsUUID — link an EXISTING guardian; omit to create one from person | "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f" | student.dto.ts:89-91 |
person | PersonInputDto | No (required if guardianId is absent) | — | @IsOptional, @ValidateNested, @Type(() => PersonInputDto) | — | student.dto.ts:93-97 |
relationship | enum GUARDIAN_RELATIONSHIPS | Yes | — | @IsEnum — never 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 |
kind | enum GUARDIAN_KINDS | No | "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 |
isPrimary | boolean | No | false | @IsOptional, @IsBoolean | true | student.dto.ts:163-166 |
isLegalGuardian | boolean | No | false | @IsOptional, @IsBoolean | false | student.dto.ts:115-116 |
isEmergencyContact | boolean | No | false | @IsOptional, @IsBoolean | false | student.dto.ts:117-118 |
canPickup | boolean | No | false | @IsOptional, @IsBoolean | true | student.dto.ts:119-120 |
livesWith | boolean | No | false | @IsOptional, @IsBoolean | true | student.dto.ts:121-122 |
grantSignIn | boolean | No | false | @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 guardianId | true | student.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
person | PersonInputDto | Yes | — | @ValidateNested, @Type(() => PersonInputDto) | — | student.dto.ts:126-129 |
grantSignIn | boolean | No | false | @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 identically | true | student.dto.ts:253-256 |
admissionNumber | string | No | Auto-allocated (STU-<year>-<seq>) | @IsOptional, @IsString, @MaxLength(64) — supply one only when importing an existing roll | — | student.dto.ts:131-138 |
imeisId | string | No | — | @IsOptional, @IsString, @MaxLength(64) | — | student.dto.ts:140-141 |
admissionDate | string (ISO date) | Yes | — | @IsDateString | "2026-04-15" | student.dto.ts:143-145 |
recordStatus | enum STUDENT_RECORD_STATUSES | No | "active" | @IsOptional, @IsEnum | — | student.dto.ts:147-150 |
transportMode | enum TRANSPORT_MODES | No | null | @IsOptional, @IsEnum | "school_bus" | student.dto.ts:152-155 |
interestsHobbies | string | No | — | @IsOptional, @IsString, @MaxLength(2000) | — | student.dto.ts:157-158 |
medicalConditions | string | No | — | @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 |
allergies | string | No | — | @IsOptional, @IsString, @MaxLength(2000) | "Peanuts" | student.dto.ts:162-163 |
specialNeeds | string | No | — | @IsOptional, @IsString, @MaxLength(2000) | — | student.dto.ts:164-165 |
guardians | UpsertStudentGuardianDto[] | 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 isPrimary | — | student.dto.ts:259-268 |
6.10 UpdateStudentDto (body — PATCH /students/:id)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
version | string | Yes | — | @IsString — the version from the record you loaded; a mismatch is 409 PEOPLE_STALE_RECORD | "1776123300000" | student.dto.ts:180-182 |
person | PersonInputDto | No | Unchanged if omitted | @IsOptional, @ValidateNested, @Type(() => PersonInputDto) | — | student.dto.ts:184-188 |
imeisId | string | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(64) | — | student.dto.ts:190-191 |
admissionDate | string (ISO date) | No | Unchanged if omitted | @IsOptional, @IsDateString | — | student.dto.ts:192-193 |
recordStatus | enum STUDENT_RECORD_STATUSES | No | Unchanged if omitted | @IsOptional, @IsEnum | "inactive" | student.dto.ts:194-196 |
transportMode | enum TRANSPORT_MODES | No | Unchanged if omitted; explicit null clears it | @IsOptional, @IsEnum | — | student.dto.ts:197-199 |
interestsHobbies | string | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(2000) | — | student.dto.ts:200-201 |
classId | string (uuid) | No | Omitted leaves the class unchanged | @IsOptional, @IsUUID — a class publicId. Not nullable: no value clears a class | — | student.dto.ts — UpdateStudentDto |
enrolledOn | string (ISO date) | No | Today in the school's timezone | @IsOptional, @IsDateString. Ignored when classId is absent | "2026-04-15" | student.dto.ts — UpdateStudentDto |
allowOverCapacity | boolean | No | false | @IsOptional, @IsBoolean. Recorded in the activity log as an override | true | student.dto.ts — UpdateStudentDto |
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/enrollmentsdoes not apply this rule: it names its class in the URL, so choosing another year there is deliberate. Without it this endpoint would return200, write a second active enrolment in a sessioncurrentClassdoes not read from, and appear to have done nothing. versionnow covers the class. Every enrolment write touchesstudents.updated_at, so a transfer made from the class roster invalidates an already-open pupil edit form and its save is refused withPEOPLE_STALE_RECORDrather 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
medicalConditions | string | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(2000) | "Asthma, controlled" | student.dto.ts:205-206 |
allergies | string | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(2000) | "Peanuts, shellfish" | student.dto.ts:207-208 |
specialNeeds | string | No | Unchanged 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
guardians | UpsertStudentGuardianDto[] | Yes | — | @IsArray, @ValidateNested({ each: true }), @Type(() => UpsertStudentGuardianDto) — the WHOLE set; an empty array removes every guardian | [] or a list of 6.8 entries | student.dto.ts:214-218 |
6.13 ListStudentsQueryDto (query, extends QueryDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
recordStatus | enum STUDENT_RECORD_STATUSES | No | Unset (no filter) | @IsOptional, @IsEnum | ?recordStatus=active | student.dto.ts:237-239 |
gender | enum GENDERS | No | Unset | @IsOptional, @IsEnum | ?gender=female | student.dto.ts:241-243 |
bloodGroup | enum BLOOD_GROUPS | No | Unset | @IsOptional, @IsEnum | — | student.dto.ts:245-247 |
transportMode | enum TRANSPORT_MODES | No | Unset | @IsOptional, @IsEnum | — | student.dto.ts:249-251 |
hasGuardians | boolean | No | Unset | Query-string boolean transform (QueryBoolean), @IsBoolean — filters on a LIVE guardian link only | ?hasGuardians=false | student.dto.ts:253-255 |
admissionDateFrom | string (ISO date) | No | Unset | @IsOptional, @IsDateString | ?admissionDateFrom=2026-01-01 | student.dto.ts:257-258 |
admissionDateTo | string (ISO date) | No | Unset | @IsOptional, @IsDateString | — | student.dto.ts:259-260 |
recordVisibility | enum RECORD_VISIBILITIES (current | removed | all) | No | "current" (DEFAULT_RECORD_VISIBILITY) | @IsOptional, @IsIn(RECORD_VISIBILITIES) | ?recordVisibility=removed | student.dto.ts |
includeDeleted | boolean | No | Retired, and ignored — replaced by recordVisibility | @IsOptional, QueryBoolean, @IsBoolean (still validated, never read) | ?includeDeleted=true | student.dto.ts:262-263 |
sortBy | enum STUDENT_SORTABLE (admissionNumber, admissionDate, fullName, createdAt, updatedAt) | No | updatedAt | @IsOptional, @IsIn(STUDENT_SORTABLE) — a distinct field from inherited sort; see 10 | ?sortBy=admissionNumber | student.dto.ts:228-234,265-268 |
pagination, page, size, sort, order, search | Inherited from QueryDto | No | See 6.25 | Inherited | — | query.dto.ts |
pagination cannot be turned off here — StudentsService.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | string (UUID) | Yes | Server-generated | — | "01922e2c-2b3c-7c3a-9d2e-1a2b3c4d5e6f" | guardian.dto.ts:45 |
kind | enum GUARDIAN_KINDS | Yes | "person" | — | "person" | guardian.dto.ts:46-47 |
organizationName | string | null | Yes (nullable) | null | Set only when kind is "organization", derived from person.firstName — never entered separately | null | guardian.dto.ts:48-53 |
occupation | string | null | Yes (nullable) | null | — | "Farmer" | guardian.dto.ts:54 |
person | PersonDto | Yes | — | See 6.1 | — | guardian.dto.ts:55 |
childCount | number | Yes | Computed, never stored | Live linked students only — a deleted child does not count | 1 | guardian.dto.ts:56-59 |
createdAt | Date | Yes | Server-generated | — | — | guardian.dto.ts:60 |
updatedAt | Date | Yes | Server-generated | — | — | guardian.dto.ts:61 |
deletedAt | Date | null | Yes (nullable) | null | — | null | guardian.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
studentId | string (UUID) | Yes | — | — | "01922e2b-1a2b-7c3a-9d2e-1a2b3c4d5e6f" | guardian.dto.ts:67 |
admissionNumber | string | Yes | — | — | "STU-2026-0041" | guardian.dto.ts:68 |
fullName | string | null | Yes (nullable) | null | Denormalized from the student's users row | "Sita Rai" | guardian.dto.ts:69 |
relationship | enum GUARDIAN_RELATIONSHIPS | Yes | — | The slot this guardian fills for this student — father, mother, or local_guardian | "father" | guardian.dto.ts:87-88 |
isPrimary | boolean | Yes | false | — | true | guardian.dto.ts:89 |
isLegalGuardian | boolean | Yes | false | — | false | guardian.dto.ts:75 |
isEmergencyContact | boolean | Yes | false | — | false | guardian.dto.ts:76 |
canPickup | boolean | Yes | false | — | true | guardian.dto.ts:77 |
livesWith | boolean | Yes | false | — | true | guardian.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
person | PersonInputDto | Yes | — | @ValidateNested, @Type(() => PersonInputDto) | — | guardian.dto.ts:82-85 |
grantSignIn | boolean | No | false | @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 identically | true | guardian.dto.ts:131-134 |
kind | enum GUARDIAN_KINDS | No | "person" | @IsOptional, @IsEnum — organizationName is derived from person.firstName when this is "organization"; there is no separate organizationName input field | "organization" | guardian.dto.ts:87-95 |
occupation | string | No | — | @IsOptional, @IsString, @MaxLength(120) | "Farmer" | guardian.dto.ts:97-98 |
6.17 UpdateGuardianDto (body — PATCH /guardians/:id)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
person | PersonInputDto | No | Unchanged if omitted | @IsOptional, @ValidateNested, @Type(() => PersonInputDto) | — | guardian.dto.ts:102-106 |
kind | enum GUARDIAN_KINDS | No | Unchanged if omitted | @IsOptional, @IsEnum | — | guardian.dto.ts:108-111 |
occupation | string | No | Unchanged 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
kind | enum GUARDIAN_KINDS | No | Unset | @IsOptional, @IsEnum | ?kind=organization | guardian.dto.ts:123-126 |
phone | string | No | Unset | @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-9841002233 | guardian.dto.ts:128-135 |
hasChildren | boolean | No | Unset | @IsOptional, QueryBoolean, @IsBoolean — live linked student, or none | ?hasChildren=true | guardian.dto.ts:137-143 |
recordVisibility | enum RECORD_VISIBILITIES (current | removed | all) | No | "current" | @IsOptional, @IsIn(RECORD_VISIBILITIES) | ?recordVisibility=all | guardian.dto.ts |
includeDeleted | boolean | No | Retired, and ignored — replaced by recordVisibility | @IsOptional, QueryBoolean, @IsBoolean (still validated, never read) | — | guardian.dto.ts:145-146 |
pagination, page, size, sort, order, search | Inherited from QueryDto | No | See 6.25 | Inherited — sort is validated against GUARDIAN_SORTABLE (fullName, createdAt, updatedAt) in the service, not by a DTO enum | — | query.dto.ts; guardian.dto.ts:42 |
pagination cannot be turned off — GuardiansService.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
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | string (UUID) | Yes | Server-generated | — | "01922e2d-3c4d-7c3a-9d2e-1a2b3c4d5e6f" | staff.dto.ts:50 |
employeeCode | string | Yes | Auto-allocated (EMP-<year>-<seq>) | — | "EMP-2026-0007" | staff.dto.ts:51 |
joiningDate | string (ISO date) | Yes | — | — | "2026-04-15" | staff.dto.ts:52 |
experienceYears | number | null | Yes (nullable) | null | — | 4 | staff.dto.ts:53-54 |
qualification | string | null | Yes (nullable) | null | — | "M.Ed." | staff.dto.ts:55-56 |
department | StaffDepartmentRefDto | null ({ id: number; name: string }) | Yes (nullable) | null | Left-joined; null when the staff row has no department | { "id": 3, "name": "Science" } | staff.dto.ts:38-41,57-58 |
designation | StaffDesignationRefDto | null ({ id: number; name: string; isTeaching: boolean }) | Yes (nullable) | null | Left-joined; null when the staff row has no designation | { "id": 7, "name": "Senior Teacher", "isTeaching": true } | staff.dto.ts:43-47,59-60 |
employmentStatus | enum EMPLOYMENT_STATUSES | Yes | "active" | — | "active" | staff.dto.ts:61-62 |
person | PersonDto | Yes | — | See 6.1 | — | staff.dto.ts:63 |
createdAt | Date | Yes | Server-generated | — | — | staff.dto.ts:64 |
updatedAt | Date | Yes | Server-generated | — | — | staff.dto.ts:65 |
deletedAt | Date | null | Yes (nullable) | null | — | null | staff.dto.ts |
version | string | Yes | Server-generated | Opaque; 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
basicSalary | string | null (decimal) | Yes (nullable) | null | numeric(12,2), >= 0, <= 99999999.99; travels as a STRING, never a number | "45000.00" | staff.dto.ts:79-80 |
allowances | string | null (decimal) | Yes (nullable) | null | numeric(12,2), >= 0, <= 99999999.99; must be set/unset together with basicSalary | "5000.00" | staff.dto.ts:81 |
totalSalary | string | null (decimal) | Yes (nullable) | Database-generated as basic_salary + allowances | Read-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 |
bankName | string | null | Yes (nullable) | null | — | "Nabil Bank" | staff.dto.ts:87-88 |
accountNumber | string | null | Yes (nullable) | null | — | "01234567890" | staff.dto.ts:89 |
branch | string | null | Yes (nullable) | null | — | "New Baneshwor" | staff.dto.ts:90 |
panNumber | string | null | Yes (nullable) | null | — | "301234567" | staff.dto.ts:91-92 |
citizenshipNumber | string | null | Yes (nullable) | null | — | "27-01-70-12345" | staff.dto.ts:93-94 |
ssfNumber | string | null | Yes (nullable) | null | Social 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 |
citNumber | string | null | Yes (nullable) | null | Citizen Investment Trust membership number | "CIT-778812" | staff.dto.ts |
version | string | Yes | Server-generated | Opaque; 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
person | PersonInputDto | Yes | — | @ValidateNested, @Type(() => PersonInputDto) | — | staff.dto.ts:97-100 |
grantSignIn | boolean | No | false | @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 identically | true | staff.dto.ts:245-248 |
joiningDate | string (ISO date) | Yes | — | @IsDateString | "2026-04-15" | staff.dto.ts:102-104 |
experienceYears | number | No | null | @IsOptional, @Type(() => Number), @IsInt, @Min(0) | 4 | staff.dto.ts:106-111 |
qualification | string | No | — | @IsOptional, @IsString, @MaxLength(200) | "M.Ed." | staff.dto.ts:113-117 |
departmentId | number | No | null | @IsOptional, @Type(() => Number), @IsInt — required together with designationId, since a designation always belongs to a department | 3 | staff.dto.ts:119-125 |
designationId | number | No | null | @IsOptional, @Type(() => Number), @IsInt | 7 | staff.dto.ts:127-131 |
employmentStatus | enum EMPLOYMENT_STATUSES | No | "active" | @IsOptional, @IsEnum | — | staff.dto.ts:133-136 |
salary | UpdateStaffSalaryDto | No | Omit 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
version | string | Yes | — | @IsString, @IsNotEmpty — the token from the record you loaded | "1757308800000" | staff.dto.ts (UpdateStaffDto) |
person | PersonInputDto | No | Unchanged if omitted | @IsOptional, @ValidateNested, @Type(() => PersonInputDto) | — | staff.dto.ts |
joiningDate | string (ISO date) | No | Unchanged if omitted | @IsOptional, @IsDateString | — | staff.dto.ts |
experienceYears | number | No | Unchanged if omitted; explicit null clears it | @IsOptional, @Type(() => Number), @IsInt, @Min(0) | — | staff.dto.ts:149-150 |
qualification | string | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(200) | — | staff.dto.ts:152-153 |
departmentId | number | No | Unchanged if omitted; explicit null clears it | @IsOptional, @Type(() => Number), @IsInt | — | staff.dto.ts:155-156 |
designationId | number | No | Unchanged if omitted; explicit null clears it | @IsOptional, @Type(() => Number), @IsInt | — | staff.dto.ts:158-159 |
employmentStatus | enum EMPLOYMENT_STATUSES | No | Unchanged 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 job — employmentStatus 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
basicSalary | string | null | No | Unchanged if omitted; explicit null clears it | @IsOptional, @IsNumberString — must travel with allowances (both null, or both set) | "45000.00" | staff.dto.ts:173-176 |
allowances | string | null | No | Unchanged if omitted; explicit null clears it | @IsOptional, @IsNumberString | "5000.00" | staff.dto.ts:178-181 |
bankName | string | null | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(120) | "Nabil Bank" | staff.dto.ts:183-185 |
accountNumber | string | null | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(60) | — | staff.dto.ts:187-189 |
branch | string | null | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(120) | — | staff.dto.ts:191-193 |
panNumber | string | null | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(40) | — | staff.dto.ts:195-197 |
citizenshipNumber | string | null | No | Unchanged if omitted; explicit null/"" clears it | @IsOptional, @IsString, @MaxLength(40) | — | staff.dto.ts:199-201 |
ssfNumber | string | null | No | Unchanged 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 |
citNumber | string | null | No | Unchanged 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:
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
version | string | Yes | — | @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 0–99999999.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
departmentId | number | No | Unset | @IsOptional, @Type(() => Number), @IsInt | ?departmentId=3 | staff.dto.ts:228-229 |
designationId | number | No | Unset | @IsOptional, @Type(() => Number), @IsInt | ?designationId=7 | staff.dto.ts:231-232 |
designationKind | enum DESIGNATION_KINDS ("teaching", "non_teaching") | No | Unset | @IsOptional, @IsEnum — not a database column; the Teachers screen's own filter, translated to designations.isTeaching = true/false | ?designationKind=teaching | staff.dto.ts:36,234-239 |
employmentStatus | enum EMPLOYMENT_STATUSES | No | Unset | @IsOptional, @IsEnum | ?employmentStatus=active | staff.dto.ts:241-243 |
gender | enum GENDERS | No | Unset | @IsOptional, @IsEnum | — | staff.dto.ts:245-247 |
joiningDateFrom | string (ISO date) | No | Unset | @IsOptional, @IsDateString | — | staff.dto.ts:249-250 |
joiningDateTo | string (ISO date) | No | Unset | @IsOptional, @IsDateString | — | staff.dto.ts:252-253 |
recordVisibility | enum RECORD_VISIBILITIES (current | removed | all) | No | "current" | @IsOptional, @IsIn(RECORD_VISIBILITIES) | ?recordVisibility=removed | staff.dto.ts |
includeDeleted | boolean | No | Retired, and ignored — replaced by recordVisibility | @IsOptional, QueryBoolean, @IsBoolean (still validated, never read) | — | staff.dto.ts:255-256 |
pagination, page, size, sort, order, search | Inherited from QueryDto | No | See 6.25 | Inherited — sort is validated against STAFF_SORTABLE (employeeCode, joiningDate, employmentStatus, experienceYears, fullName, createdAt, updatedAt) in the service | — | query.dto.ts; staff.dto.ts:213-221 |
pagination cannot be turned off — StaffService.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
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
pagination | boolean | No | true | Query-string boolean transform, @IsBoolean | ?pagination=false | query.dto.ts:14-29 |
page | number | No | 1 | @IsInt, @Min(1) | ?page=2 | query.dto.ts:31-36 |
size | number | No | 20 | @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=50 | query.dto.ts:38-44 |
sort | string | No | "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=fullName | query.dto.ts:46-49 |
order | "asc" | "desc" | No | "desc" | @IsEnum(["asc", "desc"]) | ?order=asc | query.dto.ts:51-54 |
search | string | No | — (no filter) | @IsString, @MaxLength(100), trimmed; an all-whitespace value transforms to undefined and drops the filter | ?search=sita | query.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
phone | string | Yes | — | @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
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
GENDERS | male, female, other, prefer_not_to_say | Self-reported gender. | Filter on all three list endpoints; stored on users.gender. | person.dto.ts:15-20 |
BLOOD_GROUPS | A+, 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_STATUSES | single, married, divorced, widowed, separated | Marital status. | Stored on users.marital_status; no list filter uses it. | person.dto.ts:19-21 |
DISABILITY_TYPES | none, visual, hearing, physical, intellectual, learning, speech, multiple, other | Disability classification. | Stored on users.disability_type; no list filter uses it. | person.dto.ts:22-25 |
STUDENT_RECORD_STATUSES | active, inactive | A record-level toggle — not the enrollment lifecycle. | StudentDto.recordStatus; filter on the students list. | student.dto.ts:24 |
TRANSPORT_MODES | none, school_bus, private, walking, public_transport | How the pupil travels to school. | StudentDto.transportMode; filter on the students list. | student.dto.ts:25-27 |
GUARDIAN_RELATIONSHIPS | father, mother, local_guardian | The 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_KINDS | person, organization | What kind of guardian this is, independent of which slot it fills. | GuardianDto.kind, StudentGuardianLinkDto.kind, UpsertStudentGuardianDto.kind. | guardian.dto.ts:24 |
GUARDIAN_KINDS | person, organization | Whether 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_STATUSES | active, on_leave, suspended, resigned, terminated, retired | An 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_KINDS | teaching, non_teaching | Not 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, desc | Sort 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:65-81 |
| DTO | student.dto.ts (ListStudentsQueryDto, StudentDto) |
| Service | students.service.ts (findAll, queryList, orderBy) |
| Schema | packages/db/src/schema/school/people.ts (students) |
| Tests | students.service.integration.spec.ts (search, pagination-determinism, guardian-existence-filter cases) |
Auth and Permissions
- Auth: Required (
JwtAuthGuard). - Guard chain:
JwtAuthGuard→RoleGuard. - Permission:
Students_READ. - Object-level scope:
PeopleAccessService.scopeFor—allfor superadmin; role-name-first below that, so an active role namedguardianorstudentis scoped to that restriction (own children; own record) even if it also holdsStudents_READ; any other role getsallonly by holdingStudents_READ, and otherwise sees only its own profile row. See 5. - Guest support: None.
- Rate limit: None module-specific.
- Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | No | — |
| Query | No | search, 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. |
| Body | No | — |
GET /api/students?search=sita&recordStatus=active&hasGuardians=true&sortBy=admissionNumber&order=asc HTTP/1.1Response
{
"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:
SELECTonstudents INNER JOIN users, plus two correlated subqueries per row —guardianCount, andcurrentClass, which resolves the pupil's active enrolment in the current session down to grade, section and shift; a separateCOUNT(*)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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_ROLE_NOT_ASSIGNED / AUTH_ACTIVE_ROLE_REQUIRED | No active role, or several with none chosen. | Assign/select a role. | role.guard.ts:134-148 |
400 | PAGINATION_LIMIT_INVALID | pagination=false. | Pagination cannot be disabled for the student roll. | students.service.ts:103-116 |
400 | VALIDATION_FAILED | An invalid query value (e.g. page=0, order=up, size=150). | Fix the query string. | Global ValidationPipe |
400 | PEOPLE_INVALID_SORT_FIELD | sortBy 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 toundefinedand is dropped — the unfiltered (but still scoped) list returns, not zero rows. searchcontaining%/_: escaped before entering theILIKEhalf of the match; the trigram half takes the raw term.pagination=falseis refused with400 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.sizeabove100: rejected with400, 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 —idis appended as a deterministic tie-breaker on every ordering. hasGuardians=falsereturns students with zero live guardian links — a student whose only guardian was soft-deleted counts ashasGuardians=falseeven though astudent_guardianrow 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:93-105 |
| DTO | student.dto.ts (GuardianLookupDto) |
| Service | student-guardians.service.ts (lookupGuardiansByPhone) |
| Schema | people.ts (guardians, student_guardian) |
| Tests | students.service.integration.spec.ts ("attaches a sibling to the SAME guardian row…") |
Auth and Permissions
- Auth: Required.
- Guard chain:
JwtAuthGuard→RoleGuard. - 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 a403. - Guest support: None.
- Rate limit: None module-specific.
- Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | No | — |
| Query | No | — |
| Body | Yes | GuardianLookupDto — 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 usersfiltered on an exact phone match, plus a correlated subquery per row forlinkedStudentCount(live students only). - No cache, no writes, no jobs, no external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_INSUFFICIENT | Active role lacks Guardians_READ. | Not authorized. | role.guard.ts |
400 | VALIDATION_FAILED | phone 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
404for "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
phoneno longer silently returns an empty list — it is rejected with400 VALIDATION_FAILEDbefore 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:107-114 |
| DTO | student.dto.ts (CreateStudentDto, UpsertStudentGuardianDto) |
| Service | students.service.ts (create); student-guardians.service.ts (writeGuardianLinks, assertGuardianSetValid, resolveGuardian) |
| Schema | people.ts (students, guardians, student_guardian, students_admission_number_unique, student_single_primary_guardian, relationship_other_required) |
| Tests | students.service.integration.spec.ts (admission, no-guardian admission, sibling attach, primary validation, duplicate guardian, email conflict) |
Auth and Permissions
- Auth: Required.
- Guard chain:
JwtAuthGuard→RoleGuard. - 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
admissionNumbercollides.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | No | — |
| Query | No | — |
| Body | Yes | CreateStudentDto — 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
INSERTintousers; oneINSERTintostudents(allocating an admission number atomically if none was supplied); for each guardian entry, either a lookup-and-reuse or anINSERTintousers+guardians; oneINSERTper guardian intostudent_guardian, all initiallyis_primary = false, followed by a singleUPDATEsetting the primary flag on the one entry markedisPrimary(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 anaccount_inviteverification 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | A required field missing/invalid, or an unknown field present. | Fix the request body. | Global ValidationPipe |
400 | PERSON_SIGN_IN_FLAGS_CONFLICT | grantSignIn and person.canLogin were both sent with different values. | Send one of them, or the same value for both. | people-permissions.service.ts (resolveSignInGrant) |
403 | AUTH_FORBIDDEN | grantSignIn (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 |
400 | STUDENT_GUARDIAN_PRIMARY_REQUIRED | One or more guardians given, none marked isPrimary. | Mark exactly one guardian as primary. | student-guardians.service.ts:323-332 |
400 | STUDENT_GUARDIAN_MULTIPLE_PRIMARY | More than one guardian marked isPrimary. | Mark only one as primary. | student-guardians.service.ts:333-337 |
400 | STUDENT_REQUIRES_ONE_GUARDIAN | The 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 |
400 | GUARDIAN_SLOT_TAKEN | Two 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 |
400 | GUARDIAN_NOT_FOUND | A guardian entry supplies neither guardianId nor person. | Supply one or the other. | student-guardians.service.ts:285-291 |
404 | GUARDIAN_NOT_FOUND | guardianId references no live guardian. | Choose a valid guardian, or omit it to create one. | student-guardians.service.ts:263-282 |
409 | STUDENT_GUARDIAN_ALREADY_LINKED | The same guardianId appears twice in one admission's guardians array. | Remove the duplicate entry. | student-guardians.service.ts:219-228 |
409 | USER_EMAIL_ALREADY_EXISTS | The 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 |
409 | STUDENT_ADMISSION_NUMBER_TAKEN | A 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 |
409 | RESOURCE_ALREADY_EXISTS | A 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/guardianCountstill describe the live-link state after admission (a guardian later soft-deleted can bringguardianCountback to0), 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
admissionNumberexplicitly is for importing an existing roll — a fresh admission should omit it and letPeopleCodeServiceallocate one, timezone-correct against the school's academic year. The same applies tostudentId: omit it to auto-allocate, or supply one (formatSID-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 amax()+1read. - An organisation guardian (
kind: "organization"on the link, orthogonal torelationship) derivesguardians.organizationNamefromperson.firstName— send the organisation's whole name there, with nolastName. Any of the three slots (father,mother,local_guardian) may hold an organisation. - Reusing
guardianIdlinks to that guardian's current data; it does not re-apply anypersonfields also present in the same entry (they are ignored whenguardianIdis set — onlypersonis read when creating a new guardian).grantSignInon 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 spellingperson.canLogin) the pupil gets a record and no login, which is the right outcome for most of a roll. Granting one requiresUsers_UPDATEon top ofStudents_CREATE, because a login-capable row with an email address is a route to a session —POST /api/auth/password/forgotis 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:116-126 |
| DTO | student.dto.ts (StudentDto) |
| Service | students.service.ts (findOne, loadOne) |
| Schema | people.ts (students) |
| Tests | N/A — covered indirectly through create/update round-trips. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Students_READ. Object-level scope:PeopleAccessService.assertCanAccess— out-of-scope answers404, never403. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | Yes | id — UUID, enforced by ParseUUIDPipe. |
| Query | No | — |
| Body | No | — |
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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | id is not a valid UUID. | Fix the id. | ParseUUIDPipe |
404 | STUDENT_NOT_FOUND | No 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_ROLE_NOT_ASSIGNED | As 8.1. | As above. | — |
Edge Cases
- A soft-deleted student's id answers
404here even for a caller with full scope — this endpoint never returns a deleted row; list it via?recordVisibility=removed(orall) instead.?includeDeleted=trueno longer has this effect — see Section 10. - A guardian requesting another family's child gets byte-for-byte the same
404 STUDENT_NOT_FOUNDas 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:128-139 |
| DTO | student.dto.ts (UpdateStudentDto) |
| Service | students.service.ts (update) |
| Schema | people.ts (students) |
| Tests | students.service.integration.spec.ts ("refuses a PATCH carrying a stale version…", "leaves fields the PATCH omitted untouched") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Students_UPDATE. Object-level scope:assertCanAccess— out-of-scope is404. Guest support: none. Rate limit: none module-specific. Idempotency: not naturally idempotent — aversiontoken is required, and a resubmitted identical body after the first succeeds fails the second time with409 PEOPLE_STALE_RECORDbecause the token has already moved.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Query | No | — |
| Body | Yes | UpdateStudentDto — 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 UPDATEto read the currentversioncounter (the version check), so two concurrentPATCHes serialize rather than racing. - Database writes: an
UPDATEonusersonly for the fields thepersonobject actually carried; anUPDATEonstudentsfor the record-level fields present, always touchingupdatedAteven when onlypersonchanged — not because it is the token (it is not;students.versionis), but because it keeps the patch non-empty so the statement, and therefore thebump_row_versiontrigger, 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: anINSERTintostudent_class_enrollments, and anUPDATEclosing the pupil's previous active enrolment astransferred— both in the same transaction as the row above, through the sameenroll()the class roster uses. It also readsclasses,grades,sectionsandacademic_sessionsto resolve and validate the class, and takesFOR UPDATEon 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | Invalid 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 |
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | Refresh the list. | students.service.ts:454,594-599 |
409 | PEOPLE_STALE_RECORD | The 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) |
409 | USER_EMAIL_ALREADY_EXISTS | The new email is already held by a different live person. | Choose a different email. | person-writer.service.ts:139-163 |
404 | CLASS_NOT_FOUND | classId names no class. | Reload the class list. | class-enrollments.service.ts — enroll |
409 | CLASS_INACTIVE | The target class has been retired. | Choose a class that is still running. | class-enrollments.service.ts — enroll |
409 | CLASS_AT_CAPACITY | The 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.ts — enroll |
409 | ENROLLMENT_SESSION_NOT_CURRENT | classId names a class in another academic session. | Enrol from the class screen instead. | class-enrollments.service.ts — enroll |
409 | ENROLLMENT_DATE_OUTSIDE_SESSION | enrolledOn falls outside the class's academic session. | Choose a date inside the year. | class-enrollments.service.ts — enroll |
409 | ENROLLMENT_DATE_BEFORE_ADMISSION | enrolledOn precedes the pupil's admission date. | Choose a later date. | class-enrollments.service.ts — enroll |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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
versionnever 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
versionwith no other field is technically valid and is a no-op except that it still emits anUPDATE, so the trigger still incrementsversion— a client polling for "has anything changed" using this endpoint as a heartbeat would get a false positive. imeisId/transportMode/interestsHobbiesaccept an explicitnull(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 concurrentPATCHon 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 the409.
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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:141-150 |
| DTO | None — no body. |
| Service | students.service.ts (remove); people-deletion.service.ts (softDeleteProfile) |
| Schema | people.ts (students.deleted_at, students_admission_number_unique partial index) |
| Tests | students.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:
JwtAuthGuard→RoleGuard. Permission:Students_DELETE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a secondDELETEon an already-deleted id returns404, 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_atset on the profile row. Only if the underlying person now holds no other live profile (checked by counting livestudents/guardians/staffrows for thatuserId):users.deleted_atis also set, everyaccountcredential 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 theirusersrow and their guardian profile intact. - Releases
admissionNumberfor reuse — the unique index is partial ondeleted_at IS NULL, so a future allocation (or an explicitadmissionNumberon 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | Already removed, never existed, or not visible to you. | people-deletion.service.ts:247-278 |
409 | USER_CANNOT_DELETE_SELF | The target's underlying users.id equals the caller's own id. | You cannot delete your own account. | people-deletion.service.ts:62-67 |
403 | USER_LAST_SUPERADMIN_PROTECTED | Deleting 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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
studentsrow; the person'sguardiansprofile, login, and sessions are untouched. - Deleting the student who is a guardian's only linked child does not delete the guardian —
student_guardianrows are removed by theON DELETE cascadeFK fromstudents, 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:152-162 |
| DTO | None — no body. |
| Service | students.service.ts (restore); people-deletion.service.ts (restoreProfile) |
| Schema | people.ts (students_admission_number_unique); identity.ts (users_email_unique) |
| Tests | students.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:
JwtAuthGuard→RoleGuard. Permission:Students_UPDATE(not a separateStudents_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 —restoreProfilereturns immediately whendeletedAtis alreadynull.
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_atcleared;users.deleted_atcleared (harmless if it was alreadynullbecause another live profile kept the person alive). - Cache: every cached student list invalidated.
- No jobs, realtime events, notifications, or external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STUDENT_NOT_FOUND | No 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 |
409 | STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICT | The 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 |
409 | USER_RESTORE_EMAIL_CONFLICT | The 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 8.1. | As above. | — |
Edge Cases
- Calling this on a student who was never deleted returns
200with 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_guardianlink 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:172-182 |
| DTO | dto/account-action.dto.ts (BanAccountDto) |
| Service | students.service.ts (ban); people-account.service.ts (ban) |
| Schema | identity.ts (users.banned, banReason, bannedAt, bannedBy) |
| Tests | students.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:
JwtAuthGuard→RoleGuard. 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
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Body | Yes | BanAccountDto — 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,bannedByset, 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
JwtStrategyre-checksbannedonly on the access-token path. - Cache: every cached student list invalidated (the response's
person.bannedfield changes). - No jobs, realtime events, or external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | reason missing or over 500 characters. | Give a reason. | Global ValidationPipe |
400 | USER_BAN_REASON_REQUIRED | reason is present but blank after trimming (e.g. " "). | Give a real reason. | people-account.service.ts:70-79 |
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | As above. | people-account.service.ts:215-247 |
409 | USER_CANNOT_DELETE_SELF | The caller is banning their own account. | You cannot suspend yourself. | people-account.service.ts:225-232 (assertNotSelf) — reuses the delete-self error code |
403 | USER_SUPERADMIN_PROTECTED | The 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 |
403 | USER_LAST_SUPERADMIN_PROTECTED | The target is the last sign-in-capable superadmin. | This is the last superadmin account. | actor-authority.service.ts:315-353 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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_SELFis 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:184-193 |
| DTO | None — no body. |
| Service | students.service.ts (unban); people-account.service.ts (unban) |
| Schema | identity.ts (users.banned, banReason, bannedAt, bannedBy) |
| Tests | students.service.integration.spec.ts ("clears the reason when the account is restored") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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 null — not 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | As above. | people-account.service.ts:215-247 |
403 | USER_SUPERADMIN_PROTECTED | The 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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 —
assertMayActOnAccountreads 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:195-209 |
| DTO | dto/account-action.dto.ts (PasswordResetSentDto, response) |
| Service | students.service.ts (sendPasswordResetLink); people-account.service.ts (sendPasswordResetLink) |
| Schema | identity.ts (users.email, canLogin, banned) |
| Tests | Covered 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:
JwtAuthGuard→RoleGuard. 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
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Body | No | — (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,bannedstate. - 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | As above. | people-account.service.ts:152 |
403 | USER_SUPERADMIN_PROTECTED | The 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 |
409 | USER_EMAIL_REQUIRED | The person has no email on file. | Add an email address first. | people-account.service.ts:154-160 |
409 | USER_LOGIN_DISABLED | canLogin is false. | This person cannot sign in; a link would not work. | people-account.service.ts:161-169 |
409 | AUTH_ACCOUNT_BANNED | The account is currently suspended. | Restore the account first. | people-account.service.ts:170-176 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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: falsecannot receive a reset link at all through this endpoint — both refusals would fire,USER_EMAIL_REQUIREDfirst. - 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts (grantSignIn) |
| DTO | sign-in-access.dto.ts (GrantSignInDto → SignInAccessDto) |
| Service | shared/people-account.service.ts (setSignIn) |
| Schema | identity.ts (users.can_login), auth.ts (verification) |
- Auth: JWT.
- Permission:
Users_UPDATE— notStudents_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, becausePOST /api/auth/password/forgotis 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: falseandno_emailend with the operator calling this route again while the state is already correct.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — the pupil's profile id (UUID; ParseUUIDPipe). |
| Body | No | GrantSignInDto — 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.4b
— sent: true means the invitation was enqueued, not that it arrived.
Side Effects
UPDATE users SET can_login = trueguarded 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 a409rather than a silent overwrite of somebody else's decision. It is skipped when the person already has access.- When
inviteis notfalse, the person has an email address, and the account is not suspended: anaccount_inviteverification 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: truefor 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_loginis not part of any cached student projection.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STUDENT_NOT_FOUND | No live pupil with that id, or it is outside the actor's scope. | Not found. | people-account.service.ts (resolveUserId) |
403 | USER_SUPERADMIN_PROTECTED | The target holds the superadmin role and the caller does not. | Ask another superadmin to do this. | actor-authority.service.ts (assertMayActOnAccount) |
409 | PERSON_SIGN_IN_STATE_CHANGED | Somebody 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | No 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: falsepromises "prepare an account and invite later" andno_emailpromises "add an address and call again", and both bring the operator back to this route with the state already correct. Returning early would answer200having sent nothing, indistinguishable from success, with no other route that would ever send that invitation. invite: falseprepares the account silently. Call the route again withinvite: 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/resetmatches OTPs against password resets alone. - A suspended (
banned) person can still be granted sign-in access, but is not invited: the response carriesreason: "banned". The two flags are independent —bannedis a statement about conduct with a recorded reason,can_logina 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_logingoverns 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts (revokeSignIn) |
| DTO | sign-in-access.dto.ts (SignInAccessDto) |
| Service | shared/people-account.service.ts (setSignIn) |
| Schema | identity.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.
JwtStrategyre-readscan_loginon 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_loginis 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
200withcanLogin: 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_loginis 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:211-221 |
| DTO | student.dto.ts (StudentGuardianLinkDto) |
| Service | student-guardians.service.ts (findGuardians, loadGuardians) |
| Schema | people.ts (student_guardian) |
| Tests | Covered via the admission and guardian-management integration tests. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission: bothStudents_READandGuardians_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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STUDENT_NOT_FOUND | No 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | Missing either Students_READ or Guardians_READ. | Not authorized. | role.guard.ts |
Edge Cases
- A guardian who was soft-deleted, or whose own
usersrow was soft-deleted, is silently excluded from this list — the link row still exists instudent_guardian, but is filtered as not-live from both directions. - A student with zero live guardian links returns an empty array, matching
isRecordComplete: falseon 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:223-242 |
| DTO | student.dto.ts (SetStudentGuardiansDto, UpsertStudentGuardianDto) |
| Service | student-guardians.service.ts (setGuardians, writeGuardianLinks) |
| Schema | people.ts (student_single_primary_guardian, relationship_other_required) |
| Tests | students.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:
JwtAuthGuard→RoleGuard. Permission: bothStudents_UPDATEandGuardians_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'supdatedAtbumped).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Body | Yes | SetStudentGuardiansDto — 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_guardianrow for this student is deleted, then every entry in the new set is inserted withis_primary = false, then a single follow-upUPDATEsetsis_primary = trueon the one entry marked primary — the same two-pass writePOST /studentsuses, for the same non-deferrable-index reason. - A new guardian entry (
persongiven, noguardianId) creates a freshusers+guardiansrow exactly as at admission. - Cache: every cached student list invalidated (the
guardianCount/isRecordCompleteprojection changes). - No jobs, realtime events, notifications, or external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | Invalid entry shape, or an unknown field. | Fix the request body. | Global ValidationPipe |
400 | STUDENT_GUARDIAN_PRIMARY_REQUIRED | One or more guardians given, none marked primary. | Mark exactly one as primary. | student-guardians.service.ts:323-332 |
400 | STUDENT_GUARDIAN_MULTIPLE_PRIMARY | More than one marked primary. | Mark only one. | student-guardians.service.ts:333-337 |
400 | STUDENT_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 |
400 | GUARDIAN_SLOT_TAKEN | Two 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 |
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | As above. | student-guardians.service.ts:174,350-355 |
404 | GUARDIAN_NOT_FOUND | A 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 |
409 | STUDENT_GUARDIAN_ALREADY_LINKED | The same guardianId appears twice in the submitted set. | Remove the duplicate entry. | student-guardians.service.ts:219-228 |
409 | USER_EMAIL_ALREADY_EXISTS | A newly-created guardian's email is already held. | Choose a different email. | person-writer.service.ts:139-163 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | Missing either Students_UPDATE or Guardians_UPDATE. | Not authorized. | role.guard.ts |
Edge Cases
- Sending
guardians: []removes every guardian from the student and flipsisRecordCompleteback tofalse— 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/usersrows survive; only thestudent_guardianrow 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:244-254 |
| DTO | student.dto.ts (StudentMedicalDto) |
| Service | student-medical.service.ts (findMedical) |
| Schema | people.ts (students.medical_conditions, allergies, special_needs) |
| Tests | students.service.integration.spec.ts ("keeps health data out of the student response and behind its own endpoint") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:StudentMedical_READ(distinct fromStudents_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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | As above. | student-medical.service.ts:48-62 |
403 | PERMISSION_INSUFFICIENT | Active role lacks StudentMedical_READ, even if it holds plain Students_READ. | Not authorized to view health data. | role.guard.ts |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
Edge Cases
- Holding
Students_READalone — 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 clean403, 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
| Evidence | Path |
|---|---|
| Controller | students.controller.ts:256-267 |
| DTO | student.dto.ts (UpdateStudentMedicalDto) |
| Service | student-medical.service.ts (updateMedical) |
| Schema | people.ts (students.medical_conditions, allergies, special_needs) |
| Tests | students.service.integration.spec.ts (medical update assertions in the same case as 8.13) |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:StudentMedical_UPDATE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — noversionfield, but resubmitting the same body reapplies the same values.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Body | Yes | UpdateStudentMedicalDto, 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | An unknown field, or a value over 2000 characters. | Fix the request body. | Global ValidationPipe |
404 | STUDENT_NOT_FOUND | No live student with this id, or out of scope. | As above. | student-medical.service.ts:93 |
403 | PERMISSION_INSUFFICIENT | Active role lacks StudentMedical_UPDATE. | Not authorized. | role.guard.ts |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
Edge Cases
- No
version/staleness protection exists on this endpoint at all, unlike the main studentPATCH— two concurrent edits to a health record silently last-write-wins. - Clearing
allergiestonullthis 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:49-71 |
| DTO | guardian.dto.ts (ListGuardiansQueryDto, GuardianDto) |
| Service | guardians.service.ts (findAll, queryList, resolveSort) |
| Schema | people.ts (guardians) |
| Tests | guardians.service.spec.ts ("rejects pagination=false", "rejects an unknown sort field") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Guardians_READ. Object-level scope:PeopleAccessService.scopeFor(actor, "guardians", …). Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | No | — |
| Query | No | search, 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. |
| Body | No | — |
GET /api/guardians?search=rai&kind=person&hasChildren=true&sort=fullName&order=asc HTTP/1.1Response
{
"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 forchildCount; a separateCOUNT(*)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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_ROLE_NOT_ASSIGNED / AUTH_ACTIVE_ROLE_REQUIRED | No active role, or unresolved. | Assign/select a role. | role.guard.ts |
400 | PAGINATION_LIMIT_INVALID | pagination=false. | pagination cannot be turned off for this endpoint. | guardians.service.ts:136-141 |
400 | VALIDATION_FAILED | An invalid enum, or size over 100. | Fix the query string. | Global ValidationPipe |
400 | PEOPLE_INVALID_SORT_FIELD | sort is not fullName/createdAt/updatedAt. | Choose a supported sort field. | guardians.service.ts:277-284 |
Edge Cases
pagination=falseis always refused, even for a superadmin — the guard is unconditional inGuardiansService.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.phoneis an exact match, not a partial search —?phone=98will not find+977-9841002233; usesearchfor partial name matching orPOST /students/guardian-lookupfor the household-lookup use case.hasChildren=falsereturns guardians with zero live linked students — a guardian whose only child was soft-deleted counts ashasChildren=false.- An organisation guardian's
searchmatch comes from either its trigram/ILIKEmatch onperson.firstName(which holds the org's whole name) or a literalILIKEmatch onguardians.organization_namedirectly — 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:73-86 |
| DTO | guardian.dto.ts (GuardianChildDto) |
| Service | guardians.service.ts (findChildren, loadChildren) |
| Schema | people.ts (student_guardian) |
| Tests | N/A — exercised indirectly via the sibling/admission integration tests. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Guardians_READ. Object-level scope:assertCanAccess(actor, "guardians", id, …). Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | Yes | id — no ParseUUIDPipe; see 5's malformed-id caveat. |
| Query | No | — |
| Body | No | — |
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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | GUARDIAN_NOT_FOUND | No live guardian with this id, or out of scope. | As above. | people-access.service.ts:222-236 |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled — validate the id shape client-side. | Postgres 22P02, unmapped by AllExceptionsFilter |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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 /guardianscreates 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:88-97 |
| DTO | guardian.dto.ts (GuardianDto) |
| Service | guardians.service.ts (findOne, loadOne) |
| Schema | people.ts (guardians) |
| Tests | guardians.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:
JwtAuthGuard→RoleGuard. Permission:Guardians_READ. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | Yes | id — no ParseUUIDPipe. |
| Query | No | — |
| Body | No | — |
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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | GUARDIAN_NOT_FOUND | No 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 |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled — see 5. | Postgres 22P02 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 8.15. | As above. | — |
Edge Cases
- A soft-deleted guardian's id answers
404here 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
404a 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:99-112 |
| DTO | guardian.dto.ts (CreateGuardianDto) |
| Service | guardians.service.ts (create, grantGuardianRole) |
| Schema | people.ts (guardian_org_has_name); identity.ts (role, user_role) |
| Tests | guardians.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:
JwtAuthGuard→RoleGuard. 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
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Body | Yes | CreateGuardianDto — 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
INSERTintousers; oneINSERTintoguardians; a lookup of the seededguardianrole by name, then oneINSERTintouser_rolegranting it. - When
grantSignIn(orperson.canLogin) istrueand the guardian has an email address, the same transaction also writes anaccount_inviteverification 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | A required field missing/invalid, or an unknown field. | Fix the request body. | Global ValidationPipe |
400 | PERSON_SIGN_IN_FLAGS_CONFLICT | grantSignIn and person.canLogin were both sent with different values. | Send one of them, or the same value for both. | people-permissions.service.ts (resolveSignInGrant) |
403 | AUTH_FORBIDDEN | grantSignIn (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) |
409 | GUARDIAN_ORGANIZATION_NAME_REQUIRED | kind: "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) |
409 | USER_EMAIL_ALREADY_EXISTS | The email is already held by a live person. | Choose a different email. | person-writer.service.ts:139-163,177-182 |
500 | ROLE_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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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/guardiansrows are written, nothing commits — there is no partially-created guardian left behind. occupationacceptsnullexplicitly 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 byPersonInputDtoeven 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
guardianrole grant and sign-in access are separate things: the role says what this person may do once signed in,can_loginsays whether they may sign in at all. A guardian created withoutgrantSignInholds 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:114-127 |
| DTO | guardian.dto.ts (UpdateGuardianDto) |
| Service | guardians.service.ts (update) |
| Schema | people.ts (guardian_org_has_name) |
| Tests | Covered indirectly; the organisation-name derivation is exercised by the create-path tests and mirrored in update. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Guardians_UPDATE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: guarded by the requiredversiontoken — a repeat of the same body with the same token is refused with409 PEOPLE_STALE_RECORD, because the first one advanced the counter.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — no ParseUUIDPipe. |
| Body | Yes | UpdateGuardianDto, 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | Invalid field value, or an unknown field. | Fix the request body. | Global ValidationPipe |
404 | GUARDIAN_NOT_FOUND | No live guardian with this id, or out of scope. | As above. | guardians.service.ts:477 |
409 | GUARDIAN_ORGANIZATION_NAME_REQUIRED | The resulting kind/firstName pair would leave an organisation with a blank name. | Give the organisation a real name. | person-writer.service.ts:203-207 |
409 | USER_EMAIL_ALREADY_EXISTS | The new email is already held by a different live person. | Choose a different email. | person-writer.service.ts:139-163 |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled — see 5. | Postgres 22P02 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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 withPATCH /students/:id's mandatoryversion. - Switching
kindfrom"person"to"organization"without also sending aperson.firstNamereuses the row's currentfirstNameas 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
kindfrom"organization"back to"person"clearsorganizationNametonullon 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:129-139 |
| DTO | None — no body. |
| Service | guardians.service.ts (remove); people-deletion.service.ts (softDeleteProfile) |
| Schema | people.ts (student_guardian, guardians.deleted_at) |
| Tests | guardians.service.spec.ts ("refuses to delete a guardian with a live linked student") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Guardians_DELETE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a secondDELETEon an already-deleted id returns404.
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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | GUARDIAN_NOT_FOUND | No live guardian with this id, or out of scope. | As above. | people-deletion.service.ts:247-278 |
409 | GUARDIAN_HAS_LINKED_STUDENTS | At least one live student is still linked to this guardian. | Unlink every student first (PUT /students/:id/guardians). | people-deletion.service.ts:72-91 |
409 | USER_CANNOT_DELETE_SELF | The target's users.id equals the caller's own id. | You cannot delete your own account. | people-deletion.service.ts:62-67 |
403 | USER_LAST_SUPERADMIN_PROTECTED | Deleting 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 |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled — see 5. | Postgres 22P02 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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_guardianrow, at that point pointing at a non-live student, does not block it. - A guardian is checked for links from their side of
student_guardianonly — this is symmetric with, but a separate query from, the analogous checkPeopleDeletionServicedoes 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:141-156 |
| DTO | dto/account-action.dto.ts (BanAccountDto) |
| Service | guardians.service.ts (ban); people-account.service.ts (ban) |
| Schema | identity.ts (users.banned, banReason, bannedAt, bannedBy) |
| Tests | Same shared-service behavior as students.service.integration.spec.ts's ban cases. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Guardians_UPDATE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — no ParseUUIDPipe. |
| Body | Yes | BanAccountDto. |
{ "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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | reason missing or over 500 characters. | Give a reason. | Global ValidationPipe |
400 | USER_BAN_REASON_REQUIRED | reason blank after trimming. | Give a real reason. | people-account.service.ts:70-79 |
404 | GUARDIAN_NOT_FOUND | No live guardian with this id, or out of scope. | As above. | people-account.service.ts:215-247 |
409 | USER_CANNOT_DELETE_SELF | The caller is banning their own account. | You cannot suspend yourself. | people-account.service.ts:225-232 (assertNotSelf) |
403 | USER_SUPERADMIN_PROTECTED | The 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 |
403 | USER_LAST_SUPERADMIN_PROTECTED | The target is the last sign-in-capable superadmin. | This is the last superadmin account. | actor-authority.service.ts:315-353 |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled. | Postgres 22P02 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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.banis 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/guardianCounton 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:158-164 |
| DTO | None — no body. |
| Service | guardians.service.ts (unban); people-account.service.ts (unban) |
| Schema | identity.ts (users.banned, banReason, bannedAt, bannedBy) |
| Tests | Same shared-service behavior as the student/staff unban cases. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | GUARDIAN_NOT_FOUND | No live guardian with this id, or out of scope. | As above. | people-account.service.ts:215-247 |
403 | USER_SUPERADMIN_PROTECTED | The 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 |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled. | Postgres 22P02 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:166-184 |
| DTO | dto/account-action.dto.ts (PasswordResetSentDto, response) |
| Service | guardians.service.ts (sendPasswordResetLink); people-account.service.ts (sendPasswordResetLink) |
| Schema | identity.ts (users.email, canLogin, banned) |
| Tests | Same shared-service behavior as staff.service.spec.ts's reset-link cases. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | GUARDIAN_NOT_FOUND | No live guardian with this id, or out of scope. | As above. | people-account.service.ts:152 |
403 | USER_SUPERADMIN_PROTECTED | The 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 |
409 | USER_EMAIL_REQUIRED | No email on file. | Add an email first. | people-account.service.ts:154-160 |
409 | USER_LOGIN_DISABLED | canLogin is false. | This person cannot sign in. | people-account.service.ts:161-169 |
409 | AUTH_ACCOUNT_BANNED | The account is currently suspended. | Restore the account first. | people-account.service.ts:170-176 |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled. | Postgres 22P02 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts (grantSignIn) |
| DTO | sign-in-access.dto.ts (GrantSignInDto → SignInAccessDto) |
| Service | shared/people-account.service.ts (setSignIn) |
| Schema | identity.ts (users.can_login), auth.ts (verification) |
- Auth: JWT.
- Permission:
Users_UPDATE— notGuardians_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, becausePOST /api/auth/password/forgotis 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: falseandno_emailend with the operator calling this route again while the state is already correct.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — the guardian's profile id (UUID; ParseUUIDPipe). |
| Body | No | GrantSignInDto — 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.4b
— sent: true means the invitation was enqueued, not that it arrived.
Side Effects
UPDATE users SET can_login = trueguarded 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 a409rather than a silent overwrite of somebody else's decision. It is skipped when the person already has access.- When
inviteis notfalse, the person has an email address, and the account is not suspended: anaccount_inviteverification 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: truefor 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_loginis not part of any cached guardian projection.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | GUARDIAN_NOT_FOUND | No live guardian with that id, or it is outside the actor's scope. | Not found. | people-account.service.ts (resolveUserId) |
403 | USER_SUPERADMIN_PROTECTED | The target holds the superadmin role and the caller does not. | Ask another superadmin to do this. | actor-authority.service.ts (assertMayActOnAccount) |
409 | PERSON_SIGN_IN_STATE_CHANGED | Somebody 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | No 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: falsepromises "prepare an account and invite later" andno_emailpromises "add an address and call again", and both bring the operator back to this route with the state already correct. Returning early would answer200having sent nothing, indistinguishable from success, with no other route that would ever send that invitation. invite: falseprepares the account silently. Call the route again withinvite: 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/resetmatches OTPs against password resets alone. - A suspended (
banned) person can still be granted sign-in access, but is not invited: the response carriesreason: "banned". The two flags are independent —bannedis a statement about conduct with a recorded reason,can_logina 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_logingoverns 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts (revokeSignIn) |
| DTO | sign-in-access.dto.ts (SignInAccessDto) |
| Service | shared/people-account.service.ts (setSignIn) |
| Schema | identity.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.
JwtStrategyre-readscan_loginon 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_loginis 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
200withcanLogin: 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_loginis 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
| Evidence | Path |
|---|---|
| Controller | guardians.controller.ts:186-195 |
| DTO | None — no body. |
| Service | guardians.service.ts (restore); people-deletion.service.ts (restoreProfile) |
| Schema | identity.ts (users_email_unique) |
| Tests | Shared 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:
JwtAuthGuard→RoleGuard. Permission:Guardians_RESTORE— the only one of the three profile kinds' restore routes that does not reuse its plain_UPDATEpermission; students restore underStudents_UPDATEand staff restore underStaff_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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | GUARDIAN_NOT_FOUND | No 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 |
409 | USER_RESTORE_EMAIL_CONFLICT | The 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 |
403 | PERMISSION_INSUFFICIENT | Active role holds Guardians_UPDATE but not Guardians_RESTORE. | Not authorized to restore — a distinct grant from ordinary update. | role.guard.ts |
500 | SYS_INTERNAL_ERROR | id is not a syntactically valid UUID. | Unhandled. | Postgres 22P02 |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
Edge Cases
- Calling this on a guardian who was never deleted returns
200with 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_guardianrow was removed by a cascading delete when the guardian'susersrow was hard-deleted in some other flow — there is no such flow in this module (deletion here is always soft), but a consumer relying onrestoreProfileto 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:61-77 |
| DTO | staff.dto.ts (ListStaffQueryDto, StaffDto) |
| Service | staff.service.ts (findAll, queryList, orderBy) |
| Schema | people.ts (staff), lookups.ts (departments, designations) |
| Tests | staff.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:
JwtAuthGuard→RoleGuard. Permission:Staff_READ. Object-level scope:PeopleAccessService.scopeFor(actor, "staff", …)— a non-privileged caller (no active role, or a role withoutStaff_READ) is restricted to their own staff row only; there is no guardian/student-style broader restricted view forstaff. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | No | — |
| Query | No | search, 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. |
| Body | No | — |
GET /api/staff?departmentId=3&designationKind=teaching&employmentStatus=active&sort=fullName&order=asc HTTP/1.1Response
{
"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 aviewTag, thoughStaffDtonever 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 separateCOUNT(*)(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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_ROLE_NOT_ASSIGNED / AUTH_ACTIVE_ROLE_REQUIRED | No active role, or unresolved. | Assign/select a role. | role.guard.ts |
400 | PAGINATION_LIMIT_INVALID | pagination=false. | Pagination cannot be disabled for the staff directory. | staff.service.ts:112-117 |
400 | VALIDATION_FAILED | An invalid enum, or size over 100. | Fix the query string. | Global ValidationPipe |
400 | PEOPLE_INVALID_SORT_FIELD | sort 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=falseis 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=teachingtranslates todesignations.isTeaching = true; a staff row with no designation at all (designationIdunset) is excluded from bothteachingandnon_teachingfilters, since the underlyingLEFT JOINproducesNULLforisTeachingand neither= truenor= falsematchesNULL.- A caller restricted to their own row (holds no
Staff_READ-granting role) still receives the fullStaffDtoshape 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:79-86 |
| DTO | staff.dto.ts (CreateStaffDto) |
| Service | staff.service.ts (create, grantStaffRoles) |
| Schema | people.ts (staff_employee_code_unique, staff_designation_in_department_fk, staff_designation_needs_department) |
| Tests | staff.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:
JwtAuthGuard→RoleGuard. Permission:Staff_CREATE. Object-level scope: N/A. Guest support: none. Rate limit: none module-specific. Idempotency: None — no idempotency key.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Body | Yes | CreateStaffDto — 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
INSERTintousers; one atomic allocation ofemployeeCode(code_counters, timezone-correct year); a permission check and, ifsalarywas sent and holds a value, validation of thebasicSalary/allowancespair (StaffSalaryService.resolveSalaryForCreate); oneINSERTintostaffcarrying the resolved salary columns alongside the rest; a lookup of the chosendesignationId'sisTeachingflag (when supplied) to decide whether to also grantteacher; one batchedINSERT ... ON CONFLICT DO NOTHINGintouser_roleforstaff(andteacherif applicable). - When
grantSignIn(orperson.canLogin) istrueand the staff member has an email address, the same transaction also writes anaccount_inviteverification 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | A required field missing/invalid, or an unknown field. | Fix the request body. | Global ValidationPipe |
400 | PERSON_SIGN_IN_FLAGS_CONFLICT | grantSignIn and person.canLogin were both sent with different values. | Send one of them, or the same value for both. | people-permissions.service.ts (resolveSignInGrant) |
403 | AUTH_FORBIDDEN | grantSignIn (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) |
409 | STAFF_DESIGNATION_NOT_IN_DEPARTMENT | designationId 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) |
500 | SYS_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 |
409 | USER_EMAIL_ALREADY_EXISTS | The email is already held by a live person. | Choose a different email. | person-writer.service.ts:139-163,177-182 |
409 | STAFF_EMPLOYEE_CODE_TAKEN | A 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 |
403 | PERMISSION_INSUFFICIENT | The 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) |
400 | VALIDATION_FAILED | Inside salary: basicSalary set without allowances or vice versa. | basicSalary and allowances must be set together. | staff-salary.service.ts (resolveSalaryForCreate) |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 8.25. | As above. | — |
Edge Cases
departmentIdalone, with nodesignationId, is fully supported — a staff member assigned to a department but not yet given a specific title. The reverse (designationIdalone) is the unmapped-500 trap documented above.salaryis checked for key presence, not for whether it contains anything."salary": {}from an actor withoutStaffSalary_UPDATEstill 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 inStaffService.createchecks 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/teacherrole grants do not imply one. Roles say what somebody may do once signed in;can_loginsays whether they may sign in at all. A caretaker gets a payroll record and no login; a teacher who needs the portal is created withgrantSignIn: trueby an actor holdingUsers_UPDATE, or granted access afterwards through 8.33a. - Granting
teacheris a one-time decision made from the designation given at creation. ChangingdesignationIdlater viaPATCH /staff/:idto point at a teaching designation does not retroactively grantteacher—grantStaffRolesis only ever called fromcreate.
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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:86-98 |
| DTO | staff.dto.ts (StaffDto) |
| Service | staff.service.ts (findOne, loadOne) |
| Schema | people.ts (staff) |
| Tests | N/A — covered indirectly through create/update round-trips. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Staff_READ. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Query | No | — |
| Body | No | — |
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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | id is not a valid UUID. | Fix the id. | ParseUUIDPipe |
404 | STAFF_NOT_FOUND | No 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 8.25. | As above. | — |
Edge Cases
- A soft-deleted staff member's id answers
404here 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:98-111 |
| DTO | staff.dto.ts (UpdateStaffDto) |
| Service | staff.service.ts (update) |
| Schema | people.ts (staff_designation_in_department_fk, staff_designation_needs_department) |
| Tests | Covered indirectly; the designation/department coherence checks are exercised on create and apply identically here. |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Staff_UPDATE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: guarded by the requiredversiontoken — a repeat of the same body with the same token is refused with409 PEOPLE_STALE_RECORD.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Body | Yes | UpdateStaffDto — version 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | Invalid field value, or an unknown field. | Fix the request body. | Global ValidationPipe |
404 | STAFF_NOT_FOUND | No live staff row with this id, or out of scope. | As above. | staff.service.ts |
409 | PEOPLE_STALE_RECORD | The 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 |
409 | STAFF_DESIGNATION_NOT_IN_DEPARTMENT | The resulting designationId/departmentId pair mismatches. | Choose a designation that belongs to the chosen department. | person-writer.service.ts |
500 | SYS_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 |
409 | USER_EMAIL_ALREADY_EXISTS | The new email is already held by a different live person. | Choose a different email. | person-writer.service.ts:139-163 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 8.25. | As above. | — |
Edge Cases
- Stale-write protection is the
versiontoken, checked under the row'sFOR UPDATElock after the liveness check, so a removed staff member answers404and never409. 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
designationIdhere to point at a teaching designation does not grant theteacherrole, and changing it away from one does not revoke it — role grants are decided once, atPOST /staff, and this endpoint never touchesuser_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 fromdesignation.isTeachingon a fresh read will be wrong after such a change. employmentStatusand account suspension (POST /:id/ban) are fully independent — settingemploymentStatus: "terminated"here does not ban the account, and banning does not changeemploymentStatus. 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:111-122 |
| DTO | None — no body. |
| Service | staff.service.ts (remove); people-deletion.service.ts (softDeleteProfile) |
| Schema | people.ts (staff.deleted_at, staff_employee_code_unique) |
| Tests | staff.service.spec.ts ("soft-deletes a staff member, hides them from the list, and restores them") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Staff_DELETE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a secondDELETEreturns404.
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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STAFF_NOT_FOUND | No 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 |
409 | USER_CANNOT_DELETE_SELF | The target's users.id equals the caller's own id. | You cannot delete your own account. | people-deletion.service.ts:62-67 |
403 | USER_LAST_SUPERADMIN_PROTECTED | Deleting 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 8.25. | As above. | — |
Edge Cases
- Deleting a staff member leaves their
staff/teacherrole grants in place onuser_role—softDeleteProfileonly ever touchesstudents/guardians/staffand, conditionally,users; it never touchesuser_role. If the person'susersrow 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 functioningstaff/teacherpermissions 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_idare 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:122-134 |
| DTO | None — no body. |
| Service | staff.service.ts (restore); people-deletion.service.ts (restoreProfile) |
| Schema | people.ts (staff_employee_code_unique); identity.ts (users_email_unique) |
| Tests | staff.service.spec.ts ("refuses to restore a staff member whose employee code was reissued") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Staff_RESTORE— its own permission, distinct fromStaff_UPDATE(contrast students, which reusesStudents_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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STAFF_NOT_FOUND | No 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 |
409 | STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT | The 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 |
409 | USER_RESTORE_EMAIL_CONFLICT | The 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 |
403 | PERMISSION_INSUFFICIENT | Active role holds Staff_UPDATE but not Staff_RESTORE. | Not authorized to restore. | role.guard.ts |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
Edge Cases
- Restoring a staff member does not re-grant
teachereven if their designation is a teaching one — the role grant only ever happens onPOST /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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:141-153 |
| DTO | dto/account-action.dto.ts (BanAccountDto) |
| Service | staff.service.ts (ban); people-account.service.ts (ban) |
| Schema | identity.ts (users.banned, banReason, bannedAt, bannedBy); people.ts (staff.employment_status, kept independent) |
| Tests | staff.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:
JwtAuthGuard→RoleGuard. Permission:Staff_UPDATE. Object-level scope:assertCanAccess. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Body | Yes | BanAccountDto. |
{ "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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | reason missing or over 500 characters. | Give a reason. | Global ValidationPipe |
400 | USER_BAN_REASON_REQUIRED | reason blank after trimming. | Give a real reason. | people-account.service.ts:70-79 |
404 | STAFF_NOT_FOUND | No live staff row with this id, or out of scope. | As above. | people-account.service.ts:215-247 |
409 | USER_CANNOT_DELETE_SELF | The caller is banning their own account. | You cannot suspend yourself. | people-account.service.ts:225-232 (assertNotSelf) |
403 | USER_SUPERADMIN_PROTECTED | The 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 |
403 | USER_LAST_SUPERADMIN_PROTECTED | The target is the last sign-in-capable superadmin. | This is the last superadmin account. | actor-authority.service.ts:315-353 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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 andemploymentStatus: "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_PROTECTEDin 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:154-165 |
| DTO | None — no body. |
| Service | staff.service.ts (unban); people-account.service.ts (unban) |
| Schema | identity.ts (users.banned, banReason, bannedAt, bannedBy) |
| Tests | staff.service.spec.ts ("clears the reason when the account is restored") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STAFF_NOT_FOUND | No live staff row with this id, or out of scope. | As above. | people-account.service.ts:215-247 |
403 | USER_SUPERADMIN_PROTECTED | The 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:165-181 |
| DTO | dto/account-action.dto.ts (PasswordResetSentDto, response) |
| Service | staff.service.ts (sendPasswordResetLink); people-account.service.ts (sendPasswordResetLink) |
| Schema | identity.ts (users.email, canLogin, banned) |
| Tests | staff.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:
JwtAuthGuard→RoleGuard. 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STAFF_NOT_FOUND | No live staff row with this id, or out of scope. | As above. | people-account.service.ts:152 |
403 | USER_SUPERADMIN_PROTECTED | The 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 |
409 | USER_EMAIL_REQUIRED | No email on file. | Add an email first. | people-account.service.ts:154-160 |
409 | USER_LOGIN_DISABLED | canLogin is false. | This person cannot sign in. | people-account.service.ts:161-169 |
409 | AUTH_ACCOUNT_BANNED | The account is currently suspended. | Restore the account first. | people-account.service.ts:170-176 |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts (grantSignIn) |
| DTO | sign-in-access.dto.ts (GrantSignInDto → SignInAccessDto) |
| Service | shared/people-account.service.ts (setSignIn) |
| Schema | identity.ts (users.can_login), auth.ts (verification) |
- Auth: JWT.
- Permission:
Users_UPDATE— notStaff_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, becausePOST /api/auth/password/forgotis 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: falseandno_emailend with the operator calling this route again while the state is already correct.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — the staff member's profile id (UUID; ParseUUIDPipe). |
| Body | No | GrantSignInDto — 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.4b
— sent: true means the invitation was enqueued, not that it arrived.
Side Effects
UPDATE users SET can_login = trueguarded 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 a409rather than a silent overwrite of somebody else's decision. It is skipped when the person already has access.- When
inviteis notfalse, the person has an email address, and the account is not suspended: anaccount_inviteverification 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: truefor 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_loginis not part of any cached staff projection.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STAFF_NOT_FOUND | No live staff member with that id, or it is outside the actor's scope. | Not found. | people-account.service.ts (resolveUserId) |
403 | USER_SUPERADMIN_PROTECTED | The target holds the superadmin role and the caller does not. | Ask another superadmin to do this. | actor-authority.service.ts (assertMayActOnAccount) |
409 | PERSON_SIGN_IN_STATE_CHANGED | Somebody 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | No 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: falsepromises "prepare an account and invite later" andno_emailpromises "add an address and call again", and both bring the operator back to this route with the state already correct. Returning early would answer200having sent nothing, indistinguishable from success, with no other route that would ever send that invitation. invite: falseprepares the account silently. Call the route again withinvite: 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/resetmatches OTPs against password resets alone. - A suspended (
banned) person can still be granted sign-in access, but is not invited: the response carriesreason: "banned". The two flags are independent —bannedis a statement about conduct with a recorded reason,can_logina 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_logingoverns 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts (revokeSignIn) |
| DTO | sign-in-access.dto.ts (SignInAccessDto) |
| Service | shared/people-account.service.ts (setSignIn) |
| Schema | identity.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.
JwtStrategyre-readscan_loginon 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_loginis 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
200withcanLogin: 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_loginis 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:181-193 |
| DTO | staff.dto.ts (StaffSalaryDto) |
| Service | staff-salary.service.ts (find) |
| Schema | people.ts (staff.basic_salary, allowances, total_salary, bank_name, account_number, branch, pan_number, citizenship_number, ssf_number, cit_number) |
| Tests | staff.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:
JwtAuthGuard→RoleGuard. Permission:StaffSalary_READ(distinct fromStaff_READ), checked twice — once byRoleGuardat the route, and again insideStaffSalaryService.findviaPeoplePermissionsService.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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | STAFF_NOT_FOUND | No 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 |
403 | PERMISSION_INSUFFICIENT | The 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 |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
Edge Cases
- Holding
Staff_READalone 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, includingtotalSalary— the generated column evaluates toNULLwhen either input isNULL, never to0, so a payroll sum over unrecorded staff correctly excludes rather than zeroes them. - Every monetary field is a string. Deserializing
"45000.00"to a JavaScriptnumberand back for any subsequentPATCHrisks 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
| Evidence | Path |
|---|---|
| Controller | staff.controller.ts:193-206 |
| DTO | staff.dto.ts (UpdateStaffSalaryDto) |
| Service | staff-salary.service.ts (update) |
| Schema | people.ts (staff_salary_pair_coherent, staff_basic_salary_range, staff_allowances_range) |
| Tests | staff.service.spec.ts ("refuses a salary update that breaks the basicSalary/allowances pair") |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | id — UUID, ParseUUIDPipe. |
| Body | Yes | PatchStaffSalaryDto — version 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
staffrow is lockedFOR UPDATEat the start of one transaction, and its liveness, concurrency token and currentbasicSalary/allowancesare 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
UPDATEonstaffrestricted to the salary/bank/statutory columns present in the body, plusupdatedAt, 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | An 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 |
404 | STAFF_NOT_FOUND | No 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 |
409 | PEOPLE_STALE_RECORD | The version sent does not match the row's current token. | Reload and re-apply the change. | staff-salary.service.ts |
403 | PERMISSION_INSUFFICIENT | See 8.34's equivalent case. | Not authorized. | staff-salary.service.ts:84-86 |
500 | SYS_INTERNAL_ERROR (unmapped) | A value passes @IsNumberString but violates staff_basic_salary_range/staff_allowances_range at the database — outside 0–99999999.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) |
401 | AUTH_UNAUTHENTICATED | Missing/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
404rather than409, so a caller is never told a removed record merely moved on; and a stale caller is refused before its view of thebasicSalary/allowancespair is validated, because that view describes a row that has already changed. - One token covers both staff write routes.
PATCH /staff/:idand this endpoint write the samestaffrow and both advance the samestaff.versioncounter. 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 with409every time, with no other operator involved. - The application-level pair check runs before the write, so a caller gets the named
400 VALIDATION_FAILEDrather than the database'sstaff_salary_pair_coherent23514for the common case of breaking the pair — but the DB CHECK is still the backstop for any write that bypasses this service. - Sending only
basicSalarywhenallowancesis currentlynull(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. totalSalaryis never accepted in the body —UpdateStaffSalaryDtohas no such field; sending it produces a plain400fromforbidNonWhitelisted, 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
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/students | page/size only — pagination=false is refused (PAGINATION_LIMIT_INVALID) | 20 | 100 | admissionNumber, 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/guardians | page/size only — pagination=false is refused | 20 | 100 | fullName, createdAt, else updatedAt (via inherited sort) | search, kind, phone (exact), hasChildren, recordVisibility (includeDeleted accepted, ignored) | N/A — always paginated. |
GET /api/staff | page/size only — pagination=false is refused (PAGINATION_LIMIT_INVALID, same code as guardians) | 20 | 100 | employeeCode, 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 to100is superseded in practice byQueryDto's@Max(100), which rejects rather than clamps for any caller going through the DTO. - Broad-search detection: none —
searchalways runs as trigram similarity (threshold0.3) OR-ed with an escapedILIKE %term%on the relevant secondary field (admissionNumberfor students,organizationNamefor guardians,employeeCodefor staff), regardless of term length. - Relevance scoring: when a search term is present, results are ordered by
similarity(fullName, term)descending, ahead of whateversort/sortBywas 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 }(ortotalPage: 1withcount: 0depending on rounding —Math.ceil(0/size)is0), 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/sortvalidation is strict across this whole module, unlike the school module's lookups: an unrecognized value is refused with400 PEOPLE_INVALID_SORT_FIELD, never silently redirected to a default column.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache (list results) | Yes | Keys 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 |
| BullMQ | No | No queue import in any service in this module. | — |
| Realtime | No | No Socket.IO/SSE emission in any service in this module. | — |
| External API | No | No 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
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | DB Touches | Errors | Tests | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|
GET /api/students | StudentsController.findAll | ListStudentsQueryDto → StudentDto[] | StudentsService.findAll | JWT, Role | Students_READ | Read/write students:list:* | students, users, student_class_enrollments, classes, grades, sections, academic_sessions (all read) | 401,403,400 | Integration spec | Yes |
POST /api/students/guardian-lookup | StudentsController.guardianLookup | GuardianLookupDto → array | StudentGuardiansService.lookupGuardiansByPhone | JWT, Role | Guardians_READ | N/A | guardians, users, student_guardian | 400,401,403 | Integration spec | Yes |
POST /api/students | StudentsController.create | CreateStudentDto → CreatedStudentDto | StudentsService.create | JWT, Role | Students_CREATE | Invalidate students:* | users, students, guardians, student_guardian, code_counters; plus student_class_enrollments (write) and classes/grades/sections/academic_sessions (read) when classId is sent | 400,404,409 | Integration spec | Yes |
GET /api/students/:id | StudentsController.findOne | None → StudentDto | StudentsService.findOne | JWT, Role | Students_READ | N/A | students, users, student_class_enrollments, classes, grades, sections, academic_sessions (all read) | 400,401,403,404 | N/A | Yes |
PATCH /api/students/:id | StudentsController.update | UpdateStudentDto → StudentDto | StudentsService.update | JWT, Role | Students_UPDATE | Invalidate students:* | users, students; plus student_class_enrollments (write) and classes/grades/sections/academic_sessions (read) when classId is sent | 400,404,409 | Integration spec | Yes |
DELETE /api/students/:id | StudentsController.remove | None → null | StudentsService.remove | JWT, Role | Students_DELETE | Invalidate students:* | students, users, account | 404,409,403 | Integration spec | Yes |
POST /api/students/:id/restore | StudentsController.restore | None → StudentDto | StudentsService.restore | JWT, Role | Students_UPDATE | Invalidate students:* | students, users | 404,409 | Integration spec | Yes |
POST /api/students/:id/ban | StudentsController.ban | BanAccountDto → null | StudentsService.ban | JWT, Role | Students_UPDATE | Invalidate students:* | users | 400,404,409,403 | Integration spec | Yes |
POST /api/students/:id/unban | StudentsController.unban | None → null | StudentsService.unban | JWT, Role | Students_UPDATE | Invalidate students:* | users | 404,403 | Integration spec | Yes |
POST /api/students/:id/password-reset | StudentsController.sendPasswordResetLink | None → PasswordResetSentDto | StudentsService.sendPasswordResetLink | JWT, Role | Students_UPDATE | N/A | users (read) | 404,409,403 | Shared spec | Yes |
POST /api/students/:id/sign-in | StudentsController.grantSignIn | GrantSignInDto → SignInAccessDto | PeopleAccountService.setSignIn | JWT, Role | Users_UPDATE | N/A | users (guarded update), verification (write), notification outbox | 401,403,404,409 | Integration spec | Yes |
DELETE /api/students/:id/sign-in | StudentsController.revokeSignIn | None → SignInAccessDto | PeopleAccountService.setSignIn | JWT, Role | Users_UPDATE | N/A | users (guarded update), session (deleted) | 401,403,404,409 | Integration spec | Yes |
GET /api/students/:id/guardians | StudentsController.findGuardians | None → StudentGuardianLinkDto[] | StudentGuardiansService.findGuardians | JWT, Role | Students_READ+Guardians_READ | N/A | student_guardian, guardians, users | 404 | N/A | Yes |
PUT /api/students/:id/guardians | StudentsController.setGuardians | SetStudentGuardiansDto → StudentGuardianLinkDto[] | StudentGuardiansService.setGuardians | JWT, Role | Students_UPDATE+Guardians_UPDATE | Invalidate students:* | student_guardian, guardians, users | 400,404,409 | Integration spec | Yes |
GET /api/students/:id/medical | StudentsController.findMedical | None → StudentMedicalDto | StudentMedicalService.findMedical | JWT, Role | StudentMedical_READ | N/A | students | 404,403 | Integration spec | Yes |
PATCH /api/students/:id/medical | StudentsController.updateMedical | UpdateStudentMedicalDto → StudentMedicalDto | StudentMedicalService.updateMedical | JWT, Role | StudentMedical_UPDATE | Invalidate students:* | students | 400,404,403 | Integration spec | Yes |
GET /api/guardians | GuardiansController.findAll | ListGuardiansQueryDto → GuardianDto[] | GuardiansService.findAll | JWT, Role | Guardians_READ | Read/write guardians:list:* | guardians, users | 400,401,403 | Unit spec | Yes |
GET /api/guardians/:id/students | GuardiansController.findStudents | None → GuardianChildDto[] | GuardiansService.findChildren | JWT, Role | Guardians_READ | N/A | student_guardian, students, users | 404,500* | N/A | Yes |
GET /api/guardians/:id | GuardiansController.findOne | None → GuardianDto | GuardiansService.findOne | JWT, Role | Guardians_READ | N/A | guardians, users | 404,500* | Unit spec | Yes |
POST /api/guardians | GuardiansController.create | CreateGuardianDto → CreatedGuardianDto (201) | GuardiansService.create | JWT, Role | Guardians_CREATE | Invalidate guardians:* | users, guardians, role, user_role | 400,409,500 | Unit spec | Yes |
PATCH /api/guardians/:id | GuardiansController.update | UpdateGuardianDto → GuardianDto | GuardiansService.update | JWT, Role | Guardians_UPDATE | Invalidate guardians:* | users, guardians | 400,404,409,500* | N/A | Yes |
DELETE /api/guardians/:id | GuardiansController.remove | None → null | GuardiansService.remove | JWT, Role | Guardians_DELETE | Invalidate guardians:* | guardians, users, account | 404,409,403,500* | Unit spec | Yes |
POST /api/guardians/:id/ban | GuardiansController.ban | BanAccountDto → null | GuardiansService.ban | JWT, Role | Guardians_UPDATE | Invalidate guardians:* | users | 400,404,409,403,500* | Shared spec | Yes |
POST /api/guardians/:id/unban | GuardiansController.unban | None → null | GuardiansService.unban | JWT, Role | Guardians_UPDATE | Invalidate guardians:* | users | 404,403,500* | Shared spec | Yes |
POST /api/guardians/:id/password-reset | GuardiansController.sendPasswordResetLink | None → PasswordResetSentDto | GuardiansService.sendPasswordResetLink | JWT, Role | Guardians_UPDATE | N/A | users (read) | 404,409,403,500* | Shared spec | Yes |
POST /api/guardians/:id/sign-in | GuardiansController.grantSignIn | GrantSignInDto → SignInAccessDto | PeopleAccountService.setSignIn | JWT, Role | Users_UPDATE | N/A | users (guarded update), verification (write), notification outbox | 401,403,404,409 | Integration spec | Yes |
DELETE /api/guardians/:id/sign-in | GuardiansController.revokeSignIn | None → SignInAccessDto | PeopleAccountService.setSignIn | JWT, Role | Users_UPDATE | N/A | users (guarded update), session (deleted) | 401,403,404,409 | Integration spec | Yes |
POST /api/guardians/:id/restore | GuardiansController.restore | None → GuardianDto | GuardiansService.restore | JWT, Role | Guardians_RESTORE | Invalidate guardians:* | guardians, users | 404,409,500* | N/A | Yes |
GET /api/staff | StaffController.findAll | ListStaffQueryDto → StaffDto[] | StaffService.findAll | JWT, Role | Staff_READ | Read/write staff:list:* | staff, users, departments, designations | 400,401,403 | Unit spec | Yes |
POST /api/staff | StaffController.create | CreateStaffDto → CreatedStaffDto | StaffService.create | JWT, Role | Staff_CREATE | Invalidate staff:* | users, staff, code_counters, designations, role, user_role | 400,409,500 | Unit spec | Yes |
GET /api/staff/:id | StaffController.findOne | None → StaffDto | StaffService.findOne | JWT, Role | Staff_READ | N/A | staff, users, departments, designations | 400,404 | N/A | Yes |
PATCH /api/staff/:id | StaffController.update | UpdateStaffDto → StaffDto | StaffService.update | JWT, Role | Staff_UPDATE | Invalidate staff:* | users, staff | 400,404,409,500 | N/A | Yes |
DELETE /api/staff/:id | StaffController.remove | None → null | StaffService.remove | JWT, Role | Staff_DELETE | Invalidate staff:* | staff, users, account | 404,409,403 | Unit spec | Yes |
POST /api/staff/:id/restore | StaffController.restore | None → StaffDto | StaffService.restore | JWT, Role | Staff_RESTORE | Invalidate staff:* | staff, users | 404,409,403 | Unit spec | Yes |
POST /api/staff/:id/ban | StaffController.ban | BanAccountDto → null | StaffService.ban | JWT, Role | Staff_UPDATE | Invalidate staff:* | users | 400,404,409,403 | Unit spec | Yes |
POST /api/staff/:id/unban | StaffController.unban | None → null | StaffService.unban | JWT, Role | Staff_UPDATE | Invalidate staff:* | users | 404,403 | Unit spec | Yes |
POST /api/staff/:id/password-reset | StaffController.sendPasswordResetLink | None → PasswordResetSentDto | StaffService.sendPasswordResetLink | JWT, Role | Staff_UPDATE | N/A | users (read) | 404,409,403 | Unit spec | Yes |
POST /api/staff/:id/sign-in | StaffController.grantSignIn | GrantSignInDto → SignInAccessDto | PeopleAccountService.setSignIn | JWT, Role | Users_UPDATE | N/A | users (guarded update), verification (write), notification outbox | 401,403,404,409 | Integration spec | Yes |
DELETE /api/staff/:id/sign-in | StaffController.revokeSignIn | None → SignInAccessDto | PeopleAccountService.setSignIn | JWT, Role | Users_UPDATE | N/A | users (guarded update), session (deleted) | 401,403,404,409 | Integration spec | Yes |
GET /api/staff/:id/salary | StaffController.findSalary | None → StaffSalaryDto | StaffSalaryService.find | JWT, Role | StaffSalary_READ | N/A | staff | 404,403 | Unit spec | Yes |
PATCH /api/staff/:id/salary | StaffController.updateSalary | UpdateStaffSalaryDto → StaffSalaryDto | StaffSalaryService.update | JWT, Role | StaffSalary_UPDATE | Invalidate staff:* | staff | 400,404,403,500 | Unit spec | Yes |
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
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel | All 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. |
| QA | Reproduce 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 frontend | There 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/A | N/A — not exposed to that surface. |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Optimistic concurrency on students only | version required on PATCH /students/:id; absent from guardians and staff | A single shared concurrency mechanism across all three profile kinds | Students 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 entity | Students restore under Students_UPDATE; guardians under Guardians_RESTORE; staff under Staff_RESTORE | One consistent permission scheme for all three restores | Verified 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 alike | Allow 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 caller | An 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 guardians | A malformed id cleanly 400s on students/staff, 500s on guardians | Apply ParseUUIDPipe uniformly | No 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 fields | A colleague without the gated permission gets a clean 403 on the dedicated endpoint; the general endpoint never includes the field at all, for anyone | Conditional fields on StudentDto/StaffDto present only for permitted callers | A 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 code | staff_designation_needs_department and the two salary-range CHECKs surface as bare 500s | Add explicit pre-write validation, or map the constraint name in PersonWriterService.translate | Verified 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 0–99999999.99 range and two-decimal scale before submitting) is the only mitigation available today. |
| Guardian phone is a lookup field, never a unique identifier | GET /guardians?phone=... may return several people; there is no /guardians/search route | Enforce phone uniqueness, or add a dedicated search route | A 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
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding ParseUUIDPipe to every GuardiansController :id route | Admin panel (guardian screens only) | A malformed id that previously produced a 500 would instead produce a clean 400 VALIDATION_FAILED | None — no schema change | No | Purely 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 code | Admin panel (staff create/update) | A previously-opaque 500 becomes a named 400/409 | None | No | Strictly 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/:id | Admin panel (staff edit screen) | StaffService.update would need to call the same role-grant logic create uses whenever designationId changes | None — no schema change, user_role already supports the grant | No | Additive; 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
/apiglobal 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/StaffSalaryfield-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
ParseUUIDPipegap. - Every success, validation, auth, permission, not-found, conflict, and unmapped-error branch is documented per endpoint (§8) — including the two genuinely unmapped
500paths (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.
| Method | Path | Audience | Returns |
|---|---|---|---|
| GET | /api/mobile/guardian/children | guardian | paginated PortalChildDto |
| GET | /api/mobile/guardian/children/{id} | guardian | one PortalChildDto |
| GET | /api/mobile/guardian/children/{id}/enrollments | guardian | paginated EnrollmentDto |
| GET | /api/mobile/student/enrollments | student | paginated 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 |
|---|---|---|
guardian | 200, own children only | 403 PERMISSION_INSUFFICIENT |
student | 403 PERMISSION_INSUFFICIENT | 200, own enrolments only |
staff, teacher, superadmin | 403 PERMISSION_INSUFFICIENT | 403 PERMISSION_INSUFFICIENT |
| no role selected | 403 AUTH_ACTIVE_ROLE_REQUIRED | 403 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
- Backend doc:
/docs/developer/people/backend - Features and flows doc:
/docs/developer/people/feature
People Backend Documentation
Backend architecture, data model, services, cache, and runtime rules for the people domain (students, guardians, staff).
School Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for departments, designations, and the school profile.