Skoolsewa - School Management System Docs
Developer ResourcesLeave

Leave Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Leave.

Leave Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Schemapackages/db/src/schema/school/leave.ts, packages/db/src/migrations/0022_leave_module.sqlThe four tables, six enums, every CHECK, the overlap exclusion, the generated column, the two seeded triggers, the three seeded leave types.
APIapps/api/src/modules/leave/**/*.controller.ts, sibling API docRoute surface, actors, guards, permissions.
Backendapps/api/src/modules/leave/**/*.service.ts, sibling backend docBusiness behavior, transactions, side effects, the worker.
Permissionspackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsThe six Leave permission modules, and which of them no seeded role holds.
Guard allowlistapps/api/src/common/authorization/role.guard.tsThe twelve portal handlers that carry no @Permissions(), and the row-scope predicate documented against each.

2. Feature Summary

FieldValue
ModuleLeave
SubmoduleN/A — one module: leave-type catalogue, request lifecycle, decisions, and the attendance-application worker.
Primary user valueA 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.
ActorsPupil (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 outputsPersisted 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 docsAPI (/docs/developer/leave/api), Backend (/docs/developer/leave/backend).

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
Pupil (student session)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.
GuardianRaise 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 teacherList 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 administratorFull 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.JWT, Module_ACTION permission (LeaveTypes_*, StudentLeaves_*, StaffLeaves_*, StudentLeaveDecisions_UPDATE, StaffLeaveDecisions_UPDATE).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.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Manage the leave-type catalogueAdminPermission holder (LeaveTypes_*)GET/POST/PATCH/DELETE/POST :publicId/restore /api/leave-typesleave_typesleave_types§8.1–§8.5
File office leave for a pupilAdminPermission holder (StudentLeaves_CREATE)POST /api/student-leavesstudents, leave types, enrolment, calendar, guardiansleave_requests (pending_guardian)§8.9
File office leave for a staff memberAdminPermission holder (StaffLeaves_CREATE)POST /api/staff-leavesstaff, leave types, sessionsleave_requests (pending_school)§8.19
Correct a filed request (type/reason only)AdminPermission holder (*Leaves_UPDATE)PATCH .../:idleave_requests (locked)leave_type_id, reason§8.10, §8.20
Soft-delete a non-approved requestAdminPermission holder (*Leaves_DELETE)DELETE .../:idleave_requestsdeleted_at§8.11, §8.21
Restore a deleted requestAdminPermission holder (*Leaves_RESTORE)POST .../:id/restoreleave_requestsdeleted_at§8.12, §8.22
List / search / filter requestsAdminPermission holder (*Leaves_READ)GET .../leave_requests joined—§8.6, §8.16
Status summary countsAdminPermission holder (*Leaves_READ)GET .../summaryleave_requests—§8.7, §8.17
Decide (approve/reject, or reverse) a requestAdminPermission holder (*LeaveDecisions_UPDATE)POST .../:id/decisionleave_requests (locked)status, leave_request_decisions, leave_request_runtime, outbox event§8.13, §8.23
Re-drive a failed attendance applyAdminPermission holder (*LeaveDecisions_UPDATE)POST .../:id/reapply-attendanceleave_request_runtimeoutbox event§8.14, §8.24
Pupil raises their own leaveMobileStudentPOST /api/mobile/student/leavesown enrolment, guardians, leave type, calendarleave_requests (pending_guardian, origin self)§8.27
Pupil lists their own requestsMobileStudentGET /api/mobile/student/leavesleave_requests (own)—§8.26
Pupil cancels their own requestMobileStudentPOST .../:id/cancelown, raised by self, pendingstatus cancelled§8.28
Guardian raises leave for a childMobileGuardianPOST /api/mobile/guardian/leavesown child's enrolment, leave type, calendarleave_requests (pending_school, origin guardian)§8.30
Guardian lists their children's requestsMobileGuardianGET /api/mobile/guardian/leavesleave_requests (own children)—§8.29
Guardian consents to or refuses a child's requestMobileGuardianPOST .../:id/decisionpending_guardian, own child, addressed guardianstatus, decision row; on approve also leave_request_runtime + outbox§8.31
Guardian cancels a request they raisedMobileGuardianPOST .../:id/cancelown raised, pendingstatus cancelled§8.32
Class teacher lists their decision queueMobileTeacherGET /api/mobile/teacher/leavesleave_requests for own class—§8.33
Class teacher decides a pupil's requestMobileTeacherPOST .../:id/decisionpending_school, own classstatus, decision row; on approve also runtime + outbox§8.34
Staff member raises their own leaveMobileStaffPOST /api/mobile/staff/leavesown staff row, leave type, sessionsleave_requests (pending_school, origin self)§8.36
Staff member lists their own requestsMobileStaffGET /api/mobile/staff/leavesown—§8.35
Staff member cancels their own requestMobileStaffPOST .../:id/cancelown raised, pending_schoolstatus cancelled§8.37
System applies an approved leave to the registerWorkerSystemLeaveJob.APPLY_ATTENDANCEapproved request, calendar, live enrolment (per date)attendance sessions/records, change log, leave_request_runtime.attendance_applied_atBackend §7.6
System reverses a leave whose approval was undoneWorkerSystemLeaveJob.REVERT_ATTENDANCEchange-log rows since the applyattendance records restored, change log, leave_request_runtime.attendance_reverted_atBackend §7.7

5. User-Facing Flows

5.1 A pupil raises their own leave

Summary

A pupil signs in, opens the leave screen, and asks for time away. The request needs a parent's consent before the school ever sees it.

Preconditions

  • 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.

Main Flow

StepActor/SystemActionResultSource
1PupilPOST /api/mobile/student/leaves with type, dates, reason.DTO validated; subjectKind, origin, status, studentId, classId, enrollmentId, dayCount are all server-derived and have no field in the body.student-leave-portal.controller.ts
2BackendResolves the pupil's own id, the leave type, today's enrolment, the containing session, and the school-day count.409/422 on any resolution failure.student-leave-portal.service.ts
3BackendInserts the row: status: pending_guardian, origin: self.Row created, or the submissionId replay returns the existing one.Same
4GuardianLater, consents or refuses (flow 5.3).Advances to pending_school or ends at rejected.guardian-leave-portal.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
No live guardianPupil has zero live guardian links.Refused before the insert.LEAVE_NO_GUARDIAN_TO_CONSENT
No live enrolment todayPupil has no active class enrolment as of the school's own clock.Refused.LEAVE_STUDENT_NOT_ENROLLED
Calendar not generatedA date in the range has no calendar row.Refused.LEAVE_CALENDAR_NOT_GENERATED
Range spans two sessionsStart and end fall in different sessions, or neither.Refused.LEAVE_RANGE_OUTSIDE_SESSION
All-holiday rangeEvery date in range is a non-school day.Refused.LEAVE_NO_SCHOOL_DAYS_IN_RANGE
Overlapping live requestAnother live request already reserves part of the range for this pupil.Refused by the database exclusion constraint.LEAVE_OVERLAPPING_REQUEST_EXISTS
Retry with the same submissionIdClient resends after a timeout.Returns the existing row, 200-shaped payload, never a conflict.—

5.2 A guardian raises leave for a child

Summary

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.

Preconditions

  • 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.

Main Flow

StepActor/SystemActionResultSource
1GuardianPOST /api/mobile/guardian/leaves naming their child.A non-ward studentId 404s before any other field is considered.guardian-leave-portal.controller.ts
2BackendResolves the child through ownChildrenWhere, the leave type, today's enrolment, session and day count.404/409/422 on failure.guardian-leave-portal.service.ts
3BackendInserts the row: status: pending_school, origin: guardian.Row created — no consent step to wait for.Same

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not the caller's childstudentId does not resolve through ownChildrenWhere.404 — the id space over a roll of children is never an enumeration oracle.STUDENT_NOT_FOUND
Every other resolution failureSame as §5.1 flow.Same errors.LEAVE_STUDENT_NOT_ENROLLED, LEAVE_CALENDAR_NOT_GENERATED, LEAVE_RANGE_OUTSIDE_SESSION, LEAVE_NO_SCHOOL_DAYS_IN_RANGE, LEAVE_OVERLAPPING_REQUEST_EXISTS

5.3 A guardian consents to or refuses a child's request

Summary

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.

Preconditions

  • 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).

