Skoolsewa - School Management System Docs
Developer ResourcesLeave

Leave Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Leave.

Leave Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Schemapackages/db/src/schema/school/leave.tsFour tables, six enums, every CHECK, every index the drizzle builder can express.
Migrationpackages/db/src/migrations/0022_leave_module.sqlThe EXCLUDE constraint, the generated subject_key column, the two bump_row_version triggers, the append-only trigger, the two attendance_change_log/attendance_records FK backfills, the two notification-category CHECK rewrites, and the three seeded leave types.
Admin (student)apps/api/src/modules/leave/admin/student-leaves/*.ts, dto/*.tsOffice CRUD, decide, reapply-attendance.
Admin (staff)apps/api/src/modules/leave/admin/staff-leaves/*.ts, dto/*.tsSame shape for staff, minus the class/enrolment fields.
Leave typesapps/api/src/modules/leave/types/*.ts, dto/*.tsThe catalogue CRUD.
Decision engineapps/api/src/modules/leave/decision/*.tsThe one place a request's status changes, shared by five callers.
Shared resolutionapps/api/src/modules/leave/shared/*.tsLeave-type/subject/enrolment resolution, day-count computation, guardian-consent checking, constraint-to-error-code mapping.
Portal (guardian)apps/api/src/modules/leave/portal/guardian/*.ts, dto/*.tsOwn-children scoping, consent, cancel.
Portal (student)apps/api/src/modules/leave/portal/student/*.ts, dto/*.tsSelf-serve create/list/cancel.
Portal (teacher)apps/api/src/modules/leave/portal/teacher/*.ts, dto/*.tsClass-teacher decision queue.
Portal (staff)apps/api/src/modules/leave/portal/staff/*.ts, dto/*.tsStaff member self-serve create/list/cancel.
Workersapps/api/src/modules/leave/workers/*.tsThe one BullMQ worker, its two handlers, apply/revert logic.
Queue contractpackages/jobs/src/index.ts (QueueName.LEAVE, LeaveJob, LeaveAttendancePayload)Job names, payload shape, why no date range travels in the payload.
Outboxapps/api/src/modules/outbox/shared/outbox.service.tsThe transactional-outbox guarantee every enqueue in this module relies on.
Permissionspackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsThe six Leave* permission modules and why five seeded roles hold none of the admin ones.
Guard allowlistapps/api/src/common/authorization/role.guard.tsThe twelve permission-less portal handlers and the row-scope predicate documented against each.
Swagger membershipapps/api/src/config/swagger/swagger-documents.ts, apps/api/src/modules/mobile/mobile.module.tsWhich modules land in the admin document versus the mobile document.
Notificationspackages/db/src/notification/notification-contract.ts, migrationThe 'leave' category is registered in both places and used by neither — see §16.6.
Error codesapps/api/src/common/types/error-codes.tsAll 21 LEAVE_* codes, including the four never thrown by any current code path.
Testsapps/api/src/modules/leave/__tests__/*.spec.tsBehavior asserted against a real database — 4 files, ~3,067 lines.

2. Backend Scope and Boundaries

Owns

  • The leave-type catalogue: code, label, description, which subject kinds it applies to, active/retired state, sort order.
  • The leave-request lifecycle for both pupils and staff: filing, correcting the type/reason, soft-deleting, restoring, and the full decision chain (guardian consent where applicable, then the school step, with a reversal path out of approved).
  • The append-only decision log — every consent and refusal, in what capacity the decider acted.
  • The one small runtime table tracking whether an approved request's attendance write has been applied and/or reversed.
  • Rewriting the attendance register (student_attendance_records / staff_attendance_records) when a leave is approved, and restoring it when that approval is reversed — the one BullMQ worker on QueueName.LEAVE.
  • Four admin/office surfaces (/api/leave-types, /api/student-leaves, /api/staff-leaves, plus the decision and reapply-attendance sub-routes) and four consumer surfaces under /api/mobile (guardian, student, teacher, staff).
  • Entitlement carry-over between academic sessions (carry-over). A carry-over grant is written once per subject/type/target-session (leaveCarryOver, deduplicated by a unique target key), its expiresOn anchored to the TARGET session's own start date computed in SQL — never the run date, so the documented delete-and-re-run correction path always produces the same expiry. Writing a carry-over grant is treated as an entitlement increase for the target session: CarryOverRolloverService re-splits every already-approved request in that session under the same per-subject lock an administrator's own override re-split would take (LeaveBalanceService.reallocateAll), so a request approved before the carry-over landed is never left computed against a smaller entitlement than one approved after it — the split is monotone regardless of order.

Does Not Own

  • Class, grade, section, enrolment and staff/student identity — packages/db/src/schema/school/{classes,people}.ts. This module references them by id and by deleted_at, never writes to them.
  • The academic calendar and academic sessions — consumed read-only through CalendarDayService (owned by the attendance module's shared layer), never generated, edited or seeded here.
  • The attendance register's OWN write paths for an ordinary (non-leave) marking or correction — apps/api/src/modules/attendance/. This module writes into the SAME tables, through the same excused status vocabulary, but owns none of the register's session-lifecycle logic (open/complete/reopen/reset).
  • The transactional outbox mechanism itself — apps/api/src/modules/outbox/. This module is a producer only: it calls OutboxService.enqueue() inside its own transactions and never constructs a dispatcher or a BullMQ Queue.add() directly.
  • Authentication and permission evaluation — JwtAuthGuard, RoleGuard, RoleModule. The admin and type surfaces declare @Permissions() codes; the portal surfaces declare none and assert their audience instead (§11).
  • Notification delivery. The 'leave' notification category exists in the schema (both the TypeScript catalogue and two database CHECK constraints) but no code in this module calls NotificationService — see §16.6.
  • Payroll's own use of a staff leave request. LeaveAttributionService.unpaidDatesInRange (apps/api/src/modules/leave/shared/leave-attribution.service.ts) is the one read the salary module makes of leave_requests, restricted to approved, non-soft-deleted staff requests. The paid entitlement is consumed FIRST, so a request's unpaid days are always the LAST unpaid_days of its counted dates — the last-N slice is taken over the whole request before restricting to a payroll period's date range, so a request spanning two months never has its unpaid days misassigned to the wrong one. day_count_basis (staff_working_days or calendar_days) on the request decides which dates count at all; the resulting counted_dates/unpaid_dates_in_range are what a payslip deducts, so leave and payroll always agree on which days were unpaid. A pre-migration request with no stored counted dates is reconstructed on read and marked isLegacy.

Source of Truth

ConcernSource of TruthNotes
A request's current status and every field a subject/office/guardian can readleave_requestsThe uuidv7 PK is also the public identifier — no separate public_id.
Who decided what, and in what capacityleave_request_decisionsAppend-only by trigger, not by convention; acted_as distinguishes a parent's own consent from an administrator's override forever.
Whether an approved request's register write has run, and whether it has been reversedleave_request_runtimeSystem-written only; carries no version. Its three attendance columns are read onto the admin LeaveRequestDto as attendanceAppliedAt, attendanceDaysWritten and attendanceRevertedAt; the portal DTOs carry none of them.
Which leave types exist and what they may be used forleave_typesSmall reference table, code immutable, addressed by publicId on every write route.
Whether the school operates on a date, and which session contains itCalendarDayService over academic_calendar_days / academic_sessionsNever defaulted — a date the calendar has no row for is a 409, not treated as a school day.
The attendance register's post-leave statestudent_attendance_records / staff_attendance_records, both rewritten by LeaveAttendanceService, never by this module reading its own tablesattendance_change_log.leave_request_id is what lets a register row's history be traced back to the leave that produced it.
A pupil's live class enrolment on a given datestudent_class_enrollments, resolved PER DATE by the worker, never from the request's frozen class_id/enrollment_idThe frozen columns exist only to route the request to a class teacher at submission.

3. Module Composition

Ten NestJS modules compose this feature. There is no top-level LeaveModule aggregate — app.module.ts and mobile.module.ts import the leaves.

ModuleTypePathControllersProvidersExportsResponsibility
LeaveTypesModuleLeaf, adminleave/types/leave-types.module.tsLeaveTypesControllerLeaveTypesServiceLeaveTypesServiceThe catalogue CRUD, /api/leave-types.
StudentLeavesModuleLeaf, adminleave/admin/student-leaves/student-leaves.module.tsStudentLeavesControllerStudentLeavesServiceStudentLeavesServiceOffice CRUD, decide and reapply over pupil requests, /api/student-leaves.
StaffLeavesModuleLeaf, adminleave/admin/staff-leaves/staff-leaves.module.tsStaffLeavesControllerStaffLeavesServiceStaffLeavesServiceSame, for staff requests, /api/staff-leaves.
LeaveSharedModuleShared, no controllersleave/shared/leave-shared.module.tsNoneLeaveResolutionServiceLeaveResolutionServiceLeave-type/subject/enrolment resolution and day-count computation shared by the two admin leaves. Declares no controller, so it is NOT named in ADMIN_MODULES.
LeaveDecisionModuleShared, no controllersleave/decision/leave-decision.module.tsNoneLeaveDecisionServiceLeaveDecisionServiceThe one four-part atomic decision transaction, shared by all five deciding surfaces.
GuardianLeavePortalModuleLeaf, consumerleave/portal/guardian/guardian-leave-portal.module.tsGuardianLeavePortalControllerGuardianLeavePortalServiceNoneA guardian's own children's leave, /api/mobile/guardian/leaves.
StudentLeavePortalModuleLeaf, consumerleave/portal/student/student-leave-portal.module.tsStudentLeavePortalControllerStudentLeavePortalServiceNoneA pupil's own leave, /api/mobile/student/leaves.
TeacherLeavePortalModuleLeaf, consumerleave/portal/teacher/teacher-leave-portal.module.tsTeacherLeavePortalControllerTeacherLeavePortalServiceNoneA class teacher's decision queue, /api/mobile/teacher/leaves.
StaffLeavePortalModuleLeaf, consumerleave/portal/staff/staff-leave-portal.module.tsStaffLeavePortalControllerStaffLeavePortalServiceNoneA staff member's own leave, /api/mobile/staff/leaves.
LeaveWorkersModuleWorker, no controllersleave/workers/leave-workers.module.tsNoneLeaveAttendanceService, LeaveQueueProcessorLeaveAttendanceServiceRegisters QueueName.LEAVE and its one worker. Composed by the application root ONLY outside the fast runtime profile — importing it starts a worker that connects to Redis and begins claiming jobs.

Every one of the four consumer-facing (portal) modules imports RoleModule — RoleGuard is applied at class level on each controller and resolves RoleService from the guarded controller's own module context; omitting it is an UnknownDependenciesException at boot, not a compile error, which is why test:structure boots the app rather than statically parsing it.

LeaveSharedModule and LeaveDecisionModule declare no controller, so ADMIN_MODULES never names them — Swagger's include does not recurse, and a module contributing no controller of its own would contribute nothing to the document while looking listed.

4. File and Directory Map

apps/api/src/modules/leave/
  admin/
    staff-leaves/
      dto/
        index.ts
        staff-leave.dto.ts
      staff-leaves.controller.ts
      staff-leaves.module.ts
      staff-leaves.service.ts
    student-leaves/
      dto/
        index.ts
        student-leave.dto.ts
      student-leaves.controller.ts
      student-leaves.module.ts
      student-leaves.service.ts
  decision/
    leave-decision.module.ts
    leave-decision.service.ts
  portal/
    guardian/
      dto/guardian-leave-portal.dto.ts
      guardian-leave-portal.controller.ts
      guardian-leave-portal.mapper.ts
      guardian-leave-portal.module.ts
      guardian-leave-portal.resolvers.ts
      guardian-leave-portal.service.ts
    staff/
      dto/staff-leave-portal.dto.ts
      staff-leave-portal.controller.ts
      staff-leave-portal.module.ts
      staff-leave-portal.service.ts
    student/
      dto/student-leave-portal.dto.ts
      student-leave-portal.controller.ts
      student-leave-portal.module.ts
      student-leave-portal.service.ts
    teacher/
      dto/teacher-leave-portal.dto.ts
      teacher-leave-portal.controller.ts
      teacher-leave-portal.module.ts
      teacher-leave-portal.service.ts
  shared/
    dto/
      decide-leave.dto.ts
      index.ts
      leave-request.dto.ts
    leave-request.mapper.ts
    leave-resolution.service.ts
    leave-shared.module.ts
    leave-sql-errors.util.ts
  types/
    dto/
      index.ts
      leave-type.dto.ts
    leave-types.controller.ts
    leave-types.module.ts
    leave-types.service.ts
  workers/
    leave-attendance.service.ts
    leave-queue.processor.ts
    leave-workers.module.ts
  __tests__/
    leave-admin.integration.spec.ts
    leave-decision.integration.spec.ts
    leave-portal.integration.spec.ts
    leave-request-attendance-fields.integration.spec.ts
    leave-types.integration.spec.ts
FilePurposeKey ExportsNotes
admin/student-leaves/student-leaves.service.tsOffice CRUD, decide, reapply for pupil requests.StudentLeavesServicePOST always writes subject_kind: student, origin: staff_on_behalf, status: pending_guardian; none of those nor the resolved fields ever come from the DTO.
admin/staff-leaves/staff-leaves.service.tsSame, for staff.StaffLeavesServicePOST writes status: pending_school directly — no guardian step exists for staff.
decision/leave-decision.service.tsThe single atomic decision transaction.LeaveDecisionServiceLocks the request, computes the next status, writes the decision row, arms or emits, all inside the CALLER's transaction.
shared/leave-resolution.service.tsEvery resolution question a CREATE has to answer that is not "what did the caller type".LeaveResolutionServiceNothing here writes a row — the insert stays in each leaf's own service.
shared/leave-request.mapper.tsThe one place the admin LeaveRequestDto/LeaveDecisionDto response shape is decided.toLeaveRequestDto, toLeaveDecisionDto, LeaveRequestJoinedRowAn explicit allowlist, never a spread of a joined row. LeaveRequestJoinedRow includes the three nullable attendance columns joined from leave_request_runtime.
shared/leave-sql-errors.util.tsMaps the two constraints an admin leave-request write can hit onto named conflicts.translateLeaveRequestErrorMatches the constraint NAME, walking .cause because drizzle's wrapper drops .constraint.
portal/guardian/guardian-leave-portal.resolvers.tsSession/day-count/enrolment resolution and insert-conflict translation SHARED by the guardian and student self-serve create paths.resolveEnrollmentAsOfToday, resolveStudentLeaveType, resolveSessionAndSchoolDayCount, refuseUnpaginatedLeaveList, insertLeaveRequestPulled out of the service so it stays under the module size limit; imported by student-leave-portal.service.ts too.
portal/guardian/guardian-leave-portal.mapper.tsRow selection and DTO mapping for the guardian surface, plus isAddressedGuardian.guardianLeaveSelection, toGuardianLeaveDto, isAddressedGuardian, leaveInsertErrorTextisAddressedGuardian computes the legal→primary→any-live precedence in JS over a small (≤3) row set.
workers/leave-attendance.service.tsWriting an approved leave into the register, and taking it back.LeaveAttendanceServicePlain @Injectable(), never a second @Processor — see §9.
workers/leave-queue.processor.tsThe ONE BullMQ worker on QueueName.LEAVE.LeaveQueueProcessorhandlers is a Record over LeaveJob, so an unhandled job name is a compile error.

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/
  leave.ts                 -- four tables, six enums, every CHECK the drizzle builder can express
packages/db/src/migrations/
  0022_leave_module.sql    -- the EXCLUDE constraint, the generated column, the triggers, the seed

5.2 Tables and Collections

leave_types

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNogeneratedPKN/AInternal FK target; leave_requests.leave_type_id points here.
public_iduuidNo$defaultFn (drizzle-side only)UNIQUEN/ANo database-side default. A raw INSERT omitting it fails NOT NULL — verified by a dedicated test. The migration's seed rows carry literal uuids for this reason.
codetextNo—Partial UNIQUE WHERE deleted_at IS NULL; CHECK formatN/AImmutable after create. Lowercase, digits, underscores, starting with a letter; ≤ 40 chars. Renaming the code would silently repoint every integration reading it — only the label is editable.
labeltextNo—CHECK formatN/ANo leading/trailing whitespace, no control characters, ≤ 80 chars.
descriptiontextYesNULL—N/AFree text.
applies_to_studentbooleanNotrueCHECK (with applies_to_staff)N/AAt least one of the two flags must be true — a type applying to nobody is a row nothing can ever select.
applies_to_staffbooleanNotrueSame CHECKN/A
is_activebooleanNotrue—N/ARetired types are refused on new requests but keep displaying on existing ones.
sort_ordersmallintNo0CHECK 0..1000N/ADisplay order; no dedicated reorder endpoint, corrected via ordinary PATCH.
versionbigintNo0—N/AOptimistic-concurrency token, maintained by the leave_types_bump_row_version trigger.
created_at / updated_attimestamptzNonow()—N/A
deleted_attimestamptzYesNULL—N/ASoft delete. Reads never filter this column when joining FROM leave_requests — a retired type must not hide the requests filed against it.

Seeded (migration, ON CONFLICT DO NOTHING): sick / "Sick leave", casual / "Casual leave", annual / "Annual leave" — all three applying to both students and staff, sort_order 10/20/30.

leave_requests

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (v7)Noapp-generatedPKN/AThe public identifier — no separate public_id.
subject_kindleave_subject_kind enumNo—3 CHECKs (see below)N/Astudent or staff.
student_iduuidConditionally—FK → students.id ON DELETE RESTRICTstudentsSet iff subject_kind = student. Restrict, because people soft-delete — a soft-deleted pupil's leave history must survive intact.
staff_iduuidConditionally—FK → staff.id ON DELETE RESTRICTstaffSet iff subject_kind = staff.
leave_type_idintegerNo—FK → leave_types.id ON DELETE RESTRICTleave_types
academic_session_idintegerNo—FK → academic_sessions.id ON DELETE RESTRICTacademic_sessions
enrollment_iduuidYesNULLFK → student_class_enrollments.id ON DELETE SET NULLstudent_class_enrollmentsResolved AT SUBMISSION, for routing only — the worker re-resolves the LIVE enrolment per date, never trusting this column.
class_idintegerYesNULLFK → classes.id ON DELETE RESTRICT; CHECK (student requires it)classesRequired when subject_kind = student; always NULL for staff.
start_date / end_datedateNo—CHECK ordered, CHECK range ≤ 365 daysN/AImmutable after create.
day_countsmallintNo—CHECK 1..(end-start+1)N/ASchool days (pupil) or calendar days (staff), AS AT SUBMISSION — advisory. The authoritative "what actually happened" is leave_request_runtime.attendance_days_written.
statusleave_request_status enumNo—Multiple CHECKs tying it to decided_at/cancelled_atN/Apending_guardian / pending_school / approved / rejected / cancelled.
originleave_request_origin enumNo——N/Aself / guardian / staff_on_behalf — decides which consent is sought; the party who raised it is never the party asked to consent.
reasontextNo—CHECK format, 3–500 charsN/AFree text; visible to the pupil, the guardian and any permission holder, deliberately withheld from the teacher-facing DTO.
submission_iduuidYesNULLPartial UNIQUE WHERE submission_id IS NOT NULLN/AClient idempotency key. A replay under the same key returns the existing row, never a 409.
raised_by_user_iduuidNo—FK → users.id ON DELETE RESTRICTusers
decided_by_user_iduuidYesNULLFK → users.id ON DELETE SET NULL; CHECK (terminal carries it, pending does not)users
decided_attimestamptzYesNULLSame CHECKsN/A
decision_notetextYesNULLCHECK format, ≤ 500 charsN/A
cancelled_by_user_iduuidYesNULLFK → users.id ON DELETE SET NULL; CHECK (only when cancelled)users
cancelled_attimestamptzYesNULLSame CHECKN/A
versionbigintNo0—N/AOptimistic-concurrency token, leave_requests_bump_row_version trigger.
created_at / updated_attimestamptzNonow()—N/A
deleted_attimestamptzYesNULL—N/ASoft delete; refused for approved rows (§6.2).
subject_keyuuid—GENERATED ALWAYS AS (COALESCE(student_id, staff_id)) STOREDUsed by the EXCLUDE constraintN/AAdded by raw SQL in the migration, not in leave.ts — GiST needs ONE key and the subject lives in one of two nullable columns. Not settable; cannot drift from its sources.

Every CHECK constraint on leave_requests, and what it prevents:

ConstraintPrevents
leave_requests_subject_studentA student row missing student_id, or carrying a staff_id.
leave_requests_subject_staffThe mirror image for staff.
leave_requests_subject_presentNeither subject id set.
leave_requests_staff_has_no_guardian_stepA staff row ever reaching pending_guardian — unreachable by CHECK, not merely unused, because the schema encodes no staff reporting hierarchy to route it to.
leave_requests_student_has_classA pupil request with no class_id — there would be nothing to route it to.
leave_requests_dates_orderedend_date < start_date.
leave_requests_range_boundedA range over 365 days — the only bound on the worker's per-day loop count.
leave_requests_day_count_boundedA day_count outside [1, end-start+1].
leave_requests_decided_carries_decisionA terminal (approved/rejected) row with decided_at IS NULL.
leave_requests_pending_has_no_decisionA pending row that already carries a decision timestamp.
leave_requests_cancelled_carries_cancellationA cancelled row missing cancelled_at.
leave_requests_cancellation_only_when_cancelledA non-cancelled row carrying a cancellation timestamp.
leave_requests_reason_formatLeading/trailing whitespace, control characters, or an out-of-bounds length in reason.
leave_requests_decision_note_formatSame, for decision_note.

Every status CHECK above is written as an implication (status NOT IN (...) OR <rule>), never a closed disjunction over the enum — a closed disjunction would make ALTER TYPE leave_request_status ADD VALUE unwritable: the migration would succeed, tsc would be happy because the union really did gain the member, and every UPDATE to the new label would fail 23514 at runtime. This shape was probed against before the tables were written.

leave_request_runtime

ColumnTypeNullableDefaultNotes
leave_request_iduuidNo—PK; FK → leave_requests.id ON DELETE CASCADE.
attendance_applied_attimestamptzYesNULLSet by the worker's conditional claim.
attendance_days_writtensmallintYesNULLWhat the worker ACTUALLY wrote — compare against leave_requests.day_count's advisory count.
attendance_reverted_attimestamptzYesNULLSet by the worker's revert claim.
attendance_attempt_countsmallintNo0Declared, never incremented by any code path in this module. See §16.6.
last_error_codetextYesNULLOnly ever written as NULL (cleared on re-arm) — never populated with an actual error code by any current code path. See §16.6.
created_attimestamptzNonow()

Carries no version column and no bump_row_version trigger, deliberately. The row is created inside the APPROVAL transaction (LeaveDecisionService.armApply), not by the worker, and the worker stamps its own progress here unconditionally. Attaching the trigger would let the worker's write invalidate a version token an operator is holding on the decision screen at the exact moment the work they asked for finishes — the same reasoning student_attendance_session_runtime exists under. Only the admin LeaveRequestDto exposes it, and only three columns: attendance_applied_at, attendance_days_written and attendance_reverted_at reach the response as attendanceAppliedAt, attendanceDaysWritten and attendanceRevertedAt. StudentLeavesService.baseSelect() and StaffLeavesService.baseSelect() LEFT JOIN this table on its primary key (at most one row per request, so the join cannot multiply rows), and a request with no runtime row emits all three as null. The attempt count and the last error code reach no response; they are read only internally (reapplyAttendance's eligibility check, the worker's own claim queries).

leave_request_decisions

ColumnTypeNullableDefaultNotes
iduuid (v7)Noapp-generatedPK. Monotonic, so it orders ties in decided_at correctly.
leave_request_iduuidNo—FK → leave_requests.id ON DELETE RESTRICT.
stepleave_decision_step enumNo—guardian or school — which step of the chain this decision closed.
decisionleave_decision enumNo—approved or rejected.
acted_asleave_actor_capacity enumNo—guardian / class_teacher / permission_holder — what keeps an administrator's override distinguishable from a parent's own consent forever.
decided_by_user_iduuidYesNULLNo foreign key, deliberately — see the append-only note below.
decided_by_name_snapshottextNo—Snapshot, not joined — the log outlives the person.
notetextYesNULLCHECK format, ≤ 500 chars.
previous_status / new_statusleave_request_status enumNo—CHECK: must differ — a row asserting nothing changed is not representable.
decided_attimestamptzNonow()

No foreign key on decided_by_user_id. A referential ON DELETE SET NULL is itself an UPDATE on the child row and fires row triggers — so it would abort any DELETE FROM users that had ever touched a leave decision, because the table below is append-only by trigger. The name snapshot is what the screen renders once the person is gone.

Append-only by trigger, not by convention. leave_request_decisions_is_append_only() raises 2F002 (modifying_sql_data_not_permitted) on any UPDATE OR DELETE, installed LAST in the migration — a guard installed beside its own CREATE TABLE would refuse the same migration's bookkeeping, even though nothing in this migration writes to the log.

Index: leave_request_decisions_request_idx on (leave_request_id, decided_at DESC, id DESC) — id DESC is not decoration; now() is constant within a transaction, so a reversal writing two rows in one transaction shares one decided_at exactly, and a cursor on the timestamp alone would be unstable across that tie.

attendance_change_log.leave_request_id (added column, not a new table)

The migration adds one nullable, FK-less column to the EXISTING attendance_change_log table (owned by the attendance module) plus a partial index WHERE leave_request_id IS NOT NULL. No FK, for the identical append-only-trigger reason as leave_request_decisions.decided_by_user_id above. This is what lets a register row's history be traced back to the leave that produced it, and what bounds the revert's read to one generation (changed_at >= leave_request_runtime.attendance_applied_at).

5.3 The Overlap Exclusion — the module's most important integrity rule

Declared only in the migration (drizzle has no EXCLUDE builder):

ALTER TABLE "leave_requests" ADD CONSTRAINT "leave_requests_no_overlap"
EXCLUDE USING gist (
  "subject_key" WITH =,
  daterange("start_date", "end_date", '[]') WITH &&
) WHERE ("status" IN ('pending_guardian','pending_school','approved')
         AND "deleted_at" IS NULL);

Three things about it bind every write path:

  • It needs the btree_gist extension for the = operator on subject_key — created by 0019_timetable_module.sql and re-asserted here in case a database has run only through 0021.
  • '[]' is inclusive on both ends. The default [) makes daterange(d, d) empty, and an empty range overlaps nothing — so with the default, a single-day leave (the commonest shape there is) would collide with nothing at all. Two independent reviewers read a prose description of this constraint and both concluded exactly that; the SQL, reproduced here and in the migration and the schema file, is the authority.
  • It is partial on the live statuses and deleted_at IS NULL — a refused, cancelled or soft-deleted request stops reserving the range, so a re-file is never blocked by a request nobody can see the reason for.

A violation surfaces as PostgreSQL 23P01, mapped by translateLeaveRequestError/insertLeaveRequest (per surface) to LEAVE_OVERLAPPING_REQUEST_EXISTS, matched on the constraint NAME (.constraint, walked through .cause because drizzle's error wrapper drops it at the top level).

5.4 Relationship Diagram

5.5 Index Rationale

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
leave_types_public_id_uniquepublic_iduniqueEvery write route address by publicId.—
leave_types_code_uniquecodepartial unique, WHERE deleted_at IS NULLA retired type's code is reusable; a plain unique would reserve it forever.Restore can then collide (§16.5).
leave_requests_no_overlapsubject_key, daterange(start_date,end_date,'[]')GiST EXCLUDE, partial on live statusesOne person cannot hold two live requests covering one day.Requires btree_gist; write cost of a GiST maintenance on every insert/update of a live row.
leave_requests_submission_uniquesubmission_idpartial unique, WHERE submission_id IS NOT NULLIdempotent create under a client-supplied key.—
leave_requests_subject_idxsubject_kind, status, idbtreeThe admin list's core filter — one subject kind, filtered by status.—
leave_requests_class_idxclass_id, start_datebtreeThe admin list's class filter and date ordering.—
leave_requests_pending_idxstatus, start_datepartial btree, WHERE status IN (pending_guardian,pending_school) AND deleted_at IS NULLThe pending queues (teacher/guardian portals) scan a small, always-current slice rather than the whole table.Index maintenance only on rows actually pending — cheap, since most rows are terminal.
leave_request_decisions_request_idxleave_request_id, decided_at DESC, id DESCbtreeA request's decision history in display order, with id DESC breaking exact-timestamp ties from a same-transaction reversal.—
student_attendance_records_leave_request_idx / staff_attendance_records_leave_request_idxleave_request_idpartial btree, WHERE leave_request_id IS NOT NULLThe revert's read of which register rows a given leave wrote.—
attendance_change_log_leave_request_idxleave_request_idpartial btree, WHERE leave_request_id IS NOT NULLThe revert's bounded read of what a generation overwrote.—

6. Services and Responsibilities

6.1 LeaveTypesService

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)LeaveTypesController.findAllleave_types———
create(dto).createleave_types (uniqueness via DB)leave_types insert—LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT, LEAVE_TYPE_CODE_ALREADY_EXISTS
update(publicId, dto).updateleave_typesleave_types update; an omitted description is left alone, while null, empty or whitespace stores NULLbump_row_version firesLEAVE_TYPE_NOT_FOUND, LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT
remove(publicId).removeleave_typesdeleted_at set—LEAVE_TYPE_NOT_FOUND
restore(publicId).restoreleave_typesdeleted_at cleared—LEAVE_TYPE_NOT_FOUND, LEAVE_TYPE_CODE_ALREADY_EXISTS (restore-collision)

pagination=false is explicitly ALLOWED here (bounded by PaginationUtil.UNPAGINATED_HARD_CAP), following the reference-table convention — unlike every leave-REQUEST list in this module, which refuses it.

6.2 StudentLeavesService / StaffLeavesService

Near-identical admin services over the two subject kinds; documented once, with the differences called out.

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query).findAllleave_requests joined——PAGINATION_LIMIT_INVALID if pagination=false
summary(query).summarySame filtered set as findAll———
findOne(id).findOneleave_requests joined——LEAVE_REQUEST_NOT_FOUND
findDecisions(id).findDecisionsleave_request_decisions——LEAVE_REQUEST_NOT_FOUND
create(actor, dto).createleave type, subject, enrolment/session, calendar (student) or session only (staff), guardian existence (student)leave_requests insert—LEAVE_STUDENT_NOT_ENROLLED, LEAVE_RANGE_OUTSIDE_SESSION, LEAVE_NO_GUARDIAN_TO_CONSENT (student only), LEAVE_CALENDAR_NOT_GENERATED, LEAVE_NO_SCHOOL_DAYS_IN_RANGE (student only), LEAVE_TYPE_*, LEAVE_OVERLAPPING_REQUEST_EXISTS
update(id, dto).updateleave_requests (locked FOR UPDATE)leave_type_id/reason onlybump_row_version firesLEAVE_REQUEST_NOT_FOUND, LEAVE_VERSION_CONFLICT
remove(id).removeleave_requestsdeleted_at set—LEAVE_REQUEST_NOT_FOUND, LEAVE_CANNOT_DELETE_APPROVED
restore(id).restoreleave_requestsdeleted_at cleared—LEAVE_REQUEST_NOT_FOUND
decide(id, dto, actor).decideDelegates whole to LeaveDecisionServiceSameactedAs: permission_holder, no status pre-filter — the ONLY surface that can reach the approved → rejected reversalWhatever LeaveDecisionService throws
reapplyAttendance(id, actor).reapplyAttendanceleave_requests + leave_request_runtimeOutbox enqueue onlyEnqueues LeaveJob.APPLY_ATTENDANCE with a fresh, timestamped dedupe keyLEAVE_REQUEST_NOT_FOUND, LEAVE_ALREADY_REVERTED

Differences, staff vs. student: staff carry no class_id/enrollment_id and are not calendar-gated (computeStaffDayCount counts every calendar day); staff create enters pending_school directly and calls no guardian-existence check; staff findAll's search is a plain ilike on reason (no name-search EXISTS subquery, since the staff list has no class filter to preserve a shared buildWhere for).

Input normalization: reason is trimmed before insert/update on every create/update path (admin and portal alike). Validation order: DTO class-validator decorators run first (route-level ValidationPipe), then range bound → subject resolution → type resolution → enrolment/session resolution → day-count computation → guardian check (student only) → insert. Transaction boundaries: create is NOT wrapped in an explicit transaction (single INSERT); update/decide open one to hold the row lock across the read-check-write. Idempotency: submissionId on create; a fresh, timestamped dedupe key on every reapplyAttendance call (reusing the original key would let onConflictDoNothing swallow the retry silently). Fail-open/closed: every resolution failure is fail-closed — a missing calendar row, an unresolvable session, or zero live guardians all refuse the create rather than proceeding with a default. Logger usage: LeaveDecisionService and LeaveAttendanceService log structured lines (module: "Leave", operation, outcome, requestId); the two admin services log nothing themselves.

6.3 LeaveResolutionService

MethodReadsWritesErrors
resolveLeaveType(publicId, subjectKind)leave_types—LEAVE_TYPE_NOT_FOUND, LEAVE_TYPE_INACTIVE, LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT
resolveStudent(id) / resolveStaff(id)students/staff (live only)—STUDENT_NOT_FOUND / STAFF_NOT_FOUND
resolveLiveEnrollment(studentId, date)student_class_enrollments—Returns null rather than throwing — caller decides how to react.
resolveSessionForRange(startDate, endDate)academic_sessions via CalendarDayService—Returns null if the two ends differ or either is absent.
assertRangeBounded(startDate, endDate)— (pure)—LEAVE_RANGE_TOO_LONG
computeStudentDayCount(sessionId, startDate, endDate)academic_calendar_days—LEAVE_CALENDAR_NOT_GENERATED, LEAVE_NO_SCHOOL_DAYS_IN_RANGE
computeStaffDayCount(startDate, endDate)— (pure)——
assertGuardianAvailable(studentId)student_guardian/guardians (live)—LEAVE_NO_GUARDIAN_TO_CONSENT

Verified against .omc/plans/leave-module/master-plan.md §11 in-repo, per the service's own docblock — not re-derived here; the resolution rules documented above and in §5 are the observable behavior regardless of that plan's status.

6.4 LeaveDecisionService

The single place a leave request's status changes — five callers (guardian portal, teacher portal, both admin decide routes, and the reversal, which is the same admin route against approved) share one method rather than four copies of "flip the status, append the decision row, arm or emit, record the outbox intent".

MethodCalled ByReadsWritesSide EffectsErrors
decide(tx, input)Every deciding surface, inside the CALLER's own transactionleave_requests (FOR UPDATE, single-table — no join, or PostgreSQL refuses the lock on the nullable side of an outer join), usersleave_requests (status/decided fields), leave_request_decisions insert, leave_request_runtime upsert (on approve)Outbox enqueue of APPLY_ATTENDANCE (approve) or REVERT_ATTENDANCE (reversal)LEAVE_REQUEST_NOT_FOUND, LEAVE_VERSION_CONFLICT, LEAVE_CANNOT_DECIDE_OWN_REQUEST, LEAVE_ALREADY_DECIDED, WRITE_RETURNED_NO_ROW

Transaction boundary: runs INSIDE the caller's transaction, never opens its own — so the audience/row-scope check the caller already performed and this write can never straddle a commit boundary. Idempotency: the outbox dedupeKey is ${jobName}_${leaveRequestId} for the initial apply/revert (stable, so a retried decide within the same transaction cannot double-enqueue) and a fresh, timestamped key for a manual reapply. Response mapping: returns the raw updated row; every caller re-selects through its own DTO-shaped projection afterward, inside the same transaction, so the response reflects the committed write.

6.5 LeaveAttendanceService

MethodCalled ByReadsWritesSide EffectsErrors
apply(leaveRequestId, requestId)LeaveQueueProcessorleave_requests (must still be approved), academic_calendar_days, student_class_enrollments (per date), academic_sessions (staff), existing register rowsstudent_attendance_records/staff_attendance_records upsert per date, student_attendance_sessions insert-if-missing, attendance_change_log insert per date, leave_request_runtime conditional upsertNone outside the database — no further job is enqueued from hereUnhandled exceptions propagate to BullMQ's retry/backoff
revert(leaveRequestId, requestId)LeaveQueueProcessorleave_request_runtime (claim), attendance_change_log bounded to the current generationAttendance record restored or deleted per row, attendance_change_log insert per restoration, leave_request_runtime.attendance_reverted_atNoneSame

Fail-open/closed: fail-closed throughout — any resolution miss for a date (no live enrolment, no open session) simply writes nothing for that date and moves on (return 0), rather than aborting the whole apply; a genuinely unexpected error aborts the transaction and lets BullMQ retry the whole job. Idempotency: both methods are idempotent — apply's exactly-once claim is a conditional upsert on attendance_applied_at IS NULL; revert's is the same on attendance_reverted_at IS NULL. Per-date writes are additionally idempotent (if (existing?.status === "excused") return 0).

6.6–6.9 Portal services (GuardianLeavePortalService, StudentLeavePortalService, TeacherLeavePortalService, StaffLeavePortalService)

ServiceReadsWritesDelegates decision toNotes
GuardianLeavePortalServiceleave_requests joined through students/studentGuardian/guardians, own-children predicateleave_requests insert (create), status/cancellation fields (cancel)LeaveDecisionService.decide (decide)The only portal service that both raises AND decides.
StudentLeavePortalServiceleave_requests filtered studentId = own, guardian existenceleave_requests insert, cancellation fields— (no decide route on this surface)Shares guardian-leave-portal.resolvers.ts rather than duplicating session/day-count resolution.
TeacherLeavePortalServiceleave_requests filtered classes.class_teacher_id = staffId, joined through classes/grades/sections/students/usersStatus via LeaveDecisionService onlyLeaveDecisionService.decideRead/decide only — no create, no cancel. Response mapper never selects reason.
StaffLeavePortalServiceleave_requests filtered staffId = ownleave_requests insert, cancellation fields—Only surface whose dayCount is calendar days, computed and validated locally rather than through the shared guardian/student resolvers (which are pupil-specific).

Graduate read-only. Both pupil-facing writes call assertNotGraduatedReadOnly (apps/api/src/modules/people/shared/graduate-access.query.ts) — StudentLeavePortalService.create on the pupil's own resolved id, GuardianLeavePortalService.create on the resolved child's id — answering 403 STUDENT_GRADUATED_READ_ONLY once the pupil's graduate access state is grace or ended. Both check it AFTER a submission-id replay: retrying an already-accepted request that was raised while the pupil still attended returns the existing row unconditionally rather than a 403 produced by a graduation that happened in between. GuardianLeavePortalService.ownChildrenWhere additionally excludes a child whose graduate access has ENDED (NOT graduateAccessEnded(students.id, today), mirroring GuardianPortalService.ownChildrenWhere) from every read, so once access ends the pupil stops resolving as this guardian's child at all rather than surfacing as a read-only leave history.

Every portal service's row-scope predicate is hand-built in the service itself and is never routed through PeopleAccessService.scopeFor — see §11 for the incident this avoids.

7. Runtime Flows

7.1 Self-serve create (pupil or staff)

StepCode PathBehaviorFailure Case
1*PortalController.createDTO validated; no server-derived field accepted.400 DTO validation
2*PortalService.createResolves own identity from actor, never from the body.PERMISSION_INSUFFICIENT (wrong audience or no staff row)
3Resolution helpersLeave type, enrolment/session, day count.LEAVE_TYPE_*, LEAVE_STUDENT_NOT_ENROLLED, LEAVE_RANGE_OUTSIDE_SESSION, LEAVE_CALENDAR_NOT_GENERATED, LEAVE_NO_SCHOOL_DAYS_IN_RANGE
4insertLeaveRequestInsert, or replay via submissionId.LEAVE_OVERLAPPING_REQUEST_EXISTS (23P01), submission race resolved by re-read
StepCode PathBehaviorFailure Case
1Each surface's own decideRe-resolves the row through that surface's OWN predicate (own-child + pending_guardian for guardian; own-class + pending_school for teacher; no pre-filter for admin).404 LEAVE_REQUEST_NOT_FOUND on any scope mismatch
2LeaveDecisionService.decideLocks, version-checks, self-decision-checks, transitions.409 LEAVE_VERSION_CONFLICT, 409 LEAVE_CANNOT_DECIDE_OWN_REQUEST, 409 LEAVE_ALREADY_DECIDED
3SameWrites the decision row and, on approve/reversal, arms/emits.500 WRITE_RETURNED_NO_ROW (defensive — the preceding UPDATE ... RETURNING came back empty)

7.3 Cancel (pupil, guardian, staff — never admin)

Only the ORIGINAL raiser may cancel, and only while pending (staff: pending_school only, since staff never enter pending_guardian). Locks the row (FOR UPDATE), checks expectedVersion when supplied, sets status: cancelled plus cancelled_at/cancelled_by_user_id. No decision row is written — a cancellation is not a decision. Failure: 404 LEAVE_REQUEST_NOT_FOUND (wrong raiser, wrong status, or genuinely absent — all three produce the identical response), 409 LEAVE_VERSION_CONFLICT.

7.4 Worker apply

Covered in detail at Feature doc §5.8. Code path: LeaveQueueProcessor.process → LeaveAttendanceService.apply. Re-checks status = approved inside its own transaction (guards against a reversal racing a slow/redelivered job); resolves per-date attendance-required days (pupil) or every day (staff); writes each register row and change-log entry; claims the apply via a conditional upsert. Cache miss/hit: N/A, no cache in this module. Guest vs. logged-in branch: N/A, no HTTP caller.

7.5 Worker revert

Code path: LeaveQueueProcessor.process → LeaveAttendanceService.revert. Claims the revert via a conditional upsert on attendance_reverted_at IS NULL; reads attendance_change_log bounded to changed_at >= attendance_applied_at of the CURRENT generation; for each row, either deletes the register record (if previous_status IS NULL — the apply CREATED it) or restores the prior status; writes a matching change-log row for the restoration.

7.6 Reapply-attendance

Admin-only recovery route. Re-checks status = approved AND attendance_reverted_at IS NULL, then enqueues a fresh APPLY_ATTENDANCE job with a timestamped, non-reusable dedupe key — reusing the original key would let onConflictDoNothing swallow the operator's retry with no observable effect at all.

8. Caching

None. This module reads and writes PostgreSQL directly on every request; no Redis key is built or consulted anywhere in apps/api/src/modules/leave/.

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.LEAVE ("leave")LeaveJob.APPLY_ATTENDANCE ("leave.apply_attendance")LeaveDecisionService.decide (approve), StudentLeavesService/StaffLeavesService.reapplyAttendance (manual retry)LeaveQueueProcessor → LeaveAttendanceService.apply{ leaveRequestId, requestId, v: 1 } — no date range or day list; the worker re-resolves both, since the calendar can be reclassified between decision and runEnv-configured defaultJobOptions per queue (BULL_QUEUE_LEAVE_ATTEMPTS, BULL_QUEUE_LEAVE_BACKOFF_DELAY_MS, falling back to the repo-wide BULL_DEFAULT_* envs), exponential backoffleave_request_runtime.attendance_applied_at IS NULL conditional claim, PLUS a stable dedupeKey (${jobName}_${leaveRequestId}) on the initial enqueue, PLUS a fresh dedupe key on manual reapply
QueueName.LEAVELeaveJob.REVERT_ATTENDANCE ("leave.revert_attendance")LeaveDecisionService.decide (reversal only — approved → rejected)Same processor → LeaveAttendanceService.revertSame payload shapeSameleave_request_runtime.attendance_reverted_at IS NULL conditional claim, stable dedupe key

LeaveQueueProcessor is the ONE @Processor(QueueName.LEAVE, { concurrency: 2 }) on this queue. LeaveAttendanceService is a plain @Injectable(), never a second @Processor — @nestjs/bullmq builds one BullMQ Worker per decorated class, and two decorated classes on one queue name would compete for every job, each capable of running the wrong handler's body against the wrong payload. handlers is a Record<LeaveJob, ...>, so an unrouted job name is a compile error rather than a job that completes silently having done nothing — the process() method throws (never returns) for a name outside the map, because a return marks the job COMPLETED and it disappears from the failed-job sweep.

Both jobs are enqueued exclusively through OutboxService.enqueue(), inside the SAME transaction as the status change — never a direct queue.add(). A direct call would let the decision commit, the enqueue throw, the request answer 200, and nothing ever get scheduled.

LeaveWorkersModule is composed by the application root ONLY outside the fast runtime profile (isFastRuntimeProfile()). Importing a module that declares a @Processor STARTS a worker — a worker started during a migration or a one-shot script would connect to Redis and begin claiming jobs mid-script.

10. Realtime and Events

None. No Socket.IO/SSE event is emitted anywhere in this module.

11. Security, Auth, and Abuse Controls

Guards. JwtAuthGuard (global) plus RoleGuard on every controller in this module. LeaveTypesController, StudentLeavesController and StaffLeavesController declare @Permissions() on every handler; the four portal controllers declare NONE.

Permissions. Six PERMISSION_MODULES entries: LeaveTypes, StudentLeaves, StudentLeaveDecisions, StaffLeaves, StaffLeaveDecisions, LeaveView. *Decisions is a SEPARATE module from its CRUD counterpart specifically because PERMISSION_ACTIONS is closed at CREATE/READ/UPDATE/DELETE/RESTORE with no APPROVE — folding the decision into StudentLeaves_UPDATE would hand anyone who can fix a typo in a reason the power to rewrite a child's attendance register. LeaveView gates the panel SCREEN and zero API routes.

Who holds what, by default. rolePermissionsMap in seed-auth.ts seeds exactly five system roles: superadmin, staff, teacher, guardian, student. superadmin holds fullAccessPermissions — every Module_ACTION cross-product, including every Leave* code. The other four hold LeaveView_READ ONLY. No seeded role other than superadmin holds LeaveTypes_*, StudentLeaves_*, StaffLeaves_*, StudentLeaveDecisions_* or StaffLeaveDecisions_*. This is deliberate, not an oversight: TEACHER_PERMISSIONS = STAFF_PERMISSIONS BY REFERENCE, so any grant to staff reaches every teacher identically, and both roles carry scopeKind: "all" — granting a decision code to staff would hand every teacher in the school the power to decide any pupil's leave, unscoped. A school delegating office/admin leave duties creates a dedicated role holding exactly these codes; grantToRoles uses onConflictDoNothing and never revokes, so withholding by default is the recoverable direction.

Guest identity. N/A — every route in this module (admin and portal alike) requires a valid JWT; there is no public or guest-accessible leave route.

Admin identity. @CurrentUser()/@CurrentAdmin() resolve req.user, populated by JwtAuthGuard's strategy; both decorators return the same AuthUser shape in this codebase.

The twelve permission-less portal handlers. StudentLeavePortalController.{listMine,create,cancel}, GuardianLeavePortalController.{listMine,create,decide,cancel}, TeacherLeavePortalController.{listMine,decide}, StaffLeavePortalController.{listMine,create,cancel}. Each key is on BOTH NO_PERMISSION_ADMIN_HANDLERS in role.guard.ts and its CI mirror route-permissions.spec.ts — omitting either makes the route 403 for everyone including superadmin (guard) or fails a structure test (spec) with no other signal, because the integration specs call the services directly and never pass through the guard.

Rate limits / anti-abuse. None specific to this module beyond the platform-wide guards (JwtAuthGuard, the global ValidationPipe's whitelist/forbidNonWhitelisted).

Input normalization. reason/label/code trimmed before persistence on every write path. ValidationPipe's whitelist: true strips any field a DTO does not declare — the mechanism that makes "no field for subjectKind/status/origin" enforceable rather than merely documented; a client sending one of those gets a 400 (forbidNonWhitelisted), never a silently ignored field.

Sensitive data redaction. TeacherLeaveRequestDto omits reason entirely — an explicit field allowlist mapper, never a row spread, the same discipline StudentClassSummaryDto and every other public-facing mapper in this codebase follows.

Audit logs. Every mutating request through this module (admin CRUD, decide, reapply, and every portal create/decide/cancel) is recorded by the globally registered ActivityAuditInterceptor, keyed by the handler's @Permissions() code where one exists and by the route otherwise — including the twelve permission-less portal handlers, which fall back to their route so "not in the catalogue" still means "recorded". No leave-specific recordActivity() call exists anywhere in this module; the interceptor's coverage is the whole audit trail here.

Fail-closed behavior. Every resolution step in every create path is fail-closed (§6.2, §6.3). The worker is fail-open PER DATE (a date it cannot resolve is skipped, not aborted) but fail-closed on the request as a whole (a genuinely unexpected error aborts the transaction for that job attempt and lets BullMQ retry).

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
LEAVE_TYPE_NOT_FOUND404LeaveTypesService, LeaveResolutionService, portal resolversNo live type with that publicId.Fix the id.
LEAVE_TYPE_CODE_ALREADY_EXISTS409LeaveTypesService.translate23505 on leave_types_code_unique; also on a RESTORE colliding with a code reused while retired.Choose a different code, or address the existing live type.
LEAVE_TYPE_INACTIVE409/422LeaveResolutionService, portal resolversis_active = false.Choose an active type.
LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECT409/422LeaveTypesService, LeaveResolutionService, portal resolversapplies_to_student/applies_to_staff excludes this subject; also a type-create/update that would leave BOTH flags false.Choose an applicable type, or set at least one flag.
LEAVE_REQUEST_NOT_FOUND404Every admin/portal read, decide, cancel pathNo row matches the id — genuinely absent, wrong owner, wrong status, or wrong audience are ALL this code; the portal surfaces never distinguish them.Confirm the id and the caller's own scope.
LEAVE_OVERLAPPING_REQUEST_EXISTS409translateLeaveRequestError, insertLeaveRequest23P01 on leave_requests_no_overlap.Choose a non-overlapping range, or resolve the conflicting request first.
LEAVE_RANGE_OUTSIDE_SESSION409/422StudentLeavesService, StaffLeavesService, portal resolversRange not contained in exactly one academic session (or crosses the session's own end boundary).Split the request across sessions, or adjust the dates.
LEAVE_NO_SCHOOL_DAYS_IN_RANGE409/422LeaveResolutionService, portal resolversStudents only — every date in range is a non-attendance day.Choose a range including a school day.
LEAVE_CALENDAR_NOT_GENERATED409LeaveResolutionService, CalendarDayService, portal resolversStudents only — the session has no calendar rows for a date in range.An administrator must generate the calendar for that session.
LEAVE_RANGE_TOO_LONG409/422LeaveResolutionService, translateLeaveRequestError, portal resolversRange exceeds 365 days, or end_date < start_date (mapped from the CHECK).Shorten the range.
LEAVE_STUDENT_NOT_ENROLLED409/422StudentLeavesService, portal resolversNo live class enrolment as of the resolution date.Enrol the pupil first, or file under the correct academic period.
LEAVE_NO_GUARDIAN_TO_CONSENT409/422LeaveResolutionService, StudentLeavePortalServiceStudent-origin path only — zero live guardian links; a pending_guardian row with nobody to consent would be unexitable.Add a guardian link first.
STUDENT_GRADUATED_READ_ONLY403StudentLeavePortalService.create, GuardianLeavePortalService.create, via assertNotGraduatedReadOnlyThe pupil's (or guardian's resolved child's) graduate access state is grace or ended.Nothing can be filed for a graduated pupil; read-only access continues until the window ends.
LEAVE_ALREADY_DECIDED409LeaveDecisionService.resolveNextStatusdecision: approved against an already-approved row, or any decision against rejected/cancelled.No action needed for a re-approval; a terminal request cannot be decided again.
LEAVE_NOT_PENDING_GUARDIAN—Not thrown by any current code path.Declared for a guardian-decide-on-wrong-status condition; the actual guard is the scoped SELECT's status = pending_guardian predicate, which surfaces a mismatch as LEAVE_REQUEST_NOT_FOUND instead.—
LEAVE_NOT_PENDING_SCHOOL—Not thrown by any current code path.Same shape, for the teacher/school step.—
LEAVE_CANNOT_DECIDE_OWN_REQUEST409LeaveDecisionService.decidedecided_by would equal raised_by.Have a different person decide.
LEAVE_CANCEL_ONLY_WHILE_PENDING—Not thrown by any current code path.Declared for a cancel-on-wrong-status condition; every cancel path's scoped SELECT already filters to the cancellable statuses, so a mismatch surfaces as LEAVE_REQUEST_NOT_FOUND instead.—
LEAVE_CANNOT_DELETE_APPROVED409StudentLeavesService.remove, StaffLeavesService.removeAttempted soft delete of an approved request.Reverse it via the decide route instead.
LEAVE_ALREADY_REVERTED409StudentLeavesService.reapplyAttendance, StaffLeavesService.reapplyAttendanceRequest not approved, or attendance_reverted_at IS NOT NULL.No action needed — the register already reflects the reversal.
LEAVE_VERSION_CONFLICT409LeaveDecisionService, every update/cancel pathRow's version no longer matches expectedVersion.Reload and retry with the fresh version.
LEAVE_ATTENDANCE_APPLY_FAILED—Not thrown by any current code path.A worker-side apply failure today surfaces as an unhandled exception the queue retries, or (once attempts are exhausted) a failed BullMQ job with no HTTP-facing code — never this one.—

Four of the twenty-one declared LEAVE_* codes are verified, this session, to be thrown by no code path in apps/api/src — confirmed by a codebase-wide search, not only within this module. They are reserved, not dead by mistake: each names a real condition the schema or the queue can reach, but the code that would throw it currently reports the SAME outcome through a different, already-thrown code instead (see the table). A future change narrowing any of those shared outcomes should reach for the code already declared for it rather than inventing a new one.

14. Observability

SignalLocationPurpose
Structured log, [leave] <status> -> <status> as <capacity>LeaveDecisionService.decideEvery decision, success only (throws propagate before this line).
Structured log, [leave][success/skip] applied N day(s) for <id>LeaveAttendanceService.applyWorker apply outcome, including the "already applied"/"no longer approved" skip branches.
Structured log, [leave][success/skip] reverted N day(s) for <id>LeaveAttendanceService.revertWorker revert outcome.
[leave] no handler for job name "..." (error level)LeaveQueueProcessor.processAn unrouted job name reached the worker — should be unreachable given the Record typing, logged loudly if it ever happens anyway.
BullMQ job historyBull Board (/admin/queues, opt-in via env, dev only)Attempt count, failure reason, and retry state per job — the operator-facing substitute for leave_request_runtime.attendance_attempt_count/last_error_code, which are declared but unpopulated (§16.6).
AuditMongoDB audit_logs, via ActivityAuditInterceptorEvery mutating request in this module, success and failure, keyed by permission code or route.

No metric (Prometheus/StatsD-shaped) is emitted anywhere in this module.

15. Testing and Validation

Test TypeFilesCoverage
Unittypes/dto/leave-type-label.dto.spec.tsThe label trim-then-validate rule on create and update: whitespace-only is refused, a padded label arrives trimmed, an update may omit it.
Integrationleave-types.integration.spec.ts (277 lines)Catalogue CRUD, the code partial-unique/restore-collision interaction, the applies-to-someone CHECK (service AND database layer independently), the missing-database-default on public_id.
Integrationleave-admin.integration.spec.ts (913 lines)Full CRUD for both subject kinds, DTO field-ignoring (subject_kind/origin/status/day_count cannot be supplied), name-and-reason search, retired-type visibility, delete-refusal on approved, pagination=false refusal, the overlap exclusion (including the single-day-vs-single-day case), re-filing after refusal/cancellation, and /summary agreeing with the filtered list under a class filter and a leave-type filter.
Integrationleave-decision.integration.spec.ts (1,088 lines)The full transition table, the immutable decision-row shape (acted_as, name snapshot, both statuses), stale-version refusal, self-decision refusal, the worker writing student_attendance_records/attendance_change_log, the calendar-gate difference between pupils and staff, apply's exactly-once behavior (including against an already-reversed request), and revert's generation-bounded read.
Integrationleave-portal.integration.spec.ts (789 lines)Object-level scoping for all four portal surfaces: a pupil sees only their own; a guardian sees only their own children's (filtered by deleted_at only, never record_status); the teacher queue is scoped to classes.class_teacher_id and a classless class leaks to nobody; reason is omitted from the teacher's rows but present on the pupil's and guardian's; a guardian deciding a stranger's child, and a teacher deciding another class's request, both answer 404, never 403; a pupil cannot cancel a request their guardian raised; a pupil with zero live guardians is refused at create; a staff member sees only their own requests.
Integrationleave-request-attendance-fields.integration.spec.tsattendanceAppliedAt, attendanceDaysWritten and attendanceRevertedAt on the admin response: null for a request never approved and for one approved but not yet written, and round-tripping the stored values through detail, list, create replay, update, reapply-attendance and decision for both pupil and staff requests.

Every spec in this directory is an integration test — every one connects to the real test database (TEST_DATABASE_URL), per this repo's testing rule (no mocked database, Redis, queue, or first-party package). Run with pnpm --filter @skoolsewa/api test (or scoped to this module's test files).

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
LeaveTypesController/ServiceController/serviceCatalogue CRUD—HTTP (LeaveTypes_*)leave_typesleave_typesLEAVE_TYPE_*
StudentLeavesController/ServiceController/servicePupil office CRUD, decide, reapplyLeaveResolutionService, LeaveDecisionService, OutboxServiceHTTP (StudentLeaves_*, StudentLeaveDecisions_UPDATE)leave_requests, delegates to decision engineleave_requestsLEAVE_* (full set relevant to student paths)
StaffLeavesController/ServiceController/serviceStaff office CRUD, decide, reapplySameHTTP (StaffLeaves_*, StaffLeaveDecisions_UPDATE)Sameleave_requestsSame, minus student-only codes
LeaveResolutionServiceService, no controllerSubject/type/enrolment/session/day-count resolutionCalendarDayServiceBoth admin leaf servicesstudents/staff/leave_types/student_class_enrollments/academic_calendar_days/student_guardianReads onlyResolution errors, listed §6.3
LeaveDecisionServiceService, no controllerThe one decision transactionOutboxServiceAll 5 deciding surfacesleave_requests, leave_request_decisions, leave_request_runtime, outboxWrites status, decision log, runtime armLEAVE_VERSION_CONFLICT, LEAVE_CANNOT_DECIDE_OWN_REQUEST, LEAVE_ALREADY_DECIDED
GuardianLeavePortalController/Service/Mapper/ResolversController/service/mapper/helpersOwn-children raise/list/decide/cancelLeaveDecisionService, CalendarDayService, SchoolClockServiceHTTP, permission-lessleave_requests via own-children predicateleave_requestsLEAVE_REQUEST_NOT_FOUND, STUDENT_NOT_FOUND, GUARDIAN_NOT_FOUND, LEAVE_VERSION_CONFLICT, plus create-path resolution errors
StudentLeavePortalController/ServiceController/serviceOwn raise/list/cancelShares guardian's resolversHTTP, permission-lessleave_requests via own-id predicateleave_requestsSame shape, minus the decide errors
TeacherLeavePortalController/ServiceController/serviceOwn-class decision queueLeaveDecisionServiceHTTP, permission-lessleave_requests via own-class predicateleave_requestsLEAVE_REQUEST_NOT_FOUND, decide errors
StaffLeavePortalController/ServiceController/serviceOwn raise/list/cancelCalendarDayServiceHTTP, permission-lessleave_requests via own-staff-id predicateleave_requestsSame shape as student portal, calendar-independent day count
LeaveAttendanceServiceService, no controllerRegister apply/revert—LeaveQueueProcessor onlyAttendance tables, attendance_change_log, leave_request_runtimeWrites register + runtimePropagates unexpected errors to BullMQ
LeaveQueueProcessorWorkerRoutes LeaveJob to LeaveAttendanceServiceLeaveAttendanceServiceBullMQ, via QueueName.LEAVELeaveAttendanceService.apply/.revertNone directlyThrows on an unrouted job name
leave-sql-errors.util.ts / guardian-leave-portal.mapper.ts's leaveInsertErrorTextHelper (intentionally duplicated, not shared)Constraint-name-to-error-code translation—Every insert/update path that can hit leave_requests_no_overlap/_submission_unique/etc.———
leave-request.mapper.tsHelperAdmin response DTO shape—Both admin leaf services———

On the intentionally local, not shared, error-text walker. leave-sql-errors.util.ts (admin surfaces) and guardian-leave-portal.mapper.ts's leaveInsertErrorText (portal surfaces, also used verbatim by staff-leave-portal.service.ts's own private errorText) implement the identical five-line .cause-walking loop three times across this module. None is promoted to a shared utility — each call site's OWN error-code mapping differs (the admin util also handles range/date-ordering CHECKs the portal insert never needs to translate, since the portal DTOs validate those client-side first), so the walker itself is genuinely trivial while what each caller DOES with the matched text is not.

16.2 UML and Architecture Diagram Pack

16.3 Code Flow Narrative — LeaveDecisionService.decide

StepCode LocationWhat HappensWhy It HappensFailure/Edge Case
1Called from each surface's own decide method, already inside a transaction that surface openedEntry point and callerThe audience/row-scope check and this write must not straddle a commit boundary.—
2input: DecideLeaveInputCommand shape: request id, decision, capacity, actor, note, expected version, request id (uuid7)Explicit, typed input rather than a raw DTO — the shared engine has no HTTP awareness.—
3Caller's own audience/scope check, BEFORE this methodAuth/identity assumptionEach surface proves the caller is allowed to see this row at all; this method trusts that and re-verifies only ownership of the DECISION (self-decision, version).A caller that bypassed the scope check could still hit LEAVE_CANNOT_DECIDE_OWN_REQUEST/version, but never a row it cannot already see.
4—No further input normalizationnote/decision arrive already shaped by the caller's DTO.—
5Lock → version → self-decision, in that orderValidation orderLocking first makes the subsequent checks race-free; version before self-decision because a stale read should not also report the wrong-person error.—
6SELECT ... FOR UPDATE (single table)Reads performedDeliberately no JOIN — FOR UPDATE with no OF locks every table in the FROM, and PostgreSQL refuses it outright on the nullable side of an outer join.LEAVE_REQUEST_NOT_FOUND if the row is gone or soft-deleted.
7resolveNextStatusBusiness decisionThe transition table (§7 of the feature doc) — a pure function over (current status, decision).LEAVE_ALREADY_DECIDED for any terminal current status, or a repeated approval.
8UPDATE leave_requests ... RETURNINGWrites performedStatus, decided_at/decided_by_user_id (terminal only, per CHECK), decision_note.WRITE_RETURNED_NO_ROW (defensive) if the UPDATE affects zero rows despite the prior lock.
9Same transaction, no savepointTransactionsThe whole method runs in the caller's transaction; any throw rolls back everything, including the caller's own preceding reads/writes.—
10N/ACache behaviorNo cache exists in this module.—
11armApply/emitJobs/events/notificationsOn approve: conditional upsert on leave_request_runtime, then outbox enqueue of APPLY_ATTENDANCE. On reversal (current was approved): outbox enqueue of REVERT_ATTENDANCE only — no runtime re-arm.—
12Method returnResponse mappingReturns the raw updated row; EVERY caller re-selects its own DTO projection afterward, in the same transaction.—
13Each throw new ConflictException({ message, errorCode })Error handlingEvery failure carries an errorCode from ErrorCodes, never a bare status.—
14this.logger.log({...}, ...)Logs/metrics/audit eventsOne structured success log per decision; the global ActivityAuditInterceptor separately records the HTTP-level audit entry.—
15—Known tradeoffsSharing one method across five callers means every caller's own scope-check quality is what actually protects a row — this method protects only the DECISION itself (version, self-decision, legal transition).See §16.6 on the four unused error codes this design leaves reserved.

16.4 Data Layer Deep Dive

Covered exhaustively in §5. Summary of what is NOT in that section: Money units — N/A, no monetary field in this module. Timezone/date interpretation — every date column (start_date, end_date, academic_calendar_days.gregorian_date) is a plain SQL date, timezone-naive by construction; every timestamp column is timestamptz. JSON shapes — none; no jsonb column exists in leave.ts. Migration history — one migration, 0022_leave_module.sql, hand-extended after drizzle-kit generate for everything the generator cannot express (EXCLUDE, generated column, triggers, seed, the two notification-category CHECK rewrites). Seed data dependency — the three seeded leave types are the only rows any environment can rely on existing without an administrator creating them.

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
A subject-kind row carries exactly the matching subject id, never both, never neither.3 CHECKs on leave_requestssubject_key (the GiST key) needs exactly one source column populated.23514 (constraint violation, unmapped — reaches the caller as a raw 500 unless caught earlier by service-level resolution)leave-admin.integration.spec.ts
Staff never reach pending_guardian.leave_requests_staff_has_no_guardian_step CHECKNo staff reporting hierarchy exists to route a consent step to.23514leave-types.integration.spec.ts (indirectly, via the applies-to-someone pattern)
One person cannot hold two live requests covering one day.leave_requests_no_overlap EXCLUDE (GiST)The single most important integrity rule in the module — makes double-booking impossible, not merely unlikely.LEAVE_OVERLAPPING_REQUEST_EXISTSleave-admin.integration.spec.ts (overlap section)
A terminal decision row always carries decided_at; a pending one never does.Two CHECKs on leave_requestsMakes "is this decided" a column read, never a join to the decision log.23514leave-decision.integration.spec.ts
A decision row never asserts "nothing changed".leave_request_decisions_states_a_change CHECKEvery append to the log is a genuine transition.23514leave-decision.integration.spec.ts
The decision log is append-only.leave_request_decisions_is_append_only triggerNobody — no service, no superadmin, no raw SQL — can silently edit the audit trail.2F002Not directly tested by name; the log's own insert-only usage across every test file is the coverage.
A leave type's code is immutable after create.Not a CHECK — enforced by the DTO shape (UpdateLeaveTypeDto has no code field)Renaming the code would silently repoint every integration reading it.N/A — the field simply cannot be sentleave-types.integration.spec.ts (never changes code on update)
A leave type must apply to at least one subject kind.leave_types_applies_to_someone CHECK, plus a service-level pre-check on both create and updateA type applying to nobody is unselectable dead data.LEAVE_TYPE_NOT_APPLICABLE_TO_SUBJECTleave-types.integration.spec.ts
A request cannot be deleted once approved.Service-level check (not a CHECK — a delete is representable, just refused)A delete would leave the register rewritten with nothing left to explain why.LEAVE_CANNOT_DELETE_APPROVEDleave-admin.integration.spec.ts
A person cannot decide a request they raised.LeaveDecisionService.decideOne person must not stand at both ends of a two-party chain.LEAVE_CANNOT_DECIDE_OWN_REQUESTleave-decision.integration.spec.ts
The attendance apply and revert are each exactly-once, at the row level.Conditional upserts on leave_request_runtimeA missing runtime row and an already-applied one must not be conflated — an earlier design's bare UPDATE ... WHERE applied_at IS NULL reported zero rows for BOTH, and would have concluded "already applied" on its first run against a request with no row yet.Silent skip (return 0), loggedleave-decision.integration.spec.ts (writes nothing and reports zero the second time)
A reversal restores only the generation it undid.changed_at >= attendance_applied_at filter on the revert's change-log readMultiple apply/revert cycles under one request id would otherwise let an unbounded read return two candidate previous_status values for one date.Silent (correct) partial restorationleave-decision.integration.spec.ts (bounds revert's read to the current generation)
submissionId makes create idempotent.Partial unique index + service-level replay readA network retry must not double-file a leave.Returns the existing rowCovered implicitly by the resolvers; not a dedicated spec assertion in the files inspected.

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Attendance apply/revert are async, via the outboxA leave can cover up to 366 days, each needing up to one register writeBullMQ worker, decoupled from the decide transactionWrite the register synchronously inside decideKeeps the approve button's latency constant regardless of range lengthThe register lags the decision by however long the queue takes to drain; the worker re-checks approved to stay correct under a raceIf an operator needs synchronous confirmation the register updated, before this document is updated
leave_request_runtime carries no version/triggerThe worker must stamp progress without disturbing an operator's held tokenNo optimistic concurrency on this table at allGive it a version like every mutable tableThe worker's own writes must never invalidate a decision-screen tokenNothing external can safely CAS against this table; nothing currently needs to, since it is exposed on no DTOIf a future admin screen exposes runtime state for direct editing
The decision engine is one shared service, not five copiesFive surfaces perform the identical four-part writeLeaveDecisionService.decide(tx, input)A decide method local to each leaf serviceFour copies of "flip status, log, arm, emit" are four chances for one to commit the status and lose the eventEvery caller must open its own transaction correctly and pass it in — a caller that forgot would silently run the decision outside any transactionIf a sixth deciding surface is ever added, it MUST reuse this service
The 'leave' notification category is registered but unusedThe migration wires both the TypeScript catalogue and the two database CHECK constraints for itProvision the category now, wire no senderWait and add the category alongside the first notificationA future notification integration needs no schema changeCurrently a guardian is not proactively told a decision is waiting — they must open the app and checkThe day a leave notification is actually built
attendance_attempt_count/last_error_code are declared, unpopulatedOperator visibility into a stuck applyDeclare the columns now, populate them later; rely on BullMQ's own attempt history and the server log meanwhileWire them into the worker's catch path nowKept the worker's error handling simple for the initial buildAn operator debugging a stuck apply today reads the server log or Bull Board, not this tableThe day an admin screen surfaces per-request retry history
Four LEAVE_* error codes are declared but never thrownAnticipated finer-grained failure reporting for wrong-status decisions/cancels and worker apply failureDeclare the codes; route the actual conditions through LEAVE_REQUEST_NOT_FOUND (row-scope mismatches) or an unhandled exception (worker failure) insteadThrow the specific codes from day oneKept the row-scope predicate pattern uniform (wrong owner, wrong status and genuinely-absent all look identical to a caller, which is also a security property — see §16.8)None of the four codes is misleading where it sits; a caller integrating against the API today should not expect to see themIf a future change deliberately distinguishes "exists but wrong status" from "not found" on any of these paths
Staff vs. student admin services are two files, not one parameterized serviceThe two subject kinds differ in class/enrolment fields, calendar-gating, and search shapeTwo near-identical servicesOne service branching on subjectKind throughoutEach stays readable as a flat CRUD service; the divergence (staff has no class filter, no guardian check, no calendar gate) would otherwise be threaded through every method as a booleanGenuine duplication — a bug fixed in one is not automatically fixed in the other, as the two buildWhere/baseSelect pairs already showIf a third subject kind is ever added

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Queue (QueueName.LEAVE)Bull Board (/admin/queues, dev-only) or server logs ([leave][success/skip]/[leave][error] lines)Jobs completing, mostly skip outcomes on redeliveryA job in the failed list, or attendance_applied_at still NULL long after approvalPOST .../:id/reapply-attendance (admin), which enqueues a fresh job with a new dedupe key
Database — overlap constraintEXPLAIN/query the row directly; the error message names the constraintInserts/updates on live rows succeed unless a genuine overlap existsRepeated 23P01/LEAVE_OVERLAPPING_REQUEST_EXISTS for a range that looks freeCheck for a soft-deleted-but-not-actually-live row, or a request in pending_*/approved the operator did not expect
Decision log integrityQuery leave_request_decisions directlyRows only ever inserted, never updatedAn attempted UPDATE/DELETE fails with 2F002Expected behavior — write a NEW decision row (via the normal decide flow) rather than editing history
Worker livenessServer log for [leave] lines; confirm LeaveWorkersModule is in the composed module list (only outside API_RUNTIME_PROFILE=fast)Jobs claimed within seconds of being enqueuedNo [leave] log activity despite pending outbox rowsConfirm the deployed process is NOT running under the fast runtime profile; confirm Redis connectivity

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
Row-scope mismatch and genuine absence are indistinguishable to every portal callerLEAVE_REQUEST_NOT_FOUND used for bothDeliberate, not a gap: a stranger cannot learn whether a row exists at all.The scoped SELECT in each portal service.None — this is a documented security property, not an omission.
attendance_attempt_count/last_error_code never populatedleave_request_runtimeAn operator has no in-database attempt history for a stuck apply; must use BullMQ's own history or the server log instead.reapply-attendance provides a manual recovery path regardless.A future change that surfaces per-request retry state on an admin screen needs these columns wired first.
Four declared LEAVE_* error codes never thrownerror-codes.tsLow — no consumer can be broken by a code that never appears; a future change narrowing an existing shared outcome should reach for the reserved code rather than inventing a new one.None needed today.Worth a lint/test asserting "every declared code is thrown somewhere" if this class of drift becomes a recurring pattern across modules.
The 'leave' notification category is fully provisioned and unusednotification-contract.ts, migration CHECKsNone operationally — nothing depends on it firing.N/AThe day a leave notification IS built, both database CHECK constraints and the TypeScript array already accept the category; only the sender needs writing.
Session-boundary exclusivity (end_date >= session.end_date refused) is easy to misread as a bugstudent-leaves.service.ts, calendar-day.service.tsLow — behavior is CORRECT and consistent across the admin and portal create paths (verified this session), but a future reader unfamiliar with the half-open [start, end) session convention could "fix" it into an inconsistency.Documented here and in the source comments.None currently; a revisit only if the session date convention itself ever changes.

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented or explicitly marked non-runtime (§4, §16.1).
  • Every controller, service, provider, processor, mapper, DTO, enum, and schema element is documented (§3, §5, §6, §16.1).
  • Every method with business behavior has a code-flow narrative (§6, §16.3 for the deepest one).
  • Every table/cache object/job payload has field-level detail (§5, §9).
  • Every index, constraint, relation, and delete behavior has rationale (§5.5, §5.2's CHECK table).
  • Every lifecycle/status transition has a state diagram and transition table (Feature doc §7).
  • Every read/write/action/job flow has sequence and activity diagrams (§7, §16.2).
  • Every business invariant is cataloged (§16.5).
  • Every cache key (none), invalidation path (none), queue job, and external call is documented (§8, §9, §10).
  • Every architectural tradeoff is documented with alternatives and revisit triggers (§16.6).
  • Every operational failure mode has a runbook entry (§16.7, §16.8).

18. Backend Completion Checklist

  • Module boundaries are documented (§2).
  • Every controller, service, DTO, schema file, job, cache key (none), and event (none) is covered.
  • Every database table has a field table and relationship diagram (§5).
  • Every runtime flow has a diagram and branch notes (§7).
  • API and features/flows docs are linked (below).
  • No claim is made without a source file or documented source reference — every discrepancy between the code and the brief this document was written from (four unused error codes, two unpopulated runtime columns, an unused notification category) is stated explicitly rather than silently corrected or omitted.

See Also

  • API doc: /docs/developer/leave/api
  • Features and flows doc: /docs/developer/leave/feature
  • Attendance (dependency — not documented here): /docs/developer/attendance
  • Academic calendar (dependency — not documented here): /docs/developer/academic-calendar

On this page

Leave Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tables and Collectionsleave_typesleave_requestsleave_request_runtimeleave_request_decisionsattendance_change_log.leave_request_id (added column, not a new table)5.3 The Overlap Exclusion — the module's most important integrity rule5.4 Relationship Diagram5.5 Index Rationale6. Services and Responsibilities6.1 LeaveTypesService6.2 StudentLeavesService / StaffLeavesService6.3 LeaveResolutionService6.4 LeaveDecisionService6.5 LeaveAttendanceService6.6–6.9 Portal services (GuardianLeavePortalService, StudentLeavePortalService, TeacherLeavePortalService, StaffLeavePortalService)7. Runtime Flows7.1 Self-serve create (pupil or staff)7.2 Guardian consent / teacher decision / admin decision7.3 Cancel (pupil, guardian, staff — never admin)7.4 Worker apply7.5 Worker revert7.6 Reapply-attendance8. Caching9. BullMQ, Schedulers, and Async Work10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error Handling14. Observability15. Testing and Validation16. Mandatory Backend Deep-Dive Pack16.1 Submodule Coverage Matrix16.2 UML and Architecture Diagram Pack16.3 Code Flow Narrative — LeaveDecisionService.decide16.4 Data Layer Deep Dive16.5 Business Logic and Invariant Catalog16.6 Tradeoffs, Alternatives, and ADR Notes16.7 Operational Runbook16.8 Backend Risk Register17. Zero-Omission Backend Checklist18. Backend Completion ChecklistSee Also