Skoolsewa - Ecommerce Docs
Developer ResourcesPeople

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 TypeFiles or DocsWhat Was Extracted
Schemapackages/db/src/schema/school/people.ts, packages/db/src/schema/identity.ts, packages/db/src/schema/school/lookups.tsTable shapes, constraints, indexes, the guardian/sibling model, the salary generated column.
BackendSibling backend doc, apps/api/src/modules/people/**/*.service.tsBusiness behavior, transactions, cache invalidation, code allocation, deletion rules.
APISibling API doc, apps/api/src/modules/people/**/*.controller.tsRoute surface, permissions, guards, request/response shape.
Authorizationpackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsWhich modules exist, which of the five seeded roles hold what by default.
Testsstudents.service.integration.spec.ts, guardians.service.spec.ts, staff.service.spec.tsConfirmed edge-case behavior — cited per row below.

2. Feature Summary

FieldValue
ModulePeople
SubmoduleStudents, Guardians, Staff (plus StudentMedical and StaffSalary as field-level sub-permissions)
Primary user valueAn 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.
ActorsSuperadmin (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 pointsPOST /students, POST /guardians, POST /staff, and the list/detail/update/delete/restore/account-action routes under each.
Main outputsA 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 docsAPI 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.

ActorCan DoCannot DoAuth RequirementNotes
SuperadminEverything 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.
GuardianRead 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.
StudentRead 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 aboveRead 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

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Admit a studentAdminAdministratorPOST /studentsGuardian rows (if linking existing), admission-number counterusers, students, student_guardianStudents: Create
List studentsAdminAdministrator, staff, teacher, guardian (own children), student (self)GET /studentsstudents joined to usersNone (cached read)Students: List
Look up guardians by phoneAdminAdministrator, staff, teacherPOST /students/guardian-lookupguardians joined to usersNoneStudents: Guardian lookup
Update a studentAdminAdministratorPATCH /students/:idCurrent students/users row, locked FOR UPDATEusers, studentsStudents: Update
Replace a student's guardiansAdminAdministratorPUT /students/:id/guardiansExisting links, candidate guardiansstudent_guardian (full replace)Students: Set guardians
Read a student's health recordAdminAdministrator with StudentMedical_READGET /students/:id/medicalstudents.medical_conditions/allergies/special_needsNoneStudents: Medical
Update a student's health recordAdminAdministrator with StudentMedical_UPDATEPATCH /students/:id/medicalSame three columnsSame three columnsStudents: Medical update
Soft delete / restore a studentAdminAdministratorDELETE /students/:id, POST /students/:id/restoreProfile-liveness count across all three profile tablesstudents.deleted_at, possibly users.deleted_at, account, sessionsStudents: Delete/Restore
Suspend / restore a student's loginAdminAdministratorPOST /students/:id/ban, .../unbanusers.bannedusers.banned, .ban_reason, .banned_at, .banned_by; deletes sessionsStudents: Ban
Email a student a password-reset linkAdminAdministratorPOST /students/:id/password-resetusers.email/can_login/bannedA verification-token rowStudents: Password reset
Give a student a portal accountAdminAdministrator holding Users_UPDATEPOST /students/:id/sign-in, or grantSignIn on POST /studentsusers.can_login/emailusers.can_login; an account_invite verification row and a queued invitation emailStudents: Grant sign-in
Take a student's portal account awayAdminAdministrator holding Users_UPDATEDELETE /students/:id/sign-inusers.can_loginusers.can_login; deletes sessionsStudents: Revoke sign-in
Create a guardianAdminAdministratorPOST /guardiansusers, guardians, user_role (grants guardian)Guardians: Create
List / read a guardian, their childrenAdminAdministrator, guardian (self)GET /guardians, GET /guardians/:id, GET /guardians/:id/studentsguardians joined to users; student_guardian joined to studentsNoneGuardians: List/Read
Update / delete / restore / ban / unban / reset a guardianAdminAdministratorPATCH/DELETE/POST :id/restore/.../ban/.../unban/.../password-reset on /guardians/:idCurrent rowusers, guardiansGuardians: reference
Give a guardian a portal account, or take it awayAdminAdministrator holding Users_UPDATEPOST/DELETE /guardians/:id/sign-in, or grantSignIn on POST /guardians and on a guardian entry inside POST /studentsusers.can_login/emailusers.can_login; an account_invite verification row and a queued invitation on a grant, deleted sessions on a revokeGuardians: Grant sign-in
Admit a staff memberAdminAdministratorPOST /staffDepartment/designation FK targets, employee-code counterusers, staff, user_role (grants staff, and teacher for a teaching designation)Staff: Create
List / read / update / delete / restore a staff memberAdminAdministrator, staff/teacher (list/read only)/staff routesstaff joined to users, departments, designationsusers, staffStaff: reference
Read / update a staff member's salary and bank detailsAdminAdministrator with StaffSalary_READ/_UPDATEGET/PATCH /staff/:id/salarystaff salary columnsSame columnsStaff: Salary
Suspend / restore a staff member's loginAdminAdministratorPOST /staff/:id/ban, .../unbanusers.banned; the target's full role set (superadmin protection)users.banned, .ban_reason, .banned_at, .banned_by; deletes sessionsStaff: Ban
Email a staff member a password-reset linkAdminAdministratorPOST /staff/:id/password-resetusers.email/can_login/banned; the target's full role setA verification-token rowStaff: Password reset
Give a staff member a portal account, or take it awayAdminAdministrator holding Users_UPDATEPOST/DELETE /staff/:id/sign-in, or grantSignIn on POST /staffusers.can_login/email; the target's full role setusers.can_login; an account_invite verification row and a queued invitation on a grant, deleted sessions on a revokeStaff: 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.email is supplied for the student or an inline guardian, it must not already belong to a live person.

Main Flow

StepActor/SystemActionResultSource
1Admissions officerSubmits POST /students with person, admissionDate, and optionally guardians[].DTO validated.students.controller.ts
2StudentsService.createValidates 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
3BackendOpens 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
4BackendReloads the full StudentDto (person + guardian count).Response returned.students.service.ts
5BackendInvalidates every cached student list.Next list read is a miss.students.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
No guardians givenguardians omitted or [].Refused before the transaction opens — a pupil must have at least one guardian.400 STUDENT_REQUIRES_ONE_GUARDIAN.
Sibling attachguardians[].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 numberadmissionNumber supplied.Used as-is; must still satisfy students_admission_number_unique.STUDENT_ADMISSION_NUMBER_TAKEN on collision.
Explicit student IDstudentId 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 setTwo entries resolve to the same guardian id.Rejected before any insert.409 STUDENT_GUARDIAN_ALREADY_LINKED.
Two entries in the same slotTwo entries both name relationship: "father" (or any other slot).Rejected before any insert, naming the slot.400 GUARDIAN_SLOT_TAKEN.
Email already heldperson.email (student's or an inline guardian's) belongs to a live person.Whole transaction aborted.409 USER_EMAIL_ALREADY_EXISTS.
Address half-filledA 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 forNeither 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_UPDATEgrantSignIn: 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 disagreegrantSignIn 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 addressgrantSignIn: 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-inguardians[].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) and Students_CREATE or Students_UPDATE/Guardians_UPDATE (to attach).