Main Flow

StepActor/SystemActionResultSource
1GuardianPOST .../:id/decision with approved or rejected.guardian-leave-portal.controller.ts
2BackendRe-resolves the row through ownChildrenWhere + pending_guardian, then checks the caller is the addressed guardian.404 on any mismatch.guardian-leave-portal.service.ts
3BackendHands off to LeaveDecisionService.decide, inside the same transaction.Approve → pending_school. Refuse → rejected (terminal).leave-decision.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not the caller's child, or not pending_guardianRow does not match the scoped SELECT.404, never 403 — a stranger cannot tell the row exists at all.LEAVE_REQUEST_NOT_FOUND
Not the addressed guardianCaller's child, but a different guardian is the one consent is sought from.404, for the same reason.LEAVE_REQUEST_NOT_FOUND
Stale expectedVersionSomeone else decided or edited it first.409.LEAVE_VERSION_CONFLICT
Refusedecision: rejected.Terminates at rejected. A parent declining is an answer, not an escalation — this needs no permission.—
Approvedecision: approved.Advances to pending_school; nothing is written to the attendance register yet.—

5.4 A class teacher decides a pupil's request

Summary

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.

Preconditions

  • The caller has a live staff row and is not acting as a portal (guardian/student) role.
  • The request is pending_school, subject_kind: student, and its class_id equals a class where classes.class_teacher_id is the caller's staff id.
  • The caller did not raise the request themselves.

