People Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the people domain (students, guardians, staff).
People Features and Flows
The people domain is the school's roll: every pupil, every parent or other responsible adult, and everyone on the payroll. One record shape underlies all three — a users row carrying name, contact, demographics and address, plus one profile row (students, guardians, or staff) carrying whatever is specific to that hat. A person who is both a teacher and a parent is two profile rows pointing at the same users row, not two people.
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Schema | packages/db/src/schema/school/people.ts, packages/db/src/schema/identity.ts, packages/db/src/schema/school/lookups.ts | Table shapes, constraints, indexes, the guardian/sibling model, the salary generated column. |
| Backend | Sibling backend doc, apps/api/src/modules/people/**/*.service.ts | Business behavior, transactions, cache invalidation, code allocation, deletion rules. |
| API | Sibling API doc, apps/api/src/modules/people/**/*.controller.ts | Route surface, permissions, guards, request/response shape. |
| Authorization | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | Which modules exist, which of the five seeded roles hold what by default. |
| Staff roles | apps/api/src/modules/people/staff/staff-roles.service.ts, staff.service.ts, staff.controller.ts, dto/staff.dto.ts, apps/api/src/modules/role/role.service.ts, apps/api/src/common/authorization/actor-authority.service.ts, apps/api/src/modules/realtime/shared/realtime-control.ts | Role resolution, authorization checks, the picker routes, the edit choices, row-visibility refusal, stream closing. |
| Admin panel | skoolsewa-frontend/app/(dashboard)/dashboard/parents/_components/, .../staff/_components/, .../teachers/ | The parent sign-in checkbox and toast, staff list invalidation, the create role select, the teacher routes. |
| Tests | students.service.integration.spec.ts, guardians.service.spec.ts, staff.service.spec.ts | Confirmed edge-case behavior — cited per row below. |
2. Feature Summary
| Field | Value |
|---|---|
| Module | People |
| Submodule | Students, Guardians, Staff (plus StudentMedical and StaffSalary as field-level sub-permissions) |
| Primary user value | An admissions or HR office can record a pupil, a parent, or an employee once, correctly, and every other module in the school platform can trust that record without re-implementing who may see it. |
| Actors | Superadmin (universal bypass), an administrator role holding the Students/Guardians/Staff permission set, staff and teacher (read-only directory access by default), guardian (their own children only), student (their own record only). |
| Main entry points | POST /students, POST /guardians, POST /staff, and the list/detail/update/delete/restore/account-action routes under each. |
| Main outputs | A users row and a profile row persisted in one transaction; a StudentDto / GuardianDto / StaffDto response; role grants (guardian, staff, teacher) issued alongside the profile; a queued password-reset email. |
| Related docs | API reference, Backend documentation. |
3. Actor Matrix
Scope is decided by the session's active role, never by the union of every role a person holds. A teacher who is also a parent, viewing as Guardian, gets the guardian scope — switching context is what makes that mean anything. See PeopleAccessService.scopeFor in the backend doc.
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
| Superadmin | Everything below, unconditionally. | Nothing is withheld. | JWT with activeRole.isSuperadmin = true. | RoleGuard short-circuits on the flag, never on a role name — a role cannot be renamed into a bypass. |
Administrator (a role holding the Students/Guardians/Staff/StaffSalary/StudentMedical permission codes) | Full CRUD, restore, ban/unban, password-reset link, on students, guardians, and staff alike. Salary and medical fields only with the matching _READ/_UPDATE code. Cannot ban, unban, or reset the credentials of an account holding the superadmin role unless the administrator is a superadmin too. | Nothing structurally, but each field group is gated separately — holding Staff_READ alone does not unlock /staff/:id/salary. | JWT, active role must hold the specific Module_ACTION code. | None of the five seeded roles hold this by default (see the Business Process Diagram Pack below); this is a role an administrator creates through the Roles module. |
Administrator also holding Users_UPDATE | Choose the roles a new staff member is admitted under, and change the roles an existing staff member holds. Grant and revoke sign-in. | Give a staff member a guardian- or pupil-scope role. Choose an explicit role for a teaching designation. Change their own roles. Change a superadmin's roles unless they are a superadmin. Add or remove a role carrying permissions they do not hold themselves. Remove the last superadmin role. | JWT, active role holds Users_UPDATE together with Staff_CREATE (admission) or Staff_UPDATE (edit). | Choosing roles is an identity change, so the staff permission alone never reaches it. Without Users_UPDATE the same admission or edit succeeds as long as it names no role. |
| Staff (seeded default) / Teacher (seeded default) | Students_READ, Guardians_READ, Staff_READ — list and read the whole directory. | Create, update, delete, restore, ban, salary, medical. | JWT, active role staff or teacher. | Seeded with read-only directory grants; see STAFF_PERMISSIONS in seed-auth.ts. A teacher holds every staff grant plus the screens for their own teaching work (TEACHER_PERMISSIONS spreads STAFF_PERMISSIONS and adds the assignment and class-limited report codes). A teacher is a staff row with a teaching designation, and holds both the staff and the teacher role because the designation implies them. |
| Guardian | Read their own children (students), read their own guardian record and their children's other guardians, read their own users row. | Read anyone outside that set — refused with the entity's own 404, never a 403. Create, update or delete anyone. | JWT, active role guardian. | Holds no module permission by default (GUARDIAN_PERMISSIONS = []); access to their own family runs entirely through object-level scope, not a permission code. |
| Student | Read their own students row and their own guardians. | Read any other student or guardian. Anything else. | JWT, active role student. | Same as guardian: no module permission, scope only. |
| Anyone with no active role, or a role holding none of the above | Read only their own staff/users row if one exists for them. | Everything else. | JWT, no usable active role. | The safe default in PeopleAccessService.selfOnlyScope — an absent case defaults to sql\false``, never to "everything". |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Admit a student | Admin | Administrator | POST /students | Guardian rows (if linking existing), admission-number counter | users, students, student_guardian | Students: Create |
| List students | Admin | Administrator, staff, teacher, guardian (own children), student (self) | GET /students | students joined to users | None (cached read) | Students: List |
| Look up guardians by phone | Admin | Administrator, staff, teacher | POST /students/guardian-lookup | guardians joined to users | None | Students: Guardian lookup |
| Update a student | Admin | Administrator | PATCH /students/:id | Current students/users row, locked FOR UPDATE | users, students | Students: Update |
| Replace a student's guardians | Admin | Administrator | PUT /students/:id/guardians | Existing links, candidate guardians | student_guardian (full replace) | Students: Set guardians |
| Read a student's health record | Admin | Administrator with StudentMedical_READ | GET /students/:id/medical | students.medical_conditions/allergies/special_needs | None | Students: Medical |
| Update a student's health record | Admin | Administrator with StudentMedical_UPDATE | PATCH /students/:id/medical | Same three columns | Same three columns | Students: Medical update |
| Soft delete / restore a student | Admin | Administrator | DELETE /students/:id, POST /students/:id/restore | Profile-liveness count across all three profile tables | students.deleted_at, possibly users.deleted_at, account, sessions | Students: Delete/Restore |
| Suspend / restore a student's login | Admin | Administrator | POST /students/:id/ban, .../unban | users.banned | users.banned, .ban_reason, .banned_at, .banned_by; deletes sessions | Students: Ban |
| Email a student a password-reset link | Admin | Administrator | POST /students/:id/password-reset | users.email/can_login/banned | A verification-token row | Students: Password reset |
| Give a student a portal account | Admin | Administrator holding Users_UPDATE | POST /students/:id/sign-in, or grantSignIn on POST /students | users.can_login/email | users.can_login; an account_invite verification row and a queued invitation email | Students: Grant sign-in |
| Take a student's portal account away | Admin | Administrator holding Users_UPDATE | DELETE /students/:id/sign-in | users.can_login | users.can_login; deletes sessions | Students: Revoke sign-in |
| Create a guardian | Admin | Administrator | POST /guardians | — | users, guardians, user_role (grants guardian) | Guardians: Create |
| List / read a guardian, their children | Admin | Administrator, guardian (self) | GET /guardians, GET /guardians/:id, GET /guardians/:id/students | guardians joined to users; student_guardian joined to students | None | Guardians: List/Read |
| Update / delete / restore / ban / unban / reset a guardian | Admin | Administrator | PATCH/DELETE/POST :id/restore/.../ban/.../unban/.../password-reset on /guardians/:id | Current row | users, guardians | Guardians: reference |
| Give a guardian a portal account, or take it away | Admin | Administrator holding Users_UPDATE | POST/DELETE /guardians/:id/sign-in, or grantSignIn on POST /guardians and on a guardian entry inside POST /students | users.can_login/email | users.can_login; an account_invite verification row and a queued invitation on a grant, deleted sessions on a revoke | Guardians: Grant sign-in |
| Admit a staff member | Admin | Administrator | POST /staff | Department/designation FK targets, employee-code counter | users, staff, user_role (grants staff, and teacher for a teaching designation; or exactly the roles named in rolePublicIds for a non-teaching designation) | Staff: Create |
| List / read / update / delete / restore a staff member | Admin | Administrator, staff/teacher (list/read only) | /staff routes | staff joined to users, departments, designations | users, staff | Staff: reference |
| Read / update a staff member's salary and bank details | Admin | Administrator with StaffSalary_READ/_UPDATE | GET/PATCH /staff/:id/salary | staff salary columns | Same columns | Staff: Salary |
| Suspend / restore a staff member's login | Admin | Administrator | POST /staff/:id/ban, .../unban | users.banned; the target's full role set (superadmin protection) | users.banned, .ban_reason, .banned_at, .banned_by; deletes sessions | Staff: Ban |
| Email a staff member a password-reset link | Admin | Administrator | POST /staff/:id/password-reset | users.email/can_login/banned; the target's full role set | A verification-token row | Staff: Password reset |
| Give a staff member a portal account, or take it away | Admin | Administrator holding Users_UPDATE | POST/DELETE /staff/:id/sign-in, or grantSignIn on POST /staff | users.can_login/email; the target's full role set | users.can_login; an account_invite verification row and a queued invitation on a grant, deleted sessions on a revoke | Staff: Grant sign-in |
| List the roles that can be given to a staff member | Admin | Administrator holding Staff_CREATE and Users_UPDATE (new member), or Staff_UPDATE and Users_UPDATE (existing member) | GET /staff/assignable-roles, GET /staff/:id/assignable-roles | The actor's own permissions and the role table | None | Staff: Assignable roles (new), Staff: Assignable roles (existing) |
| Change a staff member's roles | Admin | Administrator holding Staff_UPDATE and Users_UPDATE | PATCH /staff/:id with roleSource (and rolePublicIds for explicit) | The target's managed roles, designation, and the actor's own permissions | user_role rows; the audit log; a request to close the person's open live-update streams | Staff: Update |
| See a staff member's roles | Admin | Administrator holding Staff_UPDATE | GET /staff/:id, and the response of POST /staff and PATCH /staff/:id | user_role joined to role | None | Staff: Read |
| Limit a role to guardians or pupils | Admin | Administrator holding Roles_UPDATE | PUT /roles/:publicId/row-visibility (RoleService.setRowVisibility) | user_role joined to staff for the role | role.scope_kind | — |
| Read headline people counts | Admin | Administrator, staff, teacher (any role with Students_READ) | GET /people/summary | students, student_class_enrollments, academic_sessions; staff and designations with Staff_READ; guardians with Guardians_READ | None (60-second cached read) | People summary |
5. User-Facing Flows
5.1 Admit a student
Summary
The admissions desk records a new pupil: identity, admission details, and at least one guardian — either reusing an existing parent (the sibling case) or creating one inline.
Preconditions
- Actor authenticated, active role holding
Students_CREATE. - If guardians are linked by
guardianId, those guardian rows must already exist and be live. - If a
person.emailis supplied for the student or an inline guardian, it must not already belong to a live person.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admissions officer | Submits POST /students with person, admissionDate, and optionally guardians[]. | DTO validated. | students.controller.ts |
| 2 | StudentsService.create | Validates the guardian set — at least one entry, exactly one primary, no repeated slot. | Rejects with STUDENT_REQUIRES_ONE_GUARDIAN, STUDENT_GUARDIAN_PRIMARY_REQUIRED / _MULTIPLE_PRIMARY, or GUARDIAN_SLOT_TAKEN if invalid. | student-guardians.service.ts |
| 3 | Backend | Opens a transaction: inserts users, allocates or accepts an admission number, inserts students, writes guardian links. | All four steps commit together or none does. | students.service.ts |
| 4 | Backend | Reloads the full StudentDto (person + guardian count). | Response returned. | students.service.ts |
| 5 | Backend | Invalidates every cached student list. | Next list read is a miss. | students.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No guardians given | guardians omitted or []. | Refused before the transaction opens — a pupil must have at least one guardian. | 400 STUDENT_REQUIRES_ONE_GUARDIAN. |
| Sibling attach | guardians[].guardianId names an existing live guardian. | Links to that row; no new users/guardians insert. | Same guardian appears on both students' guardian lists. |
| Explicit admission number | admissionNumber supplied. | Used as-is; must still satisfy students_admission_number_unique. | STUDENT_ADMISSION_NUMBER_TAKEN on collision. |
| Explicit student ID | studentId supplied (school migrating an existing roll). | Canonicalised, format- and range-checked, and the year's counter advanced so a later auto-allocation cannot collide with it. | 400 STUDENT_ID_MALFORMED, 400 STUDENT_ID_SEQUENCE_OUT_OF_RANGE, or 409 STUDENT_ID_TAKEN on collision (checked against every row, including removed ones). |
| Duplicate guardian in the set | Two entries resolve to the same guardian id. | Rejected before any insert. | 409 STUDENT_GUARDIAN_ALREADY_LINKED. |
| Two entries in the same slot | Two entries both name relationship: "father" (or any other slot). | Rejected before any insert, naming the slot. | 400 GUARDIAN_SLOT_TAKEN. |
| Email already held | person.email (student's or an inline guardian's) belongs to a live person. | Whole transaction aborted. | 409 USER_EMAIL_ALREADY_EXISTS. |
| Address half-filled | A district given without its province, or similar, on either permanentAddress or currentAddress. | Rejected before any insert. | 409 ADDRESS_HIERARCHY_INVALID. |
| No sign-in access asked for | Neither grantSignIn nor person.canLogin sent. | The pupil gets a record and no portal account. This is the ordinary case. | 201, with invitation: null. |
Sign-in asked for, actor lacks Users_UPDATE | grantSignIn: true (or person.canLogin: true) from an actor holding only the create permission. | Refused before the transaction opens — an account is an identity change, not a record edit. | 403 AUTH_FORBIDDEN. |
| The two sign-in flags disagree | grantSignIn and person.canLogin both sent with different values. | Refused rather than resolved by precedence. | 400 PERSON_SIGN_IN_FLAGS_CONFLICT. |
| Sign-in granted, no email address | grantSignIn: true on somebody with no person.email. | The account is created; there is simply nowhere to send the invitation. | 201, with invitation: {"sent": false, "reason": "no_email"}. |
| A guardian in the set is granted sign-in | guardians[].grantSignIn: true on an entry creating a new guardian. | Needs Users_UPDATE as well as Guardians_CREATE; the guardian is invited in the same transaction. | 403 AUTH_FORBIDDEN without the permission. |
5.2 Attach a sibling to an existing guardian
Summary
The admissions desk searches by phone number before typing a name, so the second child of a family shares the first child's guardian row rather than creating a duplicate parent.
Preconditions
- Actor holds
Guardians_READ(for the lookup) andStudents_CREATEorStudents_UPDATE/Guardians_UPDATE(to attach).
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Officer | POST /students/guardian-lookup with the family's phone number. | Every live guardian on that number returned — a household sharing one number is normal, so this can be a list. | student-guardians.service.ts |
| 2 | Officer | Picks the matching guardian and admits the second child with guardians: [{ guardianId, relationship, ... }], or attaches via PUT /students/:id/guardians. | Second child links to the SAME guardians.id. | student-guardians.service.ts |
| 3 | Backend | resolveGuardian finds the row by id rather than creating one. | No duplicate users/guardians rows for that parent. | student-guardians.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No match on phone | No live guardian shares that number. | Empty array returned. | Office creates a new guardian inline. |
| Several matches | A household number shared by more than one adult. | All returned; office must choose. | List, not a single pick — there is deliberately no "the" match. |
| Guardian id no longer live | The chosen guardianId was soft-deleted between lookup and submit. | Rejected. | 404 GUARDIAN_NOT_FOUND. |
| Blank or missing phone number | The operator submits the lookup with no phone typed. | Rejected before the query runs, rather than matching nothing. | 400 VALIDATION_FAILED. |
5.3 Update a student, guardian, or staff member
Summary
An office edits identity or profile fields on an existing record. Students carry optimistic concurrency; guardians and staff do not.
Preconditions
- Actor holds the entity's
_UPDATEpermission and passesPeopleAccessService.assertCanAccessfor that specific row. - For a student or a staff member, the request must carry the
versiontoken last read from the record.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Editor | PATCH /students/:id (or /guardians/:id, /staff/:id) with only the changed fields. | Partial patch. | Each *.controller.ts |
| 2 | Backend | Locks the current row FOR UPDATE (students, staff) or plain read (guardians). | Row visibility confirmed. | Each *.service.ts |
| 3 | Backend (students, staff and guardians) | Compares dto.version against versionOf(current.version), under the row lock. | Mismatch aborts with no write. | students.service.ts, staff.service.ts, staff-salary.service.ts |
| 4 | Backend | Builds a patch containing only the keys the caller actually sent (PersonWriterService.buildUpdate), so an omitted field is left untouched rather than nulled. | users and the profile table updated. | person-writer.service.ts |
| 5 | Backend | Invalidates the entity's cached lists. | Next read is fresh. | Each *.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Stale version | dto.version does not match the locked row's version counter. | Write refused, nothing changes. | 409 PEOPLE_STALE_RECORD. |
| Field omitted from the body | Key absent from person or the top-level DTO. | Left exactly as it was. | Distinguished from null, which explicitly clears it. |
| Email changed to one already live elsewhere | New email collides with another live person. | Whole update rejected. | 409 USER_EMAIL_ALREADY_EXISTS. |
Guardian's kind changes to organization | organizationName recomputed from person.firstName on every write that could touch either half, not just when kind itself is sent. | Stays coherent with the CHECK constraint. | No client-visible error on the happy path. |
| Staff designation moved to a different department | designationId no longer matches departmentId. | Rejected at the database. | 409 STAFF_DESIGNATION_NOT_IN_DEPARTMENT. |
| Concurrent guardian PATCH | Two editors write the same guardian at once. | The second is refused before it writes. | 409 PEOPLE_STALE_RECORD. |
| Concurrent staff PATCH | Two editors write the same staff member at once. | The second is refused before it writes. | 409 PEOPLE_STALE_RECORD. |
5.4 Replace a student's guardian set
Summary
The office corrects the whole family picture at once — who is primary, who may collect the child, who is the emergency contact — rather than adding and removing links one at a time.
Preconditions
- Actor holds
Students_UPDATEandGuardians_UPDATE. - If any guardians are given, exactly one must be marked primary.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Editor | PUT /students/:id/guardians with the full desired set. | Validated as a whole. | student-guardians.service.ts |
| 2 | Backend | Locks the student row, deletes every existing student_guardian row for it. | Clean slate inside the transaction. | student-guardians.service.ts |
| 3 | Backend | Inserts every entry with is_primary = false first. | Avoids the non-deferrable unique index racing against rows about to be deleted. | student-guardians.service.ts |
| 4 | Backend | Sets is_primary = true on the one designated primary, as a second statement. | Exactly one primary, or none if the set is empty. | student-guardians.service.ts |
| 5 | Backend | Invalidates cached student lists (guardian count and completeness changed). | student-guardians.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Empty set | guardians: []. | All links removed; record becomes incomplete. | 200 with an empty array. |
| Zero primaries with entries | At least one entry, none marked primary. | Rejected before any write. | 400 STUDENT_GUARDIAN_PRIMARY_REQUIRED. |
| Two primaries | More than one entry marked primary. | Rejected before any write. | 400 STUDENT_GUARDIAN_MULTIPLE_PRIMARY. |
| Reassigning primary between two existing guardians | Old primary and new primary both already linked. | Both re-inserted as non-primary, then the new one flipped — never a moment with two primaries in the same statement. | Succeeds; the non-deferrable index is never hit mid-transaction. |
5.5 Read a student's health record or a staff member's salary
Summary
Two fields groups are held out of the base record and gated by their own permission, so a teacher reading the roll never incidentally reads a diagnosis, and a colleague with plain Staff_READ never incidentally reads a payslip.
Preconditions
- Actor passes
PeopleAccessService.assertCanAccessfor the base entity, and holdsStudentMedical_READ/_UPDATEorStaffSalary_READ/_UPDATErespectively.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Nurse/HR | GET /students/:id/medical or GET /staff/:id/salary. | Field-gated payload only. | student-medical.service.ts, staff-salary.service.ts |
| 2 | Backend | Checks object-level scope for the base entity, then checks the specific field permission with PeoplePermissionsService.can. | Two independent gates. | Same files |
| 3 | Backend | Reads only the gated columns — never the whole profile row. | Response never carries Students_READ/Staff_READ fields either. | Same files |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
Holds Staff_READ but not StaffSalary_READ | Colleague-level access only. | RoleGuard already refuses the route (permission declared on the handler); PeoplePermissionsService.can is the second, defense-in-depth check inside the service. | 403 PERMISSION_INSUFFICIENT. |
| Salary never set | basicSalary/allowances both NULL. | totalSalary is NULL, never 0.00. | Response shows all three as null. |
| Salary update breaks the pair | PATCH sets basicSalary but leaves a NULL allowances (or vice versa). | Rejected before the write. | 400 VALIDATION_FAILED. |
| Out-of-scope student's medical record | Requested student exists but is not this actor's. | Same 404 as a missing student — never a 403. | 404 STUDENT_NOT_FOUND. |
5.6 Suspend and restore a person's sign-in (students, guardians, staff)
Summary
An office can stop somebody signing in without touching their record — a fee-suspended pupil stays on the roll and countable, a suspended parent is still the emergency contact on file, and a staff member under investigation stays employed on paper while locked out of a session.
Preconditions
- Actor holds the entity's
_UPDATEpermission. - The target must not be the actor themselves.
- If the target holds the superadmin role, the actor must hold it too — an ordinary administrator cannot suspend, restore, or reset a superadmin's credentials no matter which entity's
_UPDATEpermission they hold.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Officer | POST /students/:id/ban (or /guardians/:id/ban, /staff/:id/ban) with a reason. | Validated non-blank. | people-account.service.ts |
| 2 | Backend | Refuses self-targeting, refuses a non-superadmin acting on a superadmin, and refuses to demote the last live superadmin. | people-account.service.ts, actor-authority.service.ts | |
| 3 | Backend | Sets users.banned = true plus reason/timestamp/actor, in a transaction. | people-account.service.ts | |
| 4 | Backend | Deletes every session the person holds. | Any refresh token becomes unusable immediately, not just at next banned check. | people-account.service.ts |
| 5 | Officer | POST /students/:id/unban later. | banned cleared and banReason/bannedAt/bannedBy cleared — a stale reason must not linger on a restored account. Still refuses a non-superadmin restoring a superadmin's account. | people-account.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Blank reason | reason empty or whitespace. | Rejected. | 400 USER_BAN_REASON_REQUIRED. |
| Self-targeting | Actor bans their own users.id. | Rejected — the UI that would undo it just refused the actor. | 409 USER_CANNOT_DELETE_SELF. |
| Target is a superadmin, actor is not | Checked against the target's full role set, not their currently active role — a superadmin acting as a guardian in this session is still protected. | Rejected before the last-superadmin count ever runs, closing the path where a clerk suspends a superadmin while a second one remains on file. | 403 USER_SUPERADMIN_PROTECTED. |
| Last superadmin | Target holds the superadmin role and no other live, password-holding superadmin exists. | Rejected. | 403 USER_LAST_SUPERADMIN_PROTECTED. |
| Already banned | Ban called twice. | Idempotent — overwrites reason/timestamp/actor. | 200, no error. |
5.7 Email a password-reset link
Summary
An administrator triggers a reset link, never a new password — the office never learns a child's or colleague's credential.
Preconditions
- Actor holds the entity's
_UPDATEpermission. - If the target holds the superadmin role, the actor must hold it too.
- Target has an email address,
canLogin = true, and is not banned.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Officer | POST /students/:id/password-reset (or /guardians/:id/..., /staff/:id/...). | people-account.service.ts | |
| 2 | Backend | Refuses a non-superadmin acting on a superadmin. | actor-authority.service.ts | |
| 3 | Backend | Loads the person, validates email present, canLogin, not banned. | people-account.service.ts | |
| 4 | Backend | Creates a single-use verification token via VerificationTokenService.createPasswordReset. | people-account.service.ts | |
| 5 | Backend | Sends the email via AuthEmailService.sendPasswordResetEmailSafe (fail-soft — a delivery failure does not fail the request). | people-account.service.ts | |
| 6 | Backend | Returns { sentTo } so the operator can confirm the address before telling the family. | people-account.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Target is a superadmin, actor is not | A reset link is a route to somebody's credentials — a clerk who could trigger one on a superadmin's account could invalidate their current password at will. | Rejected before the email/login/ban state is even loaded. | 403 USER_SUPERADMIN_PROTECTED. |
| No email on file | users.email IS NULL. | Rejected — nowhere to send it. | 409 USER_EMAIL_REQUIRED. |
canLogin = false | Person cannot sign in at all. | Rejected — a working link to a disabled account is a support call. | 409 USER_LOGIN_DISABLED. |
| Banned account | users.banned = true. | Rejected — restore the account first. | 409 AUTH_ACCOUNT_BANNED. |
| Email delivery fails | SMTP/provider error. | Request still succeeds — this path is deliberately fail-soft. | 200 {sentTo}, delivery logged separately. |
5.8 Give somebody a portal account, or take it away (students, guardians, staff)
Summary
An administrator turns sign-in access on for a person who has a record but no login, and the person receives an email inviting them to choose a password. The same screen turns it off again, ending every session they hold.
Preconditions
- The actor holds
Users_UPDATE— not the profile-kind permission. Granting sign-in creates a credential-bearing account rather than editing a pupil, parent or employee record. - The target is a live person within the actor's scope.
- The target does not hold the superadmin role unless the actor does too.
Main Flow
| Step | Actor | Action | Screen/Route | Source |
|---|---|---|---|---|
| 1 | Administrator | Switches the sign-in toggle on from the detail screen. | POST /:entity/:id/sign-in | The three controllers |
| 2 | Backend | Loads the person and their current sign-in state. | people-account.service.ts | |
| 3 | Backend | Writes can_login = true, guarded by the value it just read, unless it already holds it. | people-account.service.ts | |
| 4 | Backend | Unless the operator asked not to invite, mints a 7-day invitation and queues the email — on the same transaction as step 3, so the two commit together or not at all. | people-invitation.service.ts | |
| 5 | Administrator | Sees whether the invitation went, and to which address. | SignInAccessDto.invitation | |
| 6 | The person | Opens the link, chooses a password, and can sign in. | POST /api/auth/password/reset | auth-password.service.ts |
Revoking is the same route with DELETE, no body: can_login goes to false under the same guard,
and every session the person holds is deleted.
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Granting to somebody who already has access | The toggle is switched on twice, or an operator returns after adding a missing email address. | The state change is skipped and the invitation is decided afresh — so the second call is what actually sends it. | 200, with the invitation outcome of this call. |
| Revoking from somebody who has none | The toggle is switched off twice. | Nothing at all: no write, no session sweep. A revoke is the one direction with no second question to answer. | 200, canLogin: false, reason: "revoked". |
| Somebody else changed it first | can_login moved between this request's read and its write. | The guarded write matches no row and is reported rather than applied over the other change. | 409 PERSON_SIGN_IN_STATE_CHANGED — re-read and try again. |
| No email address | Sign-in granted to somebody with no address on file. | The grant stands; only the invitation is skipped. Add an address and grant again to send it. | 200, with reason: "no_email". |
| Invitation deliberately withheld | invite: false. | The account is prepared silently, for an operator who will tell the person another way. | 200, with reason: "not_requested". |
| Target is a superadmin, actor is not | The same protection ban, unban and reset-link all apply. | Refused before anything is read about their sign-in state. | 403 USER_SUPERADMIN_PROTECTED. |
| Target is suspended | users.banned = true. | The grant stands — the two flags are independent — but no invitation is sent, because an invitation into an account the login guard will refuse produces a support call rather than an activated account. Lift the suspension and grant again. | 200, with reason: "banned". |
| The invitation cannot be scheduled | A database or outbox failure while queueing the email. | The grant rolls back with it. Reporting somebody as invited when nothing was scheduled would leave them holding credentials they will never be told about. | The request fails; sign-in access is unchanged. |
| Invitation not opened in time | More than 7 days pass. | The link stops working. | 400 AUTH_VERIFICATION_CODE_INVALID — grant again, or send a reset link, to issue a fresh one. |
| The person also holds a reset link | An administrator sent a reset link as well as an invitation. | Whichever they use first retires the other, so a password set through one cannot be undone by the other still working. | The unused link then answers AUTH_VERIFICATION_CODE_INVALID. |
An invitation is redeemable by link only. The one-time code that a password-reset email carries is never printed in an invitation, because the code lookup matches on purpose and person with nothing to tell two live records apart — a correct code checked against the wrong record would fail and burn one of the five attempts that retire it.
Revoking is not the same as suspending. A suspension records a reason and is a statement about conduct; revoking sign-in simply says the person no longer has a portal account. Either way their record is untouched, and so is every notification addressed to them.
5.9 Move or end a graduate's portal access
Summary
A rollover graduation opens a read-only window on the pupil portal: the graduate can sign in and read their own record, but nothing else, until the window's last day. An administrator reads that window, moves its last day earlier or later, or ends it immediately; the pupil portal itself refuses any write the graduate attempts once the window is over.
Preconditions
- The actor holds
Students_READto read the window,Students_UPDATEto change it. - Changing the window additionally requires
Users_UPDATE, because ending or re-opening access can switch the graduate's sign-in on or off — an identity change noStudents_UPDATEholder is otherwise trusted with. - The target pupil is within the actor's object-level scope, checked before the graduation is even
looked up, so an out-of-scope actor gets the same
404an absent graduation produces.
Main Flow
| Step | Actor | Action | Screen/Route | Source |
|---|---|---|---|---|
| 1 | Administrator | Opens a pupil's graduate access panel. | GET /api/students/:id/graduate-access | student-graduate-access.controller.ts |
| 2 | Backend | Reads the pupil's live (unrevoked) graduation, if any, and computes none / pending / grace / ended against today's school-calendar date. | graduate-access.util.ts | |
| 3 | Administrator | Moves the last day of access, or ends it now, with a reason. | PATCH /api/students/:id/graduate-access | student-graduate-access.controller.ts |
| 4 | Backend | Locks the graduation row, applies the change inside one transaction, and writes an audit entry once it commits. | graduate-access.service.ts |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Ending access | action: "end_now". | Refused while the graduation is still ahead of today — a pupil still attending is never cut off this way. Otherwise the pupil's own scope_kind = 'student' role grants are removed unconditionally, so a grant is never left behind for someone who later gets a login for another reason. Sign-in and every live session are removed too, but only when the graduate holds no other live profile — a graduate who is also a parent keeps their login for their children. | 200, with state: "ended", or 409 GRADUATE_ACCESS_NOT_STARTED if graduation has not begun. |
| Moving the last day into the future past an ended window | action: "set_until" with a date >= today on an already-ended graduation. | Access re-opens: sign-in is restored only when the actor holds Users_UPDATE (the route already requires it) and the account has not since gained other access; role grants come back only for roles that still exist, are still pupil-scoped and are not superadmin — a role widened or deleted since revocation is not silently handed back. | 200, with state recomputed from the new date. |
| Moving the last day into the past on a live window | action: "set_until" with a date < today. | The window is shortened and access ends immediately, exactly as end_now would. | 200, with state: "ended". |
| Date outside the graduation's allowed range | The requested date precedes the graduation date or is more than 3,650 days after it. | Refused before any write. | 400 GRADUATE_ACCESS_DATE_INVALID. |
| No live graduation | The pupil has never graduated, or their graduation was revoked. | GET reports state: "none"; PATCH is refused. | 404 GRADUATE_ACCESS_NOT_FOUND. |
Actor lacks Users_UPDATE | Any PATCH, regardless of the requested action. | Refused before the graduation is even loaded, so a caller missing the permission learns that regardless of whether this pupil has graduated. | 403 GRADUATE_ACCESS_SIGN_IN_PERMISSION_REQUIRED. |
| A graduate attempts a portal write | The pupil is in grace or ended state and tries to submit or request something through their own portal. | Refused at the point the portal resolves the pupil's own identity, never on a raw id — so a stranger cannot learn a pupil has graduated from the shape of the refusal. | 403 STUDENT_GRADUATED_READ_ONLY. |
| A guardian reads their graduated child | The child's access has ended (grace is still visible; ended is not). | The child stops resolving as this guardian's child at all — the same 404 a stranger's child produces, because ending access is an audited lifecycle transition, not a record toggle a guardian's own view should see through. | 404 STUDENT_NOT_FOUND. |
| Sign-in changed by hand afterward | An administrator explicitly grants or revokes sign-in through the ordinary sign-in toggle, on either side of a graduation. | That explicit decision clears the internal flag that remembers graduate access switched sign-in off, so a later re-opening of the graduation window never overrides it. | No error — the two controls stop interacting. |
| A graduate's own read of their access | The pupil reads their own window from the portal rather than an administrator reading it. | Same four states, read through their own audience-scoped route rather than the admin one. | 200. |
5.11 Edit a staff member's salary and bank details from the staff form
The nine writable pay fields — basic salary, allowances, bank name, account number, branch, PAN, citizenship number, SSF and CIT — can be entered when a staff member is created, from the detail page's own panel, and on the staff edit form.
Because a teacher is a staff row with a teaching designation and the teachers screen reuses the same form, this covers teachers too; there is no separate teacher edit form.
The form uses the two salary endpoints: GET /staff/:id/salary behind StaffSalary_READ and
PATCH /staff/:id/salary behind StaffSalary_UPDATE. It issues the same two calls the detail panel does.
Three rules govern the block, and each one is load-bearing.
- The block renders only when the pay was actually read. Not when the request merely completed —
the client fetcher returns
{status: "forbidden"}for a 403 rather than throwing, so "the query succeeded" is true of a refusal. Rendering on that would show nine blank editable fields to somebody who may not read them, who could then type a real account number, save, and be told it saved while the write was correctly refused. - The write is gated on the same fact, not on the permission alone.
PATCH /staff/:id/salaryis a full-record write: every blank field is sent as an explicitnull, because omitting a cleared field would leave the old value in place. Without a loaded baseline, a save would therefore replace bank, PAN, citizenship, SSF and CIT with nulls — so an actor holdingStaffSalary_UPDATEand notStaffSalary_READissues no salary request at all. - The pay is sent only when it changed, compared field by field with both sides normalised: the
record stores
nullwhere the form holds"", so a naive comparison reports every untouched load as changed and rewrites the whole record on every unrelated save.
Two requests, not one. The staff patch runs first and the pay second, and only if the first
succeeded. PATCH /staff/:id accepts no salary block, so the key is stripped from that payload —
the API runs forbidNonWhitelisted and would otherwise reject the whole save. If the pay leg fails
after the staff patch landed, the message says so rather than implying the whole edit was lost.
Two further refusals apply to the write. Nobody edits their own pay or bank account, superadmin
included (403 SALARY_SELF_CHANGE_FORBIDDEN). Once the salary module holds any assignment for the
member, basicSalary and allowances change only through a salary assignment: sending a different
figure is refused with 409 STAFF_SALARY_MANAGED_BY_SALARY_MODULE, while re-sending the unchanged
figures beside a bank edit succeeds. The read reports this as managedBySalaryModule.
A staff member with no pay on file returns 200 with every column null, not a 404, so the block renders seeded blank — that is the population this change exists to serve.
Optimistic concurrency covers these fields. StaffSalaryDto carries the staff row's
version counter, and PATCH /staff/:id/salary requires it: a second operator saving pay
against a token that has moved is refused with 409 PEOPLE_STALE_RECORD rather than overwriting
the first silently. The check runs inside a transaction with the row locked, so it cannot be
raced.
The token is shared with the general staff edit, because both routes write the same staff row.
A client that saves the employment record and then the pay must send the version the first call
returned. What the token does NOT cover is the person's users row — a concurrent rename through
ban, unban, password reset or the users module does not move it.
5.12 Read the headline people counts
The dashboard shows active enrolled pupils, pupils admitted this Bikram Sambat month, employed and teaching staff, and guardians.
- The client calls
GET /api/people/summary. - The API requires
Students_READ; a role without it receives403. - The students section counts pupils within the caller's own scope.
wholeSchoolistruewhen that scope is the whole roll andfalsewhen the caller sees only part of it. - The staff section appears only for a role holding
Staff_READ, and the guardians section only for a role holdingGuardians_READ. Otherwise the section isnulland the client hides the tile. activeEnrolledcounts pupils with an active record and an active enrolment in the current academic session; it isnullwhen no session is current.monthcarries the Bikram Sambat month and its Gregorian first and last day, which boundadmittedThisMonth.
Counts are cached for 60 seconds. A student, staff, guardian or enrolment write clears them immediately; a bulk import, the graduate-access sweep or a change to a designation's teaching flag appears within 60 seconds. A slow or unavailable Redis never delays the response: the counts are read from the database.
5.13 Choose a staff member's roles
Summary
A staff member's roles decide what they can see and do once signed in. By default the job decides: every staff member holds staff, and a teaching designation adds teacher. An administrator can instead choose the roles at admission, and can change them later. Staff never hold a guardian- or pupil-scope role; a person who is also a parent or a pupil keeps that grant through their other profile, and the staff surfaces never show or touch it.
Preconditions
- Choosing roles on admission needs
Users_UPDATEas well asStaff_CREATE. Changing roles on an existing member needsUsers_UPDATEas well asStaff_UPDATE. Choosing somebody's authority is an identity change, the same line sign-in access is drawn at. An admission or edit that names no role needs only the staff permission. - Every role named must be one the actor could hand out: a role whose permissions the actor holds in full, and the superadmin role only if the actor is a superadmin.
- Changing roles on an existing member additionally requires the target to be somebody other than the actor.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Administrator | Opens the admission form; the Role select loads GET /staff/assignable-roles. | The roles the actor may hand out, minus guardian- and pupil-scope roles, in name order. | staff.controller.ts, staff-roles.service.ts (assignableRoles) |
| 2 | Administrator | Leaves the select on "From the job (Staff)", or picks one role. | Unpicked, the job decides. Picked, the chosen role replaces the job's roles: staff is not added implicitly. | StaffForm.tsx, useCreateRolePicker.ts |
| 3 | Backend (admission) | Checks Users_UPDATE before the transaction opens, then inside it refuses a teaching designation, resolves each id, and checks the actor may hand out each role. | The roles are granted in the admission transaction, or nothing is created. | staff.service.ts (create), staff-roles.service.ts (resolveForCreate) |
| 4 | Administrator | Changes an existing member's roles from the edit form's Role select, which sends PATCH /staff/:id carrying roleSource. | See the three choices below. | staff.controller.ts, StaffEditRoleField.tsx, useEditRolePicker.ts, staffEditRole.ts |
| 5 | Backend (edit) | Checks the pairing and Users_UPDATE, then the target (not self, not a protected superadmin), then the new set, inside the update transaction. | A refusal rolls the staff patch back with it. | staff.service.ts (changeRoles), staff-roles.service.ts (changeRoles) |
| 6 | Backend (edit) | Diffs the new set against the roles staff surfaces manage, checks the actor may hand out every role added and every role removed, then inserts and deletes. | Only the difference is written. | staff-roles.service.ts (replaceStaffRoles) |
| 7 | Backend (edit) | After commit, records an audit entry listing the roles added and removed, and, if any role was removed, asks the live-update service to close the person's open streams. | The person reconnects with the permissions they hold now. | staff.service.ts (afterRoleChange), realtime-control.ts |
The edit endpoint takes one of three choices:
roleSource | Effect |
|---|---|
| Omitted (keep current) | Roles are not touched. A designation change on its own never re-derives them. |
job | The roles become those the designation after this edit implies: staff, plus teacher for a teaching designation. |
explicit | The roles become exactly rolePublicIds (one to twenty ids, staff not implied). rolePublicIds is sent with explicit and only with it. |
Only the roles staff surfaces manage are replaced. A guardian- or pupil-scope role the person holds through another profile is never read, shown, added or removed by this flow.
The admin panel offers the role choice on the admission form and on the edit form. The edit form shows a Role select only to an actor holding both Staff_UPDATE and Users_UPDATE; any other viewer sees a read-only "Role: ..." line (or nothing, when the response carries no roles). The same read-only line replaces the select on the operator's own record, decided by comparing the signed-in user's id with the staff record's linked account id; while the session is unknown the select is still offered and the API refuses a self change. The select maps to the PATCH /staff/:id fields (Staff: Update); the roles come from GET /staff/:id/assignable-roles (Staff: Assignable roles (existing)).
| Select choice | Request fields | Notes |
|---|---|---|
| Keep current (the default, labelled with the roles held now) | None. The role keys are absent. | An edit that changes only the designation, or any other field, never sends a role field. |
| From the job (Staff, plus Teacher for a teaching designation); "Use the teaching roles (Staff, Teacher)" on a teaching designation | roleSource: "job" | Resolved against the designation as saved by this edit. |
| One role from the picker | roleSource: "explicit", rolePublicIds: [id] | The panel sends exactly one id. Not offered on a teaching designation. |
- On a teaching designation the select offers only Keep current and the job choice, because the API refuses an explicit role there (
400 STAFF_ROLE_TEACHER_JOB_ONLY). Switching the form to a teaching designation puts an explicit pick back on Keep current. - While the assignable list is loading or has failed, the select offers only Keep current and the job choice.
- Under the select, a "Replaces: ..." line names the roles the member holds now that the save would take away. It is empty for Keep current.
- Refusals show these messages:
PEOPLE_SELF_ROLE_CHANGE_FORBIDDENshows "You can't change your own role.";PEOPLE_ROLE_NOT_PERMITTED,USER_SUPERADMIN_PROTECTEDand a403 ROLE_NOT_FOUNDshow "You can't change this person's role.";USER_LAST_SUPERADMIN_PROTECTEDshows "This is the last superadmin; the role can't be removed.";STAFF_ROLE_SCOPE_NOT_ASSIGNABLEandSTAFF_ROLE_TEACHER_JOB_ONLYshow their own explanations;STAFF_ROLE_SOURCE_INVALIDasks the operator to reload.
Only a viewer holding Staff_UPDATE sees a member's roles: the roles list (public id and name, in name order) is present on the detail response and on the create and update responses for that viewer, and absent otherwise. A colleague reading the directory learns nothing about who holds what.
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
Actor lacks Users_UPDATE | rolePublicIds on admission, or roleSource / rolePublicIds on an edit. | Refused before anything is written. | 403 PEOPLE_ROLE_NOT_PERMITTED. |
| Unpaired edit fields | explicit without rolePublicIds, or rolePublicIds with job. | Refused. | 400 STAFF_ROLE_SOURCE_INVALID. |
| Own record | The target is the actor. Refused even when the new set is a subset of what the actor holds, because a peer role is a way to climb. | Refused. | 403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN. |
| Target is a superadmin, actor is not | Checked against the target's full role set. | Refused. | 403 USER_SUPERADMIN_PROTECTED. |
| Teaching designation | An explicit role on admission, or explicit on an edit whose resulting designation is teaching. | Refused: a teacher's roles come from the job. | 400 STAFF_ROLE_TEACHER_JOB_ONLY. |
| Unknown role | An id names no role, including one deleted between the request and the grant. | Refused; the roles are read FOR SHARE so a concurrent delete waits. | 400 ROLE_NOT_FOUND. |
| Guardian- or pupil-scope role | A chosen role is limited to guardians or pupils. | Refused on admission and edit alike, and absent from both pickers. | 400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE. |
| Role beyond the actor | A role added or removed carries permissions the actor does not hold, or is the superadmin role and the actor is not a superadmin. Removing a role is checked like adding one, so emptying a peer's roles is not a way to act above one's own level. | Refused. | 403 PEOPLE_ROLE_NOT_PERMITTED. |
| Last superadmin | The edit removes the superadmin role and no other live, unsuspended superadmin who can sign in and holds a password exists. | Refused under a row lock, so two concurrent demotions cannot reach zero. | 403 USER_LAST_SUPERADMIN_PROTECTED. |
job and a seeded role is missing | staff or teacher was deleted or never seeded. | An edit refuses rather than grant fewer roles than the job implies. (An admission or import logs the fault and carries on.) | 500 ROLE_NOT_CONFIGURED. |
| A role is removed | The person has open live-update streams and sessions acting as that role. | Streams are closed and reconnect with the new permissions; a session acting as the removed role is asked to choose a role on its next request. If the close request cannot be published, a warning is logged and the person keeps the permissions they connected with until they reconnect. | 200; the write is never failed by the close. |
| The role is later limited to guardians or pupils | PUT /roles/:publicId/row-visibility with scopeKind of guardian or student while any staff row, live or removed, holds the role. | Refused, so a staff role cannot become a guardian or pupil one after the fact. A removed member counts because a restore brings the grant back. Remove the role from them first. | 409 ROLE_SCOPE_HELD_BY_STAFF. |
5.14 Create a parent with sign-in access from the admin panel
The parent creation screen (/dashboard/parents/new) can create the parent and invite them in one submit.
- The checkbox. An operator holding
Users_UPDATEsees a "Grant sign-in access" checkbox. It renders only after the page has hydrated, because the permission is read from a client query and an unhydrated render would disagree with the server, and only on create. Editing sign-in after the parent exists happens on the parent detail page's sign-in card, not in the edit form. - What is sent. The panel sends
grantSignIn: trueonly when the box is ticked and the operator still holdsUsers_UPDATEat the moment of submit. A tab left open across a role change therefore cannot post a grant the operator can no longer see. An unticked box sends nograntSignInfield at all, and the panel never sendsperson.canLogin, so the two flags cannot disagree (400 PERSON_SIGN_IN_FLAGS_CONFLICT). - What the backend does.
POST /guardiansacceptsgrantSignIn, refuses it with403 AUTH_FORBIDDENunless the actor holdsUsers_UPDATE, and writes the person, theguardianrole grant and, when sign-in is granted and the parent has an email address, the invitation, all in one transaction. A parent with no email address is created with sign-in access and no invitation (invitation: {"sent": false, "reason": "no_email"}). The form shows a hint under the ticked box while the email field is empty. - The toast. The success message reports the invitation outcome:
Response invitation | Toast |
|---|---|
null (no grant requested) | "Parent created." |
sent: true with an address | "Parent created. Invitation sent to address." |
sent: true without one | "Parent created. Invitation sent." |
sent: false, reason: "no_email" | A warning: parent created with sign-in access, but no invitation was sent because there is no email address. |
sent: false, any other reason | A warning: parent created with sign-in access, but no invitation was sent. |
Sources: parents/_components/ParentForm.tsx, ParentSignInAccessField.tsx, useParentSignInGrant.ts, parent-form-utils.ts (buildCreateParentPayload), parent-form-announce.ts, and guardians.service.ts (create).
5.15 Staff and teacher lists after a write
The Staff and Teachers lists share one client-side query cache. After any successful staff create, update, suspend, delete, restore, unban, or sign-in grant or revoke, the panel invalidates every cached page and filter of that list, mounted or not, before it navigates or returns control. The row the operator just changed therefore shows its new state, including employment status, on the next view of either list, rather than the pre-write row for the rest of the cache lifetime. A refused write invalidates nothing. A password-reset link changes nothing the list shows and does not invalidate.
Only the list entries are invalidated. A member's salary and manager reads and the lookup reads are separate cache entries that a person write does not change.
Sources: staff/_components/staffListQuery.ts (invalidateStaffList), staffMutations.ts, StaffDetailsClient.tsx, StaffListClient.tsx, StaffTable.tsx.
5.16 Teacher routes
A teacher is a staff row with a teaching designation, so the Teachers screens reuse the staff pages rather than duplicate them.
| Route | Renders | Behavior |
|---|---|---|
/dashboard/teachers/new | The staff create form, restricted to teaching designations | The role select never renders, because a teacher's roles come from the designation. The save lands on the teacher's record under Teachers. |
/dashboard/teachers/[id] | The staff detail page | A record whose designation is not a teaching one redirects to /dashboard/staff/[id]. |
/dashboard/teachers/[id]/edit | The staff edit form | A non-teaching record redirects to /dashboard/staff/[id]/edit. |
Links, redirects after a save and row actions for a record opened under Teachers stay under /dashboard/teachers, so the sidebar keeps highlighting Teachers. The staff routes never redirect back, which keeps the pair from looping.
Sources: teachers/**, staff/_components/staff-paths.ts.
6. Admin Flows
Every write endpoint under /students, /guardians, and /staff is an admin-surface flow — there is no separate mobile or public surface for this domain. The flows below are the ones the format's checklist calls out that are not already covered as user-facing flows above.
6.1 Create
Covered as 5.1 (students), and identically shaped for guardians (POST /guardians — one transaction: users, guardians, and a guardian role grant) and staff (POST /staff — one transaction: users, staff, and staff/teacher role grants).
Whether the person gets a portal account is decided before the transaction opens, from
grantSignIn or the older person.canLogin, and requires Users_UPDATE on top of the create
permission. Absent means no: most people on a school's roll want a record and no login. Roles and
sign-in access are separate — the role grant above says what somebody may do once signed in, not
whether they may sign in at all.
On POST /staff the role grant is the job's roles (staff, plus teacher for a teaching
designation) unless rolePublicIds is sent. Sending it requires Users_UPDATE on top of
Staff_CREATE, is refused for a teaching designation, and replaces the job's roles rather than
adding to them. Flow 5.13 covers the rules.
6.2 List
Covered as the read path inside 5.1-5.7's supporting detail. Every list is scoped, cached, paginated (mandatory for guardians and staff; optional but supported for students), and searchable via trigram-plus-substring matching.
6.3 Read detail
Covered — findOne on each service, gated by PeopleAccessService.assertCanAccess.
6.4 Update
Covered as 5.3.
6.5 Reorder
Not applicable. Nothing in this domain has a manual display order.
6.6 Activate/deactivate
Nearest equivalent is employmentStatus on staff (active/on_leave/suspended/resigned/terminated/retired) and recordStatus on students (active/inactive) — both plain enum fields updated through the ordinary PATCH flow (5.3), not a dedicated action route. Login suspension (ban/unban, 5.6) is a separate axis from either.
6.7 Soft delete
6.8 Restore
6.9 Export/import
Not part of this module. Bulk admission/roll import is a separate DataImport/DataExport permission module and is out of scope here.
6.10 Moderation
Not applicable — there is no user-generated content in this domain to moderate.
6.11 Manual retry
Not applicable — every write here is synchronous; there is no queued job with a retry button.
7. Lifecycle and State Transitions
Three independent state axes exist per person, and they are deliberately never conflated:
- Profile liveness (
deleted_atonstudents/guardians/staff, and onusersonce every profile is gone) — soft delete and restore. - Login ability — two separate flags, and conflating them is a mistake the UI must not repeat.
users.can_loginsays whether the person has a portal account at all;users.bannedsays whether an existing account is currently suspended, and carries a recorded reason. Both are independent of whether the record is live, and neither has any effect on notification delivery: somebody with no portal account still receives every message the school addresses to them. - Domain status —
students.record_status(active/inactive) andstaff.employment_status(active/on_leave/suspended/resigned/terminated/retired) — a plain descriptive field the office sets, with no enforced transition graph of its own.
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
| Profile (any) | live | DELETE /:entity/:id | soft-deleted | Not deleting the actor's own row; guardian has no live linked student | users.deleted_at set only if no other live profile remains; account row and sessions deleted with it |
| Profile (any) | soft-deleted | POST /:entity/:id/restore | live | Reissuable code/email not claimed by a live row since | users.deleted_at cleared if it was set by this profile going |
| Login | active | POST /:entity/:id/ban | banned | Reason non-blank; not self; not a superadmin unless the actor is one too; not the last live superadmin | All sessions for the user deleted |
| Login | banned | POST /:entity/:id/unban | active | Not a superadmin unless the actor is one too | Reason/timestamp/actor cleared |
| Sign-in access | none (can_login = false) | POST /:entity/:id/sign-in | granted | Actor holds Users_UPDATE; not a superadmin unless the actor is one too; can_login still holds the value just read | Unless invite: false, the person has no email, or the account is suspended: a 7-day account_invite record and a queued invitation email, committed with the grant |
| Sign-in access | granted | DELETE /:entity/:id/sign-in | none | Same guards | All sessions for the user deleted |
| Sign-in access | none | POST /:entity with grantSignIn (or person.canLogin) | granted | Actor holds Users_UPDATE | The invitation is enqueued in the admission transaction, so it commits with the person or not at all |
| Staff roles | the roles the member holds | PATCH /staff/:id with roleSource: "job" or "explicit" | the job's roles, or exactly rolePublicIds | Actor holds Users_UPDATE; target is not the actor and not a superadmin unless the actor is one; no explicit roles for a teaching designation; no guardian- or pupil-scope role; the actor holds every permission of each role added or removed; the last superadmin role stays | Audit entry with the roles added and removed; open live-update streams closed when a role was removed |
| Staff roles | any | PATCH /staff/:id without roleSource, including a designation change | unchanged | None | None |
students.record_status | any | PATCH with recordStatus | any other value | None enforced — any value may follow any other | None beyond the write itself |
staff.employment_status | any | PATCH with employmentStatus | any other value | None enforced | None beyond the write itself |
Diagram (profile liveness, the only axis with real guard logic):
Login and domain-status are flat, unguarded toggles and are shown here as a single combined diagram for clarity rather than two trivial two-node graphs:
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Admit a student | users, students, code_counters, student_guardian, possibly a second users/guardians for a new parent | students:* swept | None | None | None | None |
| Admit a staff member | users, staff, code_counters, user_role (the job's roles, or exactly the chosen roles) | staff:* swept | None | None | None | None |
| Change a staff member's roles | staff (version bump), user_role (added and removed rows); a session's active_role_id is nulled by the foreign key when its role is removed | staff:* swept | None | A close-user message on the realtime control channel when a role was removed, so the person's open streams close and reconnect | Audit entry (Staff / UPDATE) with rolesAdded and rolesRemoved, when anything changed | None |
| Create a guardian | users, guardians, user_role | guardians:* swept | None | None | None | None |
| Update any profile | users (identity patch), the profile table | The entity's *:* list cache swept | None | None | None | None |
| Admit a student | users, students, student_guardian, code_counters, user_role (the pupil's student role) | students:* swept | None | None | None | Refuses the whole admission with ROLE_NOT_FOUND if the student role is unseeded |
| Set student guardians | student_guardian (full delete + reinsert), students.updated_at | students:* swept | None | None | None | None |
| Update medical record | students (three columns) | students:* swept (list projects updatedAt) | None | None | None | None |
| Update salary | staff (salary/bank columns) | staff:* swept | None | None | None | None |
| Soft delete a profile | The profile table, conditionally users, account (deleted), sessions (deleted) | Entity's *:* swept | None | None | None | None |
| Restore a profile | The profile table, conditionally users | Entity's *:* swept | None | None | None | None |
| Ban / unban | users (ban fields), sessions (deleted on ban) | Entity's *:* swept | None | None | None | None |
| Password-reset link | A verification-token row (owned by the auth module) | None | None | None | None | Password-reset email, sent fail-soft |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Stale PATCH | Two editors load the same student, both submit. | Second submission gets 409 PEOPLE_STALE_RECORD. | Reload, reapply the intended edit, resubmit. | students.service.ts |
| Guardian still has children | DELETE /guardians/:id on an active parent. | 409 GUARDIAN_HAS_LINKED_STUDENTS. | Unlink the children first (PUT /students/:id/guardians on each), then delete. | people-deletion.service.ts |
| Restore collides with a reissued code | Admission number/employee code was handed to somebody else while the record was deleted. | 409 STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICT / STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT, naming the number/code. | Assign the restored record a fresh number/code (update it), then restore succeeds. | people-deletion.service.ts |
| Restore collides with a reissued email | A live account has since taken the deleted person's address. | 409 USER_RESTORE_EMAIL_CONFLICT. | Change the email on the live holder or on the record being restored, then retry. | people-deletion.service.ts |
| A clerk tries to ban, unban, or reset a superadmin's credentials | Actor holds the entity's _UPDATE permission but does not hold the superadmin role, and the target does. | 403 USER_SUPERADMIN_PROTECTED. | Have another superadmin perform the action. | actor-authority.service.ts |
Roles chosen without Users_UPDATE | rolePublicIds on POST /staff, or roleSource / rolePublicIds on PATCH /staff/:id, from an actor holding only the staff permission. | 403 PEOPLE_ROLE_NOT_PERMITTED; nothing is written. | Have an administrator holding Users_UPDATE make the change, or resend without the role fields. | staff.service.ts |
| Role choice refused by the target or the role | Own record (403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN), a superadmin's record (403 USER_SUPERADMIN_PROTECTED), a teaching designation (400 STAFF_ROLE_TEACHER_JOB_ONLY), a guardian- or pupil-scope role (400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE), an unknown role (400 ROLE_NOT_FOUND), the last superadmin (403 USER_LAST_SUPERADMIN_PROTECTED), or a role beyond the actor's own permissions (403 PEOPLE_ROLE_NOT_PERMITTED). | The whole admission or edit is rolled back, including any staff field changes sent with it. | Pick a role from the picker, which already omits guardian- and pupil-scope roles and roles the actor cannot hand out; have another administrator or superadmin act on a protected account; for a teacher, change the designation instead. | staff-roles.service.ts |
| A role cannot be limited to guardians or pupils | PUT /roles/:publicId/row-visibility to guardian or student while any staff row holds the role. | 409 ROLE_SCOPE_HELD_BY_STAFF. | Remove the role from the staff members who hold it, then retry. | role.service.ts (setRowVisibility) |
| Live-update streams not closed after a role removal | The control channel is unavailable. | The edit still succeeds; a WARN is logged. The person keeps the permissions they connected with until their stream reconnects. | Automatic on reconnect; no operator action. | realtime-control.ts (closeUserStreams) |
| Roll/directory list requested unpaginated | ?pagination=false on /students, /guardians, or /staff. | 400 PAGINATION_LIMIT_INVALID. | Page through the results instead. | students.service.ts, guardians.service.ts, staff.service.ts |
| Redis unavailable during a read | Cache layer down. | Falls through to Postgres — slower, never a 500. | Automatic; no operator action. | redis.service.ts (getSoft) |
| Redis unavailable during invalidation | Cache layer down after a write. | Write still succeeds; a WARN is logged; stale list entries persist until their 120-second TTL. | Automatic expiry; no operator action needed for correctness (only for freshness). | redis.service.ts (delPatternSoft) |
| Unmapped constraint violation | A database CHECK/unique fires that PersonWriterService.translate does not recognise. | The raw driver error is rethrown — surfaces as an unmapped 500. | Add the constraint name to PersonWriterService.translate. | person-writer.service.ts |
11. Diagrams Required Per Module
- Actor capability diagram — §3 above.
- High-level module flow diagram — §6.1's create flowchart, representative of every write.
- Sequence diagram for each major user/admin/system flow — §5.1 through §5.7.
- State machine diagram for every lifecycle — §7.
- Data side-effect diagram for write flows — §9 and §12.6.
- Error branch diagram for critical flows — §6.7 (delete), §6.8 (restore).
12. Mandatory Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Grant sign-in | Granting to somebody who already has access | Administrator | POST /:entity/:id/sign-in | The invitation is sent, and the state is left as it stands | The can_login write is skipped; the invitation is decided on its own, which is what makes "add an email and try again" work | people-account.service.ts |
| Grant sign-in | Granting to a suspended person | Administrator | Target has banned = true | The account is granted; no invitation goes out | Reported as reason: "banned" — an invitation into an account the login guard refuses is a support call | people-account.service.ts |
| Grant sign-in | Granting without an invitation | Administrator | invite: false | The account is prepared silently | can_login written; no verification record, no email | sign-in-access.dto.ts |
| Grant sign-in | Granting to somebody with no email | Administrator | Person has no users.email | The grant stands and the response says why nothing was sent | can_login written; invitation skipped with reason: "no_email" | people-account.service.ts |
| Revoke sign-in | Revoking from somebody who never had it | Administrator | DELETE /:entity/:id/sign-in | Reported as success with the state unchanged | No write and no session sweep — the one direction allowed to return early | people-account.service.ts |
| Revoke sign-in | An unredeemed invitation is outstanding | Administrator | Revoke after inviting | The link becomes useless without being deleted | The record expires on its own within 7 days; a password set through it would not permit sign-in anyway | people-account.service.ts |
| Account invitation | The person also holds a password-reset link | The invited person | Redeeming either one | The other stops working | consumeAccountEntry retires every other account-entry record for that person | verification-token.service.ts |
| Staff roles | Admission without a role choice | Administrator | POST /staff without rolePublicIds | The member holds staff, plus teacher for a teaching designation | grantStaffRoles derives the job's roles; a missing seeded role is logged and the admission carries on | staff-roles.service.ts |
| Staff roles | Admission with a role choice | Administrator holding Users_UPDATE | POST /staff with rolePublicIds | The member holds exactly the chosen roles; staff is not implied | Resolved FOR SHARE, authorized per role, granted in the admission transaction | staff-roles.service.ts |
| Staff roles | Picker contents | Administrator | GET /staff/assignable-roles, GET /staff/:id/assignable-roles | Only roles the actor may hand out, without guardian- and pupil-scope roles | ActorAuthorityService.assignableRoles filtered by isStaffAssignableScope; the per-member route also runs the object-level access check (404 outside scope) | staff-roles.service.ts, staff.service.ts |
| Staff roles | Edit without role fields | Administrator | PATCH /staff/:id with a new designation and no roleSource | Roles stay as they were | No role read or write | staff.service.ts |
| Staff roles | Edit with roleSource: "job" | Administrator holding Users_UPDATE | PATCH /staff/:id | Roles re-derived from the designation the edit leaves in place | Diff against the managed roles; only the difference is written | staff-roles.service.ts (replaceStaffRoles) |
| Staff roles | Guardian or pupil grant held through another profile | Administrator | Any role edit on a teacher who is also a parent | The guardian grant is untouched and not shown | managedRoles excludes guardian- and pupil-scope roles from both the diff and the roles list | staff-roles.service.ts |
| Staff roles | Role removed | Administrator | Any edit that removes a role | The person's open live-update streams close and reconnect | closeUserStreams publishes close-user; failure is a WARN | staff.service.ts (afterRoleChange) |
| List students | Empty search term | Any list caller | search="" | Trimmed to nothing by QueryDto, treated as no search | No trigram threshold pinned; no %/ILIKE condition added | common/dto/query.dto.ts, students.service.ts |
| List students | % typed in a search box | Any list caller | search=100% | The literal percent sign is matched, not treated as a wildcard | escapeLikePattern escapes %, _, \ before the ILIKE half | person-writer.service.ts |
| List students | Search with fewer than 3 extractable trigrams | Any list caller | Very short or non-Latin term | Trigram half returns almost nothing; substring half still matches | Both halves of personTrigramMatch/admission-number ILIKE run and are OR'd | search.constants.ts, person-writer.service.ts |
| List (any) | Tied updated_at across a bulk-imported batch | Any list caller, page 2+ | Two rows share an identical timestamp | No row is silently dropped or duplicated across pages | id is appended as a tie-breaker to every ORDER BY | students.service.ts, guardians.service.ts, staff.service.ts |
| List students | hasGuardians=false | Any list caller | Filter for incomplete records | Only students with no live guardian link returned | not(liveGuardianExists()) — a correlated NOT EXISTS | students.service.ts |
| List guardians | hasChildren=false | Any list caller | Filter for guardians linked to nobody live | Only guardians with no live linked student returned | not(liveChildExists()) | guardians.service.ts |
| List (any) | includeDeleted=true | Any list caller | Wants to see removed records too | Both the profile row and the users row must be checked — a users row can go dark independent of the profile | Both isNull filters lifted together, never just one | students.service.ts comment on this exact bug |
| Sort | sort/sortBy names a column not on the allow-list | Any list caller | e.g. ?sort=basicSalary on staff | Refused rather than silently defaulting or leaking a ranking | 400 PEOPLE_INVALID_SORT_FIELD | staff.service.ts, students.service.ts, guardians.service.ts |
| Students list | pagination=false | Any list caller | Attempt to fetch the whole student roll unpaged | Refused — every pupil's name, date of birth, address and guardians in one response | 400 PAGINATION_LIMIT_INVALID | students.service.ts |
| Guardians list | pagination=false | Any list caller | Attempt to fetch the whole guardian directory unpaged | Refused — a restricted scope's predicate is a per-row subquery | 400 PAGINATION_LIMIT_INVALID | guardians.service.ts |
| Staff list | pagination=false | Any list caller | Attempt to fetch the whole staff directory unpaged | Refused — an unbounded read of every employment record | 400 PAGINATION_LIMIT_INVALID | staff.service.ts |
| Create student | Admission number omitted | Officer | No admissionNumber in the body | One is allocated atomically for the current academic year | code_counters upsert in the school's own timezone | people-code.service.ts |
| Create student | Admission number supplied | Officer | Importing an existing roll | Used verbatim, still uniqueness-checked | students_admission_number_unique (partial, live rows only) | students.service.ts, people.ts schema |
| Create guardian | kind: "organization" | Officer | An NGO or company is the responsible party | organizationName derived from person.firstName, never entered twice | guardian_org_has_name CHECK enforced at the database too | guardians.service.ts |
| Update guardian | person.firstName changes while kind stays organization | Officer | Renaming an org guardian | organizationName recomputed even though kind was not in the patch | newFirstName always recomputed from the merged current+patch state | guardians.service.ts |
| Update student | imeisId sent as an empty string | Officer | Clearing a field via a blank form input | Stored as NULL, not "" | blankToNull / `.trim() | |
| Delete guardian | Guardian has a live linked student | Officer | DELETE /guardians/:id | Refused, naming the reason | 409 GUARDIAN_HAS_LINKED_STUDENTS | people-deletion.service.ts |
| Delete profile | Person holds another live profile | Officer | e.g. deleting the guardian profile of someone who is also staff | users row untouched — the person still exists in the system | hasNoLiveProfile counts across all three tables | people-deletion.service.ts |
| Restore profile | Restoring a no-op | Officer | POST .../restore on an already-live row | Silent no-op, 200 | if (row.deletedAt === null) return; | people-deletion.service.ts |
| Ban | Already banned | Officer | Ban called twice | Idempotent overwrite of reason/timestamp/actor | Plain UPDATE, no uniqueness or state-machine guard | people-account.service.ts |
| Salary update | Only bankName sent, no salary figures | HR | Bank-detail-only edit | basicSalary/allowances pair check is skipped entirely | touchesBasic/touchesAllowances both false | staff-salary.service.ts |
| Search cache | Two searches differing only by field-permission | Two different actors | One holds StaffSalary_READ, one doesn't | Each gets its own cached list even though the filters are identical | viewTag folded into every cache key | people-access.service.ts |
12.2 Business Process Diagram Pack
| Diagram | Required When | Purpose |
|---|---|---|
| User journey map | Always | §5.1-§5.7 sequence diagrams. |
| Service blueprint | Multi-actor or backend-heavy flows | Below. |
| Activity diagram | Every major flow | §6.1, §6.7, §6.8. |
| State diagram | Any lifecycle | §7. |
| Swimlane diagram | Multi-actor flow | Below. |
| Sequence diagram | API-backed flow | §5.1-§5.7. |
| Data side-effect graph | Any mutation | §12.6. |
| Exception flow diagram | Critical failure scenarios | §6.7/§6.8, §10. |
Service blueprint — admission:
Swimlane — who owns each part of an admission:
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| No father/mother columns; a student has zero or more guardians of any relationship | A mandatory "Father's Name" field forces a widow or a child of unknown parentage to account for an empty box at every admission. | Every admission form asks for guardians as a list, never fixed slots. | student_guardian join table, guardianRelationshipEnum | guardians[] array on create/update | No father/mother column exists anywhere to migrate away from later | students.service.integration.spec.ts — "records a student with no guardian" |
| Two students on the same guardian row are siblings; nothing else needed | Half-siblings sharing one parent, and full siblings, need no special case. | Office links by guardianId, never re-enters the parent. | student_guardian (many-to-many) | POST /students/guardian-lookup finds the existing row | resolveGuardian links rather than creates when guardianId is given | students.service.integration.spec.ts — "attaches a sibling to the SAME guardian row" |
| Relationship is never defaulted in any UI | A prefilled "father" becomes wrong data every time an operator tabs past it. | Officer must actively choose a relationship. | @IsEnum(GUARDIAN_RELATIONSHIPS), no @ApiPropertyOptional({default:...}) on relationship | relationship is required on UpsertStudentGuardianDto | — | Schema comment, people.ts |
isRecordComplete is computed, never stored | A stored flag is a second writer that can disagree with reality; it must also account for a guardian's own soft delete. | Office sees an accurate "incomplete" flag without a background job keeping it in sync. | liveGuardianCount() correlated subquery joins student_guardian → guardians → users, all live | StudentDto.isRecordComplete | Computed at read time on every list/detail query | students.service.integration.spec.ts — "reports a student incomplete once their only guardian is deleted" |
| Creating a person does not create an account | Most people on a school's roll — a sweeper, a very young pupil, an emergency contact — need a record and no login. Handing every one of them credentials creates accounts nobody asked for and nobody watches. | The admission and staff forms carry an explicit "give this person a portal account" choice, off by default. | PersonWriterService.buildInsert takes canLogin as a required argument with no default; PeoplePermissionsService.resolveSignInGrant decides it | grantSignIn on the three create bodies, and the older person.canLogin | Every create path states the value having first checked it may | students.service.integration.spec.ts — "writes nothing at all when the grant is refused" |
Granting sign-in requires Users_UPDATE, not the profile permission | A login-capable row with an email address is a route to a session: POST /auth/password/forgot is public, so anybody who can create one can obtain one. That is an identity change, not a record edit. | An admissions clerk can admit pupils all day and cannot mint a single account. | resolveSignInGrant and the six :id/sign-in routes, all through PeoplePermissionsService.can | 403 AUTH_FORBIDDEN on a grant without the permission | can() lets a superadmin bypass; a raw membership test would deny them after a release that added codes without permissions:sync | students.service.integration.spec.ts — "refuses to grant sign-in without Users_UPDATE" |
| Sign-in access says nothing about notifications | A parent with no portal account still has a phone number and a right to be told their child is ill. Filtering messages by login capability silently drops the people least able to find out another way. | Every person addressed by a notification receives it, account or not. | Absent from the notification audience resolver and the channel send path | None — can_login is not consulted on any delivery path | can_login means "may authenticate" and nothing else | Notification module specs |
| An invitation commits with the person it invites | A fire-and-forget send after the commit loses the invitation on a restart between the two: the person exists, believes they were invited, and nothing is scheduled. | Somebody who is told they have been invited has been. | PeopleAccountService.inviteOnCreate runs on the caller's transaction, through the notification outbox | invitation on the create response reports enqueued, never delivered | The verification record, the event and its outbox row share the create's transaction | students.service.integration.spec.ts — "grants, and invites, in one call" |
| Staff are never given a guardian- or pupil-scope role | A guardian-scope role shows the holder their children's rows and a pupil-scope role shows them their own pupil rows. Neither means anything for an employee, and granting one from a staff surface hands an employee a parent's or a pupil's view of the people domain. | The pickers do not list such roles and an admission or edit that names one is refused. The Roles screen refuses to limit a role to guardians or pupils while any staff member holds it. | STAFF_EXCLUDED_ROLE_SCOPES and isStaffAssignableScope in staff-roles.service.ts, read by both pickers, the admission and edit writes, the roles list and RoleService.setRowVisibility | 400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE; 409 ROLE_SCOPE_HELD_BY_STAFF | The set an edit replaces excludes these roles, so a grant the person holds through another profile survives | staff.service.spec.ts, role.service.spec.ts |
Choosing a staff member's roles needs Users_UPDATE | The roles a person holds are the authority they sign in with. Choosing them is an identity change, not an edit to an employment record. | A clerk holding only Staff_CREATE or Staff_UPDATE can admit and edit staff all day and cannot hand anyone authority. | StaffService.assertMayChooseRoles through PeoplePermissionsService.can, so a superadmin passes | 403 PEOPLE_ROLE_NOT_PERMITTED | The check runs before the transaction on admission and as the second step of an edit's role change | staff.service.spec.ts |
| A teaching designation's roles come from the job | A teacher is a staff row with a teaching designation, and the teacher role follows the designation. A hand-picked role on top would let the two disagree. | The create form hides the Role select for a teaching designation and on the Teachers route. | StaffRolesService.assertExplicitAllowed | 400 STAFF_ROLE_TEACHER_JOB_ONLY | staff and teacher are derived by name, which is why both are system roles | staff.service.spec.ts |
| An edit changes roles only when asked | Moving a member to another designation should not silently strip a custom role chosen for them, and re-deriving by default would. | Roles change on roleSource: "job" or "explicit" and never otherwise. | StaffService.update calls changeRoles only when roleSource or rolePublicIds is present | The response carries the resulting roles for a viewer holding Staff_UPDATE | A designation change on its own writes no user_role row | staff.service.spec.ts |
| Role changes are checked in both directions and never on yourself | An actor who cannot hand out a role must not be able to strip it either, or emptying a peer's roles becomes a way to act above one's own level. Changing one's own roles, even to a subset, is a way to climb sideways into a peer role. | An administrator cannot change their own roles, a superadmin's unless they are a superadmin, or the last superadmin role at all. | StaffRolesService.changeRoles and replaceStaffRoles, over ActorAuthorityService.assertMayActOnAccount, assertMayAssignRole and assertNotLastSuperadmin | 403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN, USER_SUPERADMIN_PROTECTED, PEOPLE_ROLE_NOT_PERMITTED, USER_LAST_SUPERADMIN_PROTECTED | The last-superadmin check takes FOR UPDATE on the holders, so concurrent demotions serialize | actor-authority.service.spec.ts, staff.service.spec.ts |
| Out-of-scope reads return 404, never 403 | A 403 confirms the record exists, turning the id space into an enumeration oracle against a roll of children. | A guardian probing another family's student id learns nothing. | PeopleAccessService.assertCanAccess | Every :id route on all three controllers | No NOT_YOUR_RECORD code exists anywhere in error-codes.ts | guardians.service.spec.ts — "lets a guardian see only themselves, and 404s (never 403)" |
| Salary and health fields are omitted, not nulled | A field that appears or vanishes by permission is a response-shape function of authorization every consumer would have to model; nulling would still leak the field's existence and shape. | A colleague with Staff_READ alone sees no basicSalary key at all in the base response — not null, absent. | Separate StaffSalaryDto/StudentMedicalDto, separate services, separate permission codes | Separate GET/PATCH .../salary and .../medical routes | STAFF_SALARY_COLUMNS never appears in PERSON_SELECTION or StaffDto's query | staff.service.spec.ts — "keeps salary out of the staff response, omits it without the permission, and returns it with the permission" |
student_single_primary_guardian is a non-deferrable partial unique index | A deferrable constraint would let an intermediate two-primary state exist mid-transaction; not deferring forces the two-phase write pattern that never creates that state. | Reassigning the primary always succeeds in one call. | Partial unique index on (student_id) WHERE is_primary | PUT /students/:id/guardians | writeGuardianLinks inserts all rows non-primary, then flips one | students.service.integration.spec.ts — "moves the primary flag between guardians in one transaction" |
| Deletion is profile-scoped | One person may hold two profiles (e.g. an eighteen-year-old student who is also a sibling's guardian); person-scoped deletion would remove one via a side effect of the other. | Deleting a student never silently removes a guardian who happens to be the same person. | hasNoLiveProfile counts across students/guardians/staff before touching users | DELETE on any of the three entity routes | users.deleted_at set only when the count reaches zero | students.service.integration.spec.ts — "does not remove the person when they still hold another live profile" |
total_salary has no coalesce | A missing salary stored as 0.00 is indistinguishable from a genuine zero in a payroll sum. | HR sees null, not a misleading रु 0, for an unset salary. | generatedAlwaysAs(sql\basic_salary + allowances`)`, both columns nullable together | StaffSalaryDto.totalSalary | staff_salary_pair_coherent CHECK keeps the pair coherent | staff.service.spec.ts — salary tests |
| Codes allocated by one atomic upsert, year in school timezone | max()+1 races under concurrent admissions; a UTC year would misfile admissions for 5h45m every New Year. | Two officers admitting at once never collide on a number. | code_counters upsert with ON CONFLICT DO UPDATE ... RETURNING | admissionNumber/employeeCode on the create response | PeopleCodeService.allocate | students.service.integration.spec.ts — "allocates consecutive admission numbers without collision" |
Search pins pg_trgm.similarity_threshold per transaction | It is a SESSION setting; a pooled connection carries whatever the last borrower left. | Search results are deterministic regardless of which pooled connection served the request. | withSearchThreshold wraps the query in a transaction with set_config(..., true) | Any search= query parameter | students.service.ts, guardians.service.ts, staff.service.ts all route search through it | students.service.integration.spec.ts — "finds a student by a misspelt name" |
| Every people cache key carries a scope tag and a view tag | Omitting the view tag lets a wider-permission caller's rendering get served to a narrower one under one shared key. | A teacher never receives HR's cached staff list even if both query identically. | PeopleAccessService.cacheTags | Every list endpoint's Redis key | CacheKeyUtil.build always receives both ["scope", ...] and ["view", ...] | Comment-documented; no direct spec observed for the cache-poisoning case itself |
| Guardians and students hold no module permission by default | GET /users is the whole-school directory; granting a guardian any read on it would hand every family the entire roll. | A guardian's or student's own-record access runs entirely through object-level scope. | GUARDIAN_PERMISSIONS = [], STUDENT_PERMISSIONS = [] | PeopleAccessService.scopeFor's guardian/student branches | RoleGuard still passes because the routes above them are gated by scope, not a module code, for these two roles' own-record reads | seed-auth.ts |
studentId is never reissued; admissionNumber is | A transcript or an external system needs one identifier that never points at a different pupil later; the admission process needs a number a school migrating its roll can supply and, after a removal, reissue. | A pupil's permanent id never changes even if their admission record is removed and re-created. | students_student_id_unique (full unique index) vs students_admission_number_unique (partial, WHERE deleted_at IS NULL); separate code_counters scopes (student_id/student) | StudentDto.studentId is response-only — no CreateStudentDto/UpdateStudentDto field exists for it | PeopleCodeService.allocate("student_id", ...) runs on every create, unconditionally | people-code.service.ts allocation logic; schema comments in people.ts |
| Staff salary on create is gated on key presence, not on value | Silently ignoring an unpermitted salary key would make a rejected write indistinguishable from a successful one that had nothing to save; an actor without the permission must not be able to probe whether the field is even accepted. | An HR clerk without StaffSalary_UPDATE gets an explicit 403 rather than a staff record that quietly has no pay recorded. | StaffSalaryService.resolveSalaryForCreate checks Object.hasOwn(dto, "salary") before inspecting its contents | POST /staff salary: {} → 403; omitting the key → 201 with no salary set | staff.service.ts delegates the whole decision to StaffSalaryService rather than checking the permission itself | staff.service.spec.ts |
recordVisibility replaced includeDeleted | A boolean cannot distinguish "records I normally work with," "only the removed ones," and "both" — the old boolean answered a different question than the one the "Show removed" checkbox asked, and returned every record instead of only the removed ones. | An office searching for a removed pupil gets exactly the removed records, not the removed records mixed into everyone else. | RECORD_VISIBILITIES = ["current","removed","all"], a dedicated query field on all three list DTOs | ?recordVisibility=removed on GET /students|/guardians|/staff; ?includeDeleted= still parses but is never read | Kept on each query DTO for one release because forbidNonWhitelisted would otherwise turn a bookmarked ?includeDeleted=true link into a 400 | apps/api/src/common/dto/record-visibility.ts (rationale in the file's own comment) |
| At most one live, active Principal | A school profile, a report card header, and any future permission tied to "is this person the principal" all need one unambiguous answer, and no index can express "at most one row whose designation is a specific flagged row." | An admin cannot assign the Principal designation to a second active staff member while one already holds it — the write is refused, naming who currently holds it. | PrincipalInvariantService.assertSinglePrincipal, called from five verified write sites (staff create, staff update, bulk import, restore, employment-status-to-active) | 409 STAFF_PRINCIPAL_ALREADY_ASSIGNED on the write that would create a second principal; School Profile's principal field always reflects the current sole holder or null | No schema constraint enforces this — enforced only in application code, at every write that could break it | principal-invariant.service.ts's own docblock enumerates the five sites and the fixed lock order |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
All three profiles carry an optimistic-concurrency version | Two office staff editing the same pupil, guardian or employee at once cannot silently overwrite each other. | A version counter per profile row, incremented by the bump_row_version BEFORE UPDATE trigger and compared by one shared versionOf/matchesVersion helper. | Keep it in application code at each write site | Twelve update sites across eight files write these tables, two of them through a table selected at runtime that no grep finds. A hand-maintained increment has to be remembered at every one, forever, and forgetting it yields a check that silently does not run. | The trigger fires on every UPDATE, so a bulk data repair invalidates every open form for every row it touches. |
| Account actions check "may this actor act on this account" separately from "would this leave no superadmin" | A superadmin cannot be suspended, restored, or sent a reset link by an office clerk merely because a second superadmin happens to exist on file. | One shared ActorAuthorityService.assertMayActOnAccount, consulted by ban, unban, and sendPasswordResetLink alike, alongside the pre-existing assertNotLastSuperadmin. | Rely on the last-superadmin count alone | The count answers "would the system still have a working superadmin", which any holder of the entity's _UPDATE permission satisfies trivially whenever two or more superadmins exist — it says nothing about whether that clerk was entitled to touch this particular account. | Without the separate check, suspending, restoring, or resetting the credentials of the one account that could reverse the action would be reachable by anyone holding ordinary _UPDATE on any of the three entities. |
Guardian restore requires Guardians_RESTORE; staff restore requires Staff_RESTORE; student restore is gated by Students_UPDATE | — | — | Gate all three restores under a matching _RESTORE permission | An administrator granted Students_UPDATE but not Students_RESTORE can restore a soft-deleted student today, while the same shape of grant would not restore a guardian or staff member — a genuine inconsistency visible by comparing the three controllers. | A permission model built on the assumption that _RESTORE is required everywhere would under- or over-grant for students specifically. |
Guardian/staff :id params are plain strings; student :id uses ParseUUIDPipe | — | — | ParseUUIDPipe on every entity's :id | A malformed guardian or staff id reaches the service and fails as a plain "not found" (the query simply matches nothing) rather than a 400 at the pipe. | Slightly less precise error for a malformed id on those two entities; no correctness issue since the query still returns no row. |
| At least one guardian is required at admission | A pupil with no recorded family contact is a data gap the office cannot act on, not a legitimate partial record. | StudentGuardiansService.assertGuardianSetValid refuses an empty set with 409 STUDENT_REQUIRES_ONE_GUARDIAN. | Permit zero guardians, as an earlier version of this rule did | An admissions officer with incomplete family details at the desk cannot complete the admission until at least one guardian is entered. | Low — the admission form's create-or-select guardian step is expected to run before submit either way. |
| Salary and medical data live in the base tables, gated by a separate read path, rather than separate tables | One profile row per person, no join needed for the base record. | Simpler schema; the field-level gate is enforced in the SELECT list, not by a table boundary. | Split into staff_salary/student_medical tables | A future bulk export or raw query against staff/students must remember to exclude these columns itself — the table boundary would have made that structural. | A careless SELECT * anywhere against these tables leaks gated fields; none of the reviewed service code does this, but the schema does not prevent it. |
Fail-soft cache (getSoft/setSoft/delPatternSoft) | A Redis outage degrades to slower reads, never an outage of the people directory. | One shared pattern across all three services. | Fail-closed cache (rethrow on Redis error) | A failed invalidation leaves a stale list for up to 120 seconds — acceptable for a directory, not for a balance or a lock. | A newly admitted student can be briefly absent from a cached list during a Redis outage. |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| List (any) | Empty state | No rows match the filters | data: [], count: 0 | Empty list, no error | queryList in each service |
| Admit a student | First admission of the year | code_counters has no row yet for (student, year) | Counter row created via the upsert's INSERT branch, starts at 1 | STU-2026-0001 | people-code.service.ts |
| Admit a student | Ten-thousandth admission | Sequence reaches 10000 | Number widens past 4 digits rather than truncating | STU-2026-10000, not a collision with STU-2026-1000 | people-code.service.ts (padStart, never lpad truncation) |
| Set guardians | Duplicate action | Same PUT body submitted twice | Second call deletes and rewrites identically — idempotent by construction | 200 both times, same resulting set | student-guardians.service.ts |
| Update (students) | Concurrent action | Two PATCHes race on the same student | Second to reach the row lock sees a version mismatch (if the first already committed) or blocks briefly on the FOR UPDATE lock | 409 PEOPLE_STALE_RECORD for the loser | students.service.ts |
| Restore | Expired state | Restoring a record deleted long enough ago that its code/email were reissued | Refused with the specific conflict code | 409 naming what to change first | people-deletion.service.ts |
| Access scope | Permission mismatch | Guardian role active but session somehow carries a stale/invalid activeRole | RoleGuard throws first (PERMISSION_ROLE_NOT_ASSIGNED/AUTH_ACTIVE_ROLE_REQUIRED) before the service is ever reached | 403 | role.guard.ts |
| Access scope | Guest/self limitation | Guardian requests a student who is not theirs | Same as record-missing | 404 STUDENT_NOT_FOUND | people-access.service.ts |
| Guardian lookup | Missing dependency | Phone belongs to nobody | Empty array | Office proceeds to create | student-guardians.service.ts |
| Any list | Cache stale/miss | First read after a TTL expiry or an invalidation | Cache miss, query runs, result cached for another 120s | Same response either way — cache is invisible to the caller | redis.service.ts |
| Search (any) | Unsupported filter or sort option | sort=medicalConditions on students | Refused | 400 PEOPLE_INVALID_SORT_FIELD | students.service.ts (STUDENT_SORTABLE allow-list) |
| Staff roles | Concurrent action | A chosen role is deleted, or limited to guardians or pupils, while an admission or edit is in flight | The role is read FOR SHARE in the writer's transaction; the delete and the narrowing take the row FOR UPDATE and wait | 400 ROLE_NOT_FOUND if the delete lands first, otherwise the grant completes | staff-roles.service.ts |
| Staff roles | Duplicate action | The same explicit edit is sent twice, the second with the current version | The second finds nothing to add or remove | 200, no audit entry, no stream closed | staff-roles.service.ts (replaceStaffRoles) |
| Staff roles | Permission mismatch | A teacher who is also a parent has a role edit | The guardian grant is outside the managed set and survives; the roles list does not show it | 200 | staff-roles.service.ts (managedRoles) |
| Staff roles | Guest/self limitation | An administrator edits their own roles | Refused, even when the new set is a subset of what they hold | 403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN | staff-roles.service.ts (changeRoles) |
| Search | Queue failure | N/A — this domain has no queue | — | — | — |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Admit a student | Guardian rows (if linking), code_counters | users, students, student_guardian, code_counters | Sweeps students:* | None | Full StudentDto |
| List students | students, users, correlated student_guardian/guardians/users subqueries | None | Reads/writes students:list:... | None | StudentDto[], totalCount |
| Update salary | staff current row | staff salary columns | Sweeps staff:* | None | StaffSalaryDto |
| Soft delete | Profile row, users liveness count | Profile table, conditionally users/account/sessions | Sweeps entity *:* | None | void (ResponseDto with data: null) |
12.7 Experience Quality Checklist
- The doc explains what the actor is trying to accomplish — admit a pupil, register a parent, onboard an employee, and manage each safely afterward.
- The doc explains what the backend does that the actor does not see — atomic code allocation, the two-phase primary-guardian write, computed completeness, cache tag composition.
- The doc covers every minor flow and branch — see §12.1 and §12.5.
- The doc includes user, admin, and system flows where applicable (this domain has no guest or automated-worker flows — every entry point is an authenticated, permissioned human action).
- The doc explains business logic, tradeoffs, and rationale — §12.3, §12.4.
- The doc maps every flow to API routes and backend side effects — §4, §9, §12.6.
- The doc includes diagrams appropriate to each flow type — §5-§7, §12.2.
- The doc covers all edge cases and failure recovery — §10, §12.5.
13. Completion Checklist
- Every feature, minor action, and submodule capability is listed — §4, §12.1.
- Every actor has allowed and forbidden behavior — §3.
- Every major and minor flow includes steps, branches, and diagrams — §5, §6.
- Every lifecycle has a transition table and state diagram — §7.
- Every flow links to the API and backend docs — §4, and inline throughout §5.
- TDD dependencies are called out where they shape behavior — no sibling TDD doc exists for this module in this repository; none is referenced.
See Also
- API doc: /docs/developer/people/api
- Backend doc: /docs/developer/people/backend
Technical Introduction
The Skoolsewa backend — a NestJS API over PostgreSQL, MongoDB, Redis and BullMQ in a Turborepo and pnpm monorepo — and how to run it locally.
People Backend Documentation
Backend architecture, data model, services, cache, and runtime rules for the people domain (students, guardians, staff).