My Profile
The self-profile surface — what a person sees about their own record, which blocks appear for which person type, and the two categories of data deliberately excluded.
My Profile
GET /api/profile and /dashboard/profile. One screen that shows a person their own
record, assembled from whichever person tables hold a row for them.
1. Documentation Evidence
| Source Type | Files | What Was Extracted |
|---|---|---|
| Endpoint | apps/api/src/modules/profile/profile.controller.ts | Guards, decorator, allowlist rationale |
| Query surface | apps/api/src/modules/profile/profile.service.ts | The four self-scoped reads and the two nested-list joins |
| Response shape | apps/api/src/modules/profile/dto/profile.dto.ts | Blocks, nullability, what is absent |
| Behaviour | apps/api/src/modules/profile/profile.service.int.spec.ts | 13 cases against a real database |
| Panel | app/(dashboard)/dashboard/profile/page.tsx | Section composition and the grid |
2. Summary
| Field | Value |
|---|---|
| Who can call it | Any authenticated session with an active role |
| What it returns | The caller's own record. Never another person's |
| Permission | None declared. Allowlisted in NO_PERMISSION_ADMIN_HANDLERS |
| Why that is safe | Ownership, not permission — see below |
| Writes | None. This is a read surface |
3. Which blocks appear
person is always present. student, staff and guardian are each null unless the
caller holds a live row in that table.
| Person type | Blocks |
|---|---|
| Pupil | person + student (with their guardians) |
| Teacher or other staff | person + staff |
| Parent or guardian | person + guardian (with their children) |
| System or custom-role operator | person only |
| A teacher whose child attends | person + staff + guardian — both |
The discriminator is the record, never the role name
A person's type is decided by which person-record rows exist for their user_id, not by
activeRole.name. role.name is a mutable text column; deriving behaviour from it is a
defect this codebase has already removed once, from PeopleAccessService.
It also gets the overlap right. The unique constraints (students_user_id_unique and its
siblings) make "at most one of each" true, but nothing makes them mutually exclusive — so
the blocks are independent rather than a switch.
4. What this endpoint does NOT return, and why
Two categories are absent by design:
| Absent | Gated by | Lives at |
|---|---|---|
basicSalary, allowances, totalSalary, bank, PAN, citizenship, SSF, CIT | StaffSalary_READ | GET /staff/:id/salary |
medicalConditions, allergies, specialNeeds | StudentMedical_READ | GET /students/:id/medical |
These are the only two modules in the system that gate at the field level rather than
the row level, and each already has a dedicated service that re-checks the permission.
student-medical.service.ts states the failure this avoids: "the moment any aggregate
response composes this method to save a round trip, it is off the code path and nothing is
left." This endpoint is that aggregate, and it does not compose them.
An earlier design returned both, self-only, on the argument that requiring the permission
would mean only people who can see everyone's pay could see their own. That argument is
false: row scope comes from activeRole.scopeKind, not from holding the permission
(people-access.service.ts). A self-scoped role holding StaffSalary_READ already reads
its own pay and nobody else's.
Adding own-pay here later is therefore not a one-line change. The staff and teacher
roles are seeded scope_kind = 'all', so granting them StaffSalary_READ would return
{ kind: "all" } and expose every colleague's salary. It needs a self-scoped role or a
self-only read that does not route through scopeFor.
5. Cross-person data
Two lists cross from the caller's record to another person's: a student's guardians and a guardian's children. Both carry name, relationship and primary flag only — no phone number, no admission number. Neither is needed to answer "who are my children", and this route declares no permission, so it says the least that answers the question.
Both filter liveness twice — on the person-type row and on that person's users row.
student_guardian has no deleted_at of its own and a link outlives a soft-deleted
person, so filtering the link alone would list a removed guardian as current. In a school
that is a safeguarding record, not a tidiness detail.
6. Not applicable
No schema change, no migration, no queue job, no realtime event, no cache. The feature reads existing columns.
Admin Route Guarding
How the admin panel decides which pages a role may open — the server guard, the exemption list, the denial and outage destinations, and the failure modes each one exists to prevent.
Profile — Backend
Module shape, the guard chain for a handler that declares no permission, and why row selection does not route through PeopleAccessService.