Main Flow

StepActor/SystemActionResultSource
1TeacherGET /api/mobile/teacher/leaves to see the queue.Rows carry the leave type and never the free-text reason.teacher-leave-portal.service.ts
2TeacherPOST .../:id/decision.teacher-leave-portal.controller.ts
3BackendRe-resolves the row through classes.class_teacher_id = staffId AND status = pending_school.404 on any mismatch.teacher-leave-portal.service.ts
4BackendHands off to LeaveDecisionService.decide.Approve → approved, and the attendance apply is armed. Refuse → rejected.leave-decision.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not this teacher's class, or wrong statusRow falls outside the class-teacher predicate.404, never 403.LEAVE_REQUEST_NOT_FOUND
Teacher deciding their own requestThe class teacher is also the requester (rare, but possible for a staff account that also files a note).Refused.LEAVE_CANNOT_DECIDE_OWN_REQUEST
ApproveAdvances to approved.Schedules LeaveJob.APPLY_ATTENDANCE; the register is rewritten asynchronously, not inside this request.—
A staff (not student) requestNever appears in this queue — the query filters subject_kind = student unconditionally.——

5.5 A staff member raises their own leave

Summary

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.

Preconditions

  • The caller has a live staff row.
  • The leave type applies to staff.
  • The range falls inside exactly one academic session — staff are not calendar-gated, so no per-day school-day check runs.

Main Flow

StepActor/SystemActionResultSource
1Staff memberPOST /api/mobile/staff/leaves.staff-leave-portal.controller.ts
2BackendResolves own staff id, the leave type, the containing session, and the calendar-day count of the WHOLE range.staff-leave-portal.service.ts
3BackendInserts the row: status: pending_school, origin: self.Same

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
No staff row (e.g. superadmin)Caller is acting as a portal role, or has no live staff row.Refused.PERMISSION_INSUFFICIENT
Type does not apply to staffapplies_to_staff: false.Refused.LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT
Overlapping live requestAnother live request already reserves part of the range.Refused.LEAVE_OVERLAPPING_REQUEST_EXISTS
Leave over a holiday or exam dayStaff are not calendar-gated.Counted and accepted — this is legitimate.—

5.6 An administrator files leave on someone's behalf

Summary

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.

Preconditions

  • 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.

Main Flow

StepActor/SystemActionResultSource
1OfficePOST /api/student-leaves or POST /api/staff-leaves, naming the subject.subject_kind, origin: staff_on_behalf, status, and every resolved field are fixed by the service — never taken from the body.student-leaves.service.ts, staff-leaves.service.ts
2BackendSame resolution and calendar/session checks as the self-serve paths.Same
3BackendInserts: pupil → pending_guardian; staff → pending_school (no guardian step exists for staff).Same

Branches and Edge Cases

