Leave API Reference
Complete API contracts for Leave, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Leave - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin (office/permission-holder) and mobile-audience (guardian, student, teacher, staff) APIs owned by Leave.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/leave/**/*.controller.ts (9 controllers) | Routes, methods, guards, decorators, status codes. |
| DTOs | apps/api/src/modules/leave/**/dto/*.ts | Request, query, response, validation, examples. |
| Services | apps/api/src/modules/leave/**/*.service.ts | Behavior, side effects, response mapping, errors. |
| Schema | packages/db/src/schema/school/leave.ts, migration 0022_leave_module.sql | IDs, enums, persisted fields, constraints. |
| Jobs/outbox | packages/jobs/src/index.ts (QueueName.LEAVE, LeaveJob), apps/api/src/modules/outbox/shared/outbox.service.ts | Async events, the outbox guarantee. |
| Permissions | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | The six Leave* permission modules; which of them no seeded role holds. |
| Guard allowlist | apps/api/src/common/authorization/role.guard.ts | The twelve permission-less portal handlers. |
| Swagger membership | apps/api/src/config/swagger/swagger-documents.ts, apps/api/src/modules/mobile/mobile.module.ts | Admin-document vs. mobile-document routing. |
| Error registry | apps/api/src/common/types/error-codes.ts | All 21 LEAVE_* codes. |
| Tests | apps/api/src/modules/leave/__tests__/*.spec.ts | Confirmed request/response shapes and edge cases. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Leave |
| Module slug | leave |
| Primary actors | pupil (student session), guardian, class teacher, staff member, permission-holding administrator, internal system (worker) |
| API surfaces | admin (/api/leave-types, /api/student-leaves, /api/staff-leaves), mobile (/api/mobile/{student,guardian,teacher,staff}/leaves) |
| Base route prefixes | /api/leave-types, /api/student-leaves, /api/staff-leaves, /api/mobile/student/leaves, /api/mobile/guardian/leaves, /api/mobile/teacher/leaves, /api/mobile/staff/leaves |
| Auth model | JwtAuthGuard (global) + RoleGuard. Admin surfaces: @Permissions("Module_ACTION"). Mobile surfaces: no @Permissions() — assertPortalAudience (student, guardian), assertTeacherStaffRow (teacher) or assertStaffRow (staff) instead. |
| Persistence | PostgreSQL only. No Redis, no MongoDB read/write in this module (MongoDB audit logging happens through the global interceptor, not module code). |
| Runtime source of truth | leave_requests/leave_types tables; leave_request_runtime for internal apply/revert state, exposed on no DTO. |
| Sibling docs | Backend (/docs/developer/leave/backend), Features and flows (/docs/developer/leave/feature). |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Leave type | The administrator-managed catalogue entry a request is filed under (sick, casual, annual seeded). | leave.ts (leaveTypes) | Every create/list DTO. |
| Subject | The pupil or staff member the request is about. subjectKind discriminates. | leave.ts (leaveSubjectKindEnum) | LeaveRequestDto.subjectKind |
| Origin | Who raised the request — decides which consent is sought. self (the subject), guardian, or staff_on_behalf (the office). | leave.ts (leaveRequestOriginEnum) | LeaveRequestDto.origin |
| Status | The request's position in its lifecycle: pending_guardian, pending_school, approved, rejected, cancelled. | leave.ts (leaveRequestStatusEnum) | LeaveRequestDto.status |
| Decision | One approved/rejected answer, permanently logged. | leave.ts (leaveDecisionEnum) | LeaveDecisionDto.decision |
| Decision step | Which stage a decision closed — guardian or school. | leave.ts (leaveDecisionStepEnum) | LeaveDecisionDto.step |
| Acted-as capacity | In what capacity the decider acted — guardian, class_teacher, or permission_holder. Distinguishes a parent's own consent from an administrator's override forever. | leave.ts (leaveActorCapacityEnum) | LeaveDecisionDto.actedAs |
| Reversal | Deciding rejected against an ALREADY-approved request — the only exit from approved, and the trigger for restoring the attendance register. | leave-decision.service.ts | Admin decide route only |
version | The row's optimistic-concurrency token, a plain integer counter, sent back as a string on read and required on every write that can conflict. | leave.ts (bigint column), row-version.ts | Every LeaveRequestDto/LeaveTypeDto, every decide/update/cancel body |
submissionId | Client-generated uuid v7 making a create idempotent. A replay under the same key returns the ORIGINAL row, never a 409. | leave-request.dto.ts | Every create DTO |
| Day count | School days (pupil paths) or calendar days (staff paths), computed AS AT SUBMISSION — advisory, not necessarily what the register ends up reflecting. | leave-resolution.service.ts | LeaveRequestDto.dayCount and both portal DTOs |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/leave-types | Permission holder | JwtAuthGuard, RoleGuard | LeaveTypes_READ | LeaveTypesController | List the catalogue. |
| Admin | POST | /api/leave-types | Permission holder | Same | LeaveTypes_CREATE | LeaveTypesController | Create a leave type. |
| Admin | PATCH | /api/leave-types/:publicId | Permission holder | Same | LeaveTypes_UPDATE | LeaveTypesController | Correct a leave type (not code). |
| Admin | DELETE | /api/leave-types/:publicId | Permission holder | Same | LeaveTypes_DELETE | LeaveTypesController | Retire (soft delete) a leave type. |
| Admin | POST | /api/leave-types/:publicId/restore | Permission holder | Same | LeaveTypes_RESTORE | LeaveTypesController | Restore a retired leave type. |
| Admin | GET | /api/student-leaves | Permission holder | Same | StudentLeaves_READ | StudentLeavesController | List pupil leave requests. |
| Admin | GET | /api/student-leaves/summary | Permission holder | Same | StudentLeaves_READ | StudentLeavesController | Status counts over the same filter set. |
| Admin | GET | /api/student-leaves/:id | Permission holder | Same | StudentLeaves_READ | StudentLeavesController | One pupil request. |
| Admin | GET | /api/student-leaves/:id/decisions | Permission holder | Same | StudentLeaves_READ | StudentLeavesController | Its decision trail. |
| Admin | POST | /api/student-leaves | Permission holder | Same | StudentLeaves_CREATE | StudentLeavesController | File office leave for a pupil. |
| Admin | PATCH | /api/student-leaves/:id | Permission holder | Same | StudentLeaves_UPDATE | StudentLeavesController | Correct type/reason. |
| Admin | DELETE | /api/student-leaves/:id | Permission holder | Same | StudentLeaves_DELETE | StudentLeavesController | Soft-delete a non-approved request. |
| Admin | POST | /api/student-leaves/:id/restore | Permission holder | Same | StudentLeaves_RESTORE | StudentLeavesController | Restore a deleted request. |
| Admin | POST | /api/student-leaves/:id/decision | Permission holder | Same | StudentLeaveDecisions_UPDATE | StudentLeavesController | Decide, or reverse an approved, request. |
| Admin | POST | /api/student-leaves/:id/reapply-attendance | Permission holder | Same | StudentLeaveDecisions_UPDATE | StudentLeavesController | Re-drive a dead-lettered attendance apply. |
| Admin | GET | /api/staff-leaves | Permission holder | Same | StaffLeaves_READ | StaffLeavesController | List staff leave requests. |
| Admin | GET | /api/staff-leaves/summary | Permission holder | Same | StaffLeaves_READ | StaffLeavesController | Status counts. |
| Admin | GET | /api/staff-leaves/:id | Permission holder | Same | StaffLeaves_READ | StaffLeavesController | One staff request. |
| Admin | GET | /api/staff-leaves/:id/decisions | Permission holder | Same | StaffLeaves_READ | StaffLeavesController | Its decision trail. |
| Admin | POST | /api/staff-leaves | Permission holder | Same | StaffLeaves_CREATE | StaffLeavesController | File office leave for a staff member. |
| Admin | PATCH | /api/staff-leaves/:id | Permission holder | Same | StaffLeaves_UPDATE | StaffLeavesController | Correct type/reason. |
| Admin | DELETE | /api/staff-leaves/:id | Permission holder | Same | StaffLeaves_DELETE | StaffLeavesController | Soft-delete a non-approved request. |
| Admin | POST | /api/staff-leaves/:id/restore | Permission holder | Same | StaffLeaves_RESTORE | StaffLeavesController | Restore a deleted request. |
| Admin | POST | /api/staff-leaves/:id/decision | Permission holder | Same | StaffLeaveDecisions_UPDATE | StaffLeavesController | Decide, or reverse an approved, request. |
| Admin | POST | /api/staff-leaves/:id/reapply-attendance | Permission holder | Same | StaffLeaveDecisions_UPDATE | StaffLeavesController | Re-drive a dead-lettered attendance apply. |
| Mobile | GET | /api/mobile/student/leaves | Pupil | JwtAuthGuard, RoleGuard | None — audience-asserted | StudentLeavePortalController | List my own requests. |
| Mobile | POST | /api/mobile/student/leaves | Pupil | Same | None | StudentLeavePortalController | Raise my own leave. |
| Mobile | POST | /api/mobile/student/leaves/:id/cancel | Pupil | Same | None | StudentLeavePortalController | Cancel my own pending request. |
| Mobile | GET | /api/mobile/guardian/leaves | Guardian | Same | None | GuardianLeavePortalController | List my children's requests. |
| Mobile | POST | /api/mobile/guardian/leaves | Guardian | Same | None | GuardianLeavePortalController | Raise leave for my child. |
| Mobile | POST | /api/mobile/guardian/leaves/:id/decision | Guardian | Same | None | GuardianLeavePortalController | Consent to or refuse a child's request. |
| Mobile | POST | /api/mobile/guardian/leaves/:id/cancel | Guardian | Same | None | GuardianLeavePortalController | Cancel a request I raised. |
| Mobile | GET | /api/mobile/teacher/leaves | Class teacher | Same | None | TeacherLeavePortalController | List my class's decision queue. |
| Mobile | POST | /api/mobile/teacher/leaves/:id/decision | Class teacher | Same | None | TeacherLeavePortalController | Approve or refuse a pupil's request. |
| Mobile | GET | /api/mobile/staff/leaves | Staff member | Same | None | StaffLeavePortalController | List my own requests. |
| Mobile | POST | /api/mobile/staff/leaves | Staff member | Same | None | StaffLeavePortalController | Raise my own leave. |
| Mobile | POST | /api/mobile/staff/leaves/:id/cancel | Staff member | Same | None | StaffLeavePortalController | Cancel my own pending request. |
37 routes total: 5 leave-type, 10 pupil-admin, 10 staff-admin, 3 student-portal, 4 guardian-portal, 2 teacher-portal, 3 staff-portal. LeaveTypesModule, StudentLeavesModule and StaffLeavesModule are named in ADMIN_MODULES (swagger-documents.ts); the four portal modules are reached via MOBILE_CHILDREN in mobile.module.ts and appear in the mobile document by reflection, never by a second listing in ADMIN_MODULES.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Admin (types, student-leaves, staff-leaves) | @UseGuards(JwtAuthGuard, RoleGuard) + @Permissions("Module_ACTION") per handler | req.user.id, req.user.activeRole | See §4 | No | RoleGuard resolves the caller's grants against the permission catalogue; superadmin bypasses the check. |
| Mobile — student | @UseGuards(JwtAuthGuard, RoleGuard), no @Permissions() | activeRole.scopeKind === "student" | N/A | No | assertPortalAudience(actor, "student") — proves WHO is calling; resolves no row. |
| Mobile — guardian | Same | activeRole.scopeKind === "guardian" | N/A | No | assertPortalAudience(actor, "guardian"). |
| Mobile — teacher | Same | activeRole.name === "teacher", plus a live staff row | N/A | No | assertTeacherStaffRow(db, actor) — refuses any other active role with 403 PERMISSION_INSUFFICIENT (a teacher acting as staff switches role first), then resolves and returns the caller's own staff.id. |
| Mobile — staff | Same | Any active role that is not guardian/student scoped, plus a live staff row | N/A | No | assertStaffRow(db, actor) — the staff-row half of the teacher check, without the role name. Non-teaching staff, and a teacher acting as either staff or teacher, reach their own leave here. staff and teacher both carry scopeKind: "all", so the role name is the only thing that tells the two audiences apart. |
Every route in this module requires a valid session. There is no @Public() route and no guest-accessible route anywhere in Leave.
Which permission a caller must hold. LeaveTypes_*, StudentLeaves_*, StaffLeaves_*, StudentLeaveDecisions_UPDATE, StaffLeaveDecisions_UPDATE are all held by superadmin only, among the five seeded system roles (superadmin, staff, teacher, guardian, student). staff/teacher/guardian/student hold LeaveView_READ only (gates a panel screen, zero API routes). A school delegating office/admin leave duties creates a role and grants it the specific codes it needs.
The twelve permission-less mobile handlers and why they are still safe. RoleGuard returns true at the point it checks NO_PERMISSION_ADMIN_HANDLERS, BEFORE it reads request.user or refuses a missing active role — so the ONLY control on these routes is JwtAuthGuard (proves a session exists) plus the handler's own assertPortalAudience/assertTeacherStaffRow/assertStaffRow call (proves WHO is calling) plus the service's own hand-built row predicate (decides WHICH rows). None of the three routes to PeopleAccessService.scopeFor, which would have returned {kind: "all"} for the seeded staff/teacher roles and leaked every pupil's requests to any teacher session.
403 vs. 404, precisely. An audience mismatch (a guardian session calling a teacher route, for instance — not directly possible here since each controller is mounted under its own audience, but relevant if a caller forges the wrong role) is 403. A row outside the caller's own scope — someone else's child, another teacher's class, an already-decided request — is 404, identical to a row that does not exist at all. This is deliberate: an id space over a roll of children must never become an enumeration oracle.
6. DTO and Model Reference
6.1 CreateLeaveTypeDto (request, admin POST /api/leave-types)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
code | string | Yes | N/A | ^[a-z][a-z0-9_]*$, max 40 | "sick" | leave-type.dto.ts |
label | string | Yes | N/A | Trimmed, then 1–80 chars; whitespace-only is refused with 400 VALIDATION_FAILED | "Sick leave" | Same |
description | string | No | null | max 500 chars | — | Same |
appliesToStudent | boolean | No | true | — | — | Same |
appliesToStaff | boolean | No | true | — | — | Same |
isActive | boolean | No | true | — | — | Same |
sortOrder | number | No | 0 | int, 0–1000 | — | Same |
6.2 UpdateLeaveTypeDto (request, admin PATCH /api/leave-types/:publicId)
Every field from §6.1 except code, all optional. code is deliberately absent — immutable after create.
description is the one field whose omission and null differ: omitting it leaves the stored description unchanged, while null, an empty string or whitespace clears it to null. Any other value is trimmed. On create, an empty or whitespace-only description is stored as null too.
description is the only nullable field. Sending null for label, appliesToStudent, appliesToStaff, isActive or sortOrder is refused with 400 VALIDATION_FAILED; omit the field to leave it unchanged.
6.3 ListLeaveTypesQueryDto (query, admin GET /api/leave-types)
Extends QueryDto (pagination, page, size, sort, order, search — see §10). Adds:
| Field | Type | Required | Default | Validation | Source |
|---|---|---|---|---|---|
isActive | boolean | No | — | query-string boolean coercion | leave-type.dto.ts |
appliesToStudent | boolean | No | — | Same | Same |
appliesToStaff | boolean | No | — | Same | Same |
includeDeleted | boolean | No | false | Same | Same |
search matches label OR code.
6.4 LeaveTypeDto (response)
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | Address this row by it. |
code | string | No | |
label | string | No | |
description | string | Yes | |
appliesToStudent / appliesToStaff | boolean | No | |
isActive | boolean | No | |
sortOrder | number | No | |
version | string | No | Send back as version on the next update. |
createdAt / updatedAt | Date (ISO string on the wire) | No |
6.5 CreateStudentLeaveDto (request, admin POST /api/student-leaves)
Extends CreateLeaveRequestBaseDto (§6.9) and adds:
| Field | Type | Required | Validation | Example | Source |
|---|---|---|---|---|---|
studentId | string (uuid) | Yes | @IsUUID | — | student-leave.dto.ts |
studentId is the ONLY field beyond the shared base — every other server-derived column (subjectKind, origin, status, classId, enrollmentId, academicSessionId, dayCount, raisedByUserId) is fixed by the service, never accepted here. Naming which pupil is unavoidable on a flat /api/student-leaves collection route with no :studentId path segment.
6.6 UpdateStudentLeaveDto (request, admin PATCH /api/student-leaves/:id)
Identical to UpdateLeaveRequestBaseDto (§6.10) — no pupil-specific field. Dates are immutable after create; only leaveTypeId and reason are accepted.
6.7 ListStudentLeavesQueryDto (query, admin GET /api/student-leaves)
Extends ListLeaveRequestsQueryDto (§6.11) and adds:
| Field | Type | Required | Validation | Source |
|---|---|---|---|---|
classId | string (uuid) | No | @IsUUID — the class's publicId, resolved at submission | student-leave.dto.ts |
6.8 CreateStaffLeaveDto / UpdateStaffLeaveDto / ListStaffLeavesQueryDto (staff admin equivalents)
CreateStaffLeaveDto extends CreateLeaveRequestBaseDto and adds staffId: string (uuid, required) — the colleague this leave is filed for (staff.id). UpdateStaffLeaveDto is identical to UpdateLeaveRequestBaseDto. ListStaffLeavesQueryDto adds NO field beyond ListLeaveRequestsQueryDto — staff carry no class to filter by. Source: staff-leave.dto.ts.
6.9 CreateLeaveRequestBaseDto (shared base)
| Field | Type | Required | Validation | Example | Source |
|---|---|---|---|---|---|
leaveTypeId | string (uuid) | Yes | @IsUUID — the type's publicId | — | leave-request.dto.ts |
startDate | string (date) | Yes | @IsISO8601 | "2026-08-10" | Same |
endDate | string (date) | Yes | @IsISO8601 | "2026-08-12" | Same |
reason | string | Yes | 3–500 chars | — | Same |
submissionId | string (uuid) | No | @IsUUID — idempotency key | — | Same |
6.10 UpdateLeaveRequestBaseDto (shared base)
| Field | Type | Required | Validation | Source |
|---|---|---|---|---|
version | string | Yes | The version from the record you loaded | leave-request.dto.ts |
leaveTypeId | string (uuid) | No | @IsUUID | Same |
reason | string | No | 3–500 chars | Same |
6.11 ListLeaveRequestsQueryDto (shared admin query base)
Extends QueryDto (§10). Adds:
| Field | Type | Required | Validation | Source |
|---|---|---|---|---|
status | enum | No | One of LEAVE_REQUEST_STATUSES (§7) | leave-request.dto.ts |
leaveTypeId | string (uuid) | No | @IsUUID — the type's publicId | Same |
startDateFrom / startDateTo | string (date) | No | @IsISO8601 | Same |
includeDeleted | boolean | No, default false | Query-string boolean coercion | Same |
6.12 LeaveRequestDto (response, admin)
| Field | Type | Nullable | Notes |
|---|---|---|---|
id | string (uuid v7) | No | The public identifier — no separate publicId. |
subjectKind | "student" | "staff" | No | |
subjectId | string | No | students.id or staff.id. |
subjectName | string | No | Denormalised for display. |
subjectCode | string | No | Admission number (student) or employee code (staff). |
classId | string (uuid) | Yes | The class's publicId, as at submission. Student requests only — always null for staff. |
leaveTypeId | string (uuid) | No | The type's publicId. |
leaveTypeCode / leaveTypeLabel | string | No | |
academicSessionId | string (uuid) | No | The session's publicId. |
startDate / endDate | string (date) | No | |
dayCount | number | No | School days (student) or calendar days (staff), as at submission — advisory. |
status | enum | No | See §7. |
origin | "self" | "guardian" | "staff_on_behalf" | No | |
reason | string | No | |
submissionId | string (uuid) | Yes | |
raisedByUserId | string | No | |
decidedByUserId | string | Yes | |
decidedAt | Date | Yes | |
decisionNote | string | Yes | |
cancelledByUserId | string | Yes | |
cancelledAt | Date | Yes | |
attendanceAppliedAt | Date (ISO string on the wire) | Yes | When the attendance worker finished writing the register for this approved request. null until it has run, and for a request that was never approved. An approved request with this null and attendanceRevertedAt also null is the state reapply-attendance recovers. |
attendanceDaysWritten | number | Yes | How many register days the worker actually wrote, against dayCount's advisory count. null until the worker has run; 0 is a real answer (the worker ran and wrote nothing). |
attendanceRevertedAt | Date (ISO string on the wire) | Yes | When the attendance write was reversed because the approval was reversed. null when it never was. A reversed request cannot be re-applied. |
version | string | No | |
createdAt / updatedAt | Date | No |
The three attendance* fields are present on every response that returns this DTO — list, detail, create (including an idempotent replay by submissionId), update, restore, decision and reapply-attendance — for both pupil and staff requests. They are null together for a request with no runtime row.
6.13 LeaveDecisionDto (response, admin — GET .../:id/decisions)
| Field | Type | Nullable | Notes |
|---|---|---|---|
id | string (uuid v7) | No | |
step | "guardian" | "school" | No | |
decision | "approved" | "rejected" | No | |
actedAs | "guardian" | "class_teacher" | "permission_holder" | No | |
decidedByUserId | string | Yes | NULL only in a theoretical raw-SQL path — every service call supplies it. |
decidedByNameSnapshot | string | No | Snapshot at decision time — outlives the person. |
note | string | Yes | |
previousStatus / newStatus | enum | No | Always differ. |
decidedAt | Date | No |
6.14 LeaveSummaryDto (response, admin GET .../summary)
| Field | Type | Notes |
|---|---|---|
total | number | Count over the same filtered set the list would return. |
pendingApproval | number | pending_guardian + pending_school. |
approved | number | |
rejected | number |
6.15 DecideLeaveDto (request, admin POST .../:id/decision)
| Field | Type | Required | Validation | Example | Notes |
|---|---|---|---|---|---|
decision | "approved" | "rejected" | Yes | @IsIn | "approved" | |
note | string | No | 1–500 chars | "Medical certificate seen." | |
expectedVersion | number | Yes | int ≥ 0 | 3 | The row's version as last read. |
Carries deliberately NO subject, status, or actor field — the next status is derived from the row's CURRENT status inside the decision transaction, never sent by the caller.
6.16 CreateGuardianLeaveRequestDto (request, mobile POST /api/mobile/guardian/leaves)
| Field | Type | Required | Validation | Source |
|---|---|---|---|---|
studentId | string (uuid) | Yes | @IsUUID — the child's students.id | guardian-leave-portal.dto.ts |
leaveTypePublicId | string (uuid) | Yes | @IsUUID | Same |
startDate / endDate | string (date) | Yes | @IsDateString | Same |
reason | string | Yes | min 3 chars | Same |
submissionId | string (uuid) | No | @IsUUID | Same |
6.17 GuardianLeaveDecisionDto / GuardianCancelLeaveRequestDto (request)
GuardianLeaveDecisionDto: decision ("approved"|"rejected", required), note (optional string), expectedVersion (optional, nullable int — when omitted, the service uses the row's OWN current version rather than refusing). GuardianCancelLeaveRequestDto: expectedVersion only, same optionality.
6.18 GuardianLeaveRequestDto (response, mobile)
| Field | Type | Nullable | Notes |
|---|---|---|---|
id | string (uuid) | No | |
studentId | string (uuid) | No | |
studentName | string | No | |
leaveType | { id, code, label } | No | Nested summary, not the full LeaveTypeDto. |
startDate / endDate | string (date) | No | |
dayCount | number | No | School days. |
status | enum | No | |
origin | "self" | "guardian" | "staff_on_behalf" | No | |
reason | string | No | The guardian is entitled to their own child's stated reason. |
decisionNote | string | Yes | |
decidedAt / cancelledAt | string (ISO) | Yes | |
version | number | No | Numeric here — unlike the admin LeaveRequestDto.version, which is a string. |
createdAt | string (ISO) | No |
6.19 CreateStudentLeaveRequestDto / CancelLeaveRequestDto / StudentLeaveRequestDto (mobile, pupil self-serve)
CreateStudentLeaveRequestDto: leaveTypePublicId, startDate, endDate, reason (min 3), submissionId (optional) — no studentId field, since the route resolves the caller's own id. CancelLeaveRequestDto: expectedVersion only. StudentLeaveRequestDto matches GuardianLeaveRequestDto's shape (§6.18) minus studentId/studentName (the caller IS the subject) — reason is present, since this is the pupil's own record. Source: student-leave-portal.dto.ts.
6.20 ListTeacherLeaveRequestsQueryDto / TeacherLeaveDecisionDto / TeacherLeaveRequestDto
ListTeacherLeaveRequestsQueryDto adds nothing beyond QueryDto. TeacherLeaveDecisionDto: decision, note (optional), expectedVersion (optional, nullable). TeacherLeaveRequestDto:
| Field | Type | Nullable | Notes |
|---|---|---|---|
id | string (uuid) | No | |
leaveType | { id, code, label } | No | |
startDate / endDate | string (date) | No | |
dayCount | number | No | |
status | "pending_school" | "approved" | "rejected" | "cancelled" | No | pending_guardian is impossible here — the query never returns a request still awaiting the guardian step. |
studentId | string (uuid) | No | |
studentName | string | No | |
classLabel | string | No | e.g. "Grade 5 A (Morning)". |
version | number | No | |
createdAt | string (ISO) | No |
Deliberately carries NO reason field. This is the ONLY teacher-facing leave projection — there is no GET .../:id detail route, so there is nothing else to keep in step. Source: teacher-leave-portal.dto.ts.
6.21 CreateStaffLeaveRequestDto / StaffCancelLeaveRequestDto / StaffLeaveRequestDto
CreateStaffLeaveRequestDto: leaveTypePublicId, startDate, endDate, reason (min 3), submissionId (optional) — no staffId field. StaffCancelLeaveRequestDto: expectedVersion only. StaffLeaveRequestDto matches StudentLeaveRequestDto's shape, with dayCount documented as CALENDAR days (staff are not calendar-gated) and origin restricted to "self" | "staff_on_behalf" (a staff member never sees guardian origin on their own record — that value exists only for student-subject rows). Source: staff-leave-portal.dto.ts.
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
leave_subject_kind | student | The request is about a pupil. | Requires student_id, class_id; forbids staff_id. | leave.ts |
staff | The request is about a staff member. | Requires staff_id; forbids student_id, class_id; skips the guardian step. | Same | |
leave_request_status | pending_guardian | Awaiting a parent's consent. | Only reachable for subject_kind: student; visible to the raising pupil, the addressed guardian, and any permission holder. | Same |
pending_school | Awaiting the school's decision. | Reachable by every path; visible to the class teacher (student subject) or a permission holder (either subject). | Same | |
approved | Decided in the requester's favor. | Arms the attendance apply; cannot be cancelled; can ONLY be exited by an admin reversal. | Same | |
rejected | Decided against, OR an approved request reversed. | Terminal. | Same | |
cancelled | Withdrawn by the original raiser while pending. | Terminal. | Same | |
leave_request_origin | self | The subject (pupil or staff member) raised it themselves. | Pupil → pending_guardian; staff → pending_school. | Same |
guardian | A guardian raised it for their child. | → pending_school directly — raising IS the consent. | Same | |
staff_on_behalf | The office filed it. | Pupil → pending_guardian; staff → pending_school. | Same | |
leave_decision | approved | The decider consented/approved. | Same | |
rejected | The decider refused/rejected, or is reversing an approval. | Same | ||
leave_decision_step | guardian | This decision closed the guardian-consent step. | Same | |
school | This decision closed the school step (including a reversal). | Same | ||
leave_actor_capacity | guardian | The decider acted as the subject's guardian. | Same | |
class_teacher | The decider acted as the pupil's class teacher. | Same | ||
permission_holder | The decider acted through an admin permission (*LeaveDecisions_UPDATE). | Used for both the ordinary school-step decision by an admin AND every reversal. | Same |
8. Endpoint Reference
8.1 GET /api/leave-types
Purpose
Lists the leave-type catalogue for a select box, a management screen, or any consumer needing to resolve a code/label pair. Supports the reference-table convention: pagination=false is ALLOWED here (bounded at 1000 rows), unlike every leave-request list.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/leave/types/leave-types.controller.ts |
| DTO | apps/api/src/modules/leave/types/dto/leave-type.dto.ts |
| Service | apps/api/src/modules/leave/types/leave-types.service.ts |
| Schema | packages/db/src/schema/school/leave.ts |
| Tests | apps/api/src/modules/leave/__tests__/leave-types.integration.spec.ts |
Auth and Permissions
- Auth: JWT
- Guard chain:
JwtAuthGuard,RoleGuard - Permission:
LeaveTypes_READ - Guest support: No
- Rate limit: None specific
- Idempotency: N/A (read)
Request
| Part | Required | Details |
|---|---|---|
| Query | No | ListLeaveTypesQueryDto (§6.3) — pagination, page, size, sort (label or default sortOrder), order, search, isActive, appliesToStudent, appliesToStaff, includeDeleted. |
Response
{
"success": true,
"message": "Leave types fetched.",
"data": [
{
"publicId": "0199a1e0-0000-7000-8000-0000000f5e01",
"code": "sick",
"label": "Sick leave",
"description": null,
"appliesToStudent": true,
"appliesToStaff": true,
"isActive": true,
"sortOrder": 10,
"version": "0",
"createdAt": "2026-08-01T00:00:00.000Z",
"updatedAt": "2026-08-01T00:00:00.000Z"
}
],
"count": 3,
"currentPage": 1,
"totalPage": 1
}Side Effects
None — read only.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | — | Missing/invalid JWT | Log in again. | JwtAuthGuard |
403 | — | Missing LeaveTypes_READ | Not permitted. | RoleGuard |
Edge Cases
- Empty catalogue (never happens in a seeded environment — three types always exist unless deleted): empty array,
count: 0. ?search=blank: no filter applied (query DTO trims and drops empty strings).pagination=false: allowed, capped atPaginationUtil.UNPAGINATED_HARD_CAP(1000).
Example Requests
curl -X GET "$API_URL/api/leave-types?isActive=true" \
-H "Authorization: Bearer TOKEN"8.2 POST /api/leave-types
Purpose
Creates a new leave type. Called from a settings/catalogue management screen.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | leave-types.controller.ts |
| DTO | dto/leave-type.dto.ts (CreateLeaveTypeDto, §6.1) |
| Service | leave-types.service.ts |
| Tests | leave-types.integration.spec.ts |
Auth and Permissions
- Permission:
LeaveTypes_CREATE - Idempotency: None — no
submissionIdon this DTO; a genuine duplicatecodeis refused by the unique index, not silently deduplicated.
Request
{ "code": "sick", "label": "Sick leave", "appliesToStudent": true, "appliesToStaff": true }Response
201, same shape as §8.1's array element (one object, not wrapped in an array).
Side Effects
leave_typesinsert.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | — | DTO validation (bad code format, etc.) | Fix the request. | ValidationPipe |
409 | LEAVE_TYPE_CODE_ALREADY_EXISTS | 23505 on leave_types_code_unique | Choose a different code. | leave-types.service.ts |
409 | LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | Both appliesToStudent and appliesToStaff explicitly false | A type must apply to somebody. | Same |
Edge Cases
- Omitting both
appliesTo*flags: defaults both totrue(applies to everyone). - Reusing a code held by a RETIRED (soft-deleted) type: refused — the partial unique index still counts a live row with that code as taken only while it's live, but a NEW create with a colliding code against a retired one is fine (the retired row is excluded by the partial index); the refusal above is for a collision with a LIVE type.
Example Requests
curl -X POST "$API_URL/api/leave-types" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"code":"maternity","label":"Maternity leave","appliesToStudent":false,"appliesToStaff":true}'8.3 PATCH /api/leave-types/:publicId
Purpose
Corrects a leave type's label, description, applicability, active state, or sort order. code is not accepted.
Source Evidence
Same files as §8.2, plus UpdateLeaveTypeDto.
Auth and Permissions
Permission: LeaveTypes_UPDATE.
Request
{ "label": "Sick / medical leave", "isActive": false }All fields optional; only supplied fields are written.
Response
200, updated LeaveTypeDto — version incremented by the bump_row_version trigger.
Side Effects
leave_typesupdate. Trigger bumpsversion/updated_at.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_TYPE_NOT_FOUND | No live type with that publicId. |
409 | LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | The update would leave BOTH appliesTo* flags false (checked against the EXISTING row's other flag when only one is supplied). |
Edge Cases
- Sending
code: silently stripped byValidationPipe'swhitelist(the DTO has no such field) — never a400, never applied. - Deactivating a type currently in use: succeeds; existing requests are unaffected, only NEW requests against it are refused.
8.4 DELETE /api/leave-types/:publicId
Purpose
Retires a leave type (soft delete). Existing requests filed against it keep displaying it.
Source Evidence
Same files as §8.2.
Auth and Permissions
Permission: LeaveTypes_DELETE.
Request
No body.
Response
{ "success": true, "message": "Leave type retired.", "data": null }Side Effects
leave_types.deleted_atset.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_TYPE_NOT_FOUND | No live type with that publicId. |
Edge Cases
- Deleting a type currently
pending_guardian/pending_schoolon a live request: succeeds; reads never filterdeleted_atonleave_typesfrom theleave_requestsjoin, so those requests still show the type's code/label.
8.5 POST /api/leave-types/:publicId/restore
Purpose
Restores a retired leave type.
Source Evidence
Same files as §8.2.
Auth and Permissions
Permission: LeaveTypes_RESTORE.
Response
200, restored LeaveTypeDto.
Side Effects
leave_types.deleted_atcleared.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_TYPE_NOT_FOUND | No RETIRED type with that publicId (the query requires deleted_at IS NOT NULL). |
409 | LEAVE_TYPE_CODE_ALREADY_EXISTS | A different, LIVE type has since taken the same code — the partial-unique index makes the restore itself collide. |
Edge Cases
- The restore-collision case above is the one place
remove/restoreon this table can surface a conflict a naive reading of "it's just an undelete" would not expect.
8.6 GET /api/student-leaves
Purpose
The office's roll of pupil leave requests — the primary list screen for processing consents and decisions.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/leave/admin/student-leaves/student-leaves.controller.ts |
| DTO | dto/student-leave.dto.ts (§6.7) |
| Service | student-leaves.service.ts |
| Schema | leave.ts |
| Tests | leave-admin.integration.spec.ts |
Auth and Permissions
Permission: StudentLeaves_READ. Pagination CANNOT be disabled (PAGINATION_LIMIT_INVALID if attempted) — this is a roll of children, not a reference table.
Request
| Part | Required | Details |
|---|---|---|
| Query | No | page, size (max 100), sort (startDate or default createdAt), order, search, status, leaveTypeId, startDateFrom, startDateTo, includeDeleted, classId. |
Response
{
"success": true,
"message": "Student leaves fetched.",
"data": [
{
"id": "0199b2c0-1111-7000-8000-000000000001",
"subjectKind": "student",
"subjectId": "...",
"subjectName": "Aarav Sharma",
"subjectCode": "2026-A-0142",
"classId": "...",
"leaveTypeId": "0199a1e0-0000-7000-8000-0000000f5e01",
"leaveTypeCode": "sick",
"leaveTypeLabel": "Sick leave",
"academicSessionId": "...",
"startDate": "2026-08-10",
"endDate": "2026-08-12",
"dayCount": 3,
"status": "pending_guardian",
"origin": "self",
"reason": "Fever, advised bed rest.",
"submissionId": null,
"raisedByUserId": "...",
"decidedByUserId": null,
"decidedAt": null,
"decisionNote": null,
"cancelledByUserId": null,
"cancelledAt": null,
"attendanceAppliedAt": null,
"attendanceDaysWritten": null,
"attendanceRevertedAt": null,
"version": "0",
"createdAt": "2026-08-08T09:00:00.000Z",
"updatedAt": "2026-08-08T09:00:00.000Z"
}
],
"count": 1,
"currentPage": 1,
"totalPage": 1
}Side Effects
None.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | PAGINATION_LIMIT_INVALID | pagination=false. |
403 | — | Missing StudentLeaves_READ. |
Edge Cases
?search=matches the reason OR the pupil's name (anEXISTSsubquery, not a join, so the same builder still serves/summaryand the count).?classId=naming a classpublicIdnobody owns: matches nothing (filtered to-1), never ignored.- Rows filed against a RETIRED leave type still show the type's code/label (no
deleted_atfilter on that join). - Default sort is
createdAt, notupdatedAt— the sharedQueryDto's documented default (updatedAt) is NOT honored here; onlysort=startDatechanges the order column, any other value (including the literalupdatedAt) falls through tocreatedAt.
8.7 GET /api/student-leaves/summary
Purpose
Status tallies (total / pending / approved / rejected) for a dashboard tile or the list screen's header, over the SAME filters the list accepts.
Source Evidence
Same controller/service as §8.6. Declared BEFORE :id in the controller, or Nest would match summary as a request id.
Auth and Permissions
Permission: StudentLeaves_READ.
Request
Same query fields as §8.6 (minus pagination/sort, which are irrelevant to a count).
Response
{
"success": true,
"message": "Student leave summary fetched.",
"data": { "total": 42, "pendingApproval": 5, "approved": 30, "rejected": 7 }
}Error Cases
None specific beyond the shared auth failures.
Edge Cases
pendingApprovalsums BOTHpending_guardianandpending_school— there is no separate count per step.- Counted over a
count(*) FILTER (WHERE ...)against the identicalbuildWherepredicate the list uses, so the numbers are guaranteed to agree with what the list under the same filter would show.
8.8 GET /api/student-leaves/:id
Purpose
One pupil leave request, in full.
Source Evidence
Same as §8.6.
Auth and Permissions
Permission: StudentLeaves_READ.
Request
| Part | Required | Details |
|---|---|---|
| Params | Yes | id — the request's uuid v7 (the row's own PK, not a separate public id). |
Response
Single LeaveRequestDto (§6.12), wrapped: { "success": true, "message": "Student leave fetched.", "data": { ... } }.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | No row, or subject_kind != student. |
Edge Cases
- Requesting a
staff-subject id through this pupil-only route:404, not a type-mismatch error — the WHERE clause filterssubject_kind = studentunconditionally. - Soft-deleted rows ARE returned here (no
deleted_atfilter onfindOne) — deletion status is visible ondata, not hidden.
8.9 GET /api/student-leaves/:id/decisions
Purpose
The full decision trail for one request — every consent/refusal, in capacity and order.
Source Evidence
Same as §8.6, plus leave_request_decisions.
Auth and Permissions
Permission: StudentLeaves_READ.
Response
{
"success": true,
"message": "Decisions fetched.",
"data": [
{
"id": "...",
"step": "guardian",
"decision": "approved",
"actedAs": "guardian",
"decidedByUserId": "...",
"decidedByNameSnapshot": "Priya Sharma",
"note": null,
"previousStatus": "pending_guardian",
"newStatus": "pending_school",
"decidedAt": "2026-08-09T10:00:00.000Z"
}
]
}Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | No student-subject row with that id. |
Edge Cases
- A brand-new request: empty array, not an error.
- Ordered
decidedAt DESC, id DESC— a reversal writing two rows in one transaction (identicaldecided_at) still orders deterministically by the monotonic uuid v7id.
8.10 POST /api/student-leaves
Purpose
The office files a pupil leave request on someone's behalf — a note received by phone or on paper.
Source Evidence
Same as §8.6, plus LeaveResolutionService.
Auth and Permissions
Permission: StudentLeaves_CREATE.
Request
{
"studentId": "...",
"leaveTypeId": "0199a1e0-0000-7000-8000-0000000f5e01",
"startDate": "2026-08-10",
"endDate": "2026-08-12",
"reason": "Family travel, three days.",
"submissionId": "0199b300-0000-7000-8000-000000000099"
}subjectKind, origin, status, classId, enrollmentId, academicSessionId, dayCount, raisedByUserId are NEVER accepted — sending any of them is silently stripped (whitelist) rather than a 400, since they are not declared fields on the DTO.
Response
201, LeaveRequestDto. Always status: pending_guardian, origin: staff_on_behalf.
Side Effects
leave_requestsinsert.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | — | DTO validation. |
404 | STUDENT_NOT_FOUND / LEAVE_TYPE_NOT_FOUND | Bad studentId/leaveTypeId. |
409 | LEAVE_TYPE_INACTIVE / LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | Retired type, or a staff-only type against a pupil. |
409 | LEAVE_STUDENT_NOT_ENROLLED | No live enrolment on startDate. |
409 | LEAVE_RANGE_OUTSIDE_SESSION | Range crosses the enrolment's own session boundary (or the session's own recorded end_date, exclusive). |
409 | LEAVE_CALENDAR_NOT_GENERATED | A date in range has no calendar row. |
409 | LEAVE_NO_SCHOOL_DAYS_IN_RANGE | Every date is a non-school day. |
409 | LEAVE_NO_GUARDIAN_TO_CONSENT | Pupil has zero live guardians. |
409 | LEAVE_OVERLAPPING_REQUEST_EXISTS | 23P01 on the exclusion constraint. |
Edge Cases
- Retried
POSTwith the samesubmissionId: returns the ORIGINAL row,201-shaped, not a409. - Range partially inside the past: accepted — no "cannot be in the past" rule exists on the admin path either.
8.11 PATCH /api/student-leaves/:id
Purpose
Correct a filed request's leave type or reason. Dates, subject and status are immutable after create.
Source Evidence
Same as §8.6.
Auth and Permissions
Permission: StudentLeaves_UPDATE.
Request
{ "version": "0", "reason": "Fever, doctor's note attached." }Response
200, updated LeaveRequestDto.
Side Effects
leave_requestsupdate (lockedFOR UPDATEfirst);bump_row_versionfires.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | No pupil-subject row with that id. |
409 | LEAVE_VERSION_CONFLICT | version mismatch. |
404/409 | LEAVE_TYPE_NOT_FOUND / LEAVE_TYPE_INACTIVE / LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | Bad or inapplicable leaveTypeId. |
Edge Cases
- Sending
startDate/endDate/studentId: stripped bywhitelist, never applied — the docblock states plainly why (recomputingday_count/the exclusion window for an already-decided request would desync it from a register the decision may already have rewritten). - Omitting
leaveTypeIdandreasonboth: succeeds as a no-op WRITE (theupdatedAtstamp alone still fires the trigger, bumpingversion).
8.12 DELETE /api/student-leaves/:id
Purpose
Soft-delete a non-approved pupil leave request.
Source Evidence
Same as §8.6.
Auth and Permissions
Permission: StudentLeaves_DELETE.
Response
{ "success": true, "message": "Student leave deleted.", "data": null }Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | No live pupil-subject row with that id. |
409 | LEAVE_CANNOT_DELETE_APPROVED | status = approved. |
Edge Cases
- Deleting a
pending_guardian/pending_school/rejected/cancelledrow: succeeds — onlyapprovedis refused.
8.13 POST /api/student-leaves/:id/restore
Purpose
Restore a soft-deleted pupil leave request.
Source Evidence
Same as §8.6.
Auth and Permissions
Permission: StudentLeaves_RESTORE.
Response
200, restored LeaveRequestDto.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | No DELETED pupil-subject row with that id. |
409 | LEAVE_OVERLAPPING_REQUEST_EXISTS | Restoring re-enters the exclusion constraint's live set — a genuinely conflicting request may have been filed and approved while this one was deleted. |
Edge Cases
- Restoring does not revive an
approvedstatus's side effects — a delete could not have happened to anapprovedrow in the first place (§8.12).
8.14 POST /api/student-leaves/:id/decision
Purpose
Take the school's decision on a pupil request — OR reverse an already-approved one. This is the only route that can reach the reversal.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | student-leaves.controller.ts |
| DTO | ../../shared/dto/decide-leave.dto.ts (§6.15) |
| Service | student-leaves.service.ts, leave-decision.service.ts |
| Tests | leave-decision.integration.spec.ts |
Auth and Permissions
Permission: StudentLeaveDecisions_UPDATE — a SEPARATE permission from StudentLeaves_UPDATE, so correcting a typo never implies the power to decide.
Request
{ "decision": "approved", "expectedVersion": 1 }Response
200, updated LeaveRequestDto.
Side Effects
- Approve from
pending_school: status →approved;leave_request_decisionsinsert (step: school,actedAs: permission_holder);leave_request_runtimearmed;LeaveJob.APPLY_ATTENDANCEenqueued via the outbox. - Approve from
pending_guardian: status →pending_school; decision row only, no job. - Refuse: status →
rejected; decision row only. - Refuse an ALREADY-
approvedrow (the reversal): status →rejected; decision row;LeaveJob.REVERT_ATTENDANCEenqueued.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | Row gone or soft-deleted. |
409 | LEAVE_VERSION_CONFLICT | Stale expectedVersion. |
409 | LEAVE_CANNOT_DECIDE_OWN_REQUEST | Decider = raiser. |
409 | LEAVE_ALREADY_DECIDED | Re-approving an approved row, or deciding a rejected/cancelled one. |
Edge Cases
- No status pre-filter on this route (unlike the guardian/teacher portal decide routes) — it accepts ANY id and lets
resolveNextStatusdecide legality, which is what makes the reversal reachable here and nowhere else. - The register write from an approval is ASYNC — this response returns before the attendance job runs; the register is not guaranteed rewritten by the time
200is returned.
8.15 POST /api/student-leaves/:id/reapply-attendance
Purpose
Re-queue the attendance write for an approved request whose worker attempt dead-lettered — the decision committed and the register never caught up.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | student-leaves.controller.ts |
| Service | student-leaves.service.ts, leave-attendance.service.ts |
| Queue contract | packages/jobs/src/index.ts (LeaveJob.APPLY_ATTENDANCE) |
Auth and Permissions
Permission: StudentLeaveDecisions_UPDATE.
Request
No body.
Response
200, LeaveRequestDto (unchanged status — this route only re-queues, it does not itself write the register).
Side Effects
- Outbox enqueue of
LeaveJob.APPLY_ATTENDANCE, with a FRESH, timestampeddedupeKey(${jobName}_retry_${id}_${Date.now()}) — reusing the original key would letonConflictDoNothingsilently swallow the retry.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | Row gone or soft-deleted. |
409 | LEAVE_ALREADY_REVERTED | Not currently approved, or already reversed. |
Edge Cases
- Calling this against a request whose apply already succeeded: not refused — it re-queues anyway, and the worker's own idempotent claim (
attendance_applied_at IS NULL) makes the redundant run a no-op.
8.16–8.25 Staff-leaves admin routes (/api/staff-leaves)
Mirror §8.6–§8.15 exactly, with these differences:
| Route | Difference from the pupil equivalent |
|---|---|
GET /api/staff-leaves | Permission StaffLeaves_READ. Query has NO classId filter. search matches reason only (no name-search subquery — no class to preserve a shared join for). |
GET /api/staff-leaves/summary | Permission StaffLeaves_READ. Same counting shape. |
GET /api/staff-leaves/:id | Filters subject_kind = staff. |
GET /api/staff-leaves/:id/decisions | Identical shape. |
POST /api/staff-leaves | Body: staffId (not studentId) + the shared base fields (§6.9). Permission StaffLeaves_CREATE. Always status: pending_school, origin: staff_on_behalf — no guardian step. Errors: STAFF_NOT_FOUND in place of STUDENT_NOT_FOUND; LEAVE_RANGE_OUTSIDE_SESSION from resolveSessionForRange (no enrolment involved); NO LEAVE_STUDENT_NOT_ENROLLED, NO LEAVE_CALENDAR_NOT_GENERATED, NO LEAVE_NO_SCHOOL_DAYS_IN_RANGE, NO LEAVE_NO_GUARDIAN_TO_CONSENT — staff are not calendar-gated and carry no guardian step. |
PATCH /api/staff-leaves/:id | Permission StaffLeaves_UPDATE. Same field set (leaveTypeId, reason). |
DELETE /api/staff-leaves/:id | Permission StaffLeaves_DELETE. Same LEAVE_CANNOT_DELETE_APPROVED refusal. |
POST /api/staff-leaves/:id/restore | Permission StaffLeaves_RESTORE. |
POST /api/staff-leaves/:id/decision | Permission StaffLeaveDecisions_UPDATE. Identical transition table (§7 of the feature doc) — a staff request's ONLY school-step decider on this route is a permission holder; there is no teacher-portal equivalent for staff subjects. |
POST /api/staff-leaves/:id/reapply-attendance | Permission StaffLeaveDecisions_UPDATE. Identical mechanics. |
Response shape for every one of these ten is the SAME LeaveRequestDto/LeaveDecisionDto/LeaveSummaryDto as the pupil surface — subjectKind: "staff", classId: null always, subjectCode is the employee code rather than the admission number.
8.26 GET /api/mobile/student/leaves
Purpose
A pupil's own leave requests, for their leave screen.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/leave/portal/student/student-leave-portal.controller.ts |
| DTO | dto/student-leave-portal.dto.ts |
| Service | student-leave-portal.service.ts |
| Tests | leave-portal.integration.spec.ts |
Auth and Permissions
- Auth: JWT
- Guard chain:
JwtAuthGuard,RoleGuard - Permission: NONE —
assertPortalAudience(actor, "student") - Guest support: No
- Rate limit: None specific
- Idempotency: N/A (read)
Request
ListMyLeaveRequestsQueryDto — QueryDto fields only, no leave-specific filter. pagination=false is REFUSED (PAGINATION_LIMIT_INVALID).
Response
{
"success": true,
"message": "Leave requests fetched.",
"data": [
{
"id": "...",
"leaveType": { "id": "...", "code": "sick", "label": "Sick Leave" },
"startDate": "2026-09-20",
"endDate": "2026-09-22",
"dayCount": 3,
"status": "pending_guardian",
"origin": "self",
"reason": "Fever and viral cold, advised bed rest.",
"decisionNote": null,
"decidedAt": null,
"cancelledAt": null,
"version": 0,
"createdAt": "2026-09-18T08:00:00.000Z"
}
],
"count": 1,
"currentPage": 1,
"totalPage": 1
}Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | PERMISSION_INSUFFICIENT | Caller's activeRole.scopeKind is not student. |
400 | PAGINATION_LIMIT_INVALID | pagination=false. |
Edge Cases
- No results: empty array — a brand-new pupil with no leave history.
versionhere is a NUMBER, not a string (unlike the adminLeaveRequestDto).
8.27 POST /api/mobile/student/leaves
Purpose
A pupil raises their own leave.
Source Evidence
Same as §8.26, plus guardian-leave-portal.resolvers.ts (shared resolution helpers).
Auth and Permissions
None — assertPortalAudience(actor, "student").
Request
{
"leaveTypePublicId": "0199a1e0-0000-7000-8000-0000000f5e01",
"startDate": "2026-09-20",
"endDate": "2026-09-22",
"reason": "Fever and viral cold, advised bed rest.",
"submissionId": "0199b300-0000-7000-8000-0000000000aa"
}No studentId field — the caller's own id is resolved server-side.
Response
201, StudentLeaveRequestDto. Always status: pending_guardian, origin: self.
Side Effects
leave_requestsinsert.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | VALIDATION_FAILED | endDate < startDate (checked before the type/enrolment resolution). |
404 | LEAVE_TYPE_NOT_FOUND / STUDENT_NOT_FOUND | Bad type, or caller has no live students row. |
422 | LEAVE_TYPE_INACTIVE / LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | Retired or staff-only type. |
422 | LEAVE_STUDENT_NOT_ENROLLED | No live enrolment as of TODAY (routing only — the range itself may be in the past). |
422 | LEAVE_NO_GUARDIAN_TO_CONSENT | Zero live guardian links. |
422 | LEAVE_RANGE_OUTSIDE_SESSION / LEAVE_RANGE_TOO_LONG | Range not in one session, or > 366 days. |
409 | LEAVE_CALENDAR_NOT_GENERATED | Missing calendar row for a date in range. |
422 | LEAVE_NO_SCHOOL_DAYS_IN_RANGE | Every date is a non-school day. |
409 | LEAVE_OVERLAPPING_REQUEST_EXISTS | Exclusion constraint. |
403 | STUDENT_GRADUATED_READ_ONLY | The pupil's graduate access state is grace or ended, checked after the submissionId replay so a retry of a request raised while still attending is never turned into this refusal. |
Edge Cases
- Retroactive
startDate(in the past): accepted. - Retried with the same
submissionId: returns the original row.
8.28 POST /api/mobile/student/leaves/:id/cancel
Purpose
Withdraws a request the pupil raised themselves, while it is still pending.
Source Evidence
Same as §8.26.
Auth and Permissions
None — assertPortalAudience(actor, "student").
Request
{ "expectedVersion": 0 }expectedVersion is OPTIONAL and nullable — omitted, the check is skipped entirely.
Response
200, StudentLeaveRequestDto with status: cancelled.
Side Effects
leave_requestsupdate:status,cancelled_at,cancelled_by_user_id.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | Not raised by this caller, not in {pending_guardian, pending_school}, or genuinely absent — all identical. |
409 | LEAVE_VERSION_CONFLICT | Stale expectedVersion, when supplied. |
Edge Cases
- Cancelling a request the pupil's OWN guardian raised about them: refused (
404) — the predicate israised_by_user_id = actor.id, and the guardian, not the pupil, raised it. - Cancelling after approval: refused (
404) —approvedis outside the cancellable-status set.
8.29 GET /api/mobile/guardian/leaves
Purpose
A guardian's view of every one of their children's leave requests.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/leave/portal/guardian/guardian-leave-portal.controller.ts |
| Mapper | guardian-leave-portal.mapper.ts |
| Service | guardian-leave-portal.service.ts |
| Tests | leave-portal.integration.spec.ts |
Auth and Permissions
None — assertPortalAudience(actor, "guardian").
Request
ListGuardianLeaveRequestsQueryDto — QueryDto fields only. pagination=false refused.
Response
Array of GuardianLeaveRequestDto (§6.18) — includes studentId/studentName per row, since one guardian can have several children.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | PERMISSION_INSUFFICIENT | Not a guardian session. |
400 | PAGINATION_LIMIT_INVALID | pagination=false. |
Edge Cases
- Own-children predicate filters
deleted_atONLY (onguardians/students/users), neverrecord_status— the identity layer's rule (Backend §11, andCLAUDE.md's own worked example). - Multiple children: rows for all of them, interleaved by
createdAt DESC.
8.30 POST /api/mobile/guardian/leaves
Purpose
A guardian files leave for their own child. Raising it IS the consent.
Source Evidence
Same as §8.29, plus guardian-leave-portal.resolvers.ts.
Auth and Permissions
None — assertPortalAudience(actor, "guardian"), then the child is resolved through ownChildrenWhere.
Request
{
"studentId": "...",
"leaveTypePublicId": "0199a1e0-0000-7000-8000-0000000f5e01",
"startDate": "2026-09-20",
"endDate": "2026-09-22",
"reason": "Family travel, pre-arranged with the class teacher.",
"submissionId": "0199b300-0000-7000-8000-0000000000bb"
}Response
201, GuardianLeaveRequestDto. Always status: pending_school, origin: guardian — skips pending_guardian entirely.
Side Effects
leave_requestsinsert.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | STUDENT_NOT_FOUND | studentId does not resolve through ownChildrenWhere — a stranger's child 404s before any other field is considered. |
400 | VALIDATION_FAILED | endDate < startDate. |
404/422/409 | LEAVE_TYPE_*, LEAVE_STUDENT_NOT_ENROLLED, LEAVE_RANGE_OUTSIDE_SESSION, LEAVE_RANGE_TOO_LONG, LEAVE_CALENDAR_NOT_GENERATED, LEAVE_NO_SCHOOL_DAYS_IN_RANGE, LEAVE_OVERLAPPING_REQUEST_EXISTS | Same resolution failures as §8.27. |
403 | STUDENT_GRADUATED_READ_ONLY | The resolved child's graduate access state is grace or ended, checked after the submissionId replay and after the ward resolution — so a foreign studentId still 404s first. |
Note: NO LEAVE_NO_GUARDIAN_TO_CONSENT on this path — the caller IS a guardian, filing.
Edge Cases
- A non-ward
studentId:404, never a403— an id space over a roll of children must not become an enumeration oracle. - A child whose graduate access has ENDED (not merely
grace) never resolves as a ward at all —ownChildrenWhereexcludes them, so the request 404s asSTUDENT_NOT_FOUNDrather than reaching the graduate-access check.
8.31 POST /api/mobile/guardian/leaves/:id/decision
Purpose
The guardian's consent (or refusal) on a child's self-raised or office-filed request — the ONE decision this flow exists for.
Source Evidence
Same as §8.29, plus leave-decision.service.ts.
Auth and Permissions
None — assertPortalAudience(actor, "guardian"), row re-resolved through own-children + pending_guardian + the addressed-guardian precedence (isAddressedGuardian).
Request
{ "decision": "approved", "note": "Consented — travel dates confirmed.", "expectedVersion": 0 }expectedVersion OPTIONAL — omitted, the service uses the row's own current version (never refuses on that basis alone).
Response
200, GuardianLeaveRequestDto.
Side Effects
- Approve: status →
pending_school; decision row (step: guardian,actedAs: guardian). NO attendance job yet — that only arms on the SCHOOL step's approval. - Refuse: status →
rejected(terminal); decision row. No permission was needed to reach this.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | Not the caller's own child's request, not pending_guardian, not the addressed guardian, or genuinely absent — all four look identical. |
409 | LEAVE_VERSION_CONFLICT | Stale version. |
409 | LEAVE_CANNOT_DECIDE_OWN_REQUEST | Only reachable in practice if the guardian is somehow also the raiser of record. |
Edge Cases
- A stranger's child's request:
404, never403. - The addressed-guardian precedence: legally flagged guardian first, then primary, then any live guardian — computed in JS over a small row set, not a correlated SQL subquery.
8.32 POST /api/mobile/guardian/leaves/:id/cancel
Purpose
Withdraws a request the guardian raised themselves, while pending.
Source Evidence
Same as §8.29.
Auth and Permissions
None — assertPortalAudience(actor, "guardian").
Request
{ "expectedVersion": 0 }Response
200, GuardianLeaveRequestDto with status: cancelled.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | Not raised by this caller, not pending, or absent. |
409 | LEAVE_VERSION_CONFLICT | Stale version, when supplied. |
Edge Cases
- Cancelling AFTER guardian consent moved it to
pending_school: still cancellable — the predicate accepts either pending status, andraised_by_user_idis still the guardian regardless of which step it's now at.
8.33 GET /api/mobile/teacher/leaves
Purpose
The class teacher's decision queue — pupil requests routed to a class they are the class teacher of, awaiting the school step.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/leave/portal/teacher/teacher-leave-portal.controller.ts |
| DTO | dto/teacher-leave-portal.dto.ts |
| Service | teacher-leave-portal.service.ts |
| Tests | leave-portal.integration.spec.ts |
Auth and Permissions
None — assertTeacherStaffRow(db, actor): the active role must be teacher, and the caller must have a live staff row.
Request
ListTeacherLeaveRequestsQueryDto — QueryDto fields only. pagination=false refused.
Response
Array of TeacherLeaveRequestDto (§6.20) — no reason field.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | PERMISSION_INSUFFICIENT | The active role is not teacher (a teacher acting as staff included), or no live staff row. |
400 | PAGINATION_LIMIT_INVALID | pagination=false. |
Edge Cases
- A class with
class_teacher_id IS NULL: leaks to nobody — the predicate is never conditionally omitted. - Superadmin calling this route:
403— the active role issuperadmin, notteacher; handled by the panel as an empty state rather than an error surface.
8.34 POST /api/mobile/teacher/leaves/:id/decision
Purpose
The class teacher's approve/refuse decision — the terminal decision for a pupil's leave (subject to the admin-only reversal, §8.14).
Source Evidence
Same as §8.33, plus leave-decision.service.ts.
Auth and Permissions
None — assertTeacherStaffRow, row re-resolved through classes.class_teacher_id = staffId + pending_school + subject_kind = student.
Request
{ "decision": "approved", "note": "Approved — informed of the missed unit test.", "expectedVersion": 0 }Response
200, TeacherLeaveRequestDto.
Side Effects
- Approve: status →
approved; decision row (actedAs: class_teacher);leave_request_runtimearmed;LeaveJob.APPLY_ATTENDANCEenqueued. - Refuse: status →
rejected; decision row.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | Not this teacher's class, not pending_school, not subject_kind: student, or absent. |
409 | LEAVE_VERSION_CONFLICT | Stale version. |
409 | LEAVE_CANNOT_DECIDE_OWN_REQUEST | The class teacher raised the request themselves. |
Edge Cases
- This route can NEVER reach
approveda second time or reverse it — the WHERE clause requirespending_school, so anapprovedrow is invisible to it, not merely refused inside the transaction. - A staff (non-student) request: never matches — filtered out before the row is even locked.
8.35 GET /api/mobile/staff/leaves
Purpose
A staff member's own leave requests.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/leave/portal/staff/staff-leave-portal.controller.ts |
| DTO | dto/staff-leave-portal.dto.ts |
| Service | staff-leave-portal.service.ts |
| Tests | leave-portal.integration.spec.ts |
Auth and Permissions
None — assertStaffRow(db, actor): any active role that is not guardian/student scoped, plus a live staff row. The teacher role is not required — non-teaching staff use this route, and so does a teacher acting as either staff or teacher.
Request
ListMyStaffLeaveRequestsQueryDto — QueryDto fields only. pagination=false refused.
Response
Array of StaffLeaveRequestDto (§6.21) — reason present (the caller IS the subject); dayCount documented as CALENDAR days.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | PERMISSION_INSUFFICIENT | No live staff row, or acting as a guardian/student portal role. |
400 | PAGINATION_LIMIT_INVALID | pagination=false. |
Edge Cases
- A class teacher using this route sees only THEIR OWN staff-subject requests — never their pupils' (that's
/api/mobile/teacher/leaves, a completely separate query).
8.36 POST /api/mobile/staff/leaves
Purpose
A staff member raises their own leave — no guardian step, no calendar gate.
Source Evidence
Same as §8.35.
Auth and Permissions
None — assertStaffRow(db, actor), as §8.35.
Request
{
"leaveTypePublicId": "0199a1e0-0000-7000-8000-0000000f5e02",
"startDate": "2026-09-20",
"endDate": "2026-09-22",
"reason": "Family emergency, need three days.",
"submissionId": "0199b300-0000-7000-8000-0000000000cc"
}Response
201, StaffLeaveRequestDto. Always status: pending_school, origin: self.
Side Effects
leave_requestsinsert.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | VALIDATION_FAILED | endDate < startDate. |
404 | LEAVE_TYPE_NOT_FOUND | Bad type. |
422 | LEAVE_TYPE_INACTIVE / LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | Retired, or student-only type. |
422 | LEAVE_RANGE_OUTSIDE_SESSION / LEAVE_RANGE_TOO_LONG | Session-boundary or length failure. |
409 | LEAVE_OVERLAPPING_REQUEST_EXISTS | Exclusion constraint. |
Note: NO LEAVE_STUDENT_NOT_ENROLLED, LEAVE_CALENDAR_NOT_GENERATED, or LEAVE_NO_SCHOOL_DAYS_IN_RANGE — none of these questions apply to a staff subject.
Edge Cases
- A leave request spanning a public holiday or exam block: accepted and counted in full — staff work those days and the register needs to describe them.
8.37 POST /api/mobile/staff/leaves/:id/cancel
Purpose
Withdraws a staff member's own request while it is pending.
Source Evidence
Same as §8.35.
Auth and Permissions
None — assertStaffRow(db, actor), as §8.35.
Request
{ "expectedVersion": 0 }Response
200, StaffLeaveRequestDto with status: cancelled.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | LEAVE_REQUEST_NOT_FOUND | Not raised by this caller, not pending_school, or absent. |
409 | LEAVE_VERSION_CONFLICT | Stale version, when supplied. |
Edge Cases
- The cancellable-status set here is
{pending_school}ONLY — narrower than the pupil/guardian portals'{pending_guardian, pending_school}, because a staff request never enterspending_guardianin the first place.
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence — decide (any surface)
9.3 Error Branch
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/leave-types | page/size, pagination=false ALLOWED | 20 | 100 (paginated) | label, else sortOrder | isActive, appliesToStudent, appliesToStaff, includeDeleted, search (code/label) | 1000 unpaginated |
GET /api/student-leaves, GET /api/staff-leaves | page/size, pagination=false REFUSED | 20 | 100 | startDate, else createdAt | status, leaveTypeId, startDateFrom/startDateTo, includeDeleted, classId (pupil only), search (reason + name for pupils, reason only for staff) | N/A — always paginated |
Every /api/mobile/*/leaves list | page/size, pagination=false REFUSED | 20 | 100 | Fixed — createdAt DESC, id DESC on every portal list; no sort parameter has any effect | None beyond the implicit own-scope predicate | N/A |
PaginationUtil.normalize/getDrizzleParams/buildMetadata are the shared implementation — see docs/BACKEND_RULES_DIGEST.md's pagination rules for the mechanism itself. The sort field's documented QueryDto default is updatedAt, but no leave list orders by it — the admin lists special-case sort=startDate only and fall through to createdAt for everything else (including the literal string "updatedAt"); every portal list ignores sort entirely.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | No | No cache key is built or read anywhere in this module. | — |
| BullMQ | Yes | QueueName.LEAVE, two job names (LeaveJob.APPLY_ATTENDANCE, LeaveJob.REVERT_ATTENDANCE), always enqueued through the transactional outbox, never a direct queue.add(). | leave-decision.service.ts, student-leaves.service.ts/staff-leaves.service.ts (reapplyAttendance), leave-queue.processor.ts |
| External API | No | — |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | DB Touches | Errors | Tests | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|---|
GET /api/leave-types | LeaveTypesController.findAll | ListLeaveTypesQueryDto | LeaveTypesService.findAll | Jwt, Role | LeaveTypes_READ | N/A | N/A | leave_types | — | Yes | Yes |
POST /api/leave-types | .create | CreateLeaveTypeDto | .create | Jwt, Role | LeaveTypes_CREATE | N/A | N/A | leave_types | LEAVE_TYPE_CODE_ALREADY_EXISTS, LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | Yes | Yes |
PATCH /api/leave-types/:publicId | .update | UpdateLeaveTypeDto | .update | Jwt, Role | LeaveTypes_UPDATE | N/A | N/A | leave_types | LEAVE_TYPE_NOT_FOUND, LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT | Yes | Yes |
DELETE /api/leave-types/:publicId | .remove | — | .remove | Jwt, Role | LeaveTypes_DELETE | N/A | N/A | leave_types | LEAVE_TYPE_NOT_FOUND | Yes | Yes |
POST /api/leave-types/:publicId/restore | .restore | — | .restore | Jwt, Role | LeaveTypes_RESTORE | N/A | N/A | leave_types | LEAVE_TYPE_NOT_FOUND, LEAVE_TYPE_CODE_ALREADY_EXISTS | Yes | Yes |
GET /api/student-leaves | StudentLeavesController.findAll | ListStudentLeavesQueryDto | .findAll | Jwt, Role | StudentLeaves_READ | N/A | N/A | leave_requests joined | PAGINATION_LIMIT_INVALID | Yes | Yes |
GET /api/student-leaves/summary | .summary | Same query | .summary | Jwt, Role | StudentLeaves_READ | N/A | N/A | leave_requests | — | Yes | Yes |
GET /api/student-leaves/:id | .findOne | — | .findOne | Jwt, Role | StudentLeaves_READ | N/A | N/A | leave_requests joined | LEAVE_REQUEST_NOT_FOUND | Yes | Yes |
GET /api/student-leaves/:id/decisions | .findDecisions | — | .findDecisions | Jwt, Role | StudentLeaves_READ | N/A | N/A | leave_request_decisions | LEAVE_REQUEST_NOT_FOUND | Yes | Yes |
POST /api/student-leaves | .create | CreateStudentLeaveDto | .create | Jwt, Role | StudentLeaves_CREATE | N/A | N/A | leave_requests | 9 codes, listed §8.10 | Yes | Yes |
PATCH /api/student-leaves/:id | .update | UpdateStudentLeaveDto | .update | Jwt, Role | StudentLeaves_UPDATE | N/A | N/A | leave_requests | LEAVE_REQUEST_NOT_FOUND, LEAVE_VERSION_CONFLICT, LEAVE_TYPE_* | Yes | Yes |
DELETE /api/student-leaves/:id | .remove | — | .remove | Jwt, Role | StudentLeaves_DELETE | N/A | N/A | leave_requests | LEAVE_REQUEST_NOT_FOUND, LEAVE_CANNOT_DELETE_APPROVED | Yes | Yes |
POST /api/student-leaves/:id/restore | .restore | — | .restore | Jwt, Role | StudentLeaves_RESTORE | N/A | N/A | leave_requests | LEAVE_REQUEST_NOT_FOUND, LEAVE_OVERLAPPING_REQUEST_EXISTS | Yes | Yes |
POST /api/student-leaves/:id/decision | .decide | DecideLeaveDto | .decide → LeaveDecisionService.decide | Jwt, Role | StudentLeaveDecisions_UPDATE | N/A | LeaveJob.APPLY_ATTENDANCE/REVERT_ATTENDANCE | leave_requests, leave_request_decisions, leave_request_runtime, outbox_events | 4 codes, §8.14 | Yes | Yes |
POST /api/student-leaves/:id/reapply-attendance | .reapplyAttendance | — | .reapplyAttendance | Jwt, Role | StudentLeaveDecisions_UPDATE | N/A | LeaveJob.APPLY_ATTENDANCE | outbox_events | LEAVE_REQUEST_NOT_FOUND, LEAVE_ALREADY_REVERTED | Yes | Yes |
GET/POST/PATCH/DELETE/:id/restore/:id/decision/:id/reapply-attendance /api/staff-leaves* | StaffLeavesController.* | Staff equivalents | StaffLeavesService.* | Jwt, Role | StaffLeaves_*/StaffLeaveDecisions_UPDATE | N/A | Same two jobs | leave_requests etc. | Staff-equivalent set, §8.16–§8.25 | Yes | Yes |
GET/POST/:id/cancel /api/mobile/student/leaves* | StudentLeavePortalController.* | Portal DTOs | StudentLeavePortalService.* | Jwt, Role | None (audience) | N/A | N/A | leave_requests | §8.26–§8.28 | Yes | Yes |
GET/POST/:id/decision/:id/cancel /api/mobile/guardian/leaves* | GuardianLeavePortalController.* | Portal DTOs | GuardianLeavePortalService.* | Jwt, Role | None (audience) | N/A | Both jobs (via decide, on approve/reversal — reversal not reachable here in practice) | leave_requests, decision/runtime/outbox on decide | §8.29–§8.32 | Yes | Yes |
GET/:id/decision /api/mobile/teacher/leaves* | TeacherLeavePortalController.* | Portal DTOs | TeacherLeavePortalService.* | Jwt, Role | None (audience) | N/A | LeaveJob.APPLY_ATTENDANCE (approve only) | leave_requests, decision/runtime/outbox on decide | §8.33–§8.34 | Yes | Yes |
GET/POST/:id/cancel /api/mobile/staff/leaves* | StaffLeavePortalController.* | Portal DTOs | StaffLeavePortalService.* | Jwt, Role | None (audience) | N/A | N/A | leave_requests | §8.35–§8.37 | Yes | Yes |
13.2 Request/Response Exhaustiveness
| Example Type | Required? | Notes |
|---|---|---|
| Minimal valid request | Provided for every create/decide/cancel endpoint (§8) | Smallest valid payload shown per route. |
| Full valid request | Provided where the DTO has meaningful optional fields (leave-type create with all flags, decide with note). | |
| Authenticated request | Every example — no public/guest route exists in this module. | |
| Admin request | Every §8.1–§8.25 example. | Permission requirement stated per route. |
| Success response | Every route, §8. | Full envelope shown. |
| Empty-list response | Covered in Edge Cases per list route (§8.1, §8.6, §8.26, §8.29, §8.33, §8.35). | |
| Validation error | Covered in Error Cases, every write route. | |
| Domain error | Covered exhaustively, every route — this module's error surface IS mostly domain errors. | |
| Rate-limit/auth/permission error | 401/403 covered per route (§5, §8). No 429 — no rate limit specific to this module. |
13.3 API Diagram Pack
| Diagram | Present |
|---|---|
| Route ownership graph | §9.1 |
| Sequence diagram per major endpoint family | §9.2 (decide, the most complex family); create/list/cancel are simpler variants of the same controller→service→DB shape. |
| Activity diagram per write/action flow | Feature doc §5, §6 (per-flow branch tables serve this role at API-doc depth). |
| Error decision tree | §9.3 |
| Auth and permission flow | §5 |
| Data contract map | §6 (DTO reference) doubles as this — request DTO → service command → response DTO is 1:1 named per endpoint. |
| Cache flow | N/A — no cache. |
| Async/job flow | §11, Backend doc §9. |
| Realtime/event flow | N/A — no realtime event. |
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Web/admin panel (office screens) | Module_ACTION permissions per route (§4); version is a STRING on admin DTOs; pagination cannot be disabled on request lists. | Render errorCode from the envelope; 404 on a row and 409 on a domain rule need different UI treatment (retry vs. reload). | Stable — these routes back the existing office leave screens. |
| Web/admin panel (portal screens) | No permission check to render around — gate navigation on LeaveView_READ instead, since the routes themselves are audience-asserted, not permission-gated; version is a NUMBER on portal DTOs (inconsistent with the admin DTOs — read the type, do not assume). | A 404 on decide/cancel means "not yours, wrong status, or gone" — show a generic "this request is no longer available" rather than trying to distinguish. | Stable. |
| QA | Fixtures need a live guardian/enrolment/calendar for MOST create paths to succeed — LEAVE_NO_GUARDIAN_TO_CONSENT/LEAVE_CALENDAR_NOT_GENERATED are the two most common setup gaps in a fresh test. | Reproduce via the exact query filters documented per list endpoint. | — |
| Internal service | N/A today — nothing outside this module calls into leave services directly; consumption is exclusively via HTTP or the queue payload (LeaveAttendancePayload). | The queue payload carries only {leaveRequestId, requestId, v} — a consumer must re-fetch current state, never trust a snapshot. | Stable — v: 1 is the only version this payload has ever had. |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Pagination style | Page/size, always | Cursor pagination (exists elsewhere in this codebase as a dead utility) | Consistency with every other list in this module family; leave lists are not high-volume enough to need keyset paging. | Deep-page performance degrades like any offset scheme. | MAX_PAGE_SIZE caps size at 100; admin/portal lists never allow unbounded reads. |
| Auth mode on portal routes | Audience assertion, no @Permissions() | Grant *LeaveDecisions_*/*Leaves_* to staff/teacher/guardian/student roles directly | Would hand every teacher the power to decide any pupil's leave, unscoped (TEACHER_PERMISSIONS = STAFF_PERMISSIONS by reference). | A permission-less route is one omission away from a 403-for-everyone outage if its allowlist entries are dropped. | Mirrored in role.guard.ts AND route-permissions.spec.ts — two independent gates. |
| Error shape on row-scope mismatch | 404 uniformly, never 403 | Distinguish "not found" from "not yours" from "wrong status" with distinct codes | Prevents an id space over a roll of children becoming an enumeration oracle. | A legitimate caller cannot tell WHY their request failed from the status code alone. | The message string differs per condition even when the code does not; four codes remain reserved for a future, deliberate narrowing (§16.8 backend risk). |
| Async attendance write | Approve/reverse return before the register updates | Synchronous write inside the decide transaction | Keeps decide latency constant regardless of leave length. | A caller polling the register immediately after approval may see it unchanged. | leave_request_runtime (internal) tracks completion; reapply-attendance is the manual recovery path if it never catches up. |
version type: string (admin) vs. number (portal) | Both exist, differ by surface | Normalize to one type everywhere | Not attempted — the portal DTOs pass through the raw bigint-typed column as a JS number; the admin mapper explicitly String()s it. | A client sharing code between the two surfaces must not assume the type. | Documented here and per-DTO in §6; not treated as a bug to silently "fix" in this pass. |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a new leave_request_status value (e.g. a formal "escalated" step) | All 37 routes' status-enum surfaces; every status CHECK on leave_requests | LeaveDecisionService.resolveNextStatus needs a new branch; the implication-style CHECKs (§5.2 backend doc) are specifically shaped to make ALTER TYPE ... ADD VALUE safe | New enum label; no existing row affected | Yes — enum alteration | Additive; existing consumers reading status as a closed set need to be told before the value can appear on a live row |
| Adding a decision permission split for a currently-implicit path (e.g. giving staff a peer-review step) | role.guard.ts allowlist, permission-catalog.ts, seed-auth.ts | New permission module; new grant decision | None | Possibly — a new PERMISSION_MODULES entry needs permissions:sync | New capability, opt-in per role |
Wiring the 'leave' notification category | None of the 37 routes directly — a new outbox emission inside LeaveDecisionService/portal create paths | New NotificationService call inside existing transactions | New notification_event rows | No — the category and its two CHECK constraints already exist | Additive; no existing consumer's contract changes |
14. Zero-Omission API Checklist
- Every controller route is documented (§4, §8 — 37 routes).
- Every parent route prefix and runtime URL is documented (§4).
- Every DTO field, nested field, enum, default, transform, and validator is documented (§6, §7).
- Every response field, nullable field, generated field is documented (§6).
- Every auth, guard, permission, and guest identity branch is documented (§5).
- Every success, validation, auth, permission, not-found, conflict, and rate-limit (none) branch is documented (§8 per route).
- Every database read/write, queue job, and audit consequence is documented (§8 Side Effects, §11).
- Every route has examples for minimal request, success response, and representative failures (§8).
- Every endpoint family has route, sequence, and error diagrams (§9).
- Every tradeoff and compatibility risk is documented (§13.5, §13.6).
- The API doc links to backend and features/flows docs (below).
15. Integration Checklist
- Every route from controllers is documented — 37/37.
- Every DTO field is documented (§6).
- Every enum value is documented (§7).
- Every response envelope is documented (§8,
ResponseDtoshape). - Every error code is documented (§8 per route; all 21
LEAVE_*codes accounted for, 4 explicitly marked unused). - Every auth guard and permission (or its deliberate absence) is documented (§5).
- Every queue job is documented (§11, Backend §9); no cache key or external call exists.
- Every diagram matches the current code (§9).
- The API doc links to backend and features/flows docs.
See Also
- Backend doc:
/docs/developer/leave/backend - Features and flows doc:
/docs/developer/leave/feature