Skoolsewa - School Management System Docs
Developer ResourcesAcademic Calendar

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

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/academic-calendar/academic-calendar.module.tsControllers, providers, RoleModule import.
Day controller/servicesapps/api/src/modules/academic-calendar/days/academic-calendar.controller.ts, calendar-read.service.ts, calendar-generate.service.ts, calendar-day-write.service.tsRoutes, generation algorithm, PATCH/version semantics.
Holiday controller/servicesapps/api/src/modules/academic-calendar/holidays/school-holiday.controller.ts, school-holiday.service.ts, holiday-day-apply.service.tsHoliday CRUD, link/recompute, precedence.
Week-config controller/serviceapps/api/src/modules/academic-calendar/config/school-week-config.controller.ts, school-week-config.service.tsThe single-row upsert and the unconfigured-state contract.
DTOsapps/api/src/modules/academic-calendar/dto/calendar-day.dto.ts, holiday.dto.ts, week-config.dto.ts, index.tsRequest/response shapes, validation, field enumeration.
Schemapackages/db/src/schema/school/calendar.tsTables, enums, constraints, indexes, relations.
Migrationpackages/db/src/migrations/0020_attendance_module.sqlEnum/table order, hand-added triggers, which tables get bump_row_version.
Calendar vocabularypackages/db/src/calendar/calendar-contract.tsClassification/source enums, attendance-required set, holiday precedence, the compile-time exhaustiveness proof.
Bikram Sambat conversionpackages/db/src/calendar/bikram-sambat.tsThe only module allowed to import nepali-date-converter; local-time formatting, range/day validation.
Holiday seed datapackages/db/src/seed/seed-calendar-holidays.tsFixed-date and lunar-dated holiday tables, seedHolidaysForRange.
Audience readapps/api/src/modules/attendance/portal/calendar/calendar-portal.controller.ts, calendar-portal.service.ts, dto/portal-calendar.dto.ts, calendar-portal.module.tsThe shared, no-permission audience projection and its mobile mounting.
Mobile mountingapps/api/src/modules/mobile/mobile.module.tsCalendarPortalModule as a MOBILE_CHILDREN leaf.
Swagger documentsapps/api/src/config/swagger/swagger-documents.tsAcademicCalendarModule in ADMIN_MODULES; the mobile document reflects MOBILE_CHILDREN instead.
Error codesapps/api/src/common/types/error-codes.tsEvery CALENDAR_*, ACADEMIC_CALENDAR_*, SCHOOL_HOLIDAY_* code and its condition.
Permissionspackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsAcademicCalendar/AcademicCalendarView codes and which of the five seeded roles hold which.
Shared utilitiesapps/api/src/common/utils/pg-constraint.util.ts, apps/api/src/common/types/db-executor.type.ts, apps/api/src/common/utils/pagination.util.tsConstraint-name translation, the transaction-safe executor type, pagination normalization.
Testsapps/api/src/modules/academic-calendar/__tests__/academic-calendar.integration.spec.tsReal-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 table school_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 in apps/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 refuses ACADEMIC_SESSION_NOT_FOUND otherwise, but does not create, close, or overlap-check sessions.
  • The Bikram Sambat library itself (nepali-date-converter). packages/db/src/calendar/bikram-sambat.ts is 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 imports RoleModule so 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