BranchConditionBehaviorError/Result
Pupil has no live guardianSame as §5.1.Refused.LEAVE_NO_GUARDIAN_TO_CONSENT
Pupil's range crosses the enrolment's session boundaryendDate on or after the enrolment's own session's end_date (exclusive).Refused.LEAVE_RANGE_OUTSIDE_SESSION
Every other resolution failureShared with §5.1/§5.5.Same errors.—

5.7 An administrator decides or reverses a request

Summary

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.

Preconditions

  • 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.

Main Flow

StepActor/SystemActionResultSource
1OfficePOST .../:id/decision.student-leaves.controller.ts, staff-leaves.controller.ts
2BackendLeaveDecisionService.decide, actedAs: permission_holder.leave-decision.service.ts
3Backendpending_guardian/pending_school → approved/rejected as usual; approved + rejected decision → reversal.Reversal schedules LeaveJob.REVERT_ATTENDANCE.Same

Branches and Edge Cases

BranchConditionBehaviorError/Result
Approving an already-approved requestdecision: approved against status: approved.Refused — there is no re-approval.LEAVE_ALREADY_DECIDED
Deciding a rejected or cancelled requestTerminal states have no further transition.Refused.LEAVE_ALREADY_DECIDED
Deciding a request the caller raisedSame guard as every other decision surface.Refused.LEAVE_CANNOT_DECIDE_OWN_REQUEST
Reversing an approved requestdecision: rejected against status: approved.Status → rejected; schedules the register restore. This is the ONLY exit from approved by any action.—

5.8 The system applies an approved leave, and reverses it

Summary

Approval commits in milliseconds; the attendance register catches up asynchronously, day by day, through the outbox and one BullMQ worker.

Preconditions

  • A LeaveJob.APPLY_ATTENDANCE job exists because an approval armed leave_request_runtime and enqueued it in the same transaction.
  • The request is still approved when the worker actually runs — re-checked inside the worker's own transaction.

Main Flow

StepActor/SystemActionResultSource
1WorkerLoads the request WHERE status = approved AND deleted_at IS NULL.A miss (reversed since) is a silent skip, not an error.leave-attendance.service.ts
2WorkerResolves which dates get a record: pupils, only calendar days the calendar marks as requiring attendance; staff, every calendar day in range.Same
3WorkerFor each date, writes excused into the register, re-resolving the pupil's LIVE enrolment per date (never the request's frozen class_id).Existing register rows are updated in place; a missing session is opened first.Same
4WorkerClaims the apply via a conditional upsert on leave_request_runtime.A second run for the same request writes nothing and returns 0.Same

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Reversed before the worker ranStatus no longer approved.Skip, 0 written, logged, not an error.—
Already applied (duplicate delivery)attendance_applied_at already set.Conditional upsert claims nothing; skip.—
Pupil transferred mid-rangeThe 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 dayIdempotent no-op for that one day (still counted).——
Worker throwsAn 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 generationA 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.—

6. Admin Flows

6.1 Create (mark)

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.

6.2 List / Read detail

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.

6.3 Update (correct)

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.

6.4 Delete

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.

6.5 Reorder

Leave types carry a sortOrder field, corrected through the ordinary PATCH. There is no dedicated reorder/batch-reorder endpoint.

6.6 Activate/deactivate

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.

6.7 Soft delete / Restore

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.

6.8 Export/import

Not supported. Leave requests and types are not part of the bulk import/export surface (data-transfer).

6.9 Moderation — the decision step

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.

6.10 Manual retry

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.

7. Lifecycle and State Transitions

