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.