N/A — one module: leave-type catalogue, request lifecycle, decisions, and the attendance-application worker.
Primary user value
A pupil, a guardian or a staff member can ask to be away and see the decision; the school can approve, refuse, or correct one on their behalf; an approved request rewrites the attendance register automatically instead of leaving an office clerk to mark each day by hand.
Actors
Pupil (student session), guardian, class teacher, staff member (self), permission-holding administrator, the attendance-application worker.
Main entry points
/api/leave-types, /api/student-leaves, /api/staff-leaves (office/admin); /api/mobile/{student,guardian,teacher,staff}/leaves (self-serve and decision surfaces); the QueueName.LEAVE BullMQ worker.
Main outputs
Persisted leave_requests rows and their status, an append-only leave_request_decisions trail, rewritten student_attendance_records / staff_attendance_records rows, and attendance_change_log entries explaining every rewrite.
Related docs
API (/docs/developer/leave/api), Backend (/docs/developer/leave/backend).
Raise, list and cancel their own leave; read their own leave-type catalogue via LeaveView_READ.
Decide any request (their own included); see another pupil's requests; see a class teacher's decision reasoning beyond the outcome.
JWT, activeRole.scopeKind === "student".
assertPortalAudience proves the caller is a pupil and resolves no row — the query's own predicate is the control.
Guardian
Raise leave for their own children (auto-consented by raising); consent to or refuse a child's self-raised request; list their children's requests; cancel a request they raised while it is pending.
Raise or decide for a child who is not theirs (404, never 403); decide a request already answered; consent to their OWN self-raised request a second time (it entered pending_school directly).
JWT, activeRole.scopeKind === "guardian".
Consent is addressed to the legally flagged guardian, then the primary, then any live guardian, computed over the (small) set of live guardian links.
Class teacher
List and decide pupil leave requests for the classes where classes.class_teacher_id is their own staff id, on the school step only.
Decide a request outside their own class (404); decide a staff member's leave; see the pupil's free-text reason; decide a request they raised themselves.
JWT, the active role teacher and a live staff row (assertTeacherStaffRow); a teacher acting as staff switches role first.
The teacher-facing DTO omits reason entirely — an explicit field allowlist, not a redaction at render time. The teacher role is granted when a staff member is created on a teaching designation; after a later designation change an administrator grants or revokes it by hand — see PATCH /api/staff/:id in the People API.
Staff member (self)
Raise, list and cancel their own leave. Enters pending_school directly — no guardian step exists for staff.
Decide their own or anyone else's leave; see another colleague's requests.
JWT, a live staff row under any active role that is not a guardian/student portal role (assertStaffRow) — no teacher role needed, so non-teaching staff use it, and so does a teacher acting as staff or teacher; refused for a session with no staff row (e.g. superadmin).
dayCount here is calendar days, not school days — staff work holidays and exam days.
Permission-holding administrator
Full CRUD over the leave-type catalogue and over both subject kinds' requests; decide or reverse any request at any step; re-drive a stuck attendance apply. Held by superadmin by default; no other seeded role carries these codes.
Nothing scoped — a permission holder sees and acts on every subject.
A school delegates this to a role it creates and grants those codes to, the same mechanism every other admin-only module uses.
Attendance-application worker (system)
Write an approved leave into the attendance register; restore what it overwrote when the leave is reversed. Idempotent: re-running an already-applied or already-reverted job is a no-op.
Act on a request that is not (still) approved at the moment it runs — it re-reads status inside its own transaction and skips if a reversal overtook it.
Internal — reached only via the outbox and QueueName.LEAVE, never by an HTTP caller.
Runs at concurrency: 2, and any thrown error triggers BullMQ's own retry/backoff rather than a silent completion.
The caller's active role has scopeKind: "student".
The pupil has a live class enrolment as of today (used only to route the request to a class teacher; the leave's own dates may be in the past — see §12.5).
The pupil has at least one live guardian, or the create is refused outright.
The leave type exists, is active, and applies to students.
The academic calendar has a row for every date in the range, in the single session that contains the whole range.
A guardian files leave for their own child. Because they are the party whose consent the flow exists to collect, raising it is the consent — the request skips straight to the school step.
The caller's active role has scopeKind: "guardian".
The child is genuinely the caller's own — resolved through the same identity predicate every guardian read uses (guardians/students/users all live), never fetch-then-compare.
The child has a live enrolment as of today, and the range clears the same calendar/session checks as §5.1.
The one decision the flow is named for: a child's own request, raised by the pupil or filed by the office, needs the guardian's answer before it reaches the school.
The request is pending_guardian, belongs to the caller's own child, and the caller is the guardian the consent is addressed to — the legally flagged guardian if one exists, else the primary, else any live guardian.
The caller did not raise the request themselves (impossible here in practice, since a guardian-raised request skips this step entirely, but the guard is unconditional).
Once a pupil's request reaches the school step, the class teacher of the class the pupil is routed to can approve or refuse it — the terminal decision for a pupil's leave.
A staff member (any teacher or non-teaching employee) asks for time away. There is no line-manager or guardian step — the request reaches the school directly.
An office holder with StudentLeaves_CREATE or StaffLeaves_CREATE files leave for a pupil or a staff member who did not raise it themselves — a note received by phone or on paper, entered into the system.
Holds StudentLeaves_CREATE (pupil) or StaffLeaves_CREATE (staff).
Same resolution rules as §5.1 (pupil) or §5.5 (staff), except the office supplies studentId/staffId directly rather than it being resolved from the caller's own identity.
An office holder with StudentLeaveDecisions_UPDATE or StaffLeaveDecisions_UPDATE can decide any request at any step — including the one exit approved has: rejecting an already-approved request, which reverses it.
Holds *LeaveDecisions_UPDATE for the subject kind.
No status pre-filter on this route, unlike the portal decision routes — it loads the request by id and lets LeaveDecisionService compute the legal next state from whatever the current one is.
The enrolment on a later date differs from the one at submission.
Each date resolves its OWN live enrolment; no day is written into a class the pupil has since left.
—
Register already marked excused for that day
Idempotent no-op for that one day (still counted).
—
—
Worker throws
An unhandled error (e.g. a transient DB failure).
BullMQ retries per the queue's configured attempts/exponential backoff; after the final attempt the job is recorded as failed and reapply-attendance (§5.7-adjacent, admin-only) re-queues it.
LEAVE_ALREADY_REVERTED if that recovery route is tried against a reversed request
Reversal reads a stale generation
A request approved, reversed, then approved again.
The revert reads attendance_change_log bounded by changed_at >= attendance_applied_at of the CURRENT generation, so it restores only what the current apply wrote.
Creating a leave type (POST /api/leave-types) and filing a request on someone's behalf (§5.6) are the two "create" flows this module has — there is no admin creation of a decision or of a runtime row; those are always system- or decision-derived.
GET /api/{student,staff}-leaves (roll, filterable by status/type/date/class/search) and GET .../:id (one request) and GET .../:id/decisions (its full decision trail). GET /api/leave-types for the catalogue.
PATCH .../:id on a request corrects only leaveTypeId and reason — dates, subject, status and every resolved field are immutable after create, because recomputing day_count or the exclusion window for an already-decided request would desync it from a register the decision may already have rewritten. PATCH /api/leave-types/:publicId corrects the label, description, applicability flags, active flag and sort order — code is immutable after create.
DELETE .../:id soft-deletes a request. Refused with LEAVE_CANNOT_DELETE_APPROVED for an approved request — a delete would leave the register rewritten to excused with nothing left to explain why; the only way out of approved is the reversal in §5.7. DELETE /api/leave-types/:publicId retires a type; existing requests filed against it keep reading it, because reads never filter deleted_at on leave_types.
PATCH /api/leave-types/:publicId with isActive. An inactive type is refused on every NEW request (LEAVE_TYPE_INACTIVE) but existing requests already filed against it are unaffected.
POST .../:id/restore on a request. POST /api/leave-types/:publicId/restore on a type — this can itself collide with LEAVE_TYPE_CODE_ALREADY_EXISTS if the code was reused by a new type while the original was retired, because the code's uniqueness is a partial index scoped to live rows.
POST .../:id/decision (§5.7) is this module's moderation flow: approve, refuse, or reverse an already-approved request. It is deliberately a separate permission (*LeaveDecisions_UPDATE) from the CRUD permission (*Leaves_UPDATE) that corrects a typo in a reason, so fixing a typo never implies the power to rewrite a child's attendance register.
POST .../:id/reapply-attendance re-queues the attendance apply for an approved request whose worker attempt dead-lettered — the decision committed and the register never caught up. Refused with LEAVE_ALREADY_REVERTED if the request has since been reversed, because re-driving the apply would overwrite the days the reversal just restored.
Caller raised_by_user_id = actor; expectedVersion matches (student/guardian cancel: either status; staff cancel: pending_school only, since staff never enter pending_guardian).
Row updated; no decision row (cancellation is not a "decision"). Terminal.
Different day-count semantics per subject kind, documented per DTO.
resolveDates in the worker branches on subjectKind.
leave-decision.integration.spec.ts (does not calendar-gate staff, but does gate pupils)
The overlap exclusion is inclusive ('[]') on both date bounds and partial on the live statuses.
The default half-open range makes a single-day leave (the commonest shape) overlap nothing at all — the exact bug two independent reviewers first proposed.
A pupil/staff member cannot hold two live requests covering one day, including single-day ones.
leave_requests_no_overlap EXCLUDE constraint
409 LEAVE_OVERLAPPING_REQUEST_EXISTS
subject_key generated column carries the GiST key.
leave-admin.integration.spec.ts (overlap tests)
day_count means school days for a pupil and calendar days for a staff member.
The two subject kinds answer a different underlying question — "how many days of instruction did they miss" versus "how many days were they away".
Directly visible on LeaveRequestDto.dayCount and both portal DTOs.
leave-resolution.service.ts
DTO doc comments state which per subject kind.
Two distinct computation functions, never shared.
leave-decision.integration.spec.ts
The class teacher's responses carry the leave TYPE and never the free-text reason.
A teacher approving two days away does not need a child's diagnosis.
TeacherLeaveRequestDto has no reason field at all.
teacher-leave-portal.service.ts (selection())
Response shape differs from the pupil's and guardian's own DTOs, which DO carry reason.
Explicit allowlist mapper, never a row spread.
leave-portal.integration.spec.ts (omits reason from the teacher's rows)
The twelve /api/mobile leave handlers carry no @Permissions().
Granting *LeaveDecisions_*/*Leaves_* to staff would hand every teacher — TEACHER_PERMISSIONS = STAFF_PERMISSIONS by reference — the power to decide any pupil's leave, unscoped.
Audience assertion (assertPortalAudience/assertTeacherStaffRow/assertStaffRow) plus a hand-built row predicate is the ONLY control on these routes.
role.guard.ts (NO_PERMISSION_ADMIN_HANDLERS)
These routes 403 for any caller acting as the wrong audience, and 404 for a row outside the caller's own scope.
The predicate is built in each service, never through PeopleAccessService.scopeFor.
Attendance apply/revert run async through the outbox rather than inside the decide transaction
The approve button answers in milliseconds regardless of how long the leave is (up to 366 days)
The decide transaction never holds a lock for the duration of up to 366 register writes
Write the register synchronously inside the decision
A 366-day approval would hold the request row locked and make latency a function of range length
Mitigated: worker re-checks approved before writing, so a reversal that overtakes a slow apply is a no-op, not a race
No notification is sent on any leave event today, though the 'leave' notification category is fully provisioned in the database CHECK constraints and the TypeScript category list
None yet
The category exists so a future notification integration needs no schema change
Wire a notification on raise/decide now
A guardian or teacher is not proactively told a decision is waiting — they must open the app
See §12.4 gap note below; this is a verified, current-state gap, not a design choice with a stated rationale in code
leave_request_runtime carries no version and no bump_row_version trigger
None directly
The worker can stamp its own progress columns without invalidating a version token an operator is holding on the decision screen
Give it a version like every other mutable table
A caller reading this table (nobody does, today — it is not exposed on any DTO) cannot detect a concurrent worker write via optimistic concurrency
Low: the table is internal, and its own upserts are already conditional
leave_request_decisions is append-only by trigger, not by convention
An audit trail nobody — not even a superadmin update statement — can silently edit
A raise EXCEPTION on UPDATE/DELETE is stronger than a code-review rule
Trust services to never update it
None observed; the only cost is that a genuine data-fix requires a new decision row, not an edit
None
attendance_attempt_count and last_error_code are declared on leave_request_runtime but never populated by any code path today
None yet
The columns exist for a future operator-visible retry counter without a schema change
Wire them into the worker's catch path now
An operator diagnosing a stuck apply has no attempt count or last error to read from this table — they read the server log or BullMQ's own job history instead
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 (below, and inline throughout).
Dependencies on other modules are called out — the academic calendar and attendance register (see §9, §12.2) shape this module's behavior; neither is documented here.