EntityFromEvent/ActionToGuard ConditionSide Effects
leave_requests—Pupil self-raises, or office files on a pupil's behalfpending_guardianLive guardian exists; pupil enrolled; calendar generated; range in one session; no overlapping live request.Row created.
leave_requests—Guardian raises for their child, staff raises their own, or office files for staffpending_schoolSame resolution checks, minus the guardian step.Row created.
leave_requestspending_guardianGuardian consents (approved)pending_schoolCaller is the addressed guardian; expectedVersion matches.Decision row appended.
leave_requestspending_guardianGuardian refuses (rejected)rejectedSame.Decision row appended. Terminal — no permission required to reach it.
leave_requestspending_schoolClass teacher (pupil) or permission holder decides approvedapprovedClass-teacher predicate, or *LeaveDecisions_UPDATE; decider ≠ raiser.Decision row appended; leave_request_runtime armed; LeaveJob.APPLY_ATTENDANCE enqueued.
leave_requestspending_schoolClass teacher or permission holder decides rejectedrejectedSame.Decision row appended. Terminal.
leave_requestsapprovedPermission holder decides rejected (the reversal — the ONLY exit from approved)rejected*LeaveDecisions_UPDATE; decider ≠ raiser.Decision row appended; LeaveJob.REVERT_ATTENDANCE enqueued.
leave_requestspending_guardian / pending_schoolRaiser cancelscancelledCaller 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.
leave_request_runtimearmed, unappliedWorker appliesappliedRequest still approved at run time.Attendance rows written; attendance_applied_at/attendance_days_written set.
leave_request_runtimeappliedWorker revertsrevertedattendance_reverted_at IS NULL and attendance_applied_at IS NOT NULL.Attendance rows restored; attendance_reverted_at set.

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Raise (any surface)leave_requests insertNoneNoneNoneNoneNone — see §12.4
Guardian/teacher/admin decide → approveleave_requests update, leave_request_decisions insert, leave_request_runtime upsert, outbox_events insertNoneLeaveJob.APPLY_ATTENDANCE (async)NoneNoneNone
Guardian/teacher/admin decide → refuseleave_requests update, leave_request_decisions insertNoneNoneNoneNoneNone
Admin reverse (approved → rejected)Same as approve, plus outbox_events for the revert jobNoneLeaveJob.REVERT_ATTENDANCE (async)NoneNoneNone
Cancelleave_requests update (status, cancelled_at, cancelled_by_user_id)NoneNoneNoneNoneNone
Worker applystudent_attendance_records/staff_attendance_records upsert, attendance_change_log insert (per date), student_attendance_sessions insert-if-missing (pupils), leave_request_runtime conditional upsertNoneNoneNoneNoneNone
Worker revertAttendance record update/delete (per date), attendance_change_log insert (per date), leave_request_runtime updateNoneNoneNoneNoneNone
Leave-type CRUDleave_types insert/updateNoneNoneNoneNoneNone

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Overlapping requestThe GiST exclusion constraint rejects the insert.409 LEAVE_OVERLAPPING_REQUEST_EXISTS.Choose a non-overlapping range, or cancel/reverse the conflicting one first.leave-sql-errors.util.ts, migration EXCLUDE constraint.
Version conflictSomeone else decided, cancelled or edited the row first.409 LEAVE_VERSION_CONFLICT.Reload and retry with the fresh version.leave-decision.service.ts and every write path.
Stuck attendance applyThe worker's job dead-lettered after retries.The request shows approved but the register never changed.POST .../:id/reapply-attendance (admin only).leave-attendance.service.ts, admin controllers.
Attempted double-approvaldecision: approved against an already-approved row.409 LEAVE_ALREADY_DECIDED.No action needed — it is already approved.leave-decision.service.ts
Row not visible to callerA guardian/teacher/pupil route's scoped SELECT matches nothing.404 LEAVE_REQUEST_NOT_FOUND, identical whether the row does not exist, is not theirs, or is in the wrong status.Confirm the id and that the caller is the right audience.Every portal decide/cancel path.
Worker skip (reversal overtook the apply)The request is no longer approved when the job runs.Silent skip, logged, 0 days written — not surfaced to any caller.None needed; this is the correct outcome.leave-attendance.service.ts

