Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers.
Scope: Admin calendar generation/read/correction, admin holiday CRUD, admin week configuration, and the one mobile-audience calendar read owned by Academic Calendar.
admin/superadmin (full CRUD), staff/teacher/guardian/student (view screen only, via a separate permission code that gates zero API routes), any authenticated user (the portal read)
API surfaces
admin (/api/academic-sessions/:id/calendar, /api/holidays, /api/school/week-config, /api/calendar/today), mobile (/api/mobile/calendar)
JWT for every route (JwtAuthGuard, RoleGuard); admin routes additionally require @Permissions("AcademicCalendar_..."); the mobile route requires only a valid JWT and asserts no role or permission at all
Persistence
PostgreSQL — school_week_config, school_holidays, academic_calendar_days, school_holiday_days. Redis is used only by GET /api/calendar/upcoming (a 60-second read cache). No BullMQ or realtime dependency.
Runtime source of truth
academic_calendar_days.classification for whether a date is a school day; school_week_config row existence for whether the school has configured its week at all
auto (generated from the weekend rule), seed (platform-populated holiday), school (admin-created holiday), admin_modified (an operator's own edit, protected from regeneration).
calendar-contract.ts:37-42
CalendarDayDto.source, SchoolHolidayDto.source.
Attendance-required classification
school_day and special_school_day — the only classifications on which attendance is expected.
calendar-contract.ts:62-65
CalendarDayDto.attendanceExpected, PortalCalendarDayDto.attendanceExpected, derived, never stored.
Holiday precedence
emergency_holiday > public_holiday > school_holiday > special_school_day — which classification a day takes when two holidays cover it.
calendar-contract.ts:78-83
HolidayDayApplyService.recomputeDay.
Bikram Sambat (BS) date
The Nepali calendar date stored on every calendar day; supported range BS 2000–2090.
packages/db/src/calendar/bikram-sambat.ts:47-49
CalendarDayDto.bsYear/bsMonth/bsDay, the month-view route, the portal route's query params.
Configured (week)
Whether school_week_config has ever been saved. There is no seeded default; row existence is this flag.
packages/db/src/schema/school/calendar.ts:85-99
SchoolWeekConfigDto.configured.
Staleness (coverage)
auto-sourced rows whose generated_from_weekend_days no longer matches the live school_week_config.weekend_days.
calendar-read.service.ts:316-361
CalendarCoverageDto.staleDays.
Admin-modified
A day whose source was set to admin_modified by a PATCH; excluded from regeneration's delete predicate and from precedence recomputation.
Any authenticated user (pupil, guardian, teacher, staff)
JwtAuthGuard, RoleGuard
None
CalendarPortalController
Shared audience read of one BS month.
Admin
GET
/api/calendar/today
Any authenticated user
JwtAuthGuard, RoleGuard
None
CalendarTodayController
Today in Asia/Kathmandu with its BS date and Devanagari rendering.
Admin
GET
/api/calendar/upcoming
Any authenticated user
JwtAuthGuard, RoleGuard
None
CalendarUpcomingController
The next N days of the school calendar, with multi-day events.
Every path above is read directly from the controller's @Controller() base plus its method decorator: AcademicCalendarController declares @Controller("academic-sessions/:sessionPublicId/calendar") (days/academic-calendar.controller.ts:35); SchoolHolidayController declares a bare @Controller() and states its own full path per method (holidays/school-holiday.controller.ts:42-112), which is why the list/create routes are session-scoped while update/delete are holiday-id-scoped with no common prefix; SchoolWeekConfigController declares @Controller("school/week-config") (config/school-week-config.controller.ts:23); CalendarPortalController declares @Controller("calendar") (calendar-portal.controller.ts:35), and the /api/mobile prefix is added by RouterModule.register() in apps/api/src/modules/mobile/mobile.module.ts:64-80, never by the controller decorator itself. The global prefix /api is applied in apps/api/src/main.ts:86.
AcademicCalendarModule is registered in ADMIN_MODULES (apps/api/src/config/swagger/swagger-documents.ts:88) and therefore appears in the admin Swagger document; CalendarPortalModule is registered as a MOBILE_CHILDREN leaf (apps/api/src/modules/mobile/mobile.module.ts:69) and therefore appears only in the mobile Swagger document, which is filtered by isMobilePath/isAdminPath in apps/api/src/config/swagger/swagger.shared.ts:29-38.
req.user, injected via @CurrentAdmin() on create for createdByUserId
AcademicCalendar_CREATE/_READ/_UPDATE/_DELETE
No
Same guard chain, per-method permission codes.
Admin (week config)
JwtAuthGuard, RoleGuard
req.user, injected via @CurrentAdmin() on update for configuredByUserId
AcademicCalendar_READ/_UPDATE
No
—
Mobile (portal calendar)
JwtAuthGuard, RoleGuard
req.user.id (unused by the handler itself)
None declared
No (JWT still required)
Explained in detail below.
Today (/api/calendar/today)
JwtAuthGuard, RoleGuard
None read
None declared
No (JWT still required)
Allowlisted as "CalendarTodayController.today"; the response is only today's date and its BS equivalent.
Upcoming (/api/calendar/upcoming)
JwtAuthGuard, RoleGuard
None read
None declared
No (JWT still required)
Allowlisted as "CalendarUpcomingController.upcoming"; the service selects fields by hand and never reads the administrator note.
Which roles actually hold the admin permission codes: only superadmin is granted fullAccessPermissions, which includes every AcademicCalendar_* CRUD code (packages/db/src/seed/seed-auth.ts:214). The other four seeded roles — staff, teacher, guardian, student — hold onlyAcademicCalendarView_READ (seed-auth.ts:110,162,195), a code that gates the panel's calendar screen and appears on zero API routes (packages/db/src/authorization/permission-catalog.ts:234-243). In practice this means every admin route in this module is reachable only by superadmin out of the box, and a school that wants a non-superadmin operator to manage the calendar must grant AcademicCalendar_* to a custom role explicitly.
CalendarPortalController.monthView carries no @Permissions() decorator at all (calendar-portal.controller.ts:36-49) — the calendar names no person, so unlike every other portal route in the attendance module, it asserts no audience. Because RoleGuard otherwise treats a non-mobile-looking handler with no @Permissions() as an admin surface and throws, this handler is explicitly allowlisted in NO_PERMISSION_ADMIN_HANDLERS under the key "CalendarPortalController.monthView" (apps/api/src/common/authorization/role.guard.ts:228) and mirrored in test/structure/route-permissions.spec.ts. What actually makes the route safe with no permission check is the response shape: PortalCalendarDayDto enumerates every field by hand and is never built by spreading a row, so a column such as note (administrator-internal text) can never reach this response even if added to academic_calendar_days later (apps/api/src/modules/attendance/portal/calendar/dto/portal-calendar.dto.ts:71-93).
weekendDays and schoolDayCutoffMinute are always present, even when configured is false — the service returns { configured: false, weekendDays: [], schoolDayCutoffMinute: 1020, version: null } rather than omitting them (school-week-config.service.ts:38-49). This is a deliberate fix for a real defect: the settings screen crashed on weekendDays is not iterable the first time it met an unconfigured school, which is every school, exactly once (week-config.dto.ts:33-40).
@IsArray, max 6 items, each @IsInt @Min(0) @Max(6)
[5, 6]
week-config.dto.ts:73-78
schoolDayCutoffMinute
number
Yes
—
@IsInt @Min(0) @Max(1440)
1020
week-config.dto.ts:80-83
expectedVersion
number | null
Yes (but may be null)
—
@IsOptional @IsInt @Min(0)
3, or null on first save
week-config.dto.ts:86-94
weekendDays may be an empty array — a seven-day residential school has no weekend, and this is a legal, saved configuration, not an incomplete one. Distinctness (no repeated day) is checked server-side, not by class-validator and not by a CHECK — an array CHECK cannot express "no duplicates" without a subquery (school-week-config.service.ts:143-149).
Inherits pagination, page, size, sort, order, search from QueryDto (apps/api/src/common/dto/query.dto.ts:14-64) — sort/order/search are accepted but ignored, since the list is always ordered by gregorianDate.
Setting any field other than expectedVersion on an auto/seed day moves it to admin_modified, protecting it from the next regeneration and from holiday precedence recomputation.
@IsString @MaxLength(120), backstopped by a no-pad/no-control-char CHECK
"School closed"
holiday.dto.ts:47-50
classification
enum
Yes
—
one of CALENDAR_DAY_CLASSIFICATION; school_day refused by the service
"school_holiday"
holiday.dto.ts:52-54
fromDate
string
Yes
—
@IsISO8601
"2026-09-19"
holiday.dto.ts:56-57
toDate
string
Yes
—
@IsISO8601; must be >= fromDate (service + CHECK)
"2026-09-21"
holiday.dto.ts:59
reason
string | null
No
null
@IsString @MaxLength(500)
"Maintenance"
holiday.dto.ts:61-69
Server-generated/overwritten fields not accepted from the client: id, source (always written as "school" for this route), createdByUserId (from @CurrentAdmin()), version (always 0 on insert).
FROZEN — enumerated by hand at CalendarPortalService.monthView's mapper, never a spread.
Field
Type
Required
Default
Validation
Example
Source
gregorianDate
string
Yes
—
—
"2026-04-15"
portal-calendar.dto.ts:84
bsYear
number
Yes
—
—
2083
portal-calendar.dto.ts:85
bsMonth
number
Yes
—
—
1
portal-calendar.dto.ts:86
bsDay
number
Yes
—
—
1
portal-calendar.dto.ts:87
dayOfWeek
number
Yes
—
0..6, 0 = Sunday
3
portal-calendar.dto.ts:88
classification
enum
Yes
—
one of CALENDAR_DAY_CLASSIFICATION
"school_day"
portal-calendar.dto.ts:89-90
attendanceExpected
boolean
Yes
—
Derived, never stored
true
portal-calendar.dto.ts:91
eventName
string | null
No
null
—
"Indra Jatra" or null
portal-calendar.dto.ts:92
Deliberately absent: note, reason, source, needsReview, any holiday id, createdByUserId, configuredByUserId, version, generatedFromWeekendDays — every one of them either administrator-internal, an internal-provenance field, or a concurrency/staleness detail the audience side has no use for (portal-calendar.dto.ts:71-82).
Both bsYear and bsMonth must be supplied together or omitted together — supplying only one is refused with CALENDAR_BS_DATE_INVALID rather than silently defaulting the missing half to the current year (calendar-portal.service.ts:38-55).
@IsInt @Min(1) @Max(31); a non-integer or out-of-range value is 400 VALIDATION_FAILED
14
CalendarUpcomingDto (response):
Field
Type
Required
Notes
today
string
Yes
The school-local date, YYYY-MM-DD.
academicSessionName
string | null
Yes (nullable)
The current session's name; null when none is current.
days
CalendarUpcomingDayDto[]
Yes
Exactly days consecutive dates; the first is today.
events
CalendarUpcomingEventDto[]
Yes
Runs of notable days; may be empty.
CalendarUpcomingDayDto:
Field
Type
Notes
date
string
YYYY-MM-DD.
dayOfWeek
number
0–6, 0 = Sunday.
bsYear, bsMonth, bsDay
number | null
The BS date: the stored value when a calendar row exists, otherwise converted from the Gregorian date. null together when the date is outside the converter's range.
classification
CalendarDayClassification | null
See §7. null when the calendar has no row for the date.
isOperatingDay
boolean | null
Whether attendance is expected (school_day and special_school_day). null when there is no row; a date with no row is neither a school day nor a holiday.
eventName
string | null
The row's event name.
CalendarUpcomingEventDto:
Field
Type
Notes
name
string | null
The shared event name; null for an unnamed run of a non-ordinary classification.
classification
CalendarDayClassification
The shared classification.
startDate, endDate
string
First and last date of the run, inclusive.
startBs, endBs
{ year, month, day } | null
BS dates of the first and last day; null when either has no BS date.
The administrator note column is never selected and appears in no field.
Attendance not expected; outranks school_holiday/special_school_day in precedence.
calendar-contract.ts:19
CalendarDayClassification
school_holiday
A school-specific closure.
Attendance not expected.
calendar-contract.ts:20
CalendarDayClassification
weekend
A configured non-operating weekday.
Attendance not expected; derived from school_week_config.weekend_days at generation time.
calendar-contract.ts:21
CalendarDayClassification
special_school_day
A school day with different rules (e.g. sports day).
Attendance is expected — the one non-school_day classification that requires it.
calendar-contract.ts:22
CalendarDayClassification
emergency_holiday
An unplanned closure (e.g. weather).
Attendance not expected; outranks every other holiday classification.
calendar-contract.ts:23
CalendarDayClassification
other
Anything not covered above.
Attendance not expected.
calendar-contract.ts:24
CalendarDaySource
auto
Generated from the weekend rule.
Rewritten by the next regeneration if still unlinked from any holiday.
calendar-contract.ts:38
CalendarDaySource
seed
Populated by the platform's reference holiday data.
Not deletable via DELETE /holidays/:id; reclassify instead.
calendar-contract.ts:39
CalendarDaySource
school
Created by an administrator.
Deletable.
calendar-contract.ts:40
CalendarDaySource
admin_modified
An operator's own edit to a day.
Protected from regeneration and from precedence recomputation.
calendar-contract.ts:41
classification = school_day on a school_holidays row is refused by SchoolHolidayService.assertNotSchoolDay, not by the database enum itself — see the backend doc's §16.6 for why this is a service rule rather than a CHECK.
Generates or repairs the calendar for one academic session. Call it once after a session is created and its week is configured, and again any time the week configuration changes, a holiday was created before the calendar existed, or the coverage response reports staleDays > 0. It is additive-with-repair and safe to call repeatedly.
Idempotency: Result-idempotent, not diff-idempotent — every call deletes and reinserts every unlinked auto row, so a stale weekend snapshot self-heals on every run.
Database writes: deletes unlinked auto rows of academic_calendar_days, inserts every missing date, re-links and recomputes every existing holiday of the session via HolidayDayApplyService.reapplyAllForSession.
No cache, job, realtime, notification, or external call.
A session with zero calendar rows and no existing holidays generates every day as auto, generated = expectedDays, deleted = 0, repaired = 0.
Calling generate a second time with nothing changed reports generated = presentDays, deleted = presentDays (every previously-inserted auto row is unlinked and gets rewritten) — the response's coverage is identical both times; only the diff counters differ (confirmed by the integration spec's "generates a full session and is idempotent (in RESULT) on a second run" case).
An admin_modified day is excluded from the delete predicate entirely — it is never deleted or reinserted, and generated/deleted both drop by exactly one relative to a run with no such day.
A holiday created before the first generate() call is picked up on the next run through reapplyAllForSession, not lost.
The paginated, filterable day list for one session, plus a session-wide coverage summary that is always computed independently of the list's own filters. Used to power a table/list view of the calendar, and to detect staleness after a week-configuration change.
bsYear, bsMonth (1–12), classification, from/to (YYYY-MM-DD), plus pagination/page/size inherited from QueryDto (sort/order/search are accepted but ignored — the list is always ordered by gregorianDate).
coverage is computed independently of every filter on the list — filtering to classification=weekend still returns the session's full expectedDays/presentDays/staleDays, not a filtered subset.
An empty filtered result returns days: [] with a populated, non-empty coverage block.
staleDays is scoped to source = 'auto' rows only — a holiday-covered day is never counted as stale, since its classification does not come from the weekend rule.
One Bikram Sambat month rendered as a grid, with each day carrying the names of every holiday covering it. This is the admin calendar screen's primary month view.
A BS month with zero generated rows returns an empty array [], never a 404 — the calendar simply has not been generated that far, or the session does not cover that month.
holidayNames is [] for a day covered by no holiday, never omitted.
The range check on bsYear/bsMonth runs before session resolution, so an out-of-range year is refused even for a session id that would itself not resolve.
The single-day detail view — used by the day drawer/dialog when an administrator clicks a date, showing the full holiday rows (not just names) currently covering it.
A date never generated for the session returns 404 CALENDAR_DAY_NOT_FOUND, never a synthesized default row — a missing row is never treated as a school day.
holidays is [] for a day covered by no holiday.
attendanceExists reflects real recorded attendance regardless of attendanceExpected — a day where attendance was recorded despite not being expected still reports attendanceExists: true.
Corrects one calendar day — reclassifying it, adding an event name or an internal note, or flagging it for review. Every write moves an auto/seed day to admin_modified, which is what protects it from the next regeneration and from holiday precedence recomputation.
A body carrying only expectedVersion (no other field) still succeeds and still bumps version by exactly one — the service assigns needsReview to its own current value so the UPDATE is non-trivial and the trigger fires (calendar-day-write.service.ts:107-112).
Patching a day that is already admin_modified keeps it admin_modified — the source transition is one-directional through this route.
note and eventName can each be cleared by sending null.
The re-read after the write is inside the same transaction — the response is never an echo of the request body.
The array is the top-level data; count/currentPage/totalPage are top-level envelope fields, not nested a second time inside data — the service deliberately returns { items, count, page, size } rather than { data, count }, because nesting a second data broke a screen that called holidays.map on an object (school-holiday.service.ts:88-91).
A session with no holidays returns data: [], count: 0, currentPage: 1, totalPage: 0.
pagination=false returns every holiday of the session up to PaginationUtil.UNPAGINATED_HARD_CAP (1000), with count computed from the returned row count rather than a separate COUNT(*).
Creates a holiday and immediately applies it: every calendar day in [fromDate, toDate] is linked and recomputed in the same transaction. The range must already be fully generated.
Database writes: school_holidays insert; school_holiday_days insert for every covered day; academic_calendar_days update for every day whose classification/source actually changes (via HolidayDayApplyService).
createdByUserId is set from the authenticated caller (@CurrentAdmin()).
No cache, job, realtime, notification, or external call.
A single-day holiday is created with fromDate === toDate.
Creating a holiday whose range overlaps an existing holiday of a different classification does not error — both link to the shared days, and HOLIDAY_PRECEDENCE decides which classification the overlapping days show.
Creating a holiday over a range that includes an admin_modified day still links it (school_holiday_days gets the row), but recomputeDay leaves that day's classification/source untouched — the admin's edit wins.
Updates a holiday's name, classification, range, or reason. Narrowing the range reverts the days that fall out; widening applies to the days that fall in; a classification change rewrites every day the holiday still owns.
Database writes: school_holidays update (version-guarded); every existing school_holiday_days link for this holiday deleted; new links inserted for the new range; recomputation of every day newly covered and every day that fell out.
No cache, job, realtime, notification, or external call.
Narrowing toDate reverts every day that fell out of the range to whatever classification the remaining holidays (or the weekend rule) dictate — never left classified as the old holiday.
Changing only classification (same range) rewrites every currently-linked day's classification/source, without touching the link set itself.
A patch body that changes nothing meaningful still sends name = existing.name internally so the UPDATE is non-trivial and the version bumps (school-holiday.service.ts:255).
Deletes a holiday and recomputes every day it covered from whichever holidays remain. Refused for seeded holidays — reclassify instead of deleting, so the record of what the platform populated is preserved.
Deleting a holiday that is the only one covering a set of days reverts each to weekend/school_day (against the live week configuration) and source: 'auto'.
Deleting a holiday that shares days with another still-active holiday leaves those days classified by whichever remaining holiday wins under HOLIDAY_PRECEDENCE.
Deleting a holiday that covers an admin_modified day leaves that day's classification/source untouched.
Reads the school-wide weekend and attendance-cutoff configuration. Called before offering calendar generation, to decide whether to show the "configure your week first" prompt.
Every field is present even though the school has never saved a configuration — weekendDays is [], schoolDayCutoffMinute is the shipped default (1020 = 17:00), and version is null so the client knows to send expectedVersion: null on its first save. This shape exists because the settings screen previously crashed with weekendDays is not iterable when the array was omitted entirely (school-week-config.service.ts:38-49).
weekendDays: [] is returned both when the school is genuinely unconfigured and when a configured school has deliberately chosen a seven-day week — configured is the only field that distinguishes the two.
weekendDays: [] is accepted and saved — a seven-day school is a legal, complete configuration.
Sending expectedVersion: null when a row already exists is refused with the same ATTENDANCE_VERSION_CONFLICT a stale numeric version would produce — the ?? -1 fallback guarantees null can never accidentally match a real version.
schoolDayCutoffMinute accepts the full 0..1440 range, including 1440 (midnight-exclusive end of day) and 0 (a school with no cutoff grace at all).
The one calendar read every audience shares — pupil, guardian, teacher, and staff alike. Renders one Bikram Sambat month, defaulting to the session containing today and the BS month containing today when no query parameters are supplied. This is the mobile/portal counterpart of §8.3, with a deliberately narrower response shape and no permission check.
Guard chain: JwtAuthGuard, RoleGuard — but no@Permissions() decorator.
Permission: None. Allowlisted in NO_PERMISSION_ADMIN_HANDLERS under "CalendarPortalController.monthView" (apps/api/src/common/authorization/role.guard.ts:228) so RoleGuard does not treat the missing decorator as a misconfigured admin route.
Guest support: No — a valid JWT is still required; there is simply no role/permission check beyond it.
Omitting both bsYear and bsMonth resolves "today" through SchoolClockService, in the school's own timezone, and converts to BS via the same wrapper the admin side uses.
A pupil, parent, teacher, or staff member all receive the identical response shape for the same query — there is no audience-conditional field.
Requesting a month that is technically covered by the session but was never generated (e.g. the session exists but generate() was never called) returns 409 ACADEMIC_CALENDAR_NOT_GENERATED, not an empty array.
Today's date in Asia/Kathmandu, its Bikram Sambat equivalent, and a Devanagari rendering of the BS date and the weekday. Every audience's page header renders it. It is pure computation: no database, cache, queue, Redis or MongoDB is touched.
Guard chain: JwtAuthGuard, RoleGuard — but no@Permissions() decorator.
Permission: None. Allowlisted in NO_PERMISSION_ADMIN_HANDLERS under "CalendarTodayController.today". The response holds only today's date in Asia/Kathmandu and its BS equivalent — no user, school or row data — so a session is the whole requirement.
The date is taken in Asia/Kathmandu (UTC+05:45), not in the server's timezone. The day rolls at Nepal midnight, which is 18:15 UTC.
dayOfWeek is computed from the Nepal date string through UTC arithmetic, so the server's timezone cannot move it. 0 is Sunday.
When the Nepal date is outside the converter's supported range (BS 2000–2090), the response is still 200. bs, bsYear, bsMonth, bsDay, bsDisplayNp and weekdayNp are all null together; date and dayOfWeek are present.
A BS day below 10 renders zero-padded in bsDisplayNp (for example आश्विन ०५, २०८३).
The next N days of the school calendar, starting today, with each day's Bikram Sambat date, classification and whether attendance is expected, plus the multi-day events inside the window. Every audience's dashboard renders it.
Guard chain: JwtAuthGuard, RoleGuard — but no@Permissions() decorator.
Permission: None. Allowlisted in NO_PERMISSION_ADMIN_HANDLERS under "CalendarUpcomingController.upcoming". The response names no person, enumerates its fields and never includes the administrator note, so a session is the whole requirement.
today is the school-local date, not the server's. The window is days consecutive dates from it, enumerated by UTC arithmetic on the date string, so it cannot fail near the edge of the Bikram Sambat range.
One calendar row is chosen per date from active academic sessions only. When two active sessions hold the same date, the current session's row wins, then the lower session id. Archived (inactive) sessions never contribute.
A date with no row has classification, isOperatingDay and eventName set to null; its BS fields are converted from the Gregorian date.
events groups consecutive days that share a classification and an event name. A day is notable when it has an event name, or its classification is neither school_day nor weekend. A day that is not notable, or has no row, ends the current run.
Key: calendar:upcoming: with the school-local date and the requested days; the value is the whole response.
TTL 60 seconds. No calendar write invalidates the key: a holiday create, update or delete, a day correction or a regeneration is visible within 60 seconds.
The read goes through cacheGetFast: the Redis read races a 250 ms budget, and a timeout, an error or an outage is a miss, so the route reads PostgreSQL. The write is fire-and-forget (cacheSetFast).
The response is the same for every caller, so one entry serves all audiences.
Omitting days returns seven days; days=1 returns only today.
The response always holds exactly days entries, even when the calendar has not been generated: every day then carries null classification and events is empty.
A session with no current flag gives academicSessionName: null; calendar rows are still returned for active sessions.
Fixed: gregorianDate ascending (sort/order accepted but ignored)
bsYear, bsMonth, classification, from, to
1000 when pagination=false
GET .../holidays
page/size (QueryDto)
20
100
Fixed: fromDate ascending, then id (sort/order accepted but ignored)
None beyond session scope
1000 when pagination=false
GET .../calendar/months/:bsYear/:bsMonth
None — whole month
N/A
N/A
Fixed: bsDay ascending
Path params only
Bounded by month length (≤32)
GET /api/mobile/calendar
None — whole month
N/A
N/A
Fixed: bsDay ascending
sessionPublicId, bsYear+bsMonth
Bounded by month length (≤32)
No endpoint in this module supports free-text search — the inherited QueryDto.search field is accepted (it is part of the shared base class) but has no effect on any query built by these services. No relevance scoring exists. Only GET /api/calendar/upcoming is cached; see §11.
calendar:upcoming: keys (school-local date and days), 60-second TTL. The read goes through cacheGetFast (250 ms budget; a slow or unavailable Redis is a miss and the route reads PostgreSQL); the write is fire-and-forget. No calendar writer invalidates the key, so an edit is visible within 60 seconds. Every other route reads PostgreSQL directly.
Every applicable example type is present in §8 per endpoint: minimal/full request bodies (holiday create, week-config update), public/guest behavior (not applicable — every route requires a JWT), authenticated and admin requests (all endpoints), success responses with full envelopes, empty-list responses (§8.2, §8.6, §8.3), validation errors, domain errors, and version-conflict errors. 401/403 are not shown as JSON bodies because they are produced by the shared guard stack, not by any service in this module, and their shape is documented once in the platform's own auth documentation rather than duplicated per endpoint here.
See §9 for the route-ownership graph, the generate sequence, the holiday-create error decision tree, and the admin-vs-portal auth flow. A cache-flow diagram is omitted — no route in this module reads or writes a cache. An async/job-flow diagram is omitted for the same reason regarding BullMQ. A realtime/event-flow diagram is omitted — no event is ever emitted.
Must call GET /school/week-config before offering generation; must read coverage.staleDays to prompt regeneration; must send expectedVersion on every PATCH/PUT.
Show CALENDAR_WEEKEND_NOT_CONFIGURED as a first-run prompt, not a generic error; show ATTENDANCE_VERSION_CONFLICT as a reload prompt.
Stable.
Mobile app (calendar tab)
Uses GET /api/mobile/calendar with no query params for "this month"; must supply bsYear+bsMonth together if the user navigates to a different month.
ACADEMIC_CALENDAR_NOT_GENERATED should render as "not yet available" rather than a hard error, since it is reachable in ordinary use (a school that has not generated far enough ahead).
Stable — the response shape is explicitly frozen (PortalCalendarDayDto's own docblock).
QA
The integration spec (academic-calendar.integration.spec.ts) is the canonical source of confirmed edge cases: additive-with-repair idempotency, admin_modified preservation, BS-boundary refusal, two-holiday precedence, seed-delete refusal, and staleness reporting.
Reproduce any reported discrepancy against that spec's fixtures first.
Stable.
Internal service
No other module currently calls into this module's services directly except through HTTP; the attendance module reads academic_calendar_days/calendar-contract.ts directly at the database/TypeScript level, not through this API.
N/A
Stable at the schema/contract level (calendar-contract.ts), which is the intended integration surface for other backend modules.
Matches every other list endpoint in this codebase; the calendar's natural page count (one academic year, ≤365 rows) never approaches cursor-pagination's motivating scale.
None significant at this table size.
The UNPAGINATED_HARD_CAP (1000) still bounds an unpaginated request.
Auth mode on the portal route
JWT required, but no @Permissions()
Require AcademicCalendarView_READ explicitly
The calendar names no person; requiring a specific permission would need granting it to every audience role, which is exactly what AcademicCalendarView_READ already models for the panel screen — duplicating that check on the API route adds a second place to keep in sync with no additional safety, since the DTO shape is the actual control.
A future column added to academic_calendar_days could leak if a mapper is ever changed to spread the row instead of enumerating fields.
PortalCalendarDayDto's docblock states the constraint explicitly; a reviewer must catch a future spread.
Error shape
The shared { success, message, data, errorCode } envelope (ResponseDto)
A calendar-specific envelope
Consistency with every other module in the codebase; frontend/mobile error handling is already written against this shape.
None.
—
Reusing ATTENDANCE_VERSION_CONFLICT rather than a calendar-specific version-conflict code
Shared code across CalendarDayWriteService, SchoolHolidayService, and SchoolWeekConfigService
A dedicated CALENDAR_VERSION_CONFLICT code
The condition and client action are identical to the attendance module's own version conflicts — a stale optimistic-concurrency token — and a second code for the same meaning would only add an entry to memorize.
A client that branches on error code text (rather than HTTP status) for calendar-specific messaging would need to know the code is shared.
Documented here and in the backend doc's error table.
Admin panel (if the field should be admin-visible)
CalendarDayDto/CalendarDayDetailDto/MonthCalendarDayDto must add the field explicitly (no spread exists to add it automatically)
New column, likely nullable
Yes, a schema migration
The portal DTO (PortalCalendarDayDto) must not automatically gain the field — a reviewer must decide inclusion explicitly, since its mapper enumerates by hand.
Wiring seedHolidaysForRange into generation
Admin panel (holiday list would show new seed rows after the next generate)
CalendarGenerateService.generate or a new call site would need to invoke it and insert school_holidays rows with source: 'seed'
New school_holidays/school_holiday_days/academic_calendar_days rows for previously-unseeded sessions
No — no schema change, only a new call site
Backward compatible; existing sessions would gain seed holidays on their next generate() call. See the backend doc §16.6 and the report accompanying this documentation set.
Changing HOLIDAY_PRECEDENCE order
Every session with overlapping holidays
HolidayDayApplyService.recomputeDay's output changes for any day currently covered by more than one holiday
No schema change; existing rows are only correct after the next generate()/holiday-write recomputation
No
Would require re-running recomputation across every existing session to bring stored classifications in line with the new order — not automatic.
Every parent route prefix and runtime URL is documented, including the mobile-mounting distinction (§4, §9.1).
Every DTO field, nested field, enum, default, transform, and validator is documented (§6, §7).
Every response field, nullable field, generated field, and omitted raw entity field is documented, including the portal DTO's deliberate omissions (§6.15).
Every auth, guard, permission, role, and the one no-permission branch is documented (§5, §9.4).
Every success, validation, auth, permission, not-found, conflict, and server-error branch is documented (§8, §13.1).
Every database read/write is documented; no cache, queue, realtime, notification, or external call exists to document (§11).
Every route has examples for minimal request, full request (where applicable), success response, and representative failures (§8).
Every endpoint family has route, sequence, activity, and error diagrams (§9).
Every tradeoff and compatibility risk is documented (§13.5, §13.6).