ConcernSource of TruthNotes
Whether a date is a school operating dayacademic_calendar_days.classificationMaterialised, 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 allExistence 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 dayHOLIDAY_PRECEDENCE order, applied by HolidayDayApplyService.recomputeDayA TypeScript constant, not a database rule — see §16.6 for why.
Whether attendance is expected on a classificationATTENDANCE_REQUIRED_CLASSIFICATIONS, via isAttendanceRequired()Never stored; computed at every read (CalendarReadService.toDto, CalendarPortalService.monthView).
A calendar day's Bikram Sambat dateacademic_calendar_days.bs_year/bs_month/bs_day, generated by bikram-sambat.tsStored 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 holidayschool_week_config.configured_by_user_id, school_holidays.created_by_user_idBoth are real foreign keys to users, written from the acting administrator on every write.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
AcademicCalendarModuleLeaf, admin-facingapps/api/src/modules/academic-calendar/academic-calendar.module.tsSchoolWeekConfigController, AcademicCalendarController, SchoolHolidayController, CalendarTodayControllerSchoolWeekConfigService, CalendarReadService, CalendarGenerateService, CalendarDayWriteService, HolidayDayApplyService, SchoolHolidayServiceNoneComposes week configuration, day generation/read/write, and holidays into one admin module; imports RoleModule so RoleGuard can resolve (academic-calendar.module.ts:1-38).
CalendarPortalModuleLeaf, mobile-facingapps/api/src/modules/attendance/portal/calendar/calendar-portal.module.tsCalendarPortalControllerCalendarPortalServiceNoneThe 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
FilePurposeKey ExportsNotes
academic-calendar.module.tsWires the five controllers and seven providers.AcademicCalendarModuleListed in ADMIN_MODULES and imported by app.module.ts.
days/academic-calendar.controller.tsGenerate, list, month-view, day-detail, day-PATCH routes.AcademicCalendarControllerAll routes require both class validators AND Permissions; base path academic-sessions/:sessionPublicId/calendar.
days/calendar-read.service.tsEvery read over academic_calendar_days; session resolution shared by every other service in the module.CalendarReadService, ResolvedSessionresolveSession/findDayRow/requireDayRow/toDayDetailDto all take an explicit executor so write paths can call them inside their own open transaction.
days/calendar-generate.service.tsThe additive-with-repair generation algorithm.CalendarGenerateServiceDelegates every holiday-application decision to HolidayDayApplyService.
days/calendar-day-write.service.tsSingle-day PATCH; moves a day to admin_modified.CalendarDayWriteServiceOwns the CHECK-constraint-to-error-code map for free-text fields on this table.
holidays/school-holiday.controller.tsHoliday list/create/update/delete routes.SchoolHolidayControllerTwo base paths on one controller class, both declared with no class-level path prefix, to keep one @ApiTags.
holidays/school-holiday.service.tsHoliday CRUD, its own validation, and constraint translation.SchoolHolidayServiceDelegates every link/recompute decision to HolidayDayApplyService; never writes school_holiday_days itself.
holidays/holiday-day-apply.service.tsThe single owner of every write to school_holiday_days and of precedence recomputation.HolidayDayApplyServiceEvery 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.tsRead/upsert school_week_config.SchoolWeekConfigControllerBase path school/week-config, no session scoping — the row is school-wide.
config/school-week-config.service.tsThe unconfigured-state contract and the upsert.SchoolWeekConfigServiceget() never returns a partial shape; update() distinguishes first-configuration from a later update by expectedVersion: null.
today/calendar-today.controller.tsToday'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.tsResponse shape of the today route.CalendarTodayDtoSix nullable fields that are null together outside the converter's range.
upcoming/calendar-upcoming.controller.tsThe next N days of the calendar.CalendarUpcomingController@Controller("calendar"), one handler upcoming, no @Permissions(); allowlisted as "CalendarUpcomingController.upcoming".
upcoming/calendar-upcoming.service.tsBuilds the window, picks one row per date, groups events, caches the result.CalendarUpcomingServiceSelects columns by hand; note is never read.
dto/calendar-upcoming.dto.tsQuery and response shapes.CalendarUpcomingQueryDto, CalendarUpcomingDto, CalendarUpcomingDayDto, CalendarUpcomingEventDto, CalendarUpcomingBsDtodays 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, CalendarTodayNo 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/*.tsRequest/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.tsThe one shared audience read.CalendarPortalController@Controller("calendar"); the /api/mobile prefix comes from RouterModule, not this decorator.
attendance/portal/calendar/calendar-portal.service.tsResolves session/month, maps rows by hand.CalendarPortalServiceNever spreads a row — see its own docblock and PortalCalendarDayDto's.
packages/db/src/calendar/bikram-sambat.tsBS ↔ Gregorian conversion, local-time-safe.bsToGregorian, gregorianToBs, enumerateGregorianDays, assertValidBsDate, formatBsDateNepali, BS_MIN_YEAR, BS_MAX_YEARThe only file permitted to import nepali-date-converter.
packages/db/src/calendar/calendar-contract.tsVocabulary and the one derived rule.CALENDAR_DAY_CLASSIFICATION, CALENDAR_DAY_SOURCE, ATTENDANCE_REQUIRED_CLASSIFICATIONS, HOLIDAY_PRECEDENCE, isAttendanceRequiredCarries a compile-time exhaustiveness proof — see §16.6.
packages/db/src/seed/seed-calendar-holidays.tsNepali holiday reference data.seedHolidaysForRange, SeedHolidayExported 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.ts

All 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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idintegerNo1PK; CHECK id = 1N/ASingle-row table. packages/db/src/schema/school/calendar.ts:103,149.
weekend_dayssmallint[]NononeCHECK length ≤ 6; CHECK <@ ARRAY[0..6]N/A0 = Sunday, matching class_timeslots.day_of_week. Empty is legal — a seven-day school. calendar.ts:104-116,155-162.
school_day_cutoff_minutesmallintNo1020CHECK 0 <= x <= 1440N/AMinutes from local midnight; 1020 = 17:00. calendar.ts:117-127,163-166.
configured_by_user_iduuidYesnoneFK → users.id, ON DELETE set nullusersThe acting administrator on the last save. calendar.ts:128-131.
versionbigintNo0Never indexedN/AOptimistic-concurrency token, server-owned via bump_row_version. calendar.ts:132-140.
created_at / updated_attimestamptzNonow()—N/Aupdated_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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuidNogenerated (uuidv7Pk)PKN/AThe public identifier. calendar.ts:182.
academic_session_idintegerNononeFK → academic_sessions.id, ON DELETE restrictacademic_sessionsrestrict, not cascade — a year of holiday work must not vanish because a session was deleted. calendar.ts:189-191.
nametextNononeCHECK no pad/control chars, ≤120 charsN/Acalendar.ts:192,230-233.
classificationenum calendar_day_classificationNonone— (school_day refused at the service layer, not by CHECK)N/Acalendar.ts:198.
from_date / to_datedateNononeCHECK to_date >= from_dateN/ASingle-day holiday is from = to. calendar.ts:199-200,217-220.
reasontextYesnoneCHECK no pad/control chars, ≤500 charsN/AAdministrator-internal; never reaches an audience-facing response. calendar.ts:202,234-237.
sourceenum calendar_day_sourceNonone—N/Aschool (admin-created) or seed (populated by the platform). calendar.ts:203.
created_by_user_iduuidYesnoneFK → users.id, ON DELETE set nulluserscalendar.ts:204-206.
versionbigintNo0Never indexedN/Abump_row_version-maintained. calendar.ts:207.
created_at / updated_attimestamptzNonow()—N/Acalendar.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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuidNogenerated (uuidv7Pk)PKN/AThe uuid IS the public identifier, matching class_timeslots. calendar.ts:253.
academic_session_idintegerNononeFK → academic_sessions.id, ON DELETE restrict; UNIQUE with gregorian_dateacademic_sessionscalendar.ts:254-256,311-314.
gregorian_datedateNononesee unique above; own btree indexN/AYYYY-MM-DD, Asia/Kathmandu local, never toISOString(). calendar.ts:257.
bs_year / bs_month / bs_daysmallintNononeCHECK 2000<=year<=2090, 1<=month<=12, 1<=day<=32N/AMachine-generated by the BS wrapper, never client-supplied. calendar.ts:264-269,322-325.
day_of_weeksmallintNononeCHECK 0<=x<=6N/A0 = Sunday. calendar.ts:270-271,326-329.
classificationenum calendar_day_classificationNonone—N/Acalendar.ts:272.
sourceenum calendar_day_sourceNonone—N/Aauto | seed | school | admin_modified. calendar.ts:273.
event_nametextYesnoneCHECK no pad/control chars, ≤120N/AShown to every audience, including the portal. calendar.ts:274-275,330-333.
notetextYesnoneCHECK no pad/control chars, ≤500N/AAdministrator-internal — never enumerated into PortalCalendarDayDto. calendar.ts:276-283,334-337.
needs_reviewbooleanNofalse—N/ASet on lunar-dated seeded holidays whose date is approximate. calendar.ts:284-289.
generated_from_weekend_dayssmallint[]Nonone—N/AThe weekend snapshot at generation time; compared against the live config to compute staleness. calendar.ts:290-301.
versionbigintNo0Never indexedN/Acalendar.ts:302.
created_at / updated_attimestamptzNonow()—N/Acalendar.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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
holiday_iduuidNononeComposite PK; FK → school_holidays.id, ON DELETE cascadeschool_holidayscalendar.ts:380-382,391-394.
calendar_day_iduuidNononeComposite PK; FK → academic_calendar_days.id, ON DELETE cascade; own btree indexacademic_calendar_dayscalendar.ts:383-385,396-400.
created_attimestamptzNonow()—N/Acalendar.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

MethodCalled ByReadsWritesSide EffectsErrors
resolveSession(executor, sessionPublicId)Controller and every other service in this moduleacademic_sessions——ACADEMIC_SESSION_NOT_FOUND
findDayRow / requireDayRowController, write servicesacademic_calendar_days——requireDayRow throws CALENDAR_DAY_NOT_FOUND
dayDetail(sessionPublicId, date)Controlleras above, plus dayHolidays, attendanceExistsFor——ACADEMIC_SESSION_NOT_FOUND, CALENDAR_DAY_NOT_FOUND
toDayDetailDto(executor, row)dayDetail, and the write services' post-write re-readschool_holiday_days/school_holidays join, student_attendance_sessions/student_attendance_records———
list(sessionPublicId, query)Controlleracademic_calendar_days (filtered/paginated), attendance-exists set, coverage()——ACADEMIC_SESSION_NOT_FOUND
monthView(sessionPublicId, bsYear, bsMonth)Controlleracademic_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)toDayDetailDtoschool_holiday_days joined to school_holidays———
attendanceExistsFor(executor, dates)toDayDetailDto, liststudent_attendance_sessions correlated EXISTS against student_attendance_records———
coverage(session)list, CalendarGenerateService.generateacademic_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

MethodCalled ByReadsWritesSide EffectsErrors
generate(sessionPublicId)Controlleracademic_sessions, school_week_config, existing academic_calendar_days, school_holiday_days (link check)Deletes unlinked auto rows; inserts missing dates; delegates holiday reapplicationOne transaction for the whole operationCALENDAR_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

MethodCalled ByReadsWritesSide EffectsErrors
patch(sessionPublicId, date, dto)Controlleracademic_calendar_days (existing row, then re-read)academic_calendar_days update, version-guardedMoves auto/seed rows to admin_modifiedCALENDAR_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

MethodCalled ByReadsWritesSide EffectsErrors
get()Controllerschool_week_config——— (never throws; the unconfigured state is a valid response)
update(dto, actor)Controllerschool_week_configInsert (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

MethodCalled ByReadsWritesSide EffectsErrors
list(sessionPublicId, query)Controllerschool_holidays——ACADEMIC_SESSION_NOT_FOUND
create(sessionPublicId, dto, actor)Controlleracademic_sessionsschool_holidays insertDelegates link/recompute to HolidayDayApplyServiceCALENDAR_HOLIDAY_RANGE_INVALID, CALENDAR_TEXT_FORMAT_INVALID, CALENDAR_RANGE_OUTSIDE_SESSION, ACADEMIC_CALENDAR_NOT_GENERATED, WRITE_RETURNED_NO_ROW
update(holidayId, dto)Controllerschool_holidays, academic_sessionsschool_holidays update (version-guarded); unlinks then relinks school_holiday_daysRecomputes both newly-dropped and newly-covered daysSCHOOL_HOLIDAY_NOT_FOUND, CALENDAR_HOLIDAY_RANGE_INVALID, CALENDAR_RANGE_OUTSIDE_SESSION, ATTENDANCE_VERSION_CONFLICT, WRITE_RETURNED_NO_ROW
remove(holidayId)Controllerschool_holidays, school_holiday_days (existing links)school_holidays delete (cascades school_holiday_days)Recomputes every day that was linkedSCHOOL_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

MethodCalled ByReadsWritesSide EffectsErrors
linkAndRecompute(executor, holiday, sessionId)SchoolHolidayService.create/updateacademic_calendar_days in rangeschool_holiday_days insert (onConflictDoNothing)Calls recomputeMany on every linked dayACADEMIC_CALENDAR_NOT_GENERATED
recomputeMany(executor, ids)linkAndRecompute, reapplyAllForSession, both holiday-write paths, remove—Delegates to recomputeDay——
recomputeDay(executor, id)recomputeManyacademic_calendar_days, school_holiday_days+school_holidays join, school_week_config (fallback branch only)academic_calendar_days update, conditionalReturns whether the row actually changed—
reapplyAllForSession(executor, sessionId)CalendarGenerateService.generateschool_holidays, academic_calendar_days in each holiday's rangeSame insert/recompute as linkAndRecompute, for every existing holidayRe-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)

MethodCalled ByReadsWritesSide EffectsErrors
resolveMonth(query) (private)monthViewSchoolClockService.now() (only when both bsYear/bsMonth are omitted)——CALENDAR_BS_DATE_INVALID if only one of the two is supplied
resolveSessionId(sessionPublicId) (private)monthViewacademic_sessions, or CalendarDayService.sessionContaining——ACADEMIC_SESSION_NOT_FOUND
monthView(query)CalendarPortalController.monthViewacademic_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

MethodCalled ByReadsWritesSide EffectsErrors
upcoming(days)CalendarUpcomingController.upcomingThe school clock; academic_sessions; academic_calendar_days joined to active sessions—Redis read, fire-and-forget Redis write on a missNone of its own (days is validated by the DTO)
  • Window. enumerateDates(today, days) returns days ISO 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. note is 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, null isOperatingDay, null event name and a BS date converted from the Gregorian date, or null when the converter throws.
  • Events. groupEvents walks 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 not school_day or weekend.
  • Cache. See §8.

7. Runtime Flows

7.1 Generate (or repair) a calendar

StepCode PathBehaviorFailure Case
1AcademicCalendarController.generateResolves sessionPublicId from the path.—
2CalendarGenerateService.generateReads school_week_config; refuses if absent.CALENDAR_WEEKEND_NOT_CONFIGURED
3enumerateGregorianDaysEnumerates every date, converting each to BS.CALENDAR_BS_DATE_OUT_OF_RANGE, CALENDAR_BS_DATE_INVALID
4delete unlinked auto rows, insert missing datesAdditive-with-repair, inside one transaction.—
5HolidayDayApplyService.reapplyAllForSessionRe-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

StepCode PathBehaviorFailure Case
1SchoolHolidayController.createReceives DTO, already class-validator-checked.400 from the global pipe on shape errors.
2SchoolHolidayService.createService-layer guards, then session/range checks.CALENDAR_HOLIDAY_RANGE_INVALID, CALENDAR_RANGE_OUTSIDE_SESSION
3INSERT school_holidaysCHECK backstop for text/date-order.CALENDAR_TEXT_FORMAT_INVALID, CALENDAR_HOLIDAY_RANGE_INVALID
4HolidayDayApplyService.linkAndRecomputeRefuses 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

StepCode PathBehaviorFailure Case
1CalendarDayWriteService.patchText-format guard runs before any database access.CALENDAR_TEXT_FORMAT_INVALID
2session/day resolutionInside the transaction.ACADEMIC_SESSION_NOT_FOUND, CALENDAR_DAY_NOT_FOUND
3version-guarded UPDATEauto/seed moves to admin_modified; admin_modified stays admin_modified.ATTENDANCE_VERSION_CONFLICT
4re-read in the same transactionNever 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 PatternBuilderValueTTLInvalidationCaller
calendar:upcoming:shape:1-today:<YYYY-MM-DD>-days:<n>CacheKeyUtil.build("calendar:upcoming:", [...])CalendarUpcomingDto60sNone. 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, and CalendarTodayController — carries @UseGuards(JwtAuthGuard, RoleGuard).
  • Permissions. The three admin controllers gate every route with @Permissions("AcademicCalendar_..."). CalendarPortalController.monthView carries 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 RoleGuard reads a non-mobile-prefixed-looking handler with no @Permissions() as an admin surface and throws unless allowlisted, CalendarPortalController.monthView must be listed in NO_PERMISSION_ADMIN_HANDLERS (apps/api/src/common/authorization/role.guard.ts:228, key "CalendarPortalController.monthView") and its CI mirror test/structure/route-permissions.spec.ts. What actually keeps the route safe with no guard behind it is the frozen response shape — PortalCalendarDayDto enumerates every field by hand and never spreads a row, so note (administrator-internal free text) can never reach a pupil or parent through a column added later to academic_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 in NO_PERMISSION_ADMIN_HANDLERS under "CalendarTodayController.today", with its mirror in test/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 except superadmin — packages/db/src/seed/seed-auth.ts:214-220 shows staff, teacher, guardian, and student all hold at most AcademicCalendarView_READ, never the CRUD codes, because the day and holiday rows carry an administrator's free-text note/reason plus the id of who wrote them (packages/db/src/authorization/permission-catalog.ts:225-233). AcademicCalendarView_READ itself gates the panel screen, not any route — it appears on zero API routes, exactly like Schedule_READ (permission-catalog.ts:234-243) — and is granted to staff/teacher (seed-auth.ts:110), guardian (seed-auth.ts:162), and student (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, holiday name/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/reason never leave the admin surface. CalendarDayDto/CalendarDayDetailDto/MonthCalendarDayDto (admin-facing) all carry note; PortalCalendarDayDto (audience-facing) is a deliberately smaller, hand-enumerated class that carries neither note, reason, source, needsReview, any holiday id, createdByUserId, configuredByUserId, version, nor generatedFromWeekendDays (portal-calendar.dto.ts:71-82).
  • Audit logs. school_week_config.configured_by_user_id and school_holidays.created_by_user_id are the only accountability trail this module writes — both real foreign keys to users, both asserted by the integration test (academic-calendar.integration.spec.ts:157-186).
  • Fail-closed behavior. Generation refuses outright while school_week_config has 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's classification can never be school_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 CodeHTTP StatusThrown ByConditionClient Action
ACADEMIC_SESSION_NOT_FOUND404CalendarReadService.resolveSession, SchoolHolidayService.update, CalendarPortalService.resolveSessionIdsessionPublicId does not resolve, or no session contains today (portal default).Verify the id, or that a current session exists.
CALENDAR_DAY_NOT_FOUND404CalendarReadService.requireDayRowNo calendar row for that date in that session.Generate the calendar, or check the date.
CALENDAR_WEEKEND_NOT_CONFIGURED409CalendarGenerateService.generateschool_week_config has no row.Save a week configuration first.
CALENDAR_WEEKEND_INVALID422SchoolWeekConfigService.assertNoDuplicateWeekendDaysweekendDays repeats a day.Fix the payload.
CALENDAR_BS_DATE_OUT_OF_RANGE422CalendarGenerateService.generate, CalendarReadService.assertBsYearMonthInRange, gregorianToBs's translation of the library's bare errorA date falls outside BS 2000–2090.Choose a session/date inside the supported range.
CALENDAR_BS_DATE_INVALID422CalendarGenerateService.generate, CalendarReadService.assertBsYearMonthInRange, CalendarPortalService.resolveMonthAn out-of-range BS month/day, or only one of bsYear/bsMonth supplied to the portal query.Fix the payload.
CALENDAR_RANGE_OUTSIDE_SESSION422SchoolHolidayService.assertRangeInsideSessionHoliday range not wholly inside [startDate, endDate).Narrow the range.
CALENDAR_TEXT_FORMAT_INVALID422CalendarDayWriteService/SchoolHolidayService guards, and the CHECK-translation backstopFree text is empty, padded, or carries a control character.Fix the payload.
CALENDAR_HOLIDAY_RANGE_INVALID422 (service) / 422 (CHECK translation)SchoolHolidayService.assertNotSchoolDay/assertDatesOrdered, school_holidays_dates_orderedtoDate < fromDate, or classification = school_day.Fix the payload.
SCHOOL_HOLIDAY_NOT_FOUND404SchoolHolidayService.update/removeholidayId does not resolve.Verify the id.
SCHOOL_HOLIDAY_NOT_DELETABLE409SchoolHolidayService.removeHoliday source = 'seed'.Reclassify instead of deleting.
ACADEMIC_CALENDAR_NOT_GENERATED409HolidayDayApplyService.linkAndRecompute, CalendarPortalService.monthViewHoliday range not fully generated, or the portal's requested month has zero rows.Generate the calendar for that range/month first.
ATTENDANCE_VERSION_CONFLICT409CalendarDayWriteService.patch, SchoolHolidayService.update, SchoolWeekConfigService.updateSupplied expectedVersion does not match the stored version.Reload and retry. Reused from the attendance module rather than a calendar-specific code.
WRITE_RETURNED_NO_ROW500SchoolHolidayService.create, SchoolWeekConfigService.updateA RETURNING clause yielded no row on a success path.Defensive; report as a bug.

14. Observability

SignalLocationPurpose
LogFramework-default NestJS request/exception logging on every controllerFailure visibility; no module-specific Logger calls exist in these services.
Auditschool_week_config.configured_by_user_id, school_holidays.created_by_user_idWho last configured the week, and who created a given holiday.
MetricNoneNo named metric is emitted by this module.

15. Testing and Validation

Test TypeFilesCoverage
Integrationapps/api/src/modules/academic-calendar/__tests__/academic-calendar.integration.spec.tsThe 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.
Unitapps/api/src/utils/calendar/calendar-today.util.spec.tsThe 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.
Unitapps/api/src/utils/calendar/bikram-sambat.spec.tsIncludes formatBsDateNepali: the Devanagari date and weekday, and refusal of a BS date that does not exist.
Unitapps/api/src/modules/academic-calendar/today/calendar-today.controller.spec.tsThe 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

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
SchoolWeekConfigControllerControllerWeek-config routesSchoolWeekConfigServiceHTTPService—Guard/permission failures, DTO validation
SchoolWeekConfigServiceServiceThe single-row upsert and its unconfigured-state contractDatabaseControllerschool_week_configInsert/updateCALENDAR_WEEKEND_INVALID, ATTENDANCE_VERSION_CONFLICT
AcademicCalendarControllerControllerGenerate/list/month/day/PATCH routesCalendarReadService, CalendarGenerateService, CalendarDayWriteServiceHTTPAll three services—Guard/permission failures, DTO validation
CalendarReadServiceServiceEvery read of academic_calendar_days, session resolutionDatabaseController, every other service in the moduleacademic_calendar_days, academic_sessions, school_holiday_days/school_holidays, student_attendance_sessions/student_attendance_records, school_week_configRead-onlyACADEMIC_SESSION_NOT_FOUND, CALENDAR_DAY_NOT_FOUND, BS-range codes
CalendarGenerateServiceServiceAdditive-with-repair generationDatabase, CalendarReadService, HolidayDayApplyServiceControlleracademic_sessions, school_week_config, academic_calendar_days, school_holiday_daysDelete + insertCALENDAR_WEEKEND_NOT_CONFIGURED, BS-range codes
CalendarDayWriteServiceServiceSingle-day PATCHDatabase, CalendarReadService, pg-constraint.utilControlleracademic_calendar_daysUpdateCALENDAR_TEXT_FORMAT_INVALID, ATTENDANCE_VERSION_CONFLICT
SchoolHolidayControllerControllerHoliday list/create/update/deleteSchoolHolidayServiceHTTPService—Guard/permission failures, DTO validation
SchoolHolidayServiceServiceHoliday CRUD, its own guards, constraint translationDatabase, CalendarReadService, HolidayDayApplyService, pg-constraint.utilControllerschool_holidays, academic_sessionsInsert/update/deleteSee §13 holiday codes
HolidayDayApplyServiceServiceEvery write to school_holiday_days; precedence recomputationDatabase, calendar-contractCalendarGenerateService, SchoolHolidayServiceacademic_calendar_days, school_holiday_days, school_holidays, school_week_configInsert/updateACADEMIC_CALENDAR_NOT_GENERATED
CalendarPortalControllerControllerThe one shared audience readCalendarPortalServiceHTTP, mounted under /api/mobileService—Guard failure only (no @Permissions())
CalendarPortalServiceServiceAudience-safe month resolution and mappingDatabase, SchoolClockService, CalendarDayServiceControlleracademic_calendar_days, academic_sessionsRead-onlyACADEMIC_SESSION_NOT_FOUND, CALENDAR_BS_DATE_INVALID, ACADEMIC_CALENDAR_NOT_GENERATED
packages/db/src/calendar/bikram-sambat.tsPure function moduleBS ↔ Gregorian conversion, local-time-safenepali-date-converterEvery service above that touches a BS date——BikramSambatRangeError, BikramSambatInvalidDateError (translated by callers)
packages/db/src/calendar/calendar-contract.tsPure constant/type moduleClassification/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.tsPure function moduleReference holiday databikram-sambat.tsNothing 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

  1. Entry point. Called from recomputeMany, itself called by linkAndRecompute, reapplyAllForSession, SchoolHolidayService.update, and SchoolHolidayService.remove — every holiday-touching write path converges here.
  2. Command shape. (executor, calendarDayId: string).
  3. Auth/identity. None checked here — the caller has already resolved and authorized the surrounding operation; this method is purely a data-consistency step.
  4. Input normalization. None; the id is opaque.
  5. Validation order. Load the day row; if absent, return false immediately. If source === "admin_modified", return false immediately — the administrator's edit is never overwritten by a holiday membership change.
  6. Reads performed. The day row itself; the join of school_holiday_days to school_holidays for that day, ordered by holiday id (a stable tie-break); school_week_config, but only on the fallback branch where no holiday remains linked.
  7. Business decisions. HOLIDAY_PRECEDENCE.find(...) picks the first classification in precedence order that has at least one matching linked holiday. If one is found, its source (holiday's own source, school or seed) is copied onto the day; if none is found, the day falls back to weekend/school_day against the live week configuration and source: 'auto'.
  8. 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 (sameSet comparison on the weekend array).
  9. Transactions. None opened here; always runs inside the caller's already-open transaction.
  10. Cache behavior. None.
  11. Jobs/events/notifications. None.
  12. Response mapping. Returns a boolean — whether the stored row actually changed — which callers sum into a changedCount/repairedCount.
  13. 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.
  14. Logs/metrics/audit. None.
  15. 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.
StepCode LocationWhat HappensWhy It HappensFailure/Edge Case
1holiday-day-apply.service.ts:96-106Load 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.
2holiday-day-apply.service.ts:108-119Join 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.
3holiday-day-apply.service.ts:121-134HOLIDAY_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.
4holiday-day-apply.service.ts:135-147Fallback 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.
5holiday-day-apply.service.ts:149-168No-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/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
school_week_config_single_rowidCHECKExactly one row, ever.None — the table is meant to hold one row.
school_week_config_weekend_days_bounded / _validweekend_daysCHECKLength ≤ 6, values within [0,6].Cannot express "no duplicates" — left to the service.
academic_calendar_days_session_date_uniqueacademic_session_id, gregorian_dateUNIQUEOne row per date per session.Also the target generate()'s missing-date check reads against.
academic_calendar_days_session_bs_month_idxacademic_session_id, bs_year, bs_monthbtreeThe BS-month grid view.Extra index maintained on every insert; small since inserts are batched per generation.
academic_calendar_days_session_classification_idxacademic_session_id, classificationbtreeRegeneration's delete predicate; classification filters.Same.
academic_calendar_days_date_idxgregorian_datebtreeCross-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_idxacademic_session_id, from_date, to_datebtreeThe range containment queries HolidayDayApplyService runs on every apply/reapply.—
school_holiday_days_calendar_day_idxcalendar_day_idbtreeThe day → holidays direction, which is what the precedence recomputation walks; the composite PK already serves holiday → days.—

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
A calendar day's date is unique per sessionacademic_calendar_days_session_date_uniqueOne 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 configuredCalendarGenerateService.generate's pre-checkNo safe default weekend exists across all Nepali school calendars.CALENDAR_WEEKEND_NOT_CONFIGUREDacademic-calendar.integration.spec.ts:"refuses generation while school_week_config has no row"
weekendDays has no duplicateSchoolWeekConfigService.assertNoDuplicateWeekendDaysAn 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_daySchoolHolidayService.assertNotSchoolDayA 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 >= fromDateschool_holidays_dates_ordered (CHECK), mirrored by assertDatesOrderedA single-day holiday is from = to.CALENDAR_HOLIDAY_RANGE_INVALID—
A holiday's range lies wholly inside its sessionSchoolHolidayService.assertRangeInsideSessionThe 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 existHolidayDayApplyService.linkAndRecompute's day-count checkNever a partial link across a range the calendar has not generated.ACADEMIC_CALENDAR_NOT_GENERATED—
Two holidays may cover one day; precedence decides the winnerHOLIDAY_PRECEDENCE, applied in recomputeDayA 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 recomputationCalendarGenerateService's delete predicate (source = 'auto' only); recomputeDay's early returnAn 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 deletedSchoolHolidayService.removePreserves the record of what the platform populated; reclassification is offered instead.SCHOOL_HOLIDAY_NOT_DELETABLEacademic-calendar.integration.spec.ts:"refuses to delete a seed holiday"
A calendar day's BS date is bounded to 2000–2090academic_calendar_days_bs_bounded (CHECK), assertYearInRange in the wrapperThe converter's own supported range; outside it the library throws a bare Error.CALENDAR_BS_DATE_OUT_OF_RANGEacademic-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 overassertValidBsDate in the wrapperThe 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_INVALIDExercised 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 monotonicbump_row_version trigger on school_week_config, school_holidays, academic_calendar_daysOptimistic concurrency without a second writer to the same columns.ATTENDANCE_VERSION_CONFLICTacademic-calendar.integration.spec.ts:"PATCHing a day sets source = admin_modified and bumps version exactly once"
school_holiday_days has no version tokenDeliberate schema omissionThe 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-bearingCHECK constraints per column, mirrored at the service layerA 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 decisionThe compile-time exhaustiveness proof in calendar-contract.ts:92-112A 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 fieldsPortalCalendarDayDto's hand-enumerated shape, CalendarPortalService's hand-enumerated mapperThe 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

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Materialised calendar, not computed on readNeed "is this a school day" answered fast and stablyOne row per Gregorian date, storedCompute from a weekend rule + holiday list on every readAn 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 CHECKNeed "is attendance expected on this classification"ATTENDANCE_REQUIRED_CLASSIFICATIONS + compile-time exhaustiveness proofGENERATED ALWAYS AS (...) STORED; a CHECK disjunctionBoth 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 ruleTwo holidays may cover one day; deletion must recompute rather than revertHOLIDAY_PRECEDENCE, walked in recomputeDayA priority column on school_holidays, sorted at read timeThe 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 rowNepal's standard week differs from many international-curriculum schoolsNo seed; row existence is the "configured" flagSeed Sunday–Friday as the defaultA 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/triggerThe table is a pure join, replaced wholesale on every holiday editOmit bothAttach the trigger uniformly to every tableThe 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 pathThe platform wants to populate common Nepali holidays without asking an administrator to enter every festival by handseedHolidaysForRange is exported, built, and tested against the BS converter, but no controller or service in this codebase calls itWire it into CalendarGenerateService.generate, or into a session-creation hookNot 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 onesLunar-dated festivals (Dashain, Tihar, Holi, …) cannot be computed from the BS calendar aloneCurated table for BS 2082 only, needsReview: trueExtrapolate approximate dates for 2083/2084A 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

OperationHow to InspectHealthy StateFailure SignalRecovery
Calendar generationResponse coverage block (expectedDays/presentDays/staleDays)presentDays === expectedDays, staleDays === 0staleDays > 0 after a week-configuration changeCall POST .../calendar/generate again — it self-heals every stale auto row on every run.
Week-configuration gateGET /school/week-config → configuredconfigured: true before any school attempts generationCALENDAR_WEEKEND_NOT_CONFIGURED on generateAn administrator must PUT /school/week-config once.
Holiday/day desyncCompare a day's classification/source against its holidays[] in the day-detail responsesource 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-emptyShould 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 refusalCALENDAR_BS_DATE_OUT_OF_RANGE on generationSessions stay within BS 2000–2090An academic session dated far in the future or pastCorrect the session's Gregorian dates; the BS window is a library limitation, not configurable.

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
Seeded holiday data has no wiring into any write pathFeature completeness, school_holidays/seed-calendar-holidays.tssource = 'seed' rows cannot currently be created through any route or service call; the reference data exists but is unreachableNoneseedHolidaysForRange 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 loopPerformance, large sessions with many holidaysA session with many long holiday ranges pays one round trip per calendar day during generation's reapply stepNone 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 layerTest coverageA regression in assertNoDuplicateWeekendDays or assertValidBsDate's call sites would not be caught by the integration spec shownThe 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 (recomputeDay in 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