Academic Calendar Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Academic Calendar.
Academic Calendar Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/academic-calendar/academic-calendar.module.ts | Controllers, providers, RoleModule import. |
| Day controller/services | apps/api/src/modules/academic-calendar/days/academic-calendar.controller.ts, calendar-read.service.ts, calendar-generate.service.ts, calendar-day-write.service.ts | Routes, generation algorithm, PATCH/version semantics. |
| Holiday controller/services | apps/api/src/modules/academic-calendar/holidays/school-holiday.controller.ts, school-holiday.service.ts, holiday-day-apply.service.ts | Holiday CRUD, link/recompute, precedence. |
| Week-config controller/service | apps/api/src/modules/academic-calendar/config/school-week-config.controller.ts, school-week-config.service.ts | The single-row upsert and the unconfigured-state contract. |
| DTOs | apps/api/src/modules/academic-calendar/dto/calendar-day.dto.ts, holiday.dto.ts, week-config.dto.ts, index.ts | Request/response shapes, validation, field enumeration. |
| Schema | packages/db/src/schema/school/calendar.ts | Tables, enums, constraints, indexes, relations. |
| Migration | packages/db/src/migrations/0020_attendance_module.sql | Enum/table order, hand-added triggers, which tables get bump_row_version. |
| Calendar vocabulary | packages/db/src/calendar/calendar-contract.ts | Classification/source enums, attendance-required set, holiday precedence, the compile-time exhaustiveness proof. |
| Bikram Sambat conversion | packages/db/src/calendar/bikram-sambat.ts | The only module allowed to import nepali-date-converter; local-time formatting, range/day validation. |
| Holiday seed data | packages/db/src/seed/seed-calendar-holidays.ts | Fixed-date and lunar-dated holiday tables, seedHolidaysForRange. |
| Audience read | apps/api/src/modules/attendance/portal/calendar/calendar-portal.controller.ts, calendar-portal.service.ts, dto/portal-calendar.dto.ts, calendar-portal.module.ts | The shared, no-permission audience projection and its mobile mounting. |
| Mobile mounting | apps/api/src/modules/mobile/mobile.module.ts | CalendarPortalModule as a MOBILE_CHILDREN leaf. |
| Swagger documents | apps/api/src/config/swagger/swagger-documents.ts | AcademicCalendarModule in ADMIN_MODULES; the mobile document reflects MOBILE_CHILDREN instead. |
| Error codes | apps/api/src/common/types/error-codes.ts | Every CALENDAR_*, ACADEMIC_CALENDAR_*, SCHOOL_HOLIDAY_* code and its condition. |
| Permissions | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | AcademicCalendar/AcademicCalendarView codes and which of the five seeded roles hold which. |
| Shared utilities | apps/api/src/common/utils/pg-constraint.util.ts, apps/api/src/common/types/db-executor.type.ts, apps/api/src/common/utils/pagination.util.ts | Constraint-name translation, the transaction-safe executor type, pagination normalization. |
| Tests | apps/api/src/modules/academic-calendar/__tests__/academic-calendar.integration.spec.ts | Real-database behavior for generation, holidays, PATCH, and staleness. |
2. Backend Scope and Boundaries
Owns
- The materialised, one-row-per-Gregorian-date academic calendar for a session (
academic_calendar_days), its generation and repair (POST .../calendar/generate), its paginated list and BS-month grid reads, and single-day correction via PATCH. - The school-wide weekend and attendance-cutoff configuration (
school_week_config) — a single row that gates every calendar generation and has no seeded default. - Holiday intent (
school_holidays) and its application onto calendar days through the join tableschool_holiday_days, including the precedence recomputation that runs on every create, update, delete, and regeneration. - The Bikram Sambat ↔ Gregorian conversion wrapper (
packages/db/src/calendar/bikram-sambat.ts) and the vocabulary/attendance-decision constant (packages/db/src/calendar/calendar-contract.ts) that both this module and the seed package depend on. - The audience-facing calendar read (
CalendarPortalController/CalendarPortalService, physically inapps/api/src/modules/attendance/portal/calendar/), which every pupil, parent, teacher, and staff member reaches with no@Permissions()check at all — see §11 for why that is safe.
Does Not Own
- Whether attendance was actually taken on a date —
student_attendance_sessions/student_attendance_records, owned by the attendance module. This module only answers "is attendance expected" (isAttendanceRequired) and, for the admin day detail, "does at least one attendance record already exist" (CalendarReadService.attendanceExistsFor). - Academic session lifecycle and the non-overlap guarantee that makes "which session contains this date" a single answer —
academic_sessions,academic_sessions_no_active_overlap(packages/db/src/migrations/0020_attendance_module.sql:292). This module resolves a session by its public id or by containment and refusesACADEMIC_SESSION_NOT_FOUNDotherwise, but does not create, close, or overlap-check sessions. - The Bikram Sambat library itself (
nepali-date-converter).packages/db/src/calendar/bikram-sambat.tsis stated to be the only file in the repository allowed to import it directly (packages/db/src/calendar/bikram-sambat.ts:1-38); every conversion in this module and in the portal goes through the wrapper. - Authentication and role/permission evaluation —
JwtAuthGuard,RoleGuard,RoleModule. This module declares@Permissions()codes (or deliberately none, for the portal route) and importsRoleModuleso the guard can resolve, but implements no guard logic itself. - School clock / "what is today" resolution for the portal's default month —
SchoolClockService, owned by the attendance module's shared layer and only consumed here.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Whether a date is a school operating day | academic_calendar_days.classification | Materialised, not computed on read — an admin's edit or a school's weekend change must not retroactively reclassify dates already operated on (packages/db/src/schema/school/calendar.ts:22-49). |
| Whether the school has stated its weekend at all | Existence of the school_week_config row (id = 1) | No default is seeded; row existence is literally the configured flag (packages/db/src/schema/school/calendar.ts:85-99). |
| Which classification wins when holidays overlap a day | HOLIDAY_PRECEDENCE order, applied by HolidayDayApplyService.recomputeDay | A TypeScript constant, not a database rule — see §16.6 for why. |
| Whether attendance is expected on a classification | ATTENDANCE_REQUIRED_CLASSIFICATIONS, via isAttendanceRequired() | Never stored; computed at every read (CalendarReadService.toDto, CalendarPortalService.monthView). |
| A calendar day's Bikram Sambat date | academic_calendar_days.bs_year/bs_month/bs_day, generated by bikram-sambat.ts | Stored rather than computed on read so a BS-month query is an indexed range scan; never accepted from a client. |
| Who configured the week / created a holiday | school_week_config.configured_by_user_id, school_holidays.created_by_user_id | Both are real foreign keys to users, written from the acting administrator on every write. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
AcademicCalendarModule | Leaf, admin-facing | apps/api/src/modules/academic-calendar/academic-calendar.module.ts | SchoolWeekConfigController, AcademicCalendarController, SchoolHolidayController, CalendarTodayController | SchoolWeekConfigService, CalendarReadService, CalendarGenerateService, CalendarDayWriteService, HolidayDayApplyService, SchoolHolidayService | None | Composes week configuration, day generation/read/write, and holidays into one admin module; imports RoleModule so RoleGuard can resolve (academic-calendar.module.ts:1-38). |
CalendarPortalModule | Leaf, mobile-facing | apps/api/src/modules/attendance/portal/calendar/calendar-portal.module.ts | CalendarPortalController | CalendarPortalService | None | The one calendar read every audience shares; imports RoleModule and AttendanceSharedModule (calendar-portal.module.ts:1-22). |
AcademicCalendarModule is registered directly in ADMIN_MODULES (apps/api/src/config/swagger/swagger-documents.ts:88), because Swagger's include does not recurse and this module declares its controllers itself — it is the leaf, not an aggregate. CalendarPortalModule is registered as a MOBILE_CHILDREN leaf in apps/api/src/modules/mobile/mobile.module.ts:69, which is the only thing that both mounts its controller under /api/mobile (via RouterModule.register) and publishes it in the mobile Swagger document, whose membership is reflected from that same array (apps/api/src/modules/mobile/mobile.module.ts:64-68).
4. File and Directory Map
apps/api/src/modules/academic-calendar/
academic-calendar.module.ts
config/
school-week-config.controller.ts
school-week-config.service.ts
days/
academic-calendar.controller.ts
calendar-read.service.ts
calendar-generate.service.ts
calendar-day-write.service.ts
holidays/
holiday-day-apply.service.ts
school-holiday.controller.ts
school-holiday.service.ts
upcoming/
calendar-upcoming.controller.ts
calendar-upcoming.service.ts
__tests__/
today/
calendar-today.controller.ts
calendar-today.controller.spec.ts
dto/
calendar-day.dto.ts
calendar-today.dto.ts
holiday.dto.ts
week-config.dto.ts
index.ts
__tests__/
academic-calendar.integration.spec.ts
apps/api/src/modules/attendance/portal/calendar/
calendar-portal.controller.ts
calendar-portal.service.ts
calendar-portal.module.ts
dto/
portal-calendar.dto.ts
index.ts
apps/api/src/utils/calendar/
calendar-today.util.ts
calendar-today.util.spec.ts
bikram-sambat.spec.ts
packages/db/src/calendar/
bikram-sambat.ts
calendar-contract.ts
packages/db/src/schema/school/
calendar.ts
packages/db/src/seed/
seed-calendar-holidays.ts| File | Purpose | Key Exports | Notes |
|---|---|---|---|
academic-calendar.module.ts | Wires the five controllers and seven providers. | AcademicCalendarModule | Listed in ADMIN_MODULES and imported by app.module.ts. |
days/academic-calendar.controller.ts | Generate, list, month-view, day-detail, day-PATCH routes. | AcademicCalendarController | All routes require both class validators AND Permissions; base path academic-sessions/:sessionPublicId/calendar. |
days/calendar-read.service.ts | Every read over academic_calendar_days; session resolution shared by every other service in the module. | CalendarReadService, ResolvedSession | resolveSession/findDayRow/requireDayRow/toDayDetailDto all take an explicit executor so write paths can call them inside their own open transaction. |
days/calendar-generate.service.ts | The additive-with-repair generation algorithm. | CalendarGenerateService | Delegates every holiday-application decision to HolidayDayApplyService. |
days/calendar-day-write.service.ts | Single-day PATCH; moves a day to admin_modified. | CalendarDayWriteService | Owns the CHECK-constraint-to-error-code map for free-text fields on this table. |
holidays/school-holiday.controller.ts | Holiday list/create/update/delete routes. | SchoolHolidayController | Two base paths on one controller class, both declared with no class-level path prefix, to keep one @ApiTags. |
holidays/school-holiday.service.ts | Holiday CRUD, its own validation, and constraint translation. | SchoolHolidayService | Delegates every link/recompute decision to HolidayDayApplyService; never writes school_holiday_days itself. |
holidays/holiday-day-apply.service.ts | The single owner of every write to school_holiday_days and of precedence recomputation. | HolidayDayApplyService | Every method takes its executor explicitly — no default db field — because every call happens inside a caller's already-open transaction. |
config/school-week-config.controller.ts | Read/upsert school_week_config. | SchoolWeekConfigController | Base path school/week-config, no session scoping — the row is school-wide. |
config/school-week-config.service.ts | The unconfigured-state contract and the upsert. | SchoolWeekConfigService | get() never returns a partial shape; update() distinguishes first-configuration from a later update by expectedVersion: null. |
today/calendar-today.controller.ts | Today's date in Nepal with its BS equivalent. | CalendarTodayController | @Controller("calendar"), one handler today, no constructor dependencies and no @Permissions(). The only place new Date() is called. |
dto/calendar-today.dto.ts | Response shape of the today route. | CalendarTodayDto | Six nullable fields that are null together outside the converter's range. |
upcoming/calendar-upcoming.controller.ts | The next N days of the calendar. | CalendarUpcomingController | @Controller("calendar"), one handler upcoming, no @Permissions(); allowlisted as "CalendarUpcomingController.upcoming". |
upcoming/calendar-upcoming.service.ts | Builds the window, picks one row per date, groups events, caches the result. | CalendarUpcomingService | Selects columns by hand; note is never read. |
dto/calendar-upcoming.dto.ts | Query and response shapes. | CalendarUpcomingQueryDto, CalendarUpcomingDto, CalendarUpcomingDayDto, CalendarUpcomingEventDto, CalendarUpcomingBsDto | days is 1 to 31, default 7. |
utils/calendar/calendar-today.util.ts (under apps/api/src) | Pure computation of the today payload from a reference instant. | buildCalendarToday, CalendarToday | No NestJS imports, no I/O, no clock. Nepal date from getCurrentNepalDateString; weekday from Date.UTC(...).getUTCDay() on that string. Catches only BikramSambatRangeError and BikramSambatInvalidDateError, rethrowing anything else. |
dto/*.ts | Request/response/query shapes. | See §6 (API doc). | CalendarDayDto and PortalCalendarDayDto are two independent, hand-enumerated projections of the same table — never the same class. |
attendance/portal/calendar/calendar-portal.controller.ts | The one shared audience read. | CalendarPortalController | @Controller("calendar"); the /api/mobile prefix comes from RouterModule, not this decorator. |
attendance/portal/calendar/calendar-portal.service.ts | Resolves session/month, maps rows by hand. | CalendarPortalService | Never spreads a row — see its own docblock and PortalCalendarDayDto's. |
packages/db/src/calendar/bikram-sambat.ts | BS ↔ Gregorian conversion, local-time-safe. | bsToGregorian, gregorianToBs, enumerateGregorianDays, assertValidBsDate, formatBsDateNepali, BS_MIN_YEAR, BS_MAX_YEAR | The only file permitted to import nepali-date-converter. |
packages/db/src/calendar/calendar-contract.ts | Vocabulary and the one derived rule. | CALENDAR_DAY_CLASSIFICATION, CALENDAR_DAY_SOURCE, ATTENDANCE_REQUIRED_CLASSIFICATIONS, HOLIDAY_PRECEDENCE, isAttendanceRequired | Carries a compile-time exhaustiveness proof — see §16.6. |
packages/db/src/seed/seed-calendar-holidays.ts | Nepali holiday reference data. | seedHolidaysForRange, SeedHoliday | Exported and built, but not called from any file in apps/api/src or packages/db/src as of this writing — see §16.6 and the report below. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/
calendar.tsAll four tables live in this one file; there is no separate enums.ts for this schema — the two enums (calendarDayClassificationEnum, calendarDaySourceEnum) are declared at the top of calendar.ts (packages/db/src/schema/school/calendar.ts:51-78).
5.2 Tables and Collections
school_week_config
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | integer | No | 1 | PK; CHECK id = 1 | N/A | Single-row table. packages/db/src/schema/school/calendar.ts:103,149. |
weekend_days | smallint[] | No | none | CHECK length ≤ 6; CHECK <@ ARRAY[0..6] | N/A | 0 = Sunday, matching class_timeslots.day_of_week. Empty is legal — a seven-day school. calendar.ts:104-116,155-162. |
school_day_cutoff_minute | smallint | No | 1020 | CHECK 0 <= x <= 1440 | N/A | Minutes from local midnight; 1020 = 17:00. calendar.ts:117-127,163-166. |
configured_by_user_id | uuid | Yes | none | FK → users.id, ON DELETE set null | users | The acting administrator on the last save. calendar.ts:128-131. |
version | bigint | No | 0 | Never indexed | N/A | Optimistic-concurrency token, server-owned via bump_row_version. calendar.ts:132-140. |
created_at / updated_at | timestamptz | No | now() | — | N/A | updated_at is stamped by the trigger, never by $onUpdateFn. calendar.ts:141-146. |
No row is seeded (packages/db/src/migrations/0020_attendance_module.sql:228-232). Row existence is the "configured" flag: school_week_config_single_row (calendar.ts:149) is what keeps the table from ever holding more than one row, and there is deliberately no schema-level default weekend — Nepal's standard school week is Sunday–Friday while an international-curriculum school in Kathmandu runs Monday–Friday, and a wrong default would silently mis-classify a year of Fridays nobody is prompted to check.
school_holidays
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid | No | generated (uuidv7Pk) | PK | N/A | The public identifier. calendar.ts:182. |
academic_session_id | integer | No | none | FK → academic_sessions.id, ON DELETE restrict | academic_sessions | restrict, not cascade — a year of holiday work must not vanish because a session was deleted. calendar.ts:189-191. |
name | text | No | none | CHECK no pad/control chars, ≤120 chars | N/A | calendar.ts:192,230-233. |
classification | enum calendar_day_classification | No | none | — (school_day refused at the service layer, not by CHECK) | N/A | calendar.ts:198. |
from_date / to_date | date | No | none | CHECK to_date >= from_date | N/A | Single-day holiday is from = to. calendar.ts:199-200,217-220. |
reason | text | Yes | none | CHECK no pad/control chars, ≤500 chars | N/A | Administrator-internal; never reaches an audience-facing response. calendar.ts:202,234-237. |
source | enum calendar_day_source | No | none | — | N/A | school (admin-created) or seed (populated by the platform). calendar.ts:203. |
created_by_user_id | uuid | Yes | none | FK → users.id, ON DELETE set null | users | calendar.ts:204-206. |
version | bigint | No | 0 | Never indexed | N/A | bump_row_version-maintained. calendar.ts:207. |
created_at / updated_at | timestamptz | No | now() | — | N/A | calendar.ts:208-213. |
Index: school_holidays_session_range_idx on (academic_session_id, from_date, to_date) — calendar.ts:238-242, serving the range lookups HolidayDayApplyService runs on every apply.
academic_calendar_days
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid | No | generated (uuidv7Pk) | PK | N/A | The uuid IS the public identifier, matching class_timeslots. calendar.ts:253. |
academic_session_id | integer | No | none | FK → academic_sessions.id, ON DELETE restrict; UNIQUE with gregorian_date | academic_sessions | calendar.ts:254-256,311-314. |
gregorian_date | date | No | none | see unique above; own btree index | N/A | YYYY-MM-DD, Asia/Kathmandu local, never toISOString(). calendar.ts:257. |
bs_year / bs_month / bs_day | smallint | No | none | CHECK 2000<=year<=2090, 1<=month<=12, 1<=day<=32 | N/A | Machine-generated by the BS wrapper, never client-supplied. calendar.ts:264-269,322-325. |
day_of_week | smallint | No | none | CHECK 0<=x<=6 | N/A | 0 = Sunday. calendar.ts:270-271,326-329. |
classification | enum calendar_day_classification | No | none | — | N/A | calendar.ts:272. |
source | enum calendar_day_source | No | none | — | N/A | auto | seed | school | admin_modified. calendar.ts:273. |
event_name | text | Yes | none | CHECK no pad/control chars, ≤120 | N/A | Shown to every audience, including the portal. calendar.ts:274-275,330-333. |
note | text | Yes | none | CHECK no pad/control chars, ≤500 | N/A | Administrator-internal — never enumerated into PortalCalendarDayDto. calendar.ts:276-283,334-337. |
needs_review | boolean | No | false | — | N/A | Set on lunar-dated seeded holidays whose date is approximate. calendar.ts:284-289. |
generated_from_weekend_days | smallint[] | No | none | — | N/A | The weekend snapshot at generation time; compared against the live config to compute staleness. calendar.ts:290-301. |
version | bigint | No | 0 | Never indexed | N/A | calendar.ts:302. |
created_at / updated_at | timestamptz | No | now() | — | N/A | calendar.ts:303-308. |
Indexes: academic_calendar_days_session_bs_month_idx on (academic_session_id, bs_year, bs_month) for the month view (calendar.ts:339-343); academic_calendar_days_session_classification_idx on (academic_session_id, classification), which is also regeneration's delete predicate (calendar.ts:344-348); academic_calendar_days_date_idx on gregorian_date alone, for the two attendance sweeps that probe "is this date a school day" before they know the session (calendar.ts:349-354).
school_holiday_days
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
holiday_id | uuid | No | none | Composite PK; FK → school_holidays.id, ON DELETE cascade | school_holidays | calendar.ts:380-382,391-394. |
calendar_day_id | uuid | No | none | Composite PK; FK → academic_calendar_days.id, ON DELETE cascade; own btree index | academic_calendar_days | calendar.ts:383-385,396-400. |
created_at | timestamptz | No | now() | — | N/A | calendar.ts:386-388. |
This table has no version column and no bump_row_version trigger — the migration says so explicitly (packages/db/src/migrations/0020_attendance_module.sql:254-260): the trigger function fails at runtime, not at DDL time, on a table with no version column, and this join table is created/deleted wholesale rather than updated. It exists precisely because a single holiday_id column on the day row cannot represent two holidays covering one date, which happens whenever a school closure overlaps a public holiday — deleting one must recompute the day from the holidays that remain, not blindly revert it (calendar.ts:358-376).
5.3 Relationship Diagram
SCHOOL_WEEK_CONFIG is drawn with no edge to the day tables: nothing references it by foreign key, and it participates only through the runtime read every generation and recomputation performs.
6. Services and Responsibilities
6.1 CalendarReadService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
resolveSession(executor, sessionPublicId) | Controller and every other service in this module | academic_sessions | — | — | ACADEMIC_SESSION_NOT_FOUND |
findDayRow / requireDayRow | Controller, write services | academic_calendar_days | — | — | requireDayRow throws CALENDAR_DAY_NOT_FOUND |
dayDetail(sessionPublicId, date) | Controller | as above, plus dayHolidays, attendanceExistsFor | — | — | ACADEMIC_SESSION_NOT_FOUND, CALENDAR_DAY_NOT_FOUND |
toDayDetailDto(executor, row) | dayDetail, and the write services' post-write re-read | school_holiday_days/school_holidays join, student_attendance_sessions/student_attendance_records | — | — | — |
list(sessionPublicId, query) | Controller | academic_calendar_days (filtered/paginated), attendance-exists set, coverage() | — | — | ACADEMIC_SESSION_NOT_FOUND |
monthView(sessionPublicId, bsYear, bsMonth) | Controller | academic_calendar_days by BS month, holiday names via join | — | — | CALENDAR_BS_DATE_OUT_OF_RANGE, CALENDAR_BS_DATE_INVALID, ACADEMIC_SESSION_NOT_FOUND |
dayHolidays(executor, calendarDayId) | toDayDetailDto | school_holiday_days joined to school_holidays | — | — | — |
attendanceExistsFor(executor, dates) | toDayDetailDto, list | student_attendance_sessions correlated EXISTS against student_attendance_records | — | — | — |
coverage(session) | list, CalendarGenerateService.generate | academic_calendar_days, school_week_config | — | — | — |
This service is the shared read layer: calendar-generate.service.ts and calendar-day-write.service.ts both depend on it for session resolution and the just-written re-read, matching the ClassTimeslotsReadService/ClassTimeslotsWriteService split in the timetable module (calendar-read.service.ts:43-49). Input normalization: none needed on reads. Validation order: session resolution always precedes row resolution. Transaction boundaries: every method accepts an explicit executor so a caller's open transaction is reused rather than a second pooled connection opened mid-write (db-executor.type.ts:1-12). attendanceExistsFor is a single correlated EXISTS query for the whole date set, never one query per date (calendar-read.service.ts:289-314). coverage treats staleDays as scoped to source = 'auto' rows only, because a holiday-covered day's classification does not come from the weekend rule and cannot be "stale" with respect to it (calendar-read.service.ts:316-321).
6.2 CalendarGenerateService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
generate(sessionPublicId) | Controller | academic_sessions, school_week_config, existing academic_calendar_days, school_holiday_days (link check) | Deletes unlinked auto rows; inserts missing dates; delegates holiday reapplication | One transaction for the whole operation | CALENDAR_WEEKEND_NOT_CONFIGURED, CALENDAR_BS_DATE_OUT_OF_RANGE, CALENDAR_BS_DATE_INVALID |
dayBefore(iso) (private, static) | generate | — | — | — | — |
Input normalization: none — the only input is the path parameter. Validation order: session resolution → week-config existence → BS-range enumeration → the transaction's own read/delete/insert/reapply sequence. Transaction boundary: one db.transaction, covering the delete, the insert, and the full holiday reapplication. Idempotency: idempotent in result, not in reported diff — every run deletes every unlinked auto row and reinserts it, so a stale generated_from_weekend_days snapshot self-heals on every call, not only on request (calendar-generate.service.ts:31-38). Fail-closed: the session's end date is exclusive (academic_sessions_dates_ordered is end_date > start_date), so the calendar covers up to but never including it (calendar-generate.service.ts:66-68).
6.3 CalendarDayWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
patch(sessionPublicId, date, dto) | Controller | academic_calendar_days (existing row, then re-read) | academic_calendar_days update, version-guarded | Moves auto/seed rows to admin_modified | CALENDAR_TEXT_FORMAT_INVALID, CALENDAR_DAY_NOT_FOUND, ATTENDANCE_VERSION_CONFLICT |
Input normalization: eventName/note are trimmed server-side (calendar-day-write.service.ts:156-161) and pre-validated against the same pattern the database CHECK enforces (TEXT_FORMAT_PATTERN, calendar-day-write.service.ts:53-55), so a constraint violation is a backstop rather than the guard. Validation order: text-format assertion → session resolution → transaction with existing-row load → patch construction → version-guarded update. Concurrency: optimistic — version is part of the UPDATE's WHERE, and zero affected rows raises ATTENDANCE_VERSION_CONFLICT (this module reuses the attendance module's version-conflict code rather than declaring a calendar-specific one). Response mapping: always the freshly re-read row inside the same transaction, never an echo of the request body (calendar-day-write.service.ts:130-138). An empty patch still assigns a real column (needsReview to its own current value) purely so the UPDATE is non-trivial and the bump_row_version trigger fires, advancing the token even when nothing else changed (calendar-day-write.service.ts:107-112).
6.4 SchoolWeekConfigService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
get() | Controller | school_week_config | — | — | — (never throws; the unconfigured state is a valid response) |
update(dto, actor) | Controller | school_week_config | Insert (first configuration) or version-guarded update | — | CALENDAR_WEEKEND_INVALID, ATTENDANCE_VERSION_CONFLICT |
get() is the one method in this module documented to never fail: with no row, it returns { configured: false, weekendDays: [], schoolDayCutoffMinute: 1020, version: null } rather than a partial shape (school-week-config.service.ts:38-49). Validation order in update: distinctness check on weekendDays → transaction → existing-row branch. On the first configuration, an expectedVersion other than null/undefined is refused with ATTENDANCE_VERSION_CONFLICT — the caller cannot claim to have read a version that never existed (school-week-config.service.ts:77-84). On a later update, dto.expectedVersion ?? -1 guarantees a null/undefined token can never accidentally match a real version and always produces the same 409 a genuinely stale token would (school-week-config.service.ts:113-119).
6.5 SchoolHolidayService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
list(sessionPublicId, query) | Controller | school_holidays | — | — | ACADEMIC_SESSION_NOT_FOUND |
create(sessionPublicId, dto, actor) | Controller | academic_sessions | school_holidays insert | Delegates link/recompute to HolidayDayApplyService | CALENDAR_HOLIDAY_RANGE_INVALID, CALENDAR_TEXT_FORMAT_INVALID, CALENDAR_RANGE_OUTSIDE_SESSION, ACADEMIC_CALENDAR_NOT_GENERATED, WRITE_RETURNED_NO_ROW |
update(holidayId, dto) | Controller | school_holidays, academic_sessions | school_holidays update (version-guarded); unlinks then relinks school_holiday_days | Recomputes both newly-dropped and newly-covered days | SCHOOL_HOLIDAY_NOT_FOUND, CALENDAR_HOLIDAY_RANGE_INVALID, CALENDAR_RANGE_OUTSIDE_SESSION, ATTENDANCE_VERSION_CONFLICT, WRITE_RETURNED_NO_ROW |
remove(holidayId) | Controller | school_holidays, school_holiday_days (existing links) | school_holidays delete (cascades school_holiday_days) | Recomputes every day that was linked | SCHOOL_HOLIDAY_NOT_FOUND, SCHOOL_HOLIDAY_NOT_DELETABLE |
Input normalization: name/reason trimmed server-side; text-format and date-order assertions run before the transaction opens, mirroring the CHECK constraints (school-holiday.service.ts:76-78,144-147). Validation order in create: not-school-day → dates-ordered → text-format → session resolution → range-inside-session → transaction. update is the more involved of the two: the range or classification may move, so every existing link is deleted and the holiday is relinked from the new range — narrowing reverts the days that fall out, widening applies to the days newly covered, and a classification change rewrites every day the holiday still owns (school-holiday.service.ts:294-319). remove refuses a source = 'seed' holiday with SCHOOL_HOLIDAY_NOT_DELETABLE — reclassification via PATCH is offered instead, so the record of what the platform populated is never lost (school-holiday.service.ts:326-350). Response mapping: toDto enumerates every field by hand; there is no spread from the raw row.
6.6 HolidayDayApplyService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
linkAndRecompute(executor, holiday, sessionId) | SchoolHolidayService.create/update | academic_calendar_days in range | school_holiday_days insert (onConflictDoNothing) | Calls recomputeMany on every linked day | ACADEMIC_CALENDAR_NOT_GENERATED |
recomputeMany(executor, ids) | linkAndRecompute, reapplyAllForSession, both holiday-write paths, remove | — | Delegates to recomputeDay | — | — |
recomputeDay(executor, id) | recomputeMany | academic_calendar_days, school_holiday_days+school_holidays join, school_week_config (fallback branch only) | academic_calendar_days update, conditional | Returns whether the row actually changed | — |
reapplyAllForSession(executor, sessionId) | CalendarGenerateService.generate | school_holidays, academic_calendar_days in each holiday's range | Same insert/recompute as linkAndRecompute, for every existing holiday | Re-links every holiday against freshly-inserted day rows | — |
This is the single owner of every write to school_holiday_days and of the precedence recomputation; holiday create, update, delete, and calendar generation all share this one implementation rather than four (holiday-day-apply.service.ts:18-26). Every method takes its executor as an explicit first parameter with no default db field, because every call happens inside a caller's already-open transaction (holiday-day-apply.service.ts:22-26). linkAndRecompute refuses with ACADEMIC_CALENDAR_NOT_GENERATED if the day count in the requested range does not match the expected inclusive day count — never a partial link (holiday-day-apply.service.ts:34-60). recomputeDay is the precedence engine: it reads every holiday still linked to a day, finds the first classification in HOLIDAY_PRECEDENCE order that has a match, and falls back to weekend-or-school-day (recomputing against the live school_week_config) when no holiday remains — except when the day's source is already admin_modified, in which case the method returns false immediately and touches nothing, because the administrator's edit wins over any holiday membership change (holiday-day-apply.service.ts:96-147). reapplyAllForSession exists specifically so a holiday created before the first generate() call is not silently lost — the freshly-inserted day rows from that generation have no links yet, and this method builds them (holiday-day-apply.service.ts:171-176).
6.7 CalendarPortalService (audience read, owned by the attendance module)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
resolveMonth(query) (private) | monthView | SchoolClockService.now() (only when both bsYear/bsMonth are omitted) | — | — | CALENDAR_BS_DATE_INVALID if only one of the two is supplied |
resolveSessionId(sessionPublicId) (private) | monthView | academic_sessions, or CalendarDayService.sessionContaining | — | — | ACADEMIC_SESSION_NOT_FOUND |
monthView(query) | CalendarPortalController.monthView | academic_calendar_days, filtered to the resolved session/BS-month | — | — | ACADEMIC_CALENDAR_NOT_GENERATED if the month has zero rows |
This service physically lives outside apps/api/src/modules/academic-calendar/, in apps/api/src/modules/attendance/portal/calendar/, but reads the same academic_calendar_days table and is the audience-facing counterpart to CalendarReadService. It resolves "this month" in two steps that are refused rather than half-completed: supplying bsYear without bsMonth (or vice versa) is a 400, because silently defaulting the missing half to the current year would answer a different question than the one asked (calendar-portal.service.ts:38-55). With neither supplied, it asks SchoolClockService for today and converts through gregorianToBs — the same wrapper the admin side uses, so the two boundary defects documented in bikram-sambat.ts are fixed in exactly one place rather than risking a second, unaudited implementation in the portal (calendar-portal.service.ts:52-55). The response mapper enumerates every field by hand and never spreads a row — see §11 for why that is the actual security control on this route, not the guard.
6.8 CalendarUpcomingService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
upcoming(days) | CalendarUpcomingController.upcoming | The school clock; academic_sessions; academic_calendar_days joined to active sessions | — | Redis read, fire-and-forget Redis write on a miss | None of its own (days is validated by the DTO) |
- Window.
enumerateDates(today, days)returnsdaysISO dates by UTC arithmetic from the school-local date. - One row per date.
selectDistinctOn(gregorian_date)over rows from active sessions, ordered so the current session wins and the lowest session id breaks any tie. Archived sessions never surface. - Field-by-field read. The select lists the date, classification, event name and BS parts.
noteis not selected, because the route is open to every session. - Days. A date with a row carries its classification,
isOperatingDay = isAttendanceRequired(classification)and its stored BS date. A date without a row carries null classification, nullisOperatingDay, null event name and a BS date converted from the Gregorian date, or null when the converter throws. - Events.
groupEventswalks the days and merges consecutive notable days that share classification and event name. A day is notable when it has an event name, or its classification is notschool_dayorweekend. - Cache. See §8.
7. Runtime Flows
7.1 Generate (or repair) a calendar
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | AcademicCalendarController.generate | Resolves sessionPublicId from the path. | — |
| 2 | CalendarGenerateService.generate | Reads school_week_config; refuses if absent. | CALENDAR_WEEKEND_NOT_CONFIGURED |
| 3 | enumerateGregorianDays | Enumerates every date, converting each to BS. | CALENDAR_BS_DATE_OUT_OF_RANGE, CALENDAR_BS_DATE_INVALID |
| 4 | delete unlinked auto rows, insert missing dates | Additive-with-repair, inside one transaction. | — |
| 5 | HolidayDayApplyService.reapplyAllForSession | Re-links and recomputes every holiday of the session. | — |
7.2 Configure the school week — first save vs. later update
Distinctness of weekendDays is a service rule (assertNoDuplicateWeekendDays), never a CHECK — an array CHECK cannot express "no repeated element" without a subquery, and a CHECK with no application refusal in front of it is an unmapped SQLSTATE rather than validation (school-week-config.service.ts:143-149).
7.3 Create a holiday
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | SchoolHolidayController.create | Receives DTO, already class-validator-checked. | 400 from the global pipe on shape errors. |
| 2 | SchoolHolidayService.create | Service-layer guards, then session/range checks. | CALENDAR_HOLIDAY_RANGE_INVALID, CALENDAR_RANGE_OUTSIDE_SESSION |
| 3 | INSERT school_holidays | CHECK backstop for text/date-order. | CALENDAR_TEXT_FORMAT_INVALID, CALENDAR_HOLIDAY_RANGE_INVALID |
| 4 | HolidayDayApplyService.linkAndRecompute | Refuses a partial range. | ACADEMIC_CALENDAR_NOT_GENERATED |
7.4 Update a holiday — narrow, widen, or reclassify
Every existing link is deleted and rebuilt from the new range rather than diffed, because the classification may also have changed — rewriting every day the holiday still owns is simpler and provably correct, at the cost of recomputing days whose membership did not actually change (school-holiday.service.ts:294-319).
7.5 Delete a holiday
The links are read before the delete so the ids to recompute are known — the ON DELETE cascade on school_holiday_days removes them a moment later, and by the time recomputeMany runs, each day's remaining holidays (if any) are exactly what decides its new classification (school-holiday.service.ts:352-364).
7.6 PATCH a calendar day
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | CalendarDayWriteService.patch | Text-format guard runs before any database access. | CALENDAR_TEXT_FORMAT_INVALID |
| 2 | session/day resolution | Inside the transaction. | ACADEMIC_SESSION_NOT_FOUND, CALENDAR_DAY_NOT_FOUND |
| 3 | version-guarded UPDATE | auto/seed moves to admin_modified; admin_modified stays admin_modified. | ATTENDANCE_VERSION_CONFLICT |
| 4 | re-read in the same transaction | Never an echo of the request body. | — |
7.7 Portal month view
Guest vs. logged-in branch: not applicable — every caller must hold a JWT, and the route makes no audience distinction at all (see §11). Cache miss/hit: no cache exists. Race/concurrency: none — this is a pure read with no write side.
8. Caching
Only GET /api/calendar/upcoming is cached. Every other read in this module and in calendar-portal.service.ts runs directly against PostgreSQL.
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
calendar:upcoming:shape:1-today:<YYYY-MM-DD>-days:<n> | CacheKeyUtil.build("calendar:upcoming:", [...]) | CalendarUpcomingDto | 60s | None. The date in the key rolls the entry at school midnight; generation, holiday writes and day corrections are bounded by the TTL. | CalendarUpcomingService.upcoming |
The read uses cacheGetFast and the write cacheSetFast (apps/api/src/common/utils/cache-race.util.ts). The shared Redis client queues commands during an outage instead of rejecting them, so an awaited read would hold the request open. cacheGetFast races the read against a 250 ms budget and returns a miss on timeout or error; the service then reads PostgreSQL. cacheSetFast does not await, so a slow or lost write costs the next reader a miss and never delays the response. The value is identical for every caller, so a single entry per date and window length serves all audiences.
9. BullMQ, Schedulers, and Async Work
None owned by this module. Generation, holiday application, and every write are synchronous within the request's own transaction. (The attendance module's own schedulers separately read academic_calendar_days.classification to decide whether to expect a register or wake a teacher, but they are documented in the attendance module's own docs, not here.)
10. Realtime and Events
None. No Socket.IO gateway, room, or emitted event exists in this module.
11. Security, Auth, and Abuse Controls
- Guards. Every controller —
SchoolWeekConfigController,AcademicCalendarController,SchoolHolidayController,CalendarPortalController, andCalendarTodayController— carries@UseGuards(JwtAuthGuard, RoleGuard). - Permissions. The three admin controllers gate every route with
@Permissions("AcademicCalendar_...").CalendarPortalController.monthViewcarries no@Permissions()decorator at all — the calendar names no person, so unlike every other portal route in the attendance module, it needs no audience assertion (calendar-portal.controller.ts:9-31). - The no-permission route's real safety mechanism. Because
RoleGuardreads a non-mobile-prefixed-looking handler with no@Permissions()as an admin surface and throws unless allowlisted,CalendarPortalController.monthViewmust be listed inNO_PERMISSION_ADMIN_HANDLERS(apps/api/src/common/authorization/role.guard.ts:228, key"CalendarPortalController.monthView") and its CI mirrortest/structure/route-permissions.spec.ts. What actually keeps the route safe with no guard behind it is the frozen response shape —PortalCalendarDayDtoenumerates every field by hand and never spreads a row, sonote(administrator-internal free text) can never reach a pupil or parent through a column added later toacademic_calendar_days(apps/api/src/modules/attendance/portal/calendar/dto/portal-calendar.dto.ts:71-93). - The today route.
CalendarTodayController.today(GET /api/calendar/today) also carries no@Permissions()and is listed inNO_PERMISSION_ADMIN_HANDLERSunder"CalendarTodayController.today", with its mirror intest/structure/route-permissions.spec.ts. It reads no database, cache, queue, Redis or MongoDB: the response is today's date in Asia/Kathmandu and its BS equivalent, computed from the clock alone, so it carries no user, school or row data. - Object-level vs. field-level scope.
AcademicCalendar_*(full CRUD) is held by no seeded role exceptsuperadmin—packages/db/src/seed/seed-auth.ts:214-220showsstaff,teacher,guardian, andstudentall hold at mostAcademicCalendarView_READ, never the CRUD codes, because the day and holiday rows carry an administrator's free-textnote/reasonplus the id of who wrote them (packages/db/src/authorization/permission-catalog.ts:225-233).AcademicCalendarView_READitself gates the panel screen, not any route — it appears on zero API routes, exactly likeSchedule_READ(permission-catalog.ts:234-243) — and is granted tostaff/teacher(seed-auth.ts:110),guardian(seed-auth.ts:162), andstudent(seed-auth.ts:195). - Guest identity. Not applicable; every route in this module requires an authenticated JWT.
- Rate limits. No module-specific limiter; relies on the platform-wide default.
- Input normalization.
label-equivalent free text (eventName,note, holidayname/reason) is trimmed server-side before every write, matching the database CHECK constraints that reject leading/trailing whitespace and control characters. - Sensitive data redaction.
note/reasonnever leave the admin surface.CalendarDayDto/CalendarDayDetailDto/MonthCalendarDayDto(admin-facing) all carrynote;PortalCalendarDayDto(audience-facing) is a deliberately smaller, hand-enumerated class that carries neithernote,reason,source,needsReview, any holiday id,createdByUserId,configuredByUserId,version, norgeneratedFromWeekendDays(portal-calendar.dto.ts:71-82). - Audit logs.
school_week_config.configured_by_user_idandschool_holidays.created_by_user_idare the only accountability trail this module writes — both real foreign keys tousers, both asserted by the integration test (academic-calendar.integration.spec.ts:157-186). - Fail-closed behavior. Generation refuses outright while
school_week_confighas no row (CALENDAR_WEEKEND_NOT_CONFIGURED) rather than assuming a default weekend; a holiday can never link a day the calendar has not generated yet (ACADEMIC_CALENDAR_NOT_GENERATED); a holiday'sclassificationcan never beschool_day(checked in the service, since the enum itself cannot express the exclusion without a CHECK that would break on a future enum value — see §16.6).
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
ACADEMIC_SESSION_NOT_FOUND | 404 | CalendarReadService.resolveSession, SchoolHolidayService.update, CalendarPortalService.resolveSessionId | sessionPublicId does not resolve, or no session contains today (portal default). | Verify the id, or that a current session exists. |
CALENDAR_DAY_NOT_FOUND | 404 | CalendarReadService.requireDayRow | No calendar row for that date in that session. | Generate the calendar, or check the date. |
CALENDAR_WEEKEND_NOT_CONFIGURED | 409 | CalendarGenerateService.generate | school_week_config has no row. | Save a week configuration first. |
CALENDAR_WEEKEND_INVALID | 422 | SchoolWeekConfigService.assertNoDuplicateWeekendDays | weekendDays repeats a day. | Fix the payload. |
CALENDAR_BS_DATE_OUT_OF_RANGE | 422 | CalendarGenerateService.generate, CalendarReadService.assertBsYearMonthInRange, gregorianToBs's translation of the library's bare error | A date falls outside BS 2000–2090. | Choose a session/date inside the supported range. |
CALENDAR_BS_DATE_INVALID | 422 | CalendarGenerateService.generate, CalendarReadService.assertBsYearMonthInRange, CalendarPortalService.resolveMonth | An out-of-range BS month/day, or only one of bsYear/bsMonth supplied to the portal query. | Fix the payload. |
CALENDAR_RANGE_OUTSIDE_SESSION | 422 | SchoolHolidayService.assertRangeInsideSession | Holiday range not wholly inside [startDate, endDate). | Narrow the range. |
CALENDAR_TEXT_FORMAT_INVALID | 422 | CalendarDayWriteService/SchoolHolidayService guards, and the CHECK-translation backstop | Free text is empty, padded, or carries a control character. | Fix the payload. |
CALENDAR_HOLIDAY_RANGE_INVALID | 422 (service) / 422 (CHECK translation) | SchoolHolidayService.assertNotSchoolDay/assertDatesOrdered, school_holidays_dates_ordered | toDate < fromDate, or classification = school_day. | Fix the payload. |
SCHOOL_HOLIDAY_NOT_FOUND | 404 | SchoolHolidayService.update/remove | holidayId does not resolve. | Verify the id. |
SCHOOL_HOLIDAY_NOT_DELETABLE | 409 | SchoolHolidayService.remove | Holiday source = 'seed'. | Reclassify instead of deleting. |
ACADEMIC_CALENDAR_NOT_GENERATED | 409 | HolidayDayApplyService.linkAndRecompute, CalendarPortalService.monthView | Holiday range not fully generated, or the portal's requested month has zero rows. | Generate the calendar for that range/month first. |
ATTENDANCE_VERSION_CONFLICT | 409 | CalendarDayWriteService.patch, SchoolHolidayService.update, SchoolWeekConfigService.update | Supplied expectedVersion does not match the stored version. | Reload and retry. Reused from the attendance module rather than a calendar-specific code. |
WRITE_RETURNED_NO_ROW | 500 | SchoolHolidayService.create, SchoolWeekConfigService.update | A RETURNING clause yielded no row on a success path. | Defensive; report as a bug. |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | Framework-default NestJS request/exception logging on every controller | Failure visibility; no module-specific Logger calls exist in these services. |
| Audit | school_week_config.configured_by_user_id, school_holidays.created_by_user_id | Who last configured the week, and who created a given holiday. |
| Metric | None | No named metric is emitted by this module. |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Integration | apps/api/src/modules/academic-calendar/__tests__/academic-calendar.integration.spec.ts | The unconfigured week-config shape, attribution to the acting administrator, generation refusal and idempotency, admin_modified preservation across regeneration, the BS 2000–2090 boundary, multi-day holiday application and reversion on delete, two-holiday precedence and recompute-on-delete, seed-holiday delete refusal, day-PATCH version semantics, and staleness reporting after a week-configuration change. |
| Unit | apps/api/src/utils/calendar/calendar-today.util.spec.ts | The full payload for a fixed instant, the Nepal-midnight rollover of date and weekday (18:14:59Z versus 18:15:00Z), and the all-null BS fields outside the converter's range. |
| Unit | apps/api/src/utils/calendar/bikram-sambat.spec.ts | Includes formatBsDateNepali: the Devanagari date and weekday, and refusal of a BS date that does not exist. |
| Unit | apps/api/src/modules/academic-calendar/today/calendar-today.controller.spec.ts | The controller's envelope: message, a YYYY-MM-DD date, and a weekday in 0 to 6. |
Validation commands, from the repository root: pnpm --filter @skoolsewa/api test runs the whole suite; pnpm --filter @skoolsewa/api test:int runs the integration specs alone, including this module's real-database test against TestDatabase (academic-calendar.integration.spec.ts:1-15).
16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
SchoolWeekConfigController | Controller | Week-config routes | SchoolWeekConfigService | HTTP | Service | — | Guard/permission failures, DTO validation |
SchoolWeekConfigService | Service | The single-row upsert and its unconfigured-state contract | Database | Controller | school_week_config | Insert/update | CALENDAR_WEEKEND_INVALID, ATTENDANCE_VERSION_CONFLICT |
AcademicCalendarController | Controller | Generate/list/month/day/PATCH routes | CalendarReadService, CalendarGenerateService, CalendarDayWriteService | HTTP | All three services | — | Guard/permission failures, DTO validation |
CalendarReadService | Service | Every read of academic_calendar_days, session resolution | Database | Controller, every other service in the module | academic_calendar_days, academic_sessions, school_holiday_days/school_holidays, student_attendance_sessions/student_attendance_records, school_week_config | Read-only | ACADEMIC_SESSION_NOT_FOUND, CALENDAR_DAY_NOT_FOUND, BS-range codes |
CalendarGenerateService | Service | Additive-with-repair generation | Database, CalendarReadService, HolidayDayApplyService | Controller | academic_sessions, school_week_config, academic_calendar_days, school_holiday_days | Delete + insert | CALENDAR_WEEKEND_NOT_CONFIGURED, BS-range codes |
CalendarDayWriteService | Service | Single-day PATCH | Database, CalendarReadService, pg-constraint.util | Controller | academic_calendar_days | Update | CALENDAR_TEXT_FORMAT_INVALID, ATTENDANCE_VERSION_CONFLICT |
SchoolHolidayController | Controller | Holiday list/create/update/delete | SchoolHolidayService | HTTP | Service | — | Guard/permission failures, DTO validation |
SchoolHolidayService | Service | Holiday CRUD, its own guards, constraint translation | Database, CalendarReadService, HolidayDayApplyService, pg-constraint.util | Controller | school_holidays, academic_sessions | Insert/update/delete | See §13 holiday codes |
HolidayDayApplyService | Service | Every write to school_holiday_days; precedence recomputation | Database, calendar-contract | CalendarGenerateService, SchoolHolidayService | academic_calendar_days, school_holiday_days, school_holidays, school_week_config | Insert/update | ACADEMIC_CALENDAR_NOT_GENERATED |
CalendarPortalController | Controller | The one shared audience read | CalendarPortalService | HTTP, mounted under /api/mobile | Service | — | Guard failure only (no @Permissions()) |
CalendarPortalService | Service | Audience-safe month resolution and mapping | Database, SchoolClockService, CalendarDayService | Controller | academic_calendar_days, academic_sessions | Read-only | ACADEMIC_SESSION_NOT_FOUND, CALENDAR_BS_DATE_INVALID, ACADEMIC_CALENDAR_NOT_GENERATED |
packages/db/src/calendar/bikram-sambat.ts | Pure function module | BS ↔ Gregorian conversion, local-time-safe | nepali-date-converter | Every service above that touches a BS date | — | — | BikramSambatRangeError, BikramSambatInvalidDateError (translated by callers) |
packages/db/src/calendar/calendar-contract.ts | Pure constant/type module | Classification/source vocabulary, attendance-required set, holiday precedence | — | Every service and DTO in this module, plus the attendance module | — | — | Compile-time exhaustiveness proof only |
packages/db/src/seed/seed-calendar-holidays.ts | Pure function module | Reference holiday data | bikram-sambat.ts | Nothing in apps/api/src or packages/db/src as of this writing | — | — | — |
Every file in apps/api/src/modules/academic-calendar/ is represented above. CalendarPortalController/CalendarPortalService physically live under apps/api/src/modules/attendance/portal/calendar/ — a deliberate placement documented in this file per the task's own instruction, since the calendar read is an attendance-adjacent audience surface rather than an admin-CRUD one — and are included here because they are the audience-facing counterpart of CalendarReadService. SchoolClockService and CalendarDayService, imported by CalendarPortalService, are shared attendance-module infrastructure documented in the attendance module's own docs, not duplicated here.
16.2 UML and Architecture Diagram Pack
No BullMQ or realtime dependency exists for this module, and Redis serves only the 60-second read cache of GET /api/calendar/upcoming, which falls back to PostgreSQL. The deployment diagram is deliberately just the two API surfaces and PostgreSQL.
16.3 Code Flow Narrative — HolidayDayApplyService.recomputeDay
- Entry point. Called from
recomputeMany, itself called bylinkAndRecompute,reapplyAllForSession,SchoolHolidayService.update, andSchoolHolidayService.remove— every holiday-touching write path converges here. - Command shape.
(executor, calendarDayId: string). - Auth/identity. None checked here — the caller has already resolved and authorized the surrounding operation; this method is purely a data-consistency step.
- Input normalization. None; the id is opaque.
- Validation order. Load the day row; if absent, return
falseimmediately. Ifsource === "admin_modified", returnfalseimmediately — the administrator's edit is never overwritten by a holiday membership change. - Reads performed. The day row itself; the join of
school_holiday_daystoschool_holidaysfor that day, ordered by holiday id (a stable tie-break);school_week_config, but only on the fallback branch where no holiday remains linked. - Business decisions.
HOLIDAY_PRECEDENCE.find(...)picks the first classification in precedence order that has at least one matching linked holiday. If one is found, itssource(holiday's ownsource,schoolorseed) is copied onto the day; if none is found, the day falls back toweekend/school_dayagainst the live week configuration andsource: 'auto'. - Writes performed.
UPDATE academic_calendar_days SET classification, source, generated_from_weekend_days WHERE id = calendarDayId— but only when at least one of those three actually changed (sameSetcomparison on the weekend array). - Transactions. None opened here; always runs inside the caller's already-open transaction.
- Cache behavior. None.
- Jobs/events/notifications. None.
- Response mapping. Returns a
boolean— whether the stored row actually changed — which callers sum into achangedCount/repairedCount. - Error handling. None thrown from this method; a missing day row is treated as "nothing to do", not an error, because the caller (e.g. a holiday delete) may be recomputing ids for a day whose calendar row genuinely does not exist in an edge case.
- Logs/metrics/audit. None.
- Known tradeoffs. Precedence is a linear scan over four possible classifications and a handful of linked holidays per day — deliberately not expressed as a database rule; see §16.6.
| Step | Code Location | What Happens | Why It Happens | Failure/Edge Case |
|---|---|---|---|---|
| 1 | holiday-day-apply.service.ts:96-106 | Load day; short-circuit on missing row or admin_modified source. | An administrator's correction must survive every subsequent holiday edit. | A day deleted out-of-band between the caller's read and this call — returns false. |
| 2 | holiday-day-apply.service.ts:108-119 | Join linked holidays, ordered by id. | A stable order makes "which holiday's source wins on a tie" deterministic. | Two holidays of the same precedence tier covering one day — the lower-id holiday's source wins. |
| 3 | holiday-day-apply.service.ts:121-134 | HOLIDAY_PRECEDENCE.find picks the winner. | Centralizes the "which holiday outranks which" decision in one ordered array. | A day covered only by a lower-precedence holiday while a higher one exists elsewhere — irrelevant, since only links to this day are considered. |
| 4 | holiday-day-apply.service.ts:135-147 | Fallback branch reads the live week config. | A day that loses its last holiday must fall back to a real classification, not an undefined one. | school_week_config has since been deleted — never happens; the row is never deleted once created. |
| 5 | holiday-day-apply.service.ts:149-168 | No-op guard, then the conditional UPDATE. | Avoids a needless write (and a needless bump_row_version bump) when recomputation reproduces the same state. | A day recomputed twice in the same request (e.g. update's drop-then-relink) that lands on the same classification both times — the second call is a true no-op. |
16.4 Data Layer Deep Dive
Covered exhaustively in §5 for all four tables: full field tables, nullability, index rationale, constraint rationale, FK delete behavior, versioning fields (school_week_config, school_holidays, academic_calendar_days all carry version and a bump_row_version trigger; school_holiday_days deliberately carries neither), audit fields (configured_by_user_id, created_by_user_id), timezone interpretation (every date column is a bare date, always read and written as a Kathmandu-local YYYY-MM-DD string — never through Date.toISOString(), per the wrapper's own documented defect), and seed-data dependency (school_holidays.source = 'seed' rows, populated from seed-calendar-holidays.ts's vocabulary — though see §16.6 for the gap in how that seed reaches the table today).
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
school_week_config_single_row | id | CHECK | Exactly one row, ever. | None — the table is meant to hold one row. |
school_week_config_weekend_days_bounded / _valid | weekend_days | CHECK | Length ≤ 6, values within [0,6]. | Cannot express "no duplicates" — left to the service. |
academic_calendar_days_session_date_unique | academic_session_id, gregorian_date | UNIQUE | One row per date per session. | Also the target generate()'s missing-date check reads against. |
academic_calendar_days_session_bs_month_idx | academic_session_id, bs_year, bs_month | btree | The BS-month grid view. | Extra index maintained on every insert; small since inserts are batched per generation. |
academic_calendar_days_session_classification_idx | academic_session_id, classification | btree | Regeneration's delete predicate; classification filters. | Same. |
academic_calendar_days_date_idx | gregorian_date | btree | Cross-session "is this date a school day" probes made by the two attendance sweeps before they know the session. | Duplicates part of the unique index's leading column in a different order; kept because the unique index cannot serve a date-only lookup efficiently across sessions. |
school_holidays_session_range_idx | academic_session_id, from_date, to_date | btree | The range containment queries HolidayDayApplyService runs on every apply/reapply. | — |
school_holiday_days_calendar_day_idx | calendar_day_id | btree | The day → holidays direction, which is what the precedence recomputation walks; the composite PK already serves holiday → days. | — |
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| A calendar day's date is unique per session | academic_calendar_days_session_date_unique | One authoritative row per date. | (structural — generate()'s own missing-date filter prevents a duplicate insert; the constraint is the backstop) | academic-calendar.integration.spec.ts |
| Calendar generation is refused with no weekend configured | CalendarGenerateService.generate's pre-check | No safe default weekend exists across all Nepali school calendars. | CALENDAR_WEEKEND_NOT_CONFIGURED | academic-calendar.integration.spec.ts:"refuses generation while school_week_config has no row" |
weekendDays has no duplicate | SchoolWeekConfigService.assertNoDuplicateWeekendDays | An array CHECK cannot express distinctness without a subquery. | CALENDAR_WEEKEND_INVALID | — (unit-level; not exercised in the integration spec shown) |
A holiday's classification is never school_day | SchoolHolidayService.assertNotSchoolDay | A holiday recorded as a school day is a contradiction, not a variant — deliberately not a CHECK, since a closed enum disjunction breaks on a future ADD VALUE. | CALENDAR_HOLIDAY_RANGE_INVALID | — |
A holiday's toDate >= fromDate | school_holidays_dates_ordered (CHECK), mirrored by assertDatesOrdered | A single-day holiday is from = to. | CALENDAR_HOLIDAY_RANGE_INVALID | — |
| A holiday's range lies wholly inside its session | SchoolHolidayService.assertRangeInsideSession | The session end is exclusive; clipping silently would make the stated and actual range disagree. | CALENDAR_RANGE_OUTSIDE_SESSION | — |
| A holiday can only link days that already exist | HolidayDayApplyService.linkAndRecompute's day-count check | Never a partial link across a range the calendar has not generated. | ACADEMIC_CALENDAR_NOT_GENERATED | — |
| Two holidays may cover one day; precedence decides the winner | HOLIDAY_PRECEDENCE, applied in recomputeDay | A single holiday_id column could not represent overlap; deleting one holiday must not blindly revert the day if another still covers it. | (no error — a design decision, not a refusal) | academic-calendar.integration.spec.ts:"keeps a day classified by the remaining holiday when an overlapping one is deleted" |
An admin_modified day is never overwritten by regeneration or recomputation | CalendarGenerateService's delete predicate (source = 'auto' only); recomputeDay's early return | An operator's correction must survive both regeneration and holiday churn. | (no error — the write is silently skipped) | academic-calendar.integration.spec.ts:"preserves an admin_modified day across regeneration" |
| A seeded holiday cannot be deleted | SchoolHolidayService.remove | Preserves the record of what the platform populated; reclassification is offered instead. | SCHOOL_HOLIDAY_NOT_DELETABLE | academic-calendar.integration.spec.ts:"refuses to delete a seed holiday" |
| A calendar day's BS date is bounded to 2000–2090 | academic_calendar_days_bs_bounded (CHECK), assertYearInRange in the wrapper | The converter's own supported range; outside it the library throws a bare Error. | CALENDAR_BS_DATE_OUT_OF_RANGE | academic-calendar.integration.spec.ts:"refuses generation with CALENDAR_BS_DATE_OUT_OF_RANGE past BS 2090" |
| A BS day that does not exist in its month is refused, never silently rolled over | assertValidBsDate in the wrapper | The library itself does NOT throw on day 32 of a 31-day month — it silently returns the 1st of the next month. | CALENDAR_BS_DATE_INVALID | Exercised indirectly through generation's own enumeration; unit-tested in the wrapper's own spec (not read for this doc). |
version tokens are server-owned and monotonic | bump_row_version trigger on school_week_config, school_holidays, academic_calendar_days | Optimistic concurrency without a second writer to the same columns. | ATTENDANCE_VERSION_CONFLICT | academic-calendar.integration.spec.ts:"PATCHing a day sets source = admin_modified and bumps version exactly once" |
school_holiday_days has no version token | Deliberate schema omission | The function raises record "new" has no field "version" at the first UPDATE if attached to a table lacking the column; this table is created/deleted wholesale rather than updated. | (would be a 500 if mis-attached) | Migration-level; not exercised by an application-level test. |
Free text (name, reason, eventName, note) is never blank-padded or control-character-bearing | CHECK constraints per column, mirrored at the service layer | A CHECK with no application refusal in front of it surfaces as an unmapped 23514 against a field the operator cannot see. | CALENDAR_TEXT_FORMAT_INVALID | — |
Every CalendarDayClassification has an explicit attendance decision | The compile-time exhaustiveness proof in calendar-contract.ts:92-112 | A generated column or a closed CHECK disjunction both silently misclassify a value added later by ALTER TYPE ... ADD VALUE; this proof instead fails tsc at the point the value is added. | (compile error, not a runtime error) | Enforced by the TypeScript compiler on every build. |
| The portal response never carries administrator-internal fields | PortalCalendarDayDto's hand-enumerated shape, CalendarPortalService's hand-enumerated mapper | The route carries no @Permissions(); the DTO shape is the only thing standing between a column added later and a pupil's screen. | (no runtime error — a design constraint enforced by code review, not the type system) | — |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Materialised calendar, not computed on read | Need "is this a school day" answered fast and stably | One row per Gregorian date, stored | Compute from a weekend rule + holiday list on every read | An administrator's per-day edit needs somewhere to live; BS conversion is a lookup either way; a school that changes its weekend mid-year must not retroactively reclassify dates already operated on. | Storage and generation cost; staleness must be tracked explicitly (generated_from_weekend_days) rather than being structurally impossible. | If the schema ever needs to support a school with genuinely no fixed weekend at all (not currently representable differently). |
| Attendance-required set as a TypeScript constant, not a generated column or CHECK | Need "is attendance expected on this classification" | ATTENDANCE_REQUIRED_CLASSIFICATIONS + compile-time exhaustiveness proof | GENERATED ALWAYS AS (...) STORED; a CHECK disjunction | Both alternatives are traps for the same reason: ALTER TYPE ... ADD VALUE succeeds silently for a generated column (new value defaults to "not required") or breaks every future write for a CHECK — tsc failing at the point the value is added is strictly better than either. | The decision is invisible to a database client inspecting the schema directly; a reader must know to look at calendar-contract.ts. | If Postgres ever supports adding an enum value transactionally with a mandatory accompanying default expression. |
| Holiday precedence as an ordered TypeScript array, not a database rule | Two holidays may cover one day; deletion must recompute rather than revert | HOLIDAY_PRECEDENCE, walked in recomputeDay | A priority column on school_holidays, sorted at read time | The winning classification also decides the day's source, which a column-based priority cannot express as cleanly; precedence is a small, closed, rarely-changing list, not per-row data. | Changing the ranking requires a code change and redeploy, not a data migration. | If a school ever needs to set its own custom precedence between two holiday types. |
school_week_config seeds no default row | Nepal's standard week differs from many international-curriculum schools | No seed; row existence is the "configured" flag | Seed Sunday–Friday as the default | A wrong default silently mis-classifies a year of Fridays that nobody is prompted to check; an empty state that visibly blocks generation is safer than a wrong one that looks complete. | Every new school must configure its week once before using the calendar at all. | — |
school_holiday_days has no version/trigger | The table is a pure join, replaced wholesale on every holiday edit | Omit both | Attach the trigger uniformly to every table | The trigger fails at runtime (not DDL time) on a table without a version column — a uniform attachment would ship a table that works until its first UPDATE. | A developer scanning the migration for "which tables have optimistic concurrency" must read the hand-written comment, not infer it from a pattern. | — |
Seeded holiday data (seed-calendar-holidays.ts) is not currently invoked by any write path | The platform wants to populate common Nepali holidays without asking an administrator to enter every festival by hand | seedHolidaysForRange is exported, built, and tested against the BS converter, but no controller or service in this codebase calls it | Wire it into CalendarGenerateService.generate, or into a session-creation hook | Not chosen yet — this is an observed gap, not a documented design decision. See the note in the report accompanying this documentation set. | Every school_holidays row with source = 'seed' currently has to be created by hand (as the integration test itself does, inserting a source: "seed" row directly via testDatabase.db.insert), so the seeded-holiday feature described in seed-calendar-holidays.ts's own docblock is not reachable through any HTTP route today. | Wiring seedHolidaysForRange into generation, or into a session-creation flow. |
| Later years get no festival dates rather than guessed ones | Lunar-dated festivals (Dashain, Tihar, Holi, …) cannot be computed from the BS calendar alone | Curated table for BS 2082 only, needsReview: true | Extrapolate approximate dates for 2083/2084 | A wrong festival date closes a school on the wrong day or opens it on a national holiday; an empty list is visibly incomplete, a wrong list is invisibly incomplete. | An administrator must add next year's festivals by hand until the table is extended. | When BS 2083's official dates are published and can be curated. |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Calendar generation | Response coverage block (expectedDays/presentDays/staleDays) | presentDays === expectedDays, staleDays === 0 | staleDays > 0 after a week-configuration change | Call POST .../calendar/generate again — it self-heals every stale auto row on every run. |
| Week-configuration gate | GET /school/week-config → configured | configured: true before any school attempts generation | CALENDAR_WEEKEND_NOT_CONFIGURED on generate | An administrator must PUT /school/week-config once. |
| Holiday/day desync | Compare a day's classification/source against its holidays[] in the day-detail response | source matches the winning holiday's source, or auto/admin_modified with an empty holidays[] | A day shows school_day/weekend classification while holidays[] is non-empty | Should be unreachable if HolidayDayApplyService is the only writer of school_holiday_days; if seen, another write path bypassed it — a code defect, not an operator action. |
| BS-range refusal | CALENDAR_BS_DATE_OUT_OF_RANGE on generation | Sessions stay within BS 2000–2090 | An academic session dated far in the future or past | Correct the session's Gregorian dates; the BS window is a library limitation, not configurable. |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Seeded holiday data has no wiring into any write path | Feature completeness, school_holidays/seed-calendar-holidays.ts | source = 'seed' rows cannot currently be created through any route or service call; the reference data exists but is unreachable | None | seedHolidaysForRange needs to be invoked from somewhere — generation, session creation, or a dedicated admin action — before a school can benefit from it without hand-entering every holiday. |
recomputeMany/reapplyAllForSession recompute one day at a time in a loop | Performance, large sessions with many holidays | A session with many long holiday ranges pays one round trip per calendar day during generation's reapply step | None specific; acceptable at current expected volume (one academic year, a few dozen holidays) | Could be batched into a single statement if generation latency becomes noticeable on large sessions. |
No automated test exercises weekendDays distinctness or the BS-day-rollover refusal at the service layer | Test coverage | A regression in assertNoDuplicateWeekendDays or assertValidBsDate's call sites would not be caught by the integration spec shown | The BS wrapper is unit-tested separately (bikram-sambat.spec.ts, not read for this documentation pass) | Add an integration case for duplicate weekendDays and for a day that overruns its BS month, at the service boundary. |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime.
- Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
- Every method with business behavior has a code-flow narrative (
recomputeDayin full; others summarized in §6/§7 tables). - Every table/collection/cache object/job payload has field-level detail.
- Every index, constraint, relation, and delete behavior has rationale.
- Every lifecycle/status transition (day classification/source transitions) has a table; no separate state diagram is drawn because the transitions are read-and-recompute, not a formal state machine — see the feature/flows doc for the day lifecycle table.
- Every read/write/action/job flow has sequence and activity diagrams.
- Every business invariant is cataloged.
- Every cache key, invalidation path, queue job, realtime event, and external call is documented (all absent, and stated as such).
- Every architectural tradeoff is documented with alternatives and revisit triggers, including the unwired seed-data gap.
- Every operational failure mode has a runbook entry.
18. Backend Completion Checklist
- Module boundaries are documented.
- Every controller, service, DTO, schema file, job, cache key, and event is covered.
- Every database table has a field table and relationship diagram.
- Every runtime flow has a diagram and branch notes.
- API, feature/flows, and TDD docs are linked below (no TDD doc exists for this module; only API and feature/flows are linked, matching what was produced).
- No claim is made without a source file or documented source reference.
See Also
- API doc:
/docs/developer/academic-calendar/api - Features and flows doc:
/docs/developer/academic-calendar/feature
Academic Calendar Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Academic Calendar.
Academic Calendar API Reference
Complete API contracts for Academic Calendar, including routes, auth, DTOs, responses, errors, examples, and integration notes.