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. |
| 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. |
| 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 grants; see STAFF_PERMISSIONS in seed-auth.ts. A teacher holds nothing beyond a staff member today — teaching-specific grants arrive with the classes module. |
| 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) | 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 |
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.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 — could be entered when a staff member was created and only from the detail page's own panel afterwards. They are now on the staff edit form as well.
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.
No API change. Both endpoints already existed: GET /staff/:id/salary behind StaffSalary_READ
and PATCH /staff/:id/salary behind StaffSalary_UPDATE. The edit form issues the same two calls the
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.
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.
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.
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 |
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 | staff:* swept | None | None | None | 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 |
| 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 |
| 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" |
| 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) |
| 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
A modern full-stack TypeScript monorepo combining React 19, NestJS, TanStack Router, Drizzle ORM, and PostgreSQL - all managed with Turborepo and pnpm workspaces.
People Backend Documentation
Backend architecture, data model, services, cache, and runtime rules for the people domain (students, guardians, staff).