Skip to main content

School Module

Location: backend/src/school/

The school module manages schools, school membership, and join requests. Schools are the top-level organizational unit - every user belongs to a school via a school_management row, and all data (students, subjects, classes) is scoped to a school.

Files

FilePurpose
school.module.tsModule definition
school.controller.tsAPI endpoints
school.service.tsBusiness logic
dto/create-school.dto.tsValidation for school creation
dto/create-join-request.dto.tsValidation for submitting a join request
dto/approve-join-request.dto.tsValidation for approving a join request

Related guard:

FilePurpose
backend/src/auth/admin.guard.tsAdminGuard - restricts endpoints to users whose user_profile.role is admin and is_active = true

Membership Model

Three tables work together to track who belongs to which school and in what role:

school

The school record itself.

FieldTypeDescription
idUUIDPrimary key
namestringSchool name
codestring?Optional school code
school_typeenumprimary or secondary
addressstring?Optional physical address
emailstringContact email
phonestringContact phone
is_activebooleanWhether the school is active

school_management

The canonical record of who belongs to which school and in what role. A user can have at most one row per school (enforced by UNIQUE(user_id, school_id)).

FieldTypeDescription
idUUIDPrimary key
user_idUUIDFK → user_profile.id (cascade delete)
school_idUUIDFK → school.id (cascade delete)
roleenumOne of admin, member, teacher
created_attimestamptzWhen the membership was created
updated_attimestamptzLast role change

school_join_request

Pending/historic requests from a user to join a school.

FieldTypeDescription
idUUIDPrimary key
user_idUUIDFK → user_profile.id
school_idUUIDFK → school.id
statusenumpending, approved, or rejected
messagestring?Optional message from the requester
requested_attimestamptzWhen the request was submitted
reviewed_attimestamptz?When an admin reviewed it
reviewed_byUUID?FK → user_profile.id of the reviewing admin

A partial unique index prevents duplicate pending requests for the same (user_id, school_id). The service additionally rejects new requests if the user has any pending request (across all schools).

Denormalized cache on user_profile

For backwards compatibility and query simplicity, two fields on user_profile mirror the user's active membership:

FieldMirrors
user_profile.school_idThe user's currently active school
user_profile.roleThe role from the matching school_management row

These are kept in sync on every write that affects membership (school creation and join request approval). All existing services that read user_profile.role (e.g. AdminGuard, ClassTeacherGuard, enrollment, calculation, class) continue to work unchanged. school_management is the source of truth; the user_profile fields are a cache.

Roles

public.role enum values:

RoleMeaning
adminFull administrative control of the school. Can approve/reject join requests, manage all data. School creators are automatically admin.
teacherStaff member assigned to classes. Has access scoped to the classes/subjects they're assigned to (see ClassTeacherGuard).
memberGeneric school participant - for users who belong to the school but aren't teaching staff or admins.

Flows

Creating a school (auto-admin)

When a user creates a school, they become its admin immediately - no approval needed.

User → POST /schools { name, schoolType, ... }
├─ INSERT school
├─ INSERT school_management { user_id, school_id, role: 'admin' }
└─ UPDATE user_profile { school_id, role: 'admin' } (cache mirror)

Joining an existing school (request → approval)

User → PATCH /auth/onboard { firstName, lastName, schoolId }
└─ INSERT school_join_request { status: 'pending' }
└─ Response includes a `joinRequest` field; frontend redirects to /onboard/pending

[user is in pending state - no school_id set on user_profile]

Admin → GET /schools/join-requests # sees the pending request
Admin → PATCH /schools/join-requests/:id/approve { role }
├─ UPSERT school_management { user_id, school_id, role }
├─ UPDATE user_profile { school_id, role, is_active: true }
└─ UPDATE school_join_request { status: 'approved', reviewed_at, reviewed_by }

The frontend pending page polls GET /auth/me every 10 seconds; once school_id is populated, the user is auto-redirected to the dashboard.

Rejection

Admin → PATCH /schools/join-requests/:id/reject
└─ UPDATE school_join_request { status: 'rejected', reviewed_at, reviewed_by }

The user can submit a new request afterwards (no pending request blocks them anymore).

Switching schools

The "Change School" dialog in the sidebar and the school selector on the settings page both go through the same join-request flow - they POST /schools/:schoolId/join-requests rather than directly mutating user_profile.school_id. The user's active school does not change until an admin of the target school approves.

API Endpoints

All endpoints require AuthGuard. Endpoints under /schools/join-requests additionally require AdminGuard.

GET /api/schools

Returns all active schools ordered by name. Used during onboarding and in the school switcher.

Response: Array of { id, name, school_type } (the underlying table also carries a legacy parish column for previously-created schools).


POST /api/schools

Creates a new school and assigns the requesting user as its admin (inserts school_management row, mirrors role/school to user_profile). On dedicated deployments, blocked once any school exists.

Body:

{
"name": "Grenada Academy",
"code": "GA",
"schoolType": "secondary",
"address": "123 Main St",
"email": "info@school.com",
"phone": "+1473-555-0100"
}
FieldRequiredNotes
nameYes
codeNo
schoolTypeYesprimary or secondary
addressNo
emailNo
phoneNo

Response: The created school object.


POST /api/schools/:schoolId/join-requests

Submits a request to join a school. Fails with 400 if the user already has any pending join request, or 404 if the school doesn't exist or is inactive.

Body:

{ "message": "I'm a new teacher starting next term." }
FieldRequiredNotes
messageNoOptional note shown to the reviewing admin (max 500 chars)

Response: The created school_join_request row, with the school joined in.


GET /api/schools/join-requests

Requires: AdminGuard

Lists pending join requests for the admin's school, oldest first. Each item embeds the requesting user (first_name, last_name, email) and the school (id, name).

Response:

[
{
"id": "uuid",
"status": "pending",
"message": "...",
"requested_at": "2026-05-03T12:00:00Z",
"user": { "id": "uuid", "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" },
"school": { "id": "uuid", "name": "Grenada Academy" }
}
]

PATCH /api/schools/join-requests/:requestId/approve

Requires: AdminGuard

Approves a pending request. Fails with 403 if the request belongs to a different school, or 400 if it's already been reviewed.

Body:

{ "role": "member" }
FieldRequiredNotes
roleYesadmin, member, or teacher

Effects:

  1. Upserts school_management { user_id, school_id, role } (idempotent).
  2. Updates user_profile { school_id, role, is_active: true } for the requester.
  3. Marks the request approved and records reviewed_by / reviewed_at.
  4. Clears the requester's profile cache.

Response: The updated school_join_request row.


PATCH /api/schools/join-requests/:requestId/reject

Requires: AdminGuard

Rejects a pending request. Same scope checks as approve. The user's profile is not modified - they may submit a new request afterwards.

Response: The updated school_join_request row.

Caching

SchoolService uses the shared CacheService:

KeyTTLInvalidation
schools:all30 daysUpdated on POST /schools
profile:{userId}(managed by AuthService)Cleared on school create and on join-request approval

Frontend Pages

PathAudiencePurpose
/onboardNew usersPick or create a school. Selecting an existing school submits a join request.
/onboard/pendingUsers with a pending requestPolls /auth/me every 10s and auto-redirects to /dashboard once approved.
/dashboard/staff (Pending Members tab)Admins onlyLists pending join requests with approve (with role picker) / reject actions.
Sidebar → "Change School"Any userSubmits a join request to switch schools. Does not change the active school until approved.
/dashboard/settingsAny userSchool selector goes through the join-request flow; name updates apply immediately.