11. Diagrams Required Per Module

  • Actor capability diagram — §3, §4.
  • High-level module flow diagram — §7 (state diagram) and Backend §3 (component diagram).
  • Sequence diagram for each major user/admin/system flow — §5.1–§5.8.
  • State machine diagram for the lifecycle — §7.
  • Data side-effect diagram for write flows — §9, §12.6.
  • Error branch diagram for critical flows — Backend §7 (per-flow branch notes) and API §9.3.

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Create (any surface)Idempotent replay via submissionIdAny raiserRetried POST with the same submissionIdReturns the ORIGINAL row, 201-shaped, never a conflictA racing replay returns the row the OTHER transaction wroteguardian-leave-portal.resolvers.ts (insertLeaveRequest)
Create (pupil paths)Retroactive leave is allowedPupil, guardian, officestartDate before todayAccepted — enrolment resolution uses TODAY only for routing, never to gate the date rangeNo special-casing anywhere in the create pathsstudent-leave-portal.service.ts docblock
Create (pupil paths)Session boundary is exclusive on the end datePupil, guardian, officeendDate equal to (not past) the session's own recorded end_dateRefused — a session's [start_date, end_date) treats its own end date as outside itLEAVE_RANGE_OUTSIDE_SESSIONcalendar-day.service.ts (sessionContaining), student-leaves.service.ts
List (admin)Free-text search covers the reason AND the subject's namePermission holder?search=Matches eitherAn EXISTS subquery joins students/users without changing the FROM clause the count/summary sharestudent-leaves.service.ts
List (admin)A retired leave type does not hide requests filed against itPermission holderAny list/readRows still show the type's code/labelleave_types join carries no deleted_at filterstudent-leaves.service.ts, staff-leaves.service.ts
List (any surface)pagination=false is refusedAny list caller?pagination=false400PAGINATION_LIMIT_INVALID — the leave roll and every portal list are people-rolls, never unpaginatedPaginationUtil, refuseUnpaginatedLeaveList
Summary (admin)Computed over the SAME filtered set as the listPermission holderGET .../summary?...filtersCounts agree with what the list under the same filters would showOne count(*) FILTER (WHERE ...) query sharing buildWherestudent-leaves.service.ts
DecideOne person cannot stand at both endsAny deciderDecider = raiserRefused, regardless of surfaceLEAVE_CANNOT_DECIDE_OWN_REQUESTleave-decision.service.ts
Decide (teacher)The pupil's reason never reaches the teacherTeacherList or decideTeacherLeaveRequestDto carries the leave TYPE, never reasonExplicit field allowlist, not a redactionteacher-leave-portal.service.ts
Decide (guardian refuse)Refusing needs no permissionGuardiandecision: rejected on pending_guardianTerminates at rejectedThe terminal APPROVAL, not the refusal, belongs to the module permissionleave-decision.service.ts
CancelOwn RAISED, not own ROWPupil/guardian/staffCancel a request about them that someone ELSE raisedRefused — a pupil may not withdraw a request their guardian raisedLEAVE_REQUEST_NOT_FOUND (404, predicate mismatch)Each portal cancel method
CancelCannot cancel after approvalAny raiserstatus: approvedNot in the cancellable-status set on any surfaceThe exit from approved is the reversal, not a cancellationEach portal cancel method
Reapply-attendanceRefused once reversedPermission holderRequest already reversed409LEAVE_ALREADY_REVERTEDstudent-leaves.service.ts, staff-leaves.service.ts
Worker applyDuplicate delivery is a no-opSystemJob redelivered after a crash before the claim committed0 days written the second time, logged as a skipConditional upsert on leave_request_runtime.attendance_applied_at IS NULLleave-attendance.service.ts
Worker applyPer-date enrolment re-resolutionSystemPupil transferred mid-rangeEach date's record lands in the class the pupil was actually in that dayNever trusts the request's frozen class_idleave-attendance.service.ts
Worker revertBounded to the current generationSystemA request approved, reversed, approved againRestores only what the CURRENT apply wrote, not an earlier generation'schanged_at >= attendance_applied_at filter on the change-log readleave-attendance.service.ts
Type restoreCan collide with a reused codePermission holderRestoring a type whose code was reused while it was retired409LEAVE_TYPE_CODE_ALREADY_EXISTSleave-types.service.ts