Main Flow

StepActor/SystemActionResultSource
1OfficerPOST /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
2OfficerPicks 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
3BackendresolveGuardian 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

BranchConditionBehaviorError/Result
No match on phoneNo live guardian shares that number.Empty array returned.Office creates a new guardian inline.
Several matchesA 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 liveThe chosen guardianId was soft-deleted between lookup and submit.Rejected.404 GUARDIAN_NOT_FOUND.
Blank or missing phone numberThe 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 _UPDATE permission and passes PeopleAccessService.assertCanAccess for that specific row.
  • For a student or a staff member, the request must carry the version token last read from the record.

Main Flow

StepActor/SystemActionResultSource
1EditorPATCH /students/:id (or /guardians/:id, /staff/:id) with only the changed fields.Partial patch.Each *.controller.ts
2BackendLocks the current row FOR UPDATE (students, staff) or plain read (guardians).Row visibility confirmed.Each *.service.ts
3Backend (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
4BackendBuilds 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
5BackendInvalidates the entity's cached lists.Next read is fresh.Each *.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Stale versiondto.version does not match the locked row's version counter.Write refused, nothing changes.409 PEOPLE_STALE_RECORD.
Field omitted from the bodyKey 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 elsewhereNew email collides with another live person.Whole update rejected.409 USER_EMAIL_ALREADY_EXISTS.
Guardian's kind changes to organizationorganizationName 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 departmentdesignationId no longer matches departmentId.Rejected at the database.409 STAFF_DESIGNATION_NOT_IN_DEPARTMENT.
Concurrent guardian PATCHTwo editors write the same guardian at once.The second is refused before it writes.409 PEOPLE_STALE_RECORD.
Concurrent staff PATCHTwo 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_UPDATE and Guardians_UPDATE.
  • If any guardians are given, exactly one must be marked primary.

Main Flow

StepActor/SystemActionResultSource
1EditorPUT /students/:id/guardians with the full desired set.Validated as a whole.student-guardians.service.ts
2BackendLocks the student row, deletes every existing student_guardian row for it.Clean slate inside the transaction.student-guardians.service.ts
3BackendInserts every entry with is_primary = false first.Avoids the non-deferrable unique index racing against rows about to be deleted.student-guardians.service.ts
4BackendSets 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
5BackendInvalidates cached student lists (guardian count and completeness changed).student-guardians.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Empty setguardians: [].All links removed; record becomes incomplete.200 with an empty array.
Zero primaries with entriesAt least one entry, none marked primary.Rejected before any write.400 STUDENT_GUARDIAN_PRIMARY_REQUIRED.
Two primariesMore than one entry marked primary.Rejected before any write.400 STUDENT_GUARDIAN_MULTIPLE_PRIMARY.
Reassigning primary between two existing guardiansOld 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.assertCanAccess for the base entity, and holds StudentMedical_READ/_UPDATE or StaffSalary_READ/_UPDATE respectively.

Main Flow

StepActor/SystemActionResultSource
1Nurse/HRGET /students/:id/medical or GET /staff/:id/salary.Field-gated payload only.student-medical.service.ts, staff-salary.service.ts
2BackendChecks object-level scope for the base entity, then checks the specific field permission with PeoplePermissionsService.can.Two independent gates.Same files
3BackendReads 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

BranchConditionBehaviorError/Result
Holds Staff_READ but not StaffSalary_READColleague-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 setbasicSalary/allowances both NULL.totalSalary is NULL, never 0.00.Response shows all three as null.
Salary update breaks the pairPATCH sets basicSalary but leaves a NULL allowances (or vice versa).Rejected before the write.400 VALIDATION_FAILED.
Out-of-scope student's medical recordRequested 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 _UPDATE permission.
  • 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 _UPDATE permission they hold.

Main Flow

StepActor/SystemActionResultSource
1OfficerPOST /students/:id/ban (or /guardians/:id/ban, /staff/:id/ban) with a reason.Validated non-blank.people-account.service.ts
2BackendRefuses 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
3BackendSets users.banned = true plus reason/timestamp/actor, in a transaction.people-account.service.ts
4BackendDeletes every session the person holds.Any refresh token becomes unusable immediately, not just at next banned check.people-account.service.ts
5OfficerPOST /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

BranchConditionBehaviorError/Result
Blank reasonreason empty or whitespace.Rejected.400 USER_BAN_REASON_REQUIRED.
Self-targetingActor 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 notChecked 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 superadminTarget holds the superadmin role and no other live, password-holding superadmin exists.Rejected.403 USER_LAST_SUPERADMIN_PROTECTED.
Already bannedBan called twice.Idempotent — overwrites reason/timestamp/actor.200, no error.

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 _UPDATE permission.
  • 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

StepActor/SystemActionResultSource
1OfficerPOST /students/:id/password-reset (or /guardians/:id/..., /staff/:id/...).people-account.service.ts
2BackendRefuses a non-superadmin acting on a superadmin.actor-authority.service.ts
3BackendLoads the person, validates email present, canLogin, not banned.people-account.service.ts
4BackendCreates a single-use verification token via VerificationTokenService.createPasswordReset.people-account.service.ts
5BackendSends the email via AuthEmailService.sendPasswordResetEmailSafe (fail-soft — a delivery failure does not fail the request).people-account.service.ts
6BackendReturns { sentTo } so the operator can confirm the address before telling the family.people-account.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Target is a superadmin, actor is notA 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 fileusers.email IS NULL.Rejected — nowhere to send it.409 USER_EMAIL_REQUIRED.
canLogin = falsePerson cannot sign in at all.Rejected — a working link to a disabled account is a support call.409 USER_LOGIN_DISABLED.
Banned accountusers.banned = true.Rejected — restore the account first.409 AUTH_ACCOUNT_BANNED.
Email delivery failsSMTP/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

StepActorActionScreen/RouteSource
1AdministratorSwitches the sign-in toggle on from the detail screen.POST /:entity/:id/sign-inThe three controllers
2BackendLoads the person and their current sign-in state.people-account.service.ts
3BackendWrites can_login = true, guarded by the value it just read, unless it already holds it.people-account.service.ts
4BackendUnless 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
5AdministratorSees whether the invitation went, and to which address.SignInAccessDto.invitation
6The personOpens the link, chooses a password, and can sign in.POST /api/auth/password/resetauth-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

BranchConditionBehaviorError/Result
Granting to somebody who already has accessThe 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 noneThe 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 firstcan_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 addressSign-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 withheldinvite: 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 notThe 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 suspendedusers.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 scheduledA 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 timeMore 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 linkAn 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/salary is a full-record write: every blank field is sent as an explicit null, 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 holding StaffSalary_UPDATE and not StaffSalary_READ issues no salary request at all.
  • The pay is sent only when it changed, compared field by field with both sides normalised: the record stores null where 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:

  1. Profile liveness (deleted_at on students/guardians/staff, and on users once every profile is gone) — soft delete and restore.
  2. Login ability — two separate flags, and conflating them is a mistake the UI must not repeat. users.can_login says whether the person has a portal account at all; users.banned says 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.
  3. Domain statusstudents.record_status (active/inactive) and staff.employment_status (active/on_leave/suspended/resigned/terminated/retired) — a plain descriptive field the office sets, with no enforced transition graph of its own.
EntityFromEvent/ActionToGuard ConditionSide Effects
Profile (any)liveDELETE /:entity/:idsoft-deletedNot deleting the actor's own row; guardian has no live linked studentusers.deleted_at set only if no other live profile remains; account row and sessions deleted with it
Profile (any)soft-deletedPOST /:entity/:id/restoreliveReissuable code/email not claimed by a live row sinceusers.deleted_at cleared if it was set by this profile going
LoginactivePOST /:entity/:id/banbannedReason non-blank; not self; not a superadmin unless the actor is one too; not the last live superadminAll sessions for the user deleted
LoginbannedPOST /:entity/:id/unbanactiveNot a superadmin unless the actor is one tooReason/timestamp/actor cleared
Sign-in accessnone (can_login = false)POST /:entity/:id/sign-ingrantedActor holds Users_UPDATE; not a superadmin unless the actor is one too; can_login still holds the value just readUnless 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 accessgrantedDELETE /:entity/:id/sign-innoneSame guardsAll sessions for the user deleted
Sign-in accessnonePOST /:entity with grantSignIn (or person.canLogin)grantedActor holds Users_UPDATEThe invitation is enqueued in the admission transaction, so it commits with the person or not at all
students.record_statusanyPATCH with recordStatusany other valueNone enforced — any value may follow any otherNone beyond the write itself
staff.employment_statusanyPATCH with employmentStatusany other valueNone enforcedNone 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

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Admit a studentusers, students, code_counters, student_guardian, possibly a second users/guardians for a new parentstudents:* sweptNoneNoneNoneNone
Admit a staff memberusers, staff, code_counters, user_rolestaff:* sweptNoneNoneNoneNone
Create a guardianusers, guardians, user_roleguardians:* sweptNoneNoneNoneNone
Update any profileusers (identity patch), the profile tableThe entity's *:* list cache sweptNoneNoneNoneNone
Admit a studentusers, students, student_guardian, code_counters, user_role (the pupil's student role)students:* sweptNoneNoneNoneRefuses the whole admission with ROLE_NOT_FOUND if the student role is unseeded
Set student guardiansstudent_guardian (full delete + reinsert), students.updated_atstudents:* sweptNoneNoneNoneNone
Update medical recordstudents (three columns)students:* swept (list projects updatedAt)NoneNoneNoneNone
Update salarystaff (salary/bank columns)staff:* sweptNoneNoneNoneNone
Soft delete a profileThe profile table, conditionally users, account (deleted), sessions (deleted)Entity's *:* sweptNoneNoneNoneNone
Restore a profileThe profile table, conditionally usersEntity's *:* sweptNoneNoneNoneNone
Ban / unbanusers (ban fields), sessions (deleted on ban)Entity's *:* sweptNoneNoneNoneNone
Password-reset linkA verification-token row (owned by the auth module)NoneNoneNoneNonePassword-reset email, sent fail-soft

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Stale PATCHTwo 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 childrenDELETE /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 codeAdmission 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 emailA 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 credentialsActor 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 readCache layer down.Falls through to Postgres — slower, never a 500.Automatic; no operator action.redis.service.ts (getSoft)
Redis unavailable during invalidationCache 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 violationA 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

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Grant sign-inGranting to somebody who already has accessAdministratorPOST /:entity/:id/sign-inThe invitation is sent, and the state is left as it standsThe can_login write is skipped; the invitation is decided on its own, which is what makes "add an email and try again" workpeople-account.service.ts
Grant sign-inGranting to a suspended personAdministratorTarget has banned = trueThe account is granted; no invitation goes outReported as reason: "banned" — an invitation into an account the login guard refuses is a support callpeople-account.service.ts
Grant sign-inGranting without an invitationAdministratorinvite: falseThe account is prepared silentlycan_login written; no verification record, no emailsign-in-access.dto.ts
Grant sign-inGranting to somebody with no emailAdministratorPerson has no users.emailThe grant stands and the response says why nothing was sentcan_login written; invitation skipped with reason: "no_email"people-account.service.ts
Revoke sign-inRevoking from somebody who never had itAdministratorDELETE /:entity/:id/sign-inReported as success with the state unchangedNo write and no session sweep — the one direction allowed to return earlypeople-account.service.ts
Revoke sign-inAn unredeemed invitation is outstandingAdministratorRevoke after invitingThe link becomes useless without being deletedThe record expires on its own within 7 days; a password set through it would not permit sign-in anywaypeople-account.service.ts
Account invitationThe person also holds a password-reset linkThe invited personRedeeming either oneThe other stops workingconsumeAccountEntry retires every other account-entry record for that personverification-token.service.ts
List studentsEmpty search termAny list callersearch=""Trimmed to nothing by QueryDto, treated as no searchNo trigram threshold pinned; no %/ILIKE condition addedcommon/dto/query.dto.ts, students.service.ts
List students% typed in a search boxAny list callersearch=100%The literal percent sign is matched, not treated as a wildcardescapeLikePattern escapes %, _, \ before the ILIKE halfperson-writer.service.ts
List studentsSearch with fewer than 3 extractable trigramsAny list callerVery short or non-Latin termTrigram half returns almost nothing; substring half still matchesBoth halves of personTrigramMatch/admission-number ILIKE run and are OR'dsearch.constants.ts, person-writer.service.ts
List (any)Tied updated_at across a bulk-imported batchAny list caller, page 2+Two rows share an identical timestampNo row is silently dropped or duplicated across pagesid is appended as a tie-breaker to every ORDER BYstudents.service.ts, guardians.service.ts, staff.service.ts
List studentshasGuardians=falseAny list callerFilter for incomplete recordsOnly students with no live guardian link returnednot(liveGuardianExists()) — a correlated NOT EXISTSstudents.service.ts
List guardianshasChildren=falseAny list callerFilter for guardians linked to nobody liveOnly guardians with no live linked student returnednot(liveChildExists())guardians.service.ts
List (any)includeDeleted=trueAny list callerWants to see removed records tooBoth the profile row and the users row must be checked — a users row can go dark independent of the profileBoth isNull filters lifted together, never just onestudents.service.ts comment on this exact bug
Sortsort/sortBy names a column not on the allow-listAny list callere.g. ?sort=basicSalary on staffRefused rather than silently defaulting or leaking a ranking400 PEOPLE_INVALID_SORT_FIELDstaff.service.ts, students.service.ts, guardians.service.ts
Students listpagination=falseAny list callerAttempt to fetch the whole student roll unpagedRefused — every pupil's name, date of birth, address and guardians in one response400 PAGINATION_LIMIT_INVALIDstudents.service.ts
Guardians listpagination=falseAny list callerAttempt to fetch the whole guardian directory unpagedRefused — a restricted scope's predicate is a per-row subquery400 PAGINATION_LIMIT_INVALIDguardians.service.ts
Staff listpagination=falseAny list callerAttempt to fetch the whole staff directory unpagedRefused — an unbounded read of every employment record400 PAGINATION_LIMIT_INVALIDstaff.service.ts
Create studentAdmission number omittedOfficerNo admissionNumber in the bodyOne is allocated atomically for the current academic yearcode_counters upsert in the school's own timezonepeople-code.service.ts
Create studentAdmission number suppliedOfficerImporting an existing rollUsed verbatim, still uniqueness-checkedstudents_admission_number_unique (partial, live rows only)students.service.ts, people.ts schema
Create guardiankind: "organization"OfficerAn NGO or company is the responsible partyorganizationName derived from person.firstName, never entered twiceguardian_org_has_name CHECK enforced at the database tooguardians.service.ts
Update guardianperson.firstName changes while kind stays organizationOfficerRenaming an org guardianorganizationName recomputed even though kind was not in the patchnewFirstName always recomputed from the merged current+patch stateguardians.service.ts
Update studentimeisId sent as an empty stringOfficerClearing a field via a blank form inputStored as NULL, not ""blankToNull / `.trim()
Delete guardianGuardian has a live linked studentOfficerDELETE /guardians/:idRefused, naming the reason409 GUARDIAN_HAS_LINKED_STUDENTSpeople-deletion.service.ts
Delete profilePerson holds another live profileOfficere.g. deleting the guardian profile of someone who is also staffusers row untouched — the person still exists in the systemhasNoLiveProfile counts across all three tablespeople-deletion.service.ts
Restore profileRestoring a no-opOfficerPOST .../restore on an already-live rowSilent no-op, 200if (row.deletedAt === null) return;people-deletion.service.ts
BanAlready bannedOfficerBan called twiceIdempotent overwrite of reason/timestamp/actorPlain UPDATE, no uniqueness or state-machine guardpeople-account.service.ts
Salary updateOnly bankName sent, no salary figuresHRBank-detail-only editbasicSalary/allowances pair check is skipped entirelytouchesBasic/touchesAllowances both falsestaff-salary.service.ts
Search cacheTwo searches differing only by field-permissionTwo different actorsOne holds StaffSalary_READ, one doesn'tEach gets its own cached list even though the filters are identicalviewTag folded into every cache keypeople-access.service.ts

12.2 Business Process Diagram Pack

DiagramRequired WhenPurpose
User journey mapAlways§5.1-§5.7 sequence diagrams.
Service blueprintMulti-actor or backend-heavy flowsBelow.
Activity diagramEvery major flow§6.1, §6.7, §6.8.
State diagramAny lifecycle§7.
Swimlane diagramMulti-actor flowBelow.
Sequence diagramAPI-backed flow§5.1-§5.7.
Data side-effect graphAny mutation§12.6.
Exception flow diagramCritical 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

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
No father/mother columns; a student has zero or more guardians of any relationshipA 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, guardianRelationshipEnumguardians[] array on create/updateNo father/mother column exists anywhere to migrate away from laterstudents.service.integration.spec.ts — "records a student with no guardian"
Two students on the same guardian row are siblings; nothing else neededHalf-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 rowresolveGuardian links rather than creates when guardianId is givenstudents.service.integration.spec.ts — "attaches a sibling to the SAME guardian row"
Relationship is never defaulted in any UIA 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 relationshiprelationship is required on UpsertStudentGuardianDtoSchema comment, people.ts
isRecordComplete is computed, never storedA 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_guardianguardiansusers, all liveStudentDto.isRecordCompleteComputed at read time on every list/detail querystudents.service.integration.spec.ts — "reports a student incomplete once their only guardian is deleted"
Creating a person does not create an accountMost 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 itgrantSignIn on the three create bodies, and the older person.canLoginEvery create path states the value having first checked it maystudents.service.integration.spec.ts — "writes nothing at all when the grant is refused"
Granting sign-in requires Users_UPDATE, not the profile permissionA 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.can403 AUTH_FORBIDDEN on a grant without the permissioncan() lets a superadmin bypass; a raw membership test would deny them after a release that added codes without permissions:syncstudents.service.integration.spec.ts — "refuses to grant sign-in without Users_UPDATE"
Sign-in access says nothing about notificationsA 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 pathNone — can_login is not consulted on any delivery pathcan_login means "may authenticate" and nothing elseNotification module specs
An invitation commits with the person it invitesA 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 outboxinvitation on the create response reports enqueued, never deliveredThe verification record, the event and its outbox row share the create's transactionstudents.service.integration.spec.ts — "grants, and invites, in one call"
Out-of-scope reads return 404, never 403A 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.assertCanAccessEvery :id route on all three controllersNo NOT_YOUR_RECORD code exists anywhere in error-codes.tsguardians.service.spec.ts — "lets a guardian see only themselves, and 404s (never 403)"
Salary and health fields are omitted, not nulledA 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 codesSeparate GET/PATCH .../salary and .../medical routesSTAFF_SALARY_COLUMNS never appears in PERSON_SELECTION or StaffDto's querystaff.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 indexA 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_primaryPUT /students/:id/guardianswriteGuardianLinks inserts all rows non-primary, then flips onestudents.service.integration.spec.ts — "moves the primary flag between guardians in one transaction"
Deletion is profile-scopedOne 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 usersDELETE on any of the three entity routesusers.deleted_at set only when the count reaches zerostudents.service.integration.spec.ts — "does not remove the person when they still hold another live profile"
total_salary has no coalesceA 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 togetherStaffSalaryDto.totalSalarystaff_salary_pair_coherent CHECK keeps the pair coherentstaff.service.spec.ts — salary tests
Codes allocated by one atomic upsert, year in school timezonemax()+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 ... RETURNINGadmissionNumber/employeeCode on the create responsePeopleCodeService.allocatestudents.service.integration.spec.ts — "allocates consecutive admission numbers without collision"
Search pins pg_trgm.similarity_threshold per transactionIt 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 parameterstudents.service.ts, guardians.service.ts, staff.service.ts all route search through itstudents.service.integration.spec.ts — "finds a student by a misspelt name"
Every people cache key carries a scope tag and a view tagOmitting 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.cacheTagsEvery list endpoint's Redis keyCacheKeyUtil.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 defaultGET /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 branchesRoleGuard still passes because the routes above them are gated by scope, not a module code, for these two roles' own-record readsseed-auth.ts
studentId is never reissued; admissionNumber isA 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 itPeopleCodeService.allocate("student_id", ...) runs on every create, unconditionallypeople-code.service.ts allocation logic; schema comments in people.ts
Staff salary on create is gated on key presence, not on valueSilently 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 contentsPOST /staff salary: {}403; omitting the key → 201 with no salary setstaff.service.ts delegates the whole decision to StaffSalaryService rather than checking the permission itselfstaff.service.spec.ts
recordVisibility replaced includeDeletedA 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 readKept on each query DTO for one release because forbidNonWhitelisted would otherwise turn a bookmarked ?includeDeleted=true link into a 400apps/api/src/common/dto/record-visibility.ts (rationale in the file's own comment)
At most one live, active PrincipalA 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 nullNo schema constraint enforces this — enforced only in application code, at every write that could break itprincipal-invariant.service.ts's own docblock enumerates the five sites and the fixed lock order

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
All three profiles carry an optimistic-concurrency versionTwo 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 siteTwelve 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 aloneThe 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_UPDATEGate all three restores under a matching _RESTORE permissionAn 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 ParseUUIDPipeParseUUIDPipe on every entity's :idA 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 admissionA 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 didAn 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 tablesOne 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 tablesA 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

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
List (any)Empty stateNo rows match the filtersdata: [], count: 0Empty list, no errorqueryList in each service
Admit a studentFirst admission of the yearcode_counters has no row yet for (student, year)Counter row created via the upsert's INSERT branch, starts at 1STU-2026-0001people-code.service.ts
Admit a studentTen-thousandth admissionSequence reaches 10000Number widens past 4 digits rather than truncatingSTU-2026-10000, not a collision with STU-2026-1000people-code.service.ts (padStart, never lpad truncation)
Set guardiansDuplicate actionSame PUT body submitted twiceSecond call deletes and rewrites identically — idempotent by construction200 both times, same resulting setstudent-guardians.service.ts
Update (students)Concurrent actionTwo PATCHes race on the same studentSecond to reach the row lock sees a version mismatch (if the first already committed) or blocks briefly on the FOR UPDATE lock409 PEOPLE_STALE_RECORD for the loserstudents.service.ts
RestoreExpired stateRestoring a record deleted long enough ago that its code/email were reissuedRefused with the specific conflict code409 naming what to change firstpeople-deletion.service.ts
Access scopePermission mismatchGuardian role active but session somehow carries a stale/invalid activeRoleRoleGuard throws first (PERMISSION_ROLE_NOT_ASSIGNED/AUTH_ACTIVE_ROLE_REQUIRED) before the service is ever reached403role.guard.ts
Access scopeGuest/self limitationGuardian requests a student who is not theirsSame as record-missing404 STUDENT_NOT_FOUNDpeople-access.service.ts
Guardian lookupMissing dependencyPhone belongs to nobodyEmpty arrayOffice proceeds to createstudent-guardians.service.ts
Any listCache stale/missFirst read after a TTL expiry or an invalidationCache miss, query runs, result cached for another 120sSame response either way — cache is invisible to the callerredis.service.ts
Search (any)Unsupported filter or sort optionsort=medicalConditions on studentsRefused400 PEOPLE_INVALID_SORT_FIELDstudents.service.ts (STUDENT_SORTABLE allow-list)
SearchQueue failureN/A — this domain has no queue

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Admit a studentGuardian rows (if linking), code_countersusers, students, student_guardian, code_countersSweeps students:*NoneFull StudentDto
List studentsstudents, users, correlated student_guardian/guardians/users subqueriesNoneReads/writes students:list:...NoneStudentDto[], totalCount
Update salarystaff current rowstaff salary columnsSweeps staff:*NoneStaffSalaryDto
Soft deleteProfile row, users liveness countProfile table, conditionally users/account/sessionsSweeps entity *:*Nonevoid (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

On this page

People Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 Admit a studentSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 Attach a sibling to an existing guardianSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.3 Update a student, guardian, or staff memberSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.4 Replace a student's guardian setSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.5 Read a student's health record or a staff member's salarySummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.6 Suspend and restore a person's sign-in (students, guardians, staff)SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.7 Email a password-reset linkSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.8 Give somebody a portal account, or take it away (students, guardians, staff)SummaryPreconditionsMain FlowBranches and Edge Cases5.11 Edit a staff member's salary and bank details from the staff form6. Admin Flows6.1 Create6.2 List6.3 Read detail6.4 Update6.5 Reorder6.6 Activate/deactivate6.7 Soft delete6.8 Restore6.9 Export/import6.10 Moderation6.11 Manual retry7. Lifecycle and State Transitions9. Data and Side Effects by Flow10. Error and Recovery Flows11. Diagrams Required Per Module12. Mandatory Feature and Flow Deep-Dive Pack12.1 Feature Inventory With Minor Behaviors12.2 Business Process Diagram Pack12.3 Business Rules and Policy Traceability12.4 Tradeoffs and Product Rationale12.5 Flow Edge-Case Matrix12.6 Flow-to-Data Trace12.7 Experience Quality Checklist13. Completion ChecklistSee Also