Skoolsewa - Ecommerce Docs
Developer ResourcesProfile

Profile — Backend

Module shape, the guard chain for a handler that declares no permission, and why row selection does not route through PeopleAccessService.

Profile — Backend

Module

apps/api/src/modules/profile/
  profile.module.ts        imports RoleModule (RoleGuard injects RoleService)
  profile.controller.ts    one GET
  profile.service.ts       four self-scoped reads + two nested-list joins
  dto/profile.dto.ts
  profile.service.int.spec.ts

ProfileModule is registered in app.module.ts. It imports RoleModule because RoleGuard injects RoleService, and a provider resolves its dependencies in the context of the module that lists it — not the one it was written for. Omitting it fails at InstanceLoader, naming the guard rather than the module.

The guard chain

@Controller("profile")
@UseGuards(JwtAuthGuard, RoleGuard)
export class ProfileController {
  @Get()
  async find(@CurrentAdmin() actor: AuthUser) { … }
}

Three things here are deliberate and each closes a specific hole.

RoleGuard is declared explicitly

It is not a global guard — app.module.ts registers only JwtAuthGuard and MaintenanceGuard as APP_GUARD. Controllers opt in.

@CurrentAdmin, not @CurrentUser

For a handler on the allowlist, RoleGuard returns true before its own request.user check and before the active-role check that throws AUTH_ACTIVE_ROLE_REQUIRED. And @CurrentUser() asserts nothing — it returns request.user verbatim.

So the pair @CurrentUser + allowlisted handler would serve a full profile to a session that has not yet chosen a role. @CurrentAdmin() makes that assertion, and param decorators run after guards, so it is reached.

test/structure/current-admin-guard.spec.ts asserts every @CurrentAdmin site also applies RoleGuard, so the pairing is checked rather than remembered.

The allowlist entry

NO_PERMISSION_ADMIN_HANDLERS in role.guard.ts, mirrored in test/structure/route-permissions.spec.ts. The two lists are duplicated on purpose and a test asserts they are equal — the runtime deny and the CI gate must not drift.

One trap when editing that file: route-permissions.spec.ts regexes the whole guard source for "(\w+Controller\.\w+)" and asserts the resulting set equals EXEMPT. A reason string containing a quoted SomeController.method token would be picked up as an entry.

Why row selection is not PeopleAccessService.scopeFor

Every other people read in this codebase routes through scopeFor. This one does not, and that is a considered divergence.

scopeFor returns { kind: "all" } for an all-scoped role holding the module permission — which for a self-profile would then have to be intersected back down to the caller anyway. eq(<table>.userId, actor.id) admits no such value: there is no scope, present or future, that makes it return another person's row.

The service also takes no id parameter anywhere. A method that can be pointed at another user eventually is, and on a route with no declared permission that has to be structural rather than checked.

Guarantee added to the structure suite

Every assertion in route-permissions.spec.ts filters EXEMPT rows out first, and the "behind RoleGuard" assertion only covered handlers that declare a permission. An exempt handler shipped without RoleGuard therefore passed every case in the file.

A case was added in this change: every EXEMPT admin handler is still behind RoleGuard, with BackupRestoreAdminController.live excluded because it is @Public() by design and guarded by RestorePollTokenGuard instead.

Reuse

person is built by PERSON_SELECTION + toPersonDto from people/shared/person-writer.service.ts, the same projection StudentDto and GuardianDto embed.

Do not convert its lookup-name subqueries to joins. Ten names — two classification, eight geography — come back as correlated subqueries, and the constant's own comment explains why: two LEFT JOINs "would have to be added to all six call sites in the same order or the shape silently diverges — which is the drift this shared constant exists to prevent."