12.2 Business Process Diagram Pack

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
Origin decides which consent is sought; a request never asks the party who raised it to consent to it.A parent files knowing their own consent is implied; a pupil's own filing still needs a parent's word.Guardian-raised requests skip pending_guardian entirely.guardian-leave-portal.service.ts (status literal), leave-decision.service.ts (transition table)Two different entry statuses depending on origin.Two insert paths, one shared decision engine.leave-portal.integration.spec.ts
A staff request has one decision step — there is no line-manager column on staff.The schema encodes no staff reporting hierarchy; designations.is_principal is a flag, not a chain.A staff member's leave is decided by an office permission holder only, never a "manager".CHECK leave_requests_staff_has_no_guardian_stepStaff creates always enter pending_school.No teacher-portal path exists for staff subjects.leave-types.integration.spec.ts, leave-admin.integration.spec.ts
Approval overwrites the attendance register; every overwrite writes a change-log row.An approved absence must be visible on the register without a clerk marking each day.An approved pupil/staff member shows excused on every affected day, immediately traceable to the leave.leave-attendance.service.tsNone directly — async.attendance_change_log.leave_request_id links every write back to the request.leave-decision.integration.spec.ts
Pupils are calendar-gated; staff are not.Staff work holidays and exam days; refusing to record that would make the register unable to describe the days it most needs to.A pupil's dayCount counts school days only; a staff member's counts every calendar day.leave-resolution.service.ts, guardian-leave-portal.resolvers.ts, staff-leave-portal.service.tsDifferent 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 constraint409 LEAVE_OVERLAPPING_REQUEST_EXISTSsubject_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.tsDTO 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.route-permissions.spec.ts (mirrors the allowlist)

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Attendance apply/revert run async through the outbox rather than inside the decide transactionThe 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 writesWrite the register synchronously inside the decisionA 366-day approval would hold the request row locked and make latency a function of range lengthMitigated: 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 listNone yetThe category exists so a future notification integration needs no schema changeWire a notification on raise/decide nowA guardian or teacher is not proactively told a decision is waiting — they must open the appSee §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 triggerNone directlyThe worker can stamp its own progress columns without invalidating a version token an operator is holding on the decision screenGive it a version like every other mutable tableA caller reading this table (nobody does, today — it is not exposed on any DTO) cannot detect a concurrent worker write via optimistic concurrencyLow: the table is internal, and its own upserts are already conditional
leave_request_decisions is append-only by trigger, not by conventionAn audit trail nobody — not even a superadmin update statement — can silently editA raise EXCEPTION on UPDATE/DELETE is stronger than a code-review ruleTrust services to never update itNone observed; the only cost is that a genuine data-fix requires a new decision row, not an editNone
attendance_attempt_count and last_error_code are declared on leave_request_runtime but never populated by any code path todayNone yetThe columns exist for a future operator-visible retry counter without a schema changeWire them into the worker's catch path nowAn 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 insteadSee §16.8

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
Create (any)Retroactive leavestartDate in the pastAccepted; the pupil's enrolment is resolved against TODAY only for routing, not the range201student-leave-portal.service.ts
Create (any)Same-day transfer at submissionPupil transfers class on the exact day of filingWhichever enrolment row is newest wins the routing tie201, routed to the new classleave-resolution.service.ts
Create (any)Duplicate submissionIdClient retries after a timeoutReturns the original row, never a second insert or a conflict201-shaped payloadguardian-leave-portal.resolvers.ts
Create (any)Two concurrent requests racing the same submissionIdDouble-click, or a retried request racing the originalThe loser's insert 409s on the unique index; the caller re-reads by submissionId and gets the winner's rowNo duplicate row ever existsleave_requests_submission_unique partial unique index
Create (any)Single-day leave overlapping an existing single-day leave for the same personTwo one-day requests on the same dateRefused — the exclusion constraint is inclusive on both bounds specifically so this collides409 LEAVE_OVERLAPPING_REQUEST_EXISTSMigration EXCLUDE constraint comment
Create (any)Re-filing after the first request was refused or cancelledThe conflicting request is no longer liveAccepted — the exclusion is partial on live statuses and deleted_at IS NULL201Migration EXCLUDE constraint
DecideConcurrent decide on the same requestTwo admins click decide within the same windowThe FOR UPDATE lock plus expectedVersion check serialises them; the loser gets LEAVE_VERSION_CONFLICT409leave-decision.service.ts
Decide (guardian)Guardian removed between filing and decisionThe guardian link is soft-deleted after the pupil filesThe re-resolved ownChildrenWhere/isAddressedGuardian no longer matches404 LEAVE_REQUEST_NOT_FOUNDguardian-leave-portal.service.ts
Decide (teacher)Class has no teacher (class_teacher_id IS NULL)Any request routed to that classMatches nobody's queue — nullable column means an omitted predicate would leak everything, so it is never omittedNever appears, for anyone, until a teacher is assignedrole.guard.ts allowlist note
CancelCancel after approvalstatus: approvedRefused on every surface404 (not in the cancellable-status predicate)Each portal cancel
Worker applyCalendar reclassified between submission and applicationAn admin marks a day a holiday after the leave was filed but before it was approvedThe worker resolves dates from the CURRENT calendar at apply time, which may differ from the advisory dayCount recorded at submissionleave_request_runtime.attendance_days_written may differ from leave_requests.day_count; both are kept rather than one silently winningleave-attendance.service.ts docblock
Worker applyWorker crashes after writing some days but before claimingProcess killed mid-loopBullMQ redelivers; already-excused days are idempotent no-ops per day, and the remainder complete on retryEventually consistent; no duplicate writeswriteStudentDay/writeStaffDay per-day idempotency
Worker revertMultiple generations under one request idApproved, reversed, approved, reversed againEach revert reads only its own generation's change-log rows (changed_at >= attendance_applied_at)Correct restoration even across repeated cyclesleave-attendance.service.ts
Type retireRequests already filed against a retired typeAdmin sets isActive: falseExisting requests are unaffected and keep displaying the typeNo error on readleave-types.service.ts (remove)

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Raiseleave_types, students/staff, enrolments, academic_calendar_days, academic_sessions, studentGuardianleave_requestsNoneNoneid, status, dayCount, leaveType, dates, reason (subject-dependent), version
Decideleave_requests (locked), usersleave_requests, leave_request_decisions, leave_request_runtime (on approve)Noneoutbox → LeaveJob.APPLY_ATTENDANCE/REVERT_ATTENDANCE (on approve/reversal)status, decidedAt, decisionNote, version
Cancelleave_requests (locked)leave_requestsNoneNonestatus, cancelledAt, version
Worker applyleave_requests, academic_calendar_days, student_class_enrollments, academic_sessionsstudent/staff_attendance_records, student_attendance_sessions, attendance_change_log, leave_request_runtimeNoneNoneNone — worker has no HTTP response
Worker revertleave_request_runtime, attendance_change_logstudent/staff_attendance_records, attendance_change_log, leave_request_runtimeNoneNoneNone

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish (§5).
  • The doc explains what the backend does that the actor does not see (worker apply/revert, §5.8, §9).
  • The doc covers every minor flow and branch (§12.1, §12.5).
  • The doc includes user, admin, worker, and system flows (§3, §5, §6, §5.8).
  • The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4).
  • The doc maps every flow to API routes and backend side effects (§4, §12.6).
  • The doc includes diagrams appropriate to each flow type (§5, §7, §12.2, §12.6).
  • The doc covers all edge cases and failure recovery (§10, §12.5).

13. Completion Checklist

  • 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.

See Also

  • API doc: /docs/developer/leave/api
  • Backend doc: /docs/developer/leave/backend
  • Attendance (dependency — not documented here): /docs/developer/attendance
  • Academic calendar (dependency — not documented here): /docs/developer/academic-calendar

On this page

Leave Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 A pupil raises their own leaveSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 A guardian raises leave for a childSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.3 A guardian consents to or refuses a child's requestSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.4 A class teacher decides a pupil's requestSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.5 A staff member raises their own leaveSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.6 An administrator files leave on someone's behalfSummaryPreconditionsMain FlowBranches and Edge Cases5.7 An administrator decides or reverses a requestSummaryPreconditionsMain FlowBranches and Edge Cases5.8 The system applies an approved leave, and reverses itSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases6. Admin Flows6.1 Create (mark)6.2 List / Read detail6.3 Update (correct)6.4 Delete6.5 Reorder6.6 Activate/deactivate6.7 Soft delete / Restore6.8 Export/import6.9 Moderation — the decision step6.10 Manual retry7. Lifecycle and State Transitions9. Data and Side Effects by Flow10. Error and Recovery Flows11. Diagrams Required Per Module12. Mandatory Feature and Flow Deep-Dive Pack12.1 Feature Inventory With Minor Behaviors12.2 Business Process Diagram Pack12.3 Business Rules and Policy Traceability12.4 Tradeoffs and Product Rationale12.5 Flow Edge-Case Matrix12.6 Flow-to-Data Trace12.7 Experience Quality Checklist13. Completion ChecklistSee Also