Skoolsewa - School Management System Docs
Developer ResourcesAttendance

Attendance Backend Documentation

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

Attendance Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Schemapackages/db/src/schema/school/attendance.tsSix tables, three enums, every constraint, every index.
Migrationpackages/db/src/migrations/0020_attendance_module.sqlHand-written triggers, the append-only function, and the two seeded settings rows.
Shared servicesapps/api/src/modules/attendance/shared/*.tsClock, calendar gate, roster, and change-log services shared by every surface.
Admin (student)apps/api/src/modules/attendance/admin/*.ts, admin/dto/*.tsRegister read/write/lifecycle, overview, history.
Staffapps/api/src/modules/attendance/staff/*.ts, staff/dto/*.tsStaff register mark/correct/history.
Settingsapps/api/src/modules/attendance/settings/*.ts, settings/dto/*.tsNotification-channel settings, subtractive by design.
Portalapps/api/src/modules/attendance/portal/{teacher,student,guardian}/*.tsAudience-asserted consumer surfaces mounted under /api/mobile.
Workersapps/api/src/modules/attendance/workers/*.tsThe one BullMQ worker, its two handlers, and the cron that ticks them.
Queue contractpackages/jobs/src/index.ts (QueueName.ATTENDANCE, AttendanceJob, AttendanceSweepPayload)Job names, payload shape, and the reasoning against carrying a minute.
Notificationsapps/api/src/modules/notification/templates/template-registry.tsThe two attendance notification kinds, their channels and variables.
Permissionspackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsStudentAttendance, StaffAttendance, AttendanceSettings and why no seeded role holds any of their codes; AttendanceView, the screen-only code seeded to staff, teacher, guardian and student.
Guard allowlistapps/api/src/common/authorization/role.guard.ts, apps/api/test/structure/route-permissions.spec.tsThe six portal handlers that carry no @Permissions().
Error codesapps/api/src/common/types/error-codes.tsEvery ATTENDANCE_* and STAFF_ATTENDANCE_* code and its condition.
Testsapps/api/src/modules/attendance/__tests__/*.spec.tsBehaviour asserted against a real database for every surface below.

2. Backend Scope and Boundaries

Owns

  • The daily student register: opening a class's register for a date, bulk marking and unmarking a set of pupils, correcting one pupil's record, and the three session-level transitions — complete, reopen, reset.
  • The school-wide attendance overview — one row per active class for a date, with every tally computed from grouped SQL rather than a per-class round trip.
  • A pupil's attendance change history and a staff member's attendance change history, both keyset-paginated over the same append-only log.
  • The staff and teacher register — one table for both, marked, corrected, and read back through history, with no calendar gate.
  • Which delivery channels the two attendance notification kinds are allowed to use, as an administrator-only, subtractive-only setting.
  • Three read-only consumer surfaces mounted under the mobile prefix: a class teacher's own classes and their register (which also lets the teacher mark and complete, through the same transaction the admin surface uses), a pupil's own attendance, and a guardian's reading of one child's attendance.
  • Two BullMQ sweeps ticking every minute: a first-period reminder to the class teacher, and a guardian absence alert once first period has ended.

Does Not Own

  • Class, grade, section and room definitions — packages/db/src/schema/school/classes.ts. This module references classes, grades and sections by id and never writes to them.
  • Staff and student identity, including soft delete — packages/db/src/schema/school/people.ts. Every read here treats deleted_at as authoritative but never sets it.
  • The academic calendar and academic sessions — CalendarDayService (apps/api/src/modules/attendance/shared/calendar-day.service.ts) is this module's own read-only client of academic_calendar_days and academic_sessions, both owned by the academic-calendar module. Attendance consumes "is this a school day, and which session contains this date" through that service; it does not generate, edit or seed a calendar.
  • Class timetables and timeslots — packages/db/src/schema/school/timetable.ts, owned by the Timetable module. The reminder sweep reads class_timetables.status and class_timeslots to find each class's first published period; it never writes either.
  • Notification delivery, rendering, preference resolution and the transactional outbox — apps/api/src/modules/notification/. Both workers call NotificationService.send() inside their own transaction and never construct an outbox row or a rendered message themselves.
  • Authentication and permission evaluation — JwtAuthGuard, RoleGuard, RoleModule. This module declares @Permissions() codes on its admin and staff surfaces and imports RoleModule so the guard can resolve RoleService, but implements no guard logic. The portal surfaces declare no permission at all and are covered in §11.

Source of Truth

ConcernSource of TruthNotes
A pupil's marking for a class/datestudent_attendance_recordsOne row per session/student pair; its absence, not a stored status, means unmarked.
A register's lifecycle (not_started / in_progress / completed)student_attendance_sessions.statusThe operator's own optimistic-concurrency token (version) lives on this row.
System-written scheduling facts — the first-period snapshot, the reminder marker, the last accepted submissionIdstudent_attendance_session_runtimeCarries no version and no bump_row_version trigger on purpose — see §5.2 and §16.6.
Whether the school operates on a date, and which session contains itCalendarDayService over academic_calendar_days / academic_sessionsNever defaulted: a date the calendar has no row for is a 409, never treated as a school day.
Marked / present / absent / late / excused counts for a registerDerived in memory from one AttendanceRosterService.forSession() readThere is no stored counter anywhere in this module — see §16.5.
A pupil's or staff member's attendance historyattendance_change_logAppend-only by trigger; records the first marking as well as every correction.
Which channels an attendance notification kind may useattendance_notification_settingsTwo seeded rows; a missing row fails the read closed rather than defaulting.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
AttendanceSharedModuleShared, no controllersattendance/shared/attendance-shared.module.tsNoneSchoolClockService, CalendarDayService, AttendanceRosterService, AttendanceLogServiceAll fourThe four questions every attendance surface must answer identically: what time is it here, does the school operate today, who is on the roll, and what gets written to the audit trail.
AttendanceAdminModuleLeaf, admin-facingattendance/admin/attendance-admin.module.tsStudentAttendanceController, AttendanceOverviewController, StudentAttendanceHistoryControllerStudentAttendanceReadService, StudentAttendanceWriteService, StudentAttendanceLifecycleService, AttendanceOverviewService, AttendanceHistoryServiceStudentAttendanceReadService, StudentAttendanceWriteService, StudentAttendanceLifecycleServiceThe student register, its lifecycle transitions, the school-wide overview and per-pupil history. Exports its read/write/lifecycle services so TeacherAttendancePortalModule reuses the same transaction rather than duplicating it.
AttendanceStaffModuleLeaf, admin-facingattendance/staff/attendance-staff.module.tsStaffAttendanceController, StaffAttendanceHistoryControllerStaffAttendanceServiceNoneThe staff/teacher register — mark, correct, history — with no calendar gate. Two controller classes share one @Controller("attendance/staff") path so the history route can carry its own Swagger tag.
AttendanceSettingsModuleLeaf, admin-facingattendance/settings/attendance-settings.module.tsAttendanceSettingsControllerAttendanceSettingsServiceNoneWhich channels the two notification kinds may use. Deliberately does not import AttendanceSharedModule — this surface never touches the clock, the calendar, or the change log.
TeacherAttendancePortalModuleLeaf, consumer-facingattendance/portal/teacher/teacher-attendance-portal.module.tsTeacherAttendancePortalControllerTeacherAttendancePortalServiceNoneThe class teacher's own classes and register, mounted at /api/mobile/teacher/attendance. Imports AttendanceAdminModule to call its write/lifecycle services directly.
StudentAttendancePortalModuleLeaf, consumer-facingattendance/portal/student/student-attendance-portal.module.tsStudentAttendancePortalControllerStudentAttendancePortalServiceNoneA pupil's own attendance, mounted at /api/mobile/student/attendance.
GuardianAttendancePortalModuleLeaf, consumer-facingattendance/portal/guardian/guardian-attendance-portal.module.tsGuardianAttendancePortalControllerGuardianAttendancePortalServiceNoneOne child's attendance, as read by their guardian, mounted at /api/mobile/guardian/children/:studentId/attendance.
AttendanceWorkersModuleWorker, no controllersattendance/workers/attendance-workers.module.tsNoneAttendanceQueueProcessor, AttendanceReminderProcessor, AttendanceAbsenceProcessor, AttendanceSchedulerNoneRegisters QueueName.ATTENDANCE, its one worker, and the cron that ticks both per-minute sweeps. Composed by the application root outside the fast runtime profile only — importing it starts a worker.

packages/db and packages/jobs are excluded from the table above: they contribute the schema and the queue contract respectively but declare no NestJS module.

AttendanceSharedModule declares no controller, so ADMIN_MODULES and MOBILE_CHILDREN never name it — Swagger's include does not recurse, and a module contributing no controller of its own would publish nothing while looking listed. The same is true of AttendanceWorkersModule, which is composed by the application root rather than through ADMIN_MODULES.

4. File and Directory Map

apps/api/src/modules/attendance/
  shared/
    school-clock.service.ts
    calendar-day.service.ts
    attendance-roster.service.ts
    attendance-log.service.ts
    attendance.types.ts
    attendance-shared.module.ts
  admin/
    student-attendance.controller.ts
    student-attendance-read.service.ts
    student-attendance-write.service.ts
    student-attendance-lifecycle.service.ts
    student-attendance-history.controller.ts
    attendance-history.service.ts
    attendance-overview.controller.ts
    attendance-overview.service.ts
    attendance-admin.module.ts
    dto/
      student-attendance.dto.ts
      attendance-history.dto.ts
      attendance-overview.dto.ts
  staff/
    staff-attendance.controller.ts
    staff-attendance-history.controller.ts
    staff-attendance.service.ts
    attendance-staff.module.ts
    dto/
      staff-attendance.dto.ts
      index.ts
  settings/
    attendance-settings.controller.ts
    attendance-settings.service.ts
    attendance-settings.module.ts
    dto/
      attendance-settings.dto.ts
      index.ts
  portal/
    teacher/
      teacher-attendance-portal.controller.ts
      teacher-attendance-portal.service.ts
      teacher-attendance-portal.module.ts
      dto/teacher-attendance-portal.dto.ts
    student/
      student-attendance-portal.controller.ts
      student-attendance-portal.service.ts
      student-attendance-portal.module.ts
      dto/student-attendance-portal.dto.ts
    guardian/
      guardian-attendance-portal.controller.ts
      guardian-attendance-portal.service.ts
      guardian-attendance-portal.module.ts
      dto/guardian-attendance-portal.dto.ts
    shared/
      portal-attendance-summary.dto.ts
      portal-attendance-summary.service.ts
    calendar/                      (academic-calendar module; not documented here)
  workers/
    attendance.scheduler.ts
    attendance-queue.processor.ts
    attendance-reminder.processor.ts
    attendance-absence.processor.ts
    attendance-workers.module.ts
  __tests__/
    student-attendance.integration.spec.ts
    staff-attendance.integration.spec.ts
    attendance-portal.integration.spec.ts
    attendance-settings.integration.spec.ts
    attendance-sweeps.integration.spec.ts

packages/db/src/schema/school/attendance.ts
packages/db/src/migrations/0020_attendance_module.sql
packages/jobs/src/index.ts            (QueueName.ATTENDANCE, AttendanceJob, AttendanceSweepPayload)
FilePurposeKey ExportsNotes
shared/school-clock.service.tsWhat "today" and "now" mean to this school.SchoolClockService, SchoolNowReads school_profile.timezone (default Asia/Kathmandu) and derives date/minute/weekday via Intl.DateTimeFormat, never a hard-coded UTC offset (school-clock.service.ts:36-84).
shared/calendar-day.service.ts"Does the school operate on this date, and in which session."CalendarDayService, CalendarDayStatesessionContaining resolves the session whose [start_date, end_date) contains the date — never is_current (calendar-day.service.ts:36-52). requireWithinSession refuses a date outside the academic session's [start_date, end_date) with ATTENDANCE_DATE_OUTSIDE_SESSION (422); the register read, the register write and every lifecycle re-read share it. require throws ACADEMIC_CALENDAR_NOT_GENERATED (409) rather than defaulting to a school day.
shared/attendance-roster.service.tsWho is on a class's roll, and what each of them was marked.AttendanceRosterService, RosterEntry, AttendanceTallyOne LEFT JOIN query serves both the list and the tally, so the two can never disagree (attendance-roster.service.ts:44-123).
shared/attendance-log.service.tsThe attendance audit trail's one write path.AttendanceLogService, AttendanceLogEntryDiffs before writing — previousStatus !== newStatus — so a re-submitted, unchanged register appends nothing (attendance-log.service.ts:35-42). Takes the caller's transaction; there is no default executor.
shared/attendance.types.tsThe status/session-status/subject-kind/notification-kind vocabulary.ATTENDANCE_STATUS, ATTENDANCE_SESSION_STATUS, ATTENDANCE_SUBJECT_KIND, ATTENDANCE_NOTIFICATION_KINDSRe-declared rather than imported from the Drizzle enum so DTOs do not reach into the schema package for a string union.
admin/student-attendance.controller.tsAdmin routes for the student register: read, bulk mark, per-pupil correction, complete/reopen/reset.StudentAttendanceControllermark carries both StudentAttendance_CREATE and _UPDATE; correct carries only _UPDATE (student-attendance.controller.ts:34-44).
admin/student-attendance-read.service.tsResolves a class, resolves (never creates) its session, and assembles the roster envelope every read and write path returns.StudentAttendanceReadService, ResolvedAttendanceClassresolveClass/loadState are public so the write and lifecycle services can pass their open transaction and re-read inside it (student-attendance-read.service.ts:37-49). Also the only place buildClassLabel and composeFullName live.
admin/student-attendance-write.service.tsThe bulk-write transaction behind both the admin PUT and the teacher portal PUT, and the per-record PATCH correction path.StudentAttendanceWriteService, BulkMarkAttendanceInput, BulkMarkAttendanceOptionsThirteen numbered steps in one transaction — see §16.3.
admin/student-attendance-lifecycle.service.tsThe three session-level transitions: complete, reopen, reset.StudentAttendanceLifecycleService, AttendanceSessionLifecycleInputNone touches a per-pupil status directly; all three act on student_attendance_sessions.status and share requireSession/assertExpectedVersion (student-attendance-lifecycle.service.ts:219-245).
admin/student-attendance-history.controller.tsA pupil's attendance change log.StudentAttendanceHistoryControllerSplit from StudentAttendanceController so it carries its own Swagger tag.
admin/attendance-history.service.tsKeyset walk over attendance_change_log for one student.AttendanceHistoryService, AttendanceHistoryPageOrders changed_at DESC, id DESC and truncates the comparison to millisecond precision on both sides, because the driver's Date cannot round-trip Postgres's microsecond timestamptz (attendance-history.service.ts:29-41).
admin/attendance-overview.controller.ts / attendance-overview.service.tsThe school-wide, one-row-per-class attendance state for a date.AttendanceOverviewController, AttendanceOverviewServiceEvery tally is grouped SQL over classes' sessions in one pass, never a per-class round trip (attendance-overview.service.ts:31-36).
admin/attendance-admin.module.tsWires the three admin controllers and their five providers.AttendanceAdminModuleImports EnrollmentsCoreModule for ClassOccupancyService, the overview's one definition of class occupancy.
staff/staff-attendance.controller.ts / staff-attendance-history.controller.tsAdmin routes for the staff register and its history.StaffAttendanceController, StaffAttendanceHistoryControllerTwo classes on one @Controller("attendance/staff") path — a method-level @ApiTags concatenates with the class-level one rather than replacing it, and one operation with two tags fails swagger-coverage.spec.ts (staff-attendance-history.controller.ts:20-27).
staff/staff-attendance.service.tsList, bulk-write (last-write-wins), single-record patch, and keyset history for the staff register.StaffAttendanceService, StaffAttendanceRow, StaffAttendanceTallyNo calendar gate is ever consulted on this path (staff-attendance.service.ts:180-182).
settings/attendance-settings.controller.ts / attendance-settings.service.tsRead and update the two notification-channel settings rows.AttendanceSettingsController, AttendanceSettingsService, AttendanceSettingRowupdate() never creates a row — both are seeded by migration 0020 and a missing one is a 404, not an upsert (attendance-settings.service.ts:27-33).
portal/teacher/teacher-attendance-portal.service.tsThe class teacher's own classes and register.TeacherAttendancePortalServiceScopes every read and write by classes.class_teacher_id = staffId, resolved via assertTeacherStaffRow, never via PeopleAccessService.scopeFor (teacher-attendance-portal.service.ts:21-31).
portal/student/student-attendance-portal.service.tsA pupil's own attendance record.StudentAttendancePortalServiceResolves the caller's own students row by user_id, filtering deleted_at only — the identity layer (student-attendance-portal.service.ts:23-32).
portal/guardian/guardian-attendance-portal.service.tsOne child's attendance, as read by their guardian.GuardianAttendancePortalServiceThe ward check deliberately does not filter students.record_status, on pain of making a parent's read stricter than the pupil's own (guardian-attendance-portal.service.ts:31-41).
portal/shared/portal-attendance-summary.service.tsSession-to-date attendance totals shared by the pupil and guardian summary routes.PortalAttendanceSummaryServiceReads only for an id the caller has already resolved; caches 60 s.
portal/shared/portal-attendance-summary.dto.tsResponse shape of both summary routes.PortalAttendanceSummaryDto—
workers/attendance.scheduler.tsTicks the two per-minute sweeps.AttendanceSchedulerBuilds a per-minute bucket into buildJobId so BullMQ's dedupe-by-jobId cannot collapse two distinct ticks (attendance.scheduler.ts:19,42-43).
workers/attendance-queue.processor.tsThe one @Processor on QueueName.ATTENDANCE.AttendanceQueueProcessorhandlers is Record<AttendanceJob, …>, so an unhandled job name is a compile error (attendance-queue.processor.ts:31-45).
workers/attendance-reminder.processor.tsOne tick of the first-period reminder sweep.AttendanceReminderProcessor, SchoolNow (re-exported type)A plain @Injectable(), not a @Processor — see §9.
workers/attendance-absence.processor.tsOne tick of the guardian absence-alert sweep.AttendanceAbsenceProcessorBatches at 500 rows, ordered by record id, so the next tick resumes deterministically rather than re-scanning (attendance-absence.processor.ts:30).

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/attendance.ts
  attendanceStatusEnum
  attendanceSessionStatusEnum
  attendanceSubjectKindEnum
  studentAttendanceSessions
  studentAttendanceSessionRuntime
  studentAttendanceRecords
  staffAttendanceRecords
  attendanceChangeLog
  attendanceNotificationSettings

packages/db/src/migrations/0020_attendance_module.sql

5.2 Tables and Collections

student_attendance_sessions

One class's register for one date — the operator's own intent, and nothing the system writes on its own (attendance.ts:86-89).

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (uuidv7)NogeneratedPK—The public identifier; no separate public_id.
class_idintegerNo—composite FK belowclasses.id
academic_session_idintegerNo—composite FK belowacademic_sessions.idDenormalised from the class and made provably equal to it by the composite FK, not by application logic (attendance.ts:95-100).
attendance_datedateNo—unique w/ class_id—
statusattendance_session_statusNo'not_started'CHECK below—
completed_attimestamptzYes—CHECK below—Non-null iff status = 'completed'.
completed_by_user_iduuidYes—FK set nullusers.id
versionbigintNo0never indexed—The operator's optimistic-concurrency token, maintained only by bump_row_version.
created_at / updated_attimestamptzNonow()——

Constraints: student_attendance_sessions_class_session_fk — a composite FK on (class_id, academic_session_id) against classes(id, academic_session_id), ON DELETE/UPDATE restrict, targeting a UNIQUE CONSTRAINT on classes rather than a unique index (0020_attendance_module.sql:199-201); student_attendance_sessions_class_date_unique on (class_id, attendance_date); student_attendance_sessions_completed_at_matches_status, stated as an equivalence so neither incoherent row is representable (attendance.ts:141-144). Index student_attendance_sessions_date_status_idx on (attendance_date, status) — what the overview scans.

Carries bump_row_version.

student_attendance_session_runtime

Every system-written column the schedulers and the idempotency check touch, kept off the versioned session row on purpose (attendance.ts:153-160).

ColumnTypeNullableDefaultNotes
session_iduuidNo—PK, FK student_attendance_sessions.id ON DELETE cascade.
first_period_start_minutesmallintYes—Minutes from local midnight, snapshotted when the reminder sweep opens the register. NULL means "no first period known"; the absence sweep skips rather than guessing.
first_period_end_minutesmallintYes—Same snapshot.
snapshot_taken_attimestamptzYes—
reminder_sent_attimestamptzYes—Stamped with UPDATE ... WHERE reminder_sent_at IS NULL; the notification sends only if that reports one row.
last_submission_iduuidYes—The client-generated id of the last accepted bulk write — what tells an honest retry from a genuine conflict.
created_attimestamptzNonow()

CHECK student_attendance_session_runtime_minutes_bounded: both minute columns, when present, lie in [0, 1440].

Carries NO version and NO bump_row_version trigger. This is the module's central design decision — see §16.6.

student_attendance_records

One pupil's status for one day.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (uuidv7)NogeneratedPK—
session_iduuidNo—FK cascade, unique w/ student_idstudent_attendance_sessions.id
student_iduuidNo—FK restrictstudents.idrestrict never fires in practice: pupils soft-delete, so ON DELETE is not on the normal path; a departed pupil's historical attendance survives intact.
enrollment_iduuidYes—FK set nullstudent_class_enrollments.idWhich enrolment this was taken against, for a pupil who transferred mid-year.
statusattendance_statusNo———NOT NULL — unmarked is the absence of a row, never a stored null.
notetextYes—CHECK below—
forced_reasontextYes—CHECK below—Written only when an administrator force-marks a non-school day. A separate column from note so a near-limit operator note is never concatenated with it and pushed over the length CHECK.
guardian_alerted_attimestamptzYes—partial index below—Per-record, not per-session — see §16.6 for why that placement is load-bearing.
timetable_entry_iduuidYes—FK set nullclass_timetable_entries.idThe per-period seam; always NULL in this release, since attendance is daily, taken at first period.
leave_request_iduuidYes—no FK—The Leave Application seam; always NULL — that table does not exist yet.
marked_by_user_iduuidYes—FK set nullusers.id
marked_attimestamptzNonow()——
versionbigintNo0never indexed—
created_at / updated_attimestamptzNonow()——

Constraints: student_attendance_records_session_student_unique on (session_id, student_id); student_attendance_records_note_format and student_attendance_records_forced_reason_format — no leading/trailing whitespace, no control characters, bounded length (500 / 200). Indexes: student_attendance_records_student_idx on (student_id, id) — a pupil's own history as a keyset walk; student_attendance_records_pending_alert_idx, partial on (session_id) WHERE status = 'absent' AND guardian_alerted_at IS NULL — what the absence sweep scans, normally zero rows and never more than one morning's absences.

Carries bump_row_version.

staff_attendance_records

Teacher and staff attendance, in ONE table — a teacher is a staff row with a teaching designation, and there is no teacher entity to split against (attendance.ts:317-327).

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (uuidv7)NogeneratedPK—
staff_iduuidNo—FK restrict, unique w/ attendance_datestaff.id
attendance_datedateNo———
academic_session_idintegerNo—FK restrictacademic_sessions.idResolved from the session whose range CONTAINS the date, never is_current — there is no class to derive it from, so the containing range is the only correct rule.
statusattendance_statusNo———
notetextYes—CHECK below—
leave_request_iduuidYes—no FK—Same seam as the student table.
marked_by_user_iduuidYes—FK set nullusers.id
marked_attimestamptzNonow()——
versionbigintNo0never indexed—Used only by the single-record PATCH path — the bulk write is last-write-wins and never reads it.
created_at / updated_attimestamptzNonow()——

Constraints: staff_attendance_records_staff_date_unique on (staff_id, attendance_date); staff_attendance_records_note_format. Index staff_attendance_records_date_idx on (attendance_date).

Carries bump_row_version.

attendance_change_log

Every attendance transition, append-only, for both students and staff (attendance.ts:384-403).

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (uuidv7)NogeneratedPK—Monotonic, so it correctly tie-breaks changed_at.
subject_kindattendance_subject_kindNo———student or staff.
subject_iduuidNo—no FK—students.id or staff.id.
subject_name_snapshottextNo———Snapshotted because the log outlives the person.
class_idintegerYes—no FK—NULL for staff.
class_label_snapshottextYes———e.g. "Grade 5 A (Morning)" — what the screen renders when the class is gone.
attendance_datedateNo———
previous_statusattendance_statusYes—CHECK below—NULL means "was unmarked" — this is what puts the FIRST marking in history, not only corrections.
new_statusattendance_statusYes—CHECK below—NULL means "reset to unmarked".
reasontextYes—CHECK below—
changed_by_user_iduuidYes—no FK—No FK on purpose — see §16.6.
changed_by_name_snapshottextNo———
changed_attimestamptzNonow()——

Constraints: attendance_change_log_states_a_change — previous_status IS NOT NULL OR new_status IS NOT NULL; a row asserting that nothing changed is not representable. attendance_change_log_reason_format. Index attendance_change_log_subject_idx on (subject_kind, subject_id, changed_at DESC, id DESC) — the id DESC tiebreaker is mandatory, not decoration, because now() is constant within a transaction and one bulk mark writes many rows sharing one changed_at exactly. Also attendance_change_log_date_idx on (attendance_date) and attendance_change_log_class_date_idx on (class_id, attendance_date).

Carries NO version and NO bump_row_version. Carries the append-only trigger — see §5.4 and §11.

attendance_notification_settings

Which channels each attendance notification kind is allowed to use.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
kindtextNo—PK, CHECK below—Matches TEMPLATE_REGISTRY. text, not an enum, so a third kind needs a migration rather than a silent ALTER TYPE.
enabledbooleanNotrue——
channelsjsonbNo—CHECK below—An array over NOTIFICATION_CHANNEL; membership validated in the service, not by the CHECK.
updated_by_user_iduuidYes—FK set nullusers.id
versionbigintNo0never indexed—
created_at / updated_attimestamptzNonow()——

Constraints: attendance_notification_settings_kind_known — kind IN ('attendance.first_period_reminder', 'attendance.student_absent'); attendance_notification_settings_channels_array — jsonb_typeof(channels) = 'array' AND jsonb_array_length(channels) <= 4.

Carries bump_row_version. Seeded with both rows, enabled = true, channels = ["push", "in_app"] — matching CATEGORY_DEFAULTS.attendance, so the seed never advertises a channel the preference resolver would suppress for every guardian by default.

student_attendance_sessions, staff_attendance_records and student_attendance_records each carry a trigger that refuses the write when their academic_session_id (looked up directly on the first two, through session_id on the third) belongs to a session with locked_at set — see the academic-sessions backend doc's "academic-year lock" section for the trigger mechanism. student_attendance_session_runtime and attendance_change_log are not covered: the former is scheduler bookkeeping rather than the record itself, and the latter is written alongside an edit to a covered row, so the lock on that row already refuses the edit that would have produced the log entry.

5.3 The Runtime Split, Precisely

bump_row_version fires on any UPDATE, unconditionally — NEW.version := OLD.version + 1, with no IS DISTINCT FROM guard (0020_attendance_module.sql:206-211). The reminder sweep runs every minute of every school day and writes a first-period snapshot and a reminder_sent_at stamp; the idempotency check writes last_submission_id. None of those three facts is something an operator has a stake in reading atomically with version — they are the system's own bookkeeping. Had they lived on student_attendance_sessions, every sweep tick that touched an open register would silently bump the token a teacher's screen was holding, and the very next PUT from that screen would 409 with ATTENDANCE_VERSION_CONFLICT at exactly the minute the teacher was prompted to mark the register — the system denying the marking it exists to prompt. student_attendance_session_runtime exists to make that impossible by construction rather than by discipline: it has no version column, so bump_row_version cannot be attached to it at all (the function raises record "new" has no field "version" on the first UPDATE, at runtime, not at DDL time).

This is also why every session-open path — the bulk write, the reminder sweep — inserts with ON CONFLICT DO NOTHING and then re-selects, rather than issuing a no-op DO UPDATE: a no-op UPDATE is still an UPDATE, and bump_row_version cannot tell it apart from a real one.

5.4 The Append-Only Trigger

attendance_change_log_is_append_only() (0020_attendance_module.sql:263-275) is a row-level BEFORE UPDATE OR DELETE trigger that unconditionally raises, with ERRCODE = '2F002' (modifying_sql_data_not_permitted) — chosen over 23001 (restrict_violation) specifically because the latter reads as a foreign-key RESTRICT and would send a reader looking for one that does not exist here. It is row-level rather than statement-level so TRUNCATE is unaffected, which is what the integration test harness uses to clear tables between specs; a statement-level trigger here would fail every attendance test that touches the log.

5.5 Relationship Diagram

5.6 Index Rationale

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
student_attendance_sessions_class_date_uniqueclass_id, attendance_dateunique btreeOne register per class per day; also the ON CONFLICT target for lazy session creation.None — this is the natural key.
student_attendance_sessions_date_status_idxattendance_date, statusbtreeThe school-wide overview's per-date scan.Small write overhead on every session status change.
student_attendance_records_session_student_uniquesession_id, student_idunique btreeOne record per pupil per register; the bulk write's ON CONFLICT target.None.
student_attendance_records_student_idxstudent_id, idbtreeA pupil's own history/records as a keyset walk.None material.
student_attendance_records_pending_alert_idxsession_idpartial btree, WHERE status='absent' AND guardian_alerted_at IS NULLThe absence sweep's scan — kept to normally zero rows.Partial index avoids bloating on marked/alerted rows; must be recreated if the predicate ever changes.
staff_attendance_records_staff_date_uniquestaff_id, attendance_dateunique btreeOne record per person per day; the bulk write's ON CONFLICT target.None.
attendance_change_log_subject_idxsubject_kind, subject_id, changed_at DESC, id DESCbtreeBoth history routes' keyset pagination.The id DESC tiebreaker is required because now() ties within a transaction.
academic_sessions_no_active_overlapdaterange(start_date,end_date,'[)')GIST EXCLUDE, WHERE is_activeGuarantees CalendarDayService.sessionContaining has at most one answer.Owned by the academic-calendar migration; attendance's correctness depends on it holding.

6. Services and Responsibilities

6.1 SchoolClockService

MethodCalled ByReadsWritesSide EffectsErrors
timezone(executor?)Every write path, both processorsschool_profile.timezone———
nowInZone(timezone, at?)now(), and directly by the processors— (pure Intl computation)———
now(executor?)StudentAttendanceWriteService, StaffAttendanceServiceschool_profile.timezone———

Built on Intl.DateTimeFormat parts rather than a hard-coded +05:45 offset, so a school in a DST-observing zone gets the right side of a transition without this code knowing DST exists (school-clock.service.ts:45-53). hour12: false is explicitly normalised with % 24 because some ICU builds render midnight as 24, which would otherwise produce minuteOfDay = 1440 and match nothing (school-clock.service.ts:74-76).

6.2 CalendarDayService

MethodCalled ByReadsWritesSide EffectsErrors
sessionContaining(date, executor?)Both write paths, AttendanceOverviewService, both processors, ClassesReadService.findOptionsacademic_sessions———
requireWithinSession(academicSessionId, date, executor?)StudentAttendanceReadService.loadState (so the GET, bulkMark's re-read and every lifecycle re-read), StudentAttendanceWriteService.bulkMarkacademic_sessions——422 ATTENDANCE_DATE_OUTSIDE_SESSION
find(academicSessionId, date, executor?)require, both processorsacademic_calendar_days———
require(academicSessionId, date, executor?)StudentAttendanceReadService.loadState, StudentAttendanceWriteService.bulkMarkacademic_calendar_days——409 ACADEMIC_CALENDAR_NOT_GENERATED

sessionContaining selects WHERE is_active AND start_date <= date AND end_date > date and reads .limit(1) — the academic_sessions_no_active_overlap EXCLUDE constraint, not the query, guarantees at most one active session can ever cover a date. attendanceExpected on find's result is computed in TypeScript from isAttendanceRequired(classification) (packages/db/src/calendar/calendar-contract.ts:115-121) and is never stored — a value with a compile-time exhaustiveness proof over every calendar classification, so a classification added without an attendance decision fails the build rather than silently defaulting.

6.3 AttendanceRosterService

MethodCalled ByReadsWritesSide EffectsErrors
forSession(classId, sessionId, executor?)Every student-attendance read and write path, the overview, the reminder/absence sweeps' class resolutionstudent_class_enrollments, students, users, student_attendance_records———
tally(roster)Every caller of forSession that needs counts— (pure, over the array forSession returned)———

forSession is the row-scope layer: it filters students.deleted_at IS NULL, students.record_status = 'active', and the enrolment's own status = 'active' — all three, because a soft delete never fires ON DELETE and an enrolment can outlive its pupil with its own status untouched (attendance-roster.service.ts:28-38). When sessionId is null (a register never opened) the LEFT JOIN condition is forced to sql\false`, so every roster row reports status: nullrather than joining nothing at all. Ordering isrollNumber ASC NULLS LAST, admissionNumber, id` — a pupil with no assigned roll sorts last, and the two tiebreakers give pagination and the tally one stable order.

6.4 AttendanceLogService

MethodCalled ByReadsWritesSide EffectsErrors
append(executor, entries)Every write and lifecycle path in both the student and staff surfaces—attendance_change_log——

Filters entries to previousStatus !== newStatus before inserting, so a resubmitted, unchanged register appends nothing to a table the append-only trigger will never let anyone prune (attendance-log.service.ts:39-42). Takes the caller's transaction as a required parameter — there is no default executor, unlike every read service in this module — because a log row that commits independently of the change it describes would assert something that may have rolled back.

6.5 StudentAttendanceReadService

MethodCalled ByReadsWritesSide EffectsErrors
resolveClass(executor, classPublicId)Every admin and teacher-portal entry pointclasses, grades, sections——404 CLASS_NOT_FOUND
resolveClassById(executor, classId)StudentAttendanceWriteService.patchRecordsame——404 CLASS_NOT_FOUND
findSession(executor, classId, date)loadState, lifecycle's requireSessionstudent_attendance_sessions———
loadState(executor, resolvedClass, date)getState, every write/lifecycle method's final re-readcalendar, session, roster (via the other three services)——422 via CalendarDayService.requireWithinSession; 409 via CalendarDayService.require
getState(classPublicId, date)StudentAttendanceController.getAttendance(delegates)——(delegates)

loadState is the one assembly point: session-range gate (a date outside the class's own session is refused before the calendar is consulted, so a date that belongs to a different academic year is named as such rather than reported as a missing calendar row), calendar gate, session (or its confirmed absence), roster and tally, all from one population, returned identically by the GET, the bulk write, and every lifecycle transition — never an echo of the request (student-attendance-read.service.ts:139-183). buildClassLabel ("Grade {grade} {section} ({Shift})") and composeFullName (collapsed whitespace, matching the generated users.full_name expression) are static, so the admin surface, the overview and every portal render identically formatted labels and names.

6.6 StudentAttendanceWriteService

MethodCalled ByReadsWritesSide EffectsErrors
bulkMark(actor, input, options?)StudentAttendanceController.mark, TeacherAttendancePortalService.markAttendanceclasses, academic sessions, calendar, session, runtime, rosterstudent_attendance_sessions (insert/update), student_attendance_records (upsert/delete), attendance_change_log, student_attendance_session_runtimenone async — every effect is inside the one transaction422 ATTENDANCE_EMPTY_PAYLOAD, ATTENDANCE_DUPLICATE_STUDENT, ATTENDANCE_DATE_IN_FUTURE, ATTENDANCE_DATE_NOT_TODAY, ATTENDANCE_DATE_OUTSIDE_SESSION, ATTENDANCE_NOT_A_SCHOOL_DAY, ATTENDANCE_STUDENT_NOT_ENROLLED; 409 ATTENDANCE_VERSION_CONFLICT, ATTENDANCE_ALREADY_COMPLETED; 500 WRITE_RETURNED_NO_ROW
patchRecord(actor, recordId, dto)StudentAttendanceController.correctstudent_attendance_records, student_attendance_sessions, students, usersstudent_attendance_records, attendance_change_log—404 ATTENDANCE_RECORD_NOT_FOUND, ATTENDANCE_SESSION_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT

The full narrative for bulkMark is in §16.3. patchRecord is the correction path, keyed on the record's own version rather than the session's, and it is explicitly permitted against a completed session — that permission is what makes it a correction path rather than a duplicate of bulkMark. Its concurrency check is a conditional UPDATE ... WHERE id = $1 AND version = $2; zero rows returned is then disambiguated by a second SELECT into "the record no longer exists" (404) versus "someone else changed it" (409).

6.7 StudentAttendanceLifecycleService

MethodCalled ByReadsWritesSide EffectsErrors
complete(actor, input)StudentAttendanceController.complete, TeacherAttendancePortalService.completeAttendancesession, rosterstudent_attendance_sessions—404 ATTENDANCE_SESSION_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT, ATTENDANCE_ALREADY_COMPLETED; 422 ATTENDANCE_INCOMPLETE_ROSTER
reopen(_actor, input)StudentAttendanceController.reopensessionstudent_attendance_sessions—404 ATTENDANCE_SESSION_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT, ATTENDANCE_NOT_COMPLETED
reset(actor, input)StudentAttendanceController.resetsession, rosterstudent_attendance_records (delete all), attendance_change_log, student_attendance_sessions, student_attendance_session_runtime—404 ATTENDANCE_SESSION_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT

None of the three registers as not_started/in_progress/completed transitions a per-pupil status directly — each acts on student_attendance_sessions.status alone. complete refuses with ATTENDANCE_INCOMPLETE_ROSTER (422) unless every row the LIVE roster returns has a non-null status, evaluated against that roster read inside the same statement rather than against any stored counter — the tally can never disagree with the roster because they are the same query. reset deletes every record for the class/date, logs one deletion row per previously-marked pupil (newStatus: null), returns the session to not_started, and clears last_submission_id so a subsequent bulk write is not treated as a stale replay. All three require input.expectedVersion to be supplied (the field is mandatory on the DTO) — reset carries the same token as complete and reopen even though it was omitted from an early draft, because it is the most destructive path in the module and a stale screen must not be able to erase marks someone else just saved.

6.8 AttendanceHistoryService

MethodCalled ByReadsWritesSide EffectsErrors
getHistory(studentId, query)StudentAttendanceHistoryController.getHistoryattendance_change_log——400 PAGINATION_CURSOR_INVALID

Keyset on (changed_at DESC, id DESC), filtered to subjectKind = 'student'. Both the ORDER BY and the cursor comparison wrap changed_at in date_trunc('millisecond', ...), because Postgres stores timestamptz to microsecond precision while the Node driver's Date can only hold millisecond precision — without the truncation, a cursor built from a row read back through a Date compares against the database's untruncated value and never matches, silently returning zero rows for the remainder of a tie (attendance-history.service.ts:29-41). The cursor itself is base64url(JSON.stringify([changedAtIso, id])).

6.9 AttendanceOverviewService

MethodCalled ByReadsWritesSide EffectsErrors
getOverview(date)AttendanceOverviewController.getOverviewacademic_sessions (via CalendarDayService), classes, grades, sections, staff, users, student_attendance_sessions, student_attendance_records, student_attendance_session_runtime, class_timetables, class_timeslots, plus ClassOccupancyService——— (empty array on no session/no classes)

One row per active class in the session containing date. Every count — occupancy, marked/present/absent/late/excused, unalerted absences, reminder-sent timestamp, published/period-today schedule state — is resolved as a handful of grouped queries run in parallel (Promise.all), never a per-class round trip; a forty-class school costs a fixed number of queries, not forty (attendance-overview.service.ts:72-89). scheduleState is derived per class from whether its timetable is published and whether it has a period on date's weekday, computed by parsing the civil date at UTC midnight (weekdayOf, attendance-overview.service.ts:245-247) — there is no instant to get wrong in a plain calendar date, only a calendar day.

6.10 StaffAttendanceService

MethodCalled ByReadsWritesSide EffectsErrors
list(query)StaffAttendanceController.liststaff, users, designations, departments, staff_attendance_records———
bulkWrite(actor, dto)StaffAttendanceController.bulkWritestaff, users, academic_sessions, staff_attendance_recordsstaff_attendance_records (upsert/delete), attendance_change_log—422 ATTENDANCE_EMPTY_PAYLOAD, ATTENDANCE_DUPLICATE_STUDENT, ATTENDANCE_DATE_IN_FUTURE, ATTENDANCE_DATE_OUTSIDE_SESSION; 404 STAFF_ATTENDANCE_RECORD_NOT_FOUND
patch(actor, recordId, dto)StaffAttendanceController.patchstaff_attendance_records, staff, usersstaff_attendance_records, attendance_change_log—404 STAFF_ATTENDANCE_RECORD_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT
history(staffId, query)StaffAttendanceHistoryController.historyattendance_change_log——400 PAGINATION_CURSOR_INVALID

list fetches the full filtered roster (every live staff row, LEFT JOINed to that date's record) and then slices it in memory according to PaginationUtil's offset/limit — the tally is always computed over the same unsliced roster the page is drawn from, so a page boundary can never change what the tally reports. bulkWrite runs the calendar-day gate's session-membership half only (sessionContaining, 422 ATTENDANCE_DATE_OUTSIDE_SESSION if none) and never the attendanceExpected half — staff attendance is intentionally recordable on a holiday or an exam day, since refusing to record it would make the register unable to describe the days it most needs to (staff-attendance.service.ts:180-182). It is also last-write-wins: no session row exists to carry a concurrency token, so whichever PUT commits last is what the day shows; patch, in contrast, uses the record's own version, because that path has a real row to check optimistically against. history builds its keyset cursor from Postgres's own ::text rendering of changed_at fed back through ::timestamptz, a different technique from the student history's date_trunc approach but solving the identical precision-mismatch problem.

6.11 AttendanceSettingsService

MethodCalled ByReadsWritesSide EffectsErrors
list()AttendanceSettingsController.listattendance_notification_settings——404 ATTENDANCE_SETTINGS_NOT_FOUND
update(actor, kind, dto)AttendanceSettingsController.updateattendance_notification_settingsattendance_notification_settings—404 ATTENDANCE_SETTINGS_KIND_UNKNOWN, ATTENDANCE_SETTINGS_NOT_FOUND; 422 ATTENDANCE_CHANNEL_UNKNOWN; 409 ATTENDANCE_VERSION_CONFLICT

list fails closed: if either of the two known kinds is missing its row, the whole read 404s rather than returning a partial or defaulted list — the workers themselves fail closed on a missing row, and the settings screen must not claim a channel state that does not exist. update validates channel membership and uniqueness in the service (422 ATTENDANCE_CHANNEL_UNKNOWN), never creates a row, and takes a SELECT ... FOR UPDATE plus a version check before writing.

6.12 TeacherAttendancePortalService

MethodCalled ByReadsWritesSide EffectsErrors
listMyClasses(actor)TeacherAttendancePortalController.listMyClassesstaff (via assertTeacherStaffRow), classes, grades, sections, academic_sessions, ClassOccupancyService——403 via assertTeacherStaffRow
getAttendance(actor, classPublicId, date).getAttendance(delegates to StudentAttendanceReadService)——403 ATTENDANCE_NOT_CLASS_TEACHER
markAttendance(actor, dto).markAttendance(delegates to StudentAttendanceWriteService.bulkMark)(same as bulkMark)—403 ATTENDANCE_NOT_CLASS_TEACHER, plus every error bulkMark can raise
completeAttendance(actor, dto).completeAttendance(delegates to StudentAttendanceLifecycleService.complete)(same as complete)—403 ATTENDANCE_NOT_CLASS_TEACHER, plus every error complete can raise

Every method resolves the caller's live staff.id via assertTeacherStaffRow first, then asserts resolvedClass.classTeacherId === staffId (assertOwnClass, throwing ATTENDANCE_NOT_CLASS_TEACHER) before touching the shared services. markAttendance passes { refuseIfCompleted: true, requireToday: true } into the exact same bulkMark transaction the admin surface calls — never a second implementation of the write.

6.13 StudentAttendancePortalService

MethodCalled ByReadsWritesSide EffectsErrors
myAttendance(actor, query)StudentAttendancePortalController.myAttendancestudents, student_attendance_records, student_attendance_sessions, classes, grades, sections——404 STUDENT_NOT_FOUND

Resolves the caller's own students row by user_id = actor.id, filtering deleted_at only. The response carries status, date, a composed class label, a note, and the Bikram Sambat parts of the date — deliberately no marked_by and no staff identity, since a pupil reading their own attendance must not learn which staff member marked them absent.

6.14 GuardianAttendancePortalService

MethodCalled ByReadsWritesSide EffectsErrors
childAttendance(actor, studentId, query)GuardianAttendancePortalController.childAttendanceguardians, student_guardian, students, users, student_attendance_records, student_attendance_sessions, classes, grades, sections——404 STUDENT_NOT_FOUND

resolveWard joins guardians → student_guardian → students → users and filters deleted_at on all three of guardians, students and users — and nothing else. It deliberately does not filter students.record_status, because that column is an administrator's ordinary record-keeping toggle (set by a plain Students_UPDATE), and promoting it to an existence predicate here would make a parent's read of their own child stricter than the pupil's own read of themselves, and would hand any Students_UPDATE holder a silent, unlogged way to revoke a parent's access. A non-ward answers 404, never 403, so the id space is not an enumeration oracle over a roll of children.

The one addition to that predicate is NOT graduateAccessEnded(students.id, today) (graduate-access.query.ts) — a pupil whose graduate access window has ended stops being reachable as "this guardian's child" in the attendance portal, and resolveWard answers the same 404 STUDENT_NOT_FOUND a non-ward gets. This is layered on top of the identity check rather than folded into students.record_status, so the graduation state (not the unrelated record-keeping flag) is what ends a parent's read of a child's attendance.

6.14a PortalAttendanceSummaryService

MethodCalled ByReadsWritesSide EffectsErrors
forStudent(studentId)StudentAttendancePortalController.mySummary, GuardianAttendancePortalController.childSummaryacademic_sessions, academic_calendar_days, student_class_enrollments, student_attendance_sessions, student_attendance_records; the school clockNoneRedis read, fire-and-forget Redis write on a missNone of its own
  • Identity first. The controllers resolve the pupil before calling (StudentAttendancePortalService.resolveOwnStudentId from the token; GuardianAttendancePortalService.resolveChildId through the ward predicate). The service takes a resolved student id and applies no access rule of its own, so the cache entry is only reachable after the identity check and one entry serves both audiences.
  • Window. From the current session's start_date to school-local today, or to the session's end_date once it has passed. When there is no current session, or to precedes the start, the service returns an all-zero summary with null bounds and rate.
  • Totals. portalAttendanceSummary (reports/portal/queries/portal-attendance.query.ts) counts the days that are attendance-required in the calendar and fall inside one of the pupil's enrolment spans in the session, then joins the class's attendance session for that day and the pupil's record. ratePct = round((present + late) * 10000 / marked) / 100 through pctStrict: a share strictly between none and all is clamped to 0.01–99.99, so a single absence never reads 100%, while none stays 0 and all stays 100. ratePct is null when nothing is marked. unmarked is max(expectedDays - marked, 0).
  • Cache. See §8.

6.15 AttendanceReminderProcessor

MethodCalled ByReadsWritesSide EffectsErrors
process(job)AttendanceQueueProcessorsettings, calendar/session, classes/grades/sections, class_timetables, class_timeslots, student_attendance_sessions, student_attendance_session_runtime, staffstudent_attendance_sessions (lazy open), student_attendance_session_runtime (snapshot + reminder_sent_at)NotificationService.send (push/in-app/sms/email to the class teacher)none thrown — every skip is a logged early return

Full narrative in §7.7.

6.16 AttendanceAbsenceProcessor

MethodCalled ByReadsWritesSide EffectsErrors
process(job)AttendanceQueueProcessorsettings, school_week_config, calendar/session, school_profile, student_attendance_records joined through session/runtime/class/grade/section/student/userstudent_attendance_records.guardian_alerted_atNotificationService.send (to the pupil's guardians)none thrown — every skip is a logged early return

Full narrative in §7.8.

6.17 AttendanceScheduler / AttendanceQueueProcessor

MethodCalled ByReadsWritesSide EffectsErrors
AttendanceScheduler.enqueueReminderSweep() / .enqueueAbsenceSweep()@Cron(EVERY_MINUTE)—BullMQ (attendanceQueue.add)enqueues one job per bucketcaught and logged, never thrown — a throwing cron handler can take the scheduler's next tick with it
AttendanceQueueProcessor.process(job)BullMQ worker dispatch——routes to the matching processor by job.namethrows only for a job name with no handler, which the Record<AttendanceJob, …> typing makes unreachable in practice

7. Runtime Flows

7.1 Bulk mark / correct the student register

StepCode PathBehaviorFailure Case
1StudentAttendanceController.mark / TeacherAttendancePortalController.markAttendanceReceives the DTO, resolves the actor.DTO validation (400).
2StudentAttendanceWriteService.bulkMarkRejects an empty payload and duplicate studentIds before opening a transaction.422 ATTENDANCE_EMPTY_PAYLOAD / ATTENDANCE_DUPLICATE_STUDENT.
3sameRejects a future date; the portal additionally rejects a non-today date.422 ATTENDANCE_DATE_IN_FUTURE / ATTENDANCE_DATE_NOT_TODAY.
4CalendarDayService.requireWithinSession, inside the transactionConfirms the date falls within the class's own academic session (mirrors the composite FK).422 ATTENDANCE_DATE_OUTSIDE_SESSION.
5CalendarDayService.requireConfirms a calendar row exists, and whether attendance is expected.409 ACADEMIC_CALENDAR_NOT_GENERATED; 422 ATTENDANCE_NOT_A_SCHOOL_DAY unless force.
6sameOpens the register without a no-op UPDATE.500 WRITE_RETURNED_NO_ROW if the re-select somehow finds nothing.
7sameIdempotency: a repeated submissionId short-circuits to a re-read.— (200, no write).
8sameOptimistic concurrency on the session's version.409 ATTENDANCE_VERSION_CONFLICT.
9AttendanceRosterService.forSessionReads the live roster inside the transaction; every submitted studentId must be on it.422 ATTENDANCE_STUDENT_NOT_ENROLLED.
10sameOne upsert statement for marked entries, one delete for unmarked entries.—
11AttendanceLogService.appendDiffs against the pre-image roster; writes only real changes.—
12sameAdvances not_started → in_progress guarded by a conditional UPDATE.—
13sameStamps last_submission_id.—
14StudentAttendanceReadService.loadStateRe-reads the full envelope.—

7.2 Complete / reopen / reset

BranchConditionBehaviorError/Result
No session rowThe class/date was never openedrequireSession throws before any transition runs404 ATTENDANCE_SESSION_NOT_FOUND
Stale tokenexpectedVersion supplied and mismatchedRefused before any write409 ATTENDANCE_VERSION_CONFLICT
Complete an incomplete rosterAny live roster row has status: nullRefused422 ATTENDANCE_INCOMPLETE_ROSTER
Complete twicesession.status === 'completed'Refused409 ATTENDANCE_ALREADY_COMPLETED
Reopen a register never completedsession.status !== 'completed'Refused409 ATTENDANCE_NOT_COMPLETED
ResetAny prior stateDeletes every record, logs one deletion per previously-marked pupil, clears the runtime's last_submission_id, returns to not_started200, re-read state

7.3 Correct one record (PATCH .../:recordId)

Keyed on the record's own version. Permitted against a completed session — this is deliberately the correction path for a closed day. A version mismatch and a deleted record are disambiguated by a follow-up SELECT after the conditional UPDATE returns zero rows: still exists → 409 ATTENDANCE_VERSION_CONFLICT; gone → 404 ATTENDANCE_RECORD_NOT_FOUND.

7.4 Staff register bulk write (last-write-wins)

No session row exists to carry an optimistic token, so this write is last-write-wins by design; the calendar's attendanceExpected gate is never consulted here.

7.5 The teacher portal's register (reuse, not duplication)

TeacherMarkAttendanceDto carries no force/forcedReason field, and the global ValidationPipe's forbidNonWhitelisted turns sending either into a 400 — which is what makes the holiday override unreachable from a teacher session rather than merely undocumented there.

7.6 Student and guardian portal reads

Both follow the same shape: assert the audience (assertPortalAudience), resolve the caller's own identity or their ward (filtering deleted_at only — the identity layer), then select attendance records joined to sessions/classes/grades/sections for the requested date range, returning status, a composed class label, a note, and the Bikram Sambat date parts — never marked_by. A guardian additionally resolves the ward through student_guardian, and a non-ward answers 404 rather than 403.

7.7 First-period reminder sweep

The window is [startMinute - 5, startMinute) — open at the top so a reminder never arrives after the lesson has begun, and five minutes wide so a missed, late, or slow tick is recovered by the next one rather than losing that cohort permanently. The guarded stamp (UPDATE ... WHERE reminder_sent_at IS NULL) is what makes sending exactly-once across two replicas and a replayed tick, without ever touching student_attendance_sessions.version.

The session this sweep opens always belongs to the current academic session, which the academic_sessions_current_not_locked CHECK forbids ever locking — so this insert can never be refused by the year lock, and the sweep carries no lock check of its own.

7.8 Guardian absence-alert sweep

The marker is on the record, not the session — a session-level stamp set when first period ends would close the alert window before the register is usually taken (the reminder fires five minutes before the period starts, so a class marked during or after the lesson would have zero absent records at the session-level stamping moment, and no guardian would ever be told). LIMIT 500, ORDER BY id keeps each tick bounded and lets the next tick resume deterministically; in practice student_attendance_records_pending_alert_idx never holds more than one morning's absences.

Every candidate record belongs to today's session in the current academic session, which the same CHECK keeps unlockable — the guarded UPDATE guardian_alerted_at can never be refused by the year lock either.

7.9 History pagination — the shared shape, two cursor techniques

Both GET .../students/:studentId/history and GET .../staff/:staffId/history are keyset-paginated on (changed_at DESC, id DESC), with data as the array and nextCursor as a top-level envelope field, never nested. The id tiebreaker is mandatory because now() is constant within one transaction, so one bulk mark writes many rows sharing an identical changed_at. The student path truncates both sides of the comparison to millisecond precision with date_trunc; the staff path instead round-trips changed_at through Postgres's own ::text rendering. Either technique closes the same gap — a Date-based cursor silently failing to equal the microsecond-precision row it was read from — and each history route uses exactly one of them consistently.

8. Caching

The only cached reads are the two portal attendance summary routes. Every other read is either a direct query (the register, the overview, history) or a derived-in-memory computation over one query's result (the tally).

Cache Key PatternBuilderValueTTLInvalidationCaller
portal:attendance-summary:shape:1-student:<studentId>-today:<YYYY-MM-DD>CacheKeyUtil.build("portal:attendance-summary:", [...])PortalAttendanceSummaryDto60sNone. The date in the key rolls the entry at school midnight; a register write, correction or calendar edit is visible within the TTL.PortalAttendanceSummaryService.forStudent

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 computes from the database. cacheSetFast does not await, so a slow or lost write costs the next reader a miss and never delays the response.

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.ATTENDANCEAttendanceJob.FIRST_PERIOD_REMINDER ("attendance.first_period_reminder")AttendanceScheduler.enqueueReminderSweep (@Cron(EVERY_MINUTE))AttendanceQueueProcessor → AttendanceReminderProcessorAttendanceSweepPayload { correlationId? }BullMQ defaults (this queue registers no custom attempts/backoff)jobId = buildJobId(["attendance","reminder", bucket]), where bucket is floor(Date.now()/60000) — the SAME 60-second interval as the cron, so BullMQ's existing-jobId dedupe cannot collapse two distinct ticks. Exactly-once send is additionally enforced at the row level by the guarded reminder_sent_at update.
QueueName.ATTENDANCEAttendanceJob.ABSENCE_ALERT ("attendance.absence_alert")AttendanceScheduler.enqueueAbsenceSweepAttendanceQueueProcessor → AttendanceAbsenceProcessorAttendanceSweepPayload { correlationId? }BullMQ defaultsSame per-minute bucketed jobId; exactly-once send enforced by the guarded guardian_alerted_at update.

AttendanceSweepPayload deliberately carries no minute (packages/jobs/src/index.ts:1034-1044): an earlier draft passed the scheduler's own minute, and a job delayed past it would serve the wrong cohort while the correct job for that minute found the notification already deduplicated — one minute's classes would silently get nothing while the log reported two successful sweeps. Instead each worker computes the local minute itself and selects over a window, so a late job still serves the right rows.

AttendanceQueueProcessor is the ONE @Processor(QueueName.ATTENDANCE, { concurrency: 2 }) class on this queue. AttendanceReminderProcessor and AttendanceAbsenceProcessor are plain @Injectable() handlers it dispatches to by job.name through a Record<AttendanceJob, …> map — never a second @Processor on the same queue, which nestjs/bullmq would otherwise instantiate as an independent competing Worker capable of running the wrong handler against the wrong payload.

Every enqueue in this module goes through BullMQ directly from a @Cron handler with no accompanying database write — the one exemption this repository's outbox rule carries for a cron-triggered job. Both cron methods catch and log rather than throw, because a throwing cron handler can take the scheduler's next tick with it; the log line is therefore the only evidence of a failed enqueue.

AttendanceWorkersModule declares no controller and is composed by the application root outside the fast runtime profile — importing a module that declares a @Processor starts a worker, and the fast profile deliberately runs none.

10. Realtime and Events

None. This module emits no Socket.IO/SSE realtime event; the only realtime event in the whole application today is feedback.submitted, owned by the support module.

11. Security, Auth, and Abuse Controls

Guards. JwtAuthGuard (global) and RoleGuard sit on every controller in this module. The admin and staff controllers declare @Permissions() on every handler: StudentAttendance_READ/CREATE/UPDATE/DELETE, StaffAttendance_READ/CREATE/UPDATE, AttendanceSettings_READ/UPDATE.

Permissions and why they are granted to no seeded role. StudentAttendance, StaffAttendance and AttendanceSettings are all declared in PERMISSION_MODULES (packages/db/src/authorization/permission-catalog.ts:258,263,267), but seed-auth.ts grants none of their codes to any seeded role. The catalogue's own comment states why (permission-catalog.ts:243-257): TEACHER_PERMISSIONS spreads STAFF_PERMISSIONS (seed-auth.ts:184), so any grant to staff reaches every teacher role identically, and the seeded staff/teacher roles both carry scopeKind: "all" — which makes Students_READ combined with that scope an unscoped read over every pupil in the school. Granting StudentAttendance_* to staff would hand every teacher the unscoped admin surface: read any pupil's history, reset any class's day. Delegating attendance to a non-administrator is instead a role the school creates deliberately, holding these codes and nothing else; grantToRoles never revokes, so withholding by default is the recoverable direction.

AttendanceView — the screen-only code. AttendanceView (permission-catalog.ts:277) gates the panel's three attendance screens — a pupil's own, a guardian's per-child view and a class teacher's register — and appears on no API route, so it grants no data access. AttendanceView_READ is seeded to staff (seed-auth.ts:113, which reaches teacher through the spread), guardian (:254) and student (:316); permissions:sync reads those grants from rolePermissionsMap. It is also the successor of AcademicCalendarView_READ in PERMISSION_SUCCESSORS (permission-catalog.ts:1006): when permissions:sync creates AttendanceView_READ, it grants it to every role — custom roles included — that already holds AcademicCalendarView_READ (grantSuccessorsToPredecessorHolders, apps/api/scripts/sync-permissions.ts:157), so a custom role that could open the attendance screens keeps them. The backfill runs only when the code is created; revoking it from a role afterwards is not undone by a later sync. It is listed in PERSON_BOUND_SCREEN_MODULES (permission-catalog.ts:961), so a superadmin's GET /api/auth/permissions list omits it: a superadmin account has no pupil, ward or class for the screens to show. It is split from AcademicCalendarView because the calendar is also an administrator screen and cannot be withheld.

The portal's audience assertion, and the two files a new handler must touch. None of the six portal handlers below carries @Permissions(). Each instead calls assertPortalAudience(actor, "student" | "guardian") or assertTeacherStaffRow(db, actor) — functions that prove who is calling, never which rows come back; the row-scope predicate in each query is the actual control. Because each handler declares no permission, its key must appear in both NO_PERMISSION_ADMIN_HANDLERS in role.guard.ts:176-224 and its CI mirror route-permissions.spec.ts:102-107, or the route 403s for everyone including superadmin with an otherwise fully green suite (the integration specs call the services directly and never pass through the guard, so that failure mode is invisible to pnpm test).

HandlerControl
TeacherAttendancePortalController.listMyClassesassertTeacherStaffRow resolves the caller's live staff.id; the query filters classes.class_teacher_id on it. class_teacher_id is nullable, so this is the one place the predicate itself IS the control — omitting it fails open to every class in the school.
TeacherAttendancePortalController.getAttendanceSame predicate, applied before loadState.
TeacherAttendancePortalController.markAttendanceSame predicate, plus requireToday/refuseIfCompleted options that make correcting a closed day the admin surface's job, not an un-permissioned path around StudentAttendance_UPDATE.
TeacherAttendancePortalController.completeAttendanceSame predicate.
StudentAttendancePortalController.myAttendanceTakes no id at all; the pupil is students.user_id = actor.id, so there is no parameter that could name another child.
GuardianAttendancePortalController.childAttendanceThe ward check runs first and 404s on a miss, never 403 — the id space over a roll of children must not become an enumeration oracle.

Row-scope filters. Every student-facing read in this module distinguishes the identity layer (who is this, filtering deleted_at only, failing as a 404) from the row-scope layer (which of their class rows come back, filtering deleted_at, record_status = 'active', and the enrolment's own status, failing as an empty list). AttendanceRosterService.forSession is the row-scope layer; StudentAttendancePortalService's own-identity resolution and GuardianAttendancePortalService.resolveWard are the identity layer, and neither filters record_status — see §6.14 for why that omission is deliberate rather than a gap.

Input normalization. Every free-text field that maps to a Postgres CHECK carrying the "no padding, no control characters, bounded length" pattern is validated in the DTO with a matching regex (NO_PADDING_OR_CONTROL_CHARS in the student DTOs) or a regex plus a custom @NoControlCharacters() decorator (the staff DTOs) — so the CHECK is unreachable from a well-behaved client rather than surfacing as an unmapped 500.

Audit. Every mutating admin/staff route is recorded by the application-global ActivityAuditInterceptor, deriving its module/action from the handler's own @Permissions() code. This module additionally writes its own, richer, append-only trail to attendance_change_log for every marking, correction and deletion — including the very first marking — because the interceptor's audit entry answers "who did what" while the change log answers "what was this pupil's attendance, over time."

Fail-closed decisions. CalendarDayService.require never defaults an undecided date to a school day (409). AttendanceSettingsService.list/update never default a missing settings row to enabled or disabled (404). Both workers treat an absent, disabled, or channel-less settings row as "send nothing," logged and returned rather than thrown.

Rate limits. None specific to this module; it relies on whatever global throttling the application applies.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
ACADEMIC_CALENDAR_NOT_GENERATED409CalendarDayService.requireNo academic_calendar_days row for this session/date.Generate the calendar for the date before retrying.
ATTENDANCE_NOT_A_SCHOOL_DAY422StudentAttendanceWriteService.bulkMarkCalendar row exists but attendanceExpected is false, and force was not set.Resend with force: true and a forcedReason, or pick a school day.
ATTENDANCE_SESSION_NOT_FOUND404Lifecycle service, patchRecord's session lookupNo register exists for the class/date.The register must be opened (by a mark) before it can be transitioned.
ATTENDANCE_RECORD_NOT_FOUND404patchRecord, loadRecordDtoThe record id does not exist.Reload the register.
ATTENDANCE_DATE_IN_FUTURE422bulkMark, StaffAttendanceService.bulkWritedate > SchoolClockService.now().isoDate.Resend with a non-future date.
ATTENDANCE_DATE_OUTSIDE_SESSION422CalendarDayService.requireWithinSession (register read, bulkMark and every lifecycle re-read), StaffAttendanceService.bulkWriteDate falls outside the resolved academic session's range.Resend with a date inside the class's academic session.
ATTENDANCE_DATE_NOT_TODAY422bulkMark under requireToday (teacher portal only)Portal write for a date other than today, in the school's timezone.Correcting a closed day requires the admin surface.
ATTENDANCE_NOT_CLASS_TEACHER403TeacherAttendancePortalService.assertOwnClassCaller is not classes.class_teacher_id for the addressed class.Only that class's own teacher may act on this route.
ATTENDANCE_STUDENT_NOT_ENROLLED422bulkMarkA submitted studentId has no active enrolment in the class.Remove the pupil from the payload or verify the enrolment.
ATTENDANCE_ALREADY_COMPLETED409bulkMark (refuseIfCompleted), LifecycleService.completeThe register is already completed.Correct via PATCH .../:recordId, or reopen first.
ATTENDANCE_NOT_COMPLETED409LifecycleService.reopenThe register is not completed.Nothing to reopen.
ATTENDANCE_INCOMPLETE_ROSTER422LifecycleService.completeA live roster row has no record.Mark every pupil before completing.
ATTENDANCE_EMPTY_PAYLOAD422bulkMark, StaffAttendanceService.bulkWriteentries is empty.Submit at least one entry.
ATTENDANCE_DUPLICATE_STUDENT422bulkMark, StaffAttendanceService.bulkWriteThe same person appears twice in one payload.De-duplicate the payload.
ATTENDANCE_VERSION_CONFLICT409bulkMark, lifecycle methods, patchRecord, StaffAttendanceService.patch, AttendanceSettingsService.updateexpectedVersion does not match the current row.Reload and retry with the current version.
ATTENDANCE_SETTINGS_NOT_FOUND404AttendanceSettingsService.list/updateA seeded settings row is missing.Operational issue; the migration seed must be restored.
ATTENDANCE_SETTINGS_KIND_UNKNOWN404AttendanceSettingsService.update:kind is not one of the two known kinds.Use one of the two documented kinds.
ATTENDANCE_CHANNEL_UNKNOWN422AttendanceSettingsService.updateA channel is not a known NOTIFICATION_CHANNEL, or repeats.Send a de-duplicated, known-channel list.
STAFF_ATTENDANCE_RECORD_NOT_FOUND404StaffAttendanceService.bulkWrite/patchThe staff id or record id does not exist.Verify the id.
PAGINATION_CURSOR_INVALID400Both history services' cursor decodersThe cursor query param is malformed.Restart from page one (omit cursor).
CLASS_NOT_FOUND404StudentAttendanceReadService.selectClassRowThe classPublicId/classId does not resolve.Verify the class id.
STUDENT_NOT_FOUND404Student/Guardian portal servicesThe caller's own student row, or the addressed ward, does not resolve.For the guardian route, this also covers "not your ward."
WRITE_RETURNED_NO_ROW500bulkMarkThe session insert-then-reselect found nothing (unreachable in practice).Retry; report if persistent.
ACADEMIC_SESSION_LOCKED409Postgres triggers on student_attendance_sessions, staff_attendance_records, student_attendance_records (not this module's services)The register, staff record, or student record belongs to an academic session whose locked_at is set.Reopen the year (an administrator, via the rollover unlock route) before writing.

14. Observability

SignalLocationPurpose
LogAttendanceScheduler (Logger.error on enqueue failure)The only evidence of a failed enqueue, since the cron handler catches rather than throws.
LogAttendanceReminderProcessor / AttendanceAbsenceProcessor (Logger.log on every skip branch, Logger.warn on a missing/soft-deleted class teacher)Every reason a sweep tick sent nothing is stated explicitly — settings absent/disabled/channel-less, no active session, no calendar row, not a school day, past cutoff, no candidates.
LogAttendanceQueueProcessor (Logger.error on an unhandled job name)Should be unreachable given the Record<AttendanceJob, …> typing; kept as a backstop.
AuditActivityAuditInterceptor (application-global)Records every mutating admin/staff route by its @Permissions() module/action.
Auditattendance_change_logThe module's own richer trail — every marking, correction and deletion, including the first marking, with name/class snapshots that survive a rename or deletion.

No metrics are emitted by this module beyond what the global logging interceptor and BullMQ's own instrumentation provide.

15. Testing and Validation

Test TypeFilesCoverage
Integrationapps/api/src/modules/attendance/__tests__/student-attendance.integration.spec.tsRoster ordering and composed fields; the four-statement bulk write (upsert/delete, never a loop); idempotent replay; stale/null-when-already-opened version conflicts; unenrolled-student refusal; future-date refusal; holiday refusal and force-write with forcedReason; complete/incomplete-roster/reopen; reset (delete-all, log-per-deletion, status reset); first-marking history (previousStatus: null); keyset pagination stability across a shared changed_at tie.
Integrationapps/api/src/modules/attendance/__tests__/staff-attendance.integration.spec.tsCreate/update/delete across three writes with correct previousStatus each time; duplicate-staffId and empty-payload refusals; future-date and outside-session refusals; marking successfully on a holiday (the deliberate asymmetry with student attendance); version-conflict PATCH; keyset history pagination across a changed_at tie; the append-only trigger refusing a direct UPDATE with SQLSTATE 2F002.
Integrationapps/api/src/modules/attendance/__tests__/attendance-portal.integration.spec.tsTeacher portal scoped to class_teacher_id (403 for a non-owner, success for the actual teacher, non-today write refusal); guardian portal answering a non-ward with 404, never 403; student portal never returning another pupil's record.
Integrationapps/api/src/modules/attendance/__tests__/attendance-settings.integration.spec.tsBoth seeded kinds returned matching CATEGORY_DEFAULTS.attendance; unknown-kind 404; unknown-channel and repeated-channel 422; all four channels accepted and re-read after the write; stale-version 409.
Integrationapps/api/src/modules/attendance/__tests__/attendance-sweeps.integration.spec.tsReminder sweep: no double-send for one tick's cohort; silence when settings are disabled/channel-less/absent; silence on a holiday/weekend/uncalendared date; silence for a NULL or soft-deleted class teacher; silence for a class in a different session that matches the weekday; silence once completed. Absence sweep: alert after first period ends and within the window; no alert for present/late/excused; no double-alert; no alert past the cutoff minute; no alert with an absent settings row.

All five suites connect only through TEST_DATABASE_URL, seed and assert against the real schema, and never mock the database, Redis, or BullMQ — per this repository's testing rule. Validation commands:

find apps packages -name '*.tsbuildinfo' -not -path '*/node_modules/*' -delete && pnpm check-types
pnpm test
pnpm --filter @skoolsewa/api test:structure
pnpm turbo run build:docs

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
SchoolClockServiceServiceSchool-local date/minute/weekdayschool_profileBoth write paths, both processors—reads school_profileFalls back to Asia/Kathmandu if the row is absent; never throws.
CalendarDayServiceServiceSession-containing-date, calendar-row lookup, gateacademic_sessions, academic_calendar_daysBoth write paths, overview, both processors—reads only409 on a missing calendar row (require).
AttendanceRosterServiceServiceThe roster + tally populationstudent_class_enrollments, students, users, student_attendance_recordsEvery student-attendance surface—reads onlyNone — a query, not a gate.
AttendanceLogServiceServiceThe one write path into attendance_change_log—Every write/lifecycle/staff-write path—writes attendance_change_logNone thrown; a no-op on an unchanged diff.
StudentAttendanceReadServiceServiceClass resolution, session lookup, envelope assemblyCalendarDayService, AttendanceRosterServiceEvery admin/teacher-portal entry pointCalendar, Rosterreads classes, grades, sections, student_attendance_sessions404 on an unresolvable class.
StudentAttendanceWriteServiceServiceThe bulk-write transaction, the per-record PATCHRead, Calendar, Roster, Log, ClockAdmin controller, teacher portal serviceall of the abovewrites sessions/records/runtime/logSee §13.
StudentAttendanceLifecycleServiceServicecomplete/reopen/resetRead, Roster, LogAdmin controller, teacher portal (complete only)Read, Roster, Logwrites sessions/records/runtime/logSee §13.
AttendanceHistoryServiceServiceStudent history keyset pagination—StudentAttendanceHistoryController—reads attendance_change_log400 on a malformed cursor.
AttendanceOverviewServiceServiceThe school-wide date overviewCalendarDayService, ClassOccupancyServiceAttendanceOverviewControllerCalendar, Occupancyreads onlyEmpty array when no session/no active classes.
StaffAttendanceServiceServiceStaff list/bulk-write/patch/historyClock, Calendar, LogStaff controllersClock, Calendar, Logwrites staff_attendance_records/logSee §13.
AttendanceSettingsServiceServiceRead/update the two settings rows—AttendanceSettingsController—writes attendance_notification_settings404 fail-closed on a missing row.
TeacherAttendancePortalServiceServiceTeacher's own classes/register, reusing admin servicesassertTeacherStaffRow, AttendanceAdminModule's exports, ClassOccupancyServiceTeacherAttendancePortalControllerRead, Write, Lifecycle(delegated)403 on a non-owned class, a caller not acting as teacher, or a caller with no live staff row.
StudentAttendancePortalServiceServicePupil's own attendanceassertPortalAudienceStudentAttendancePortalController—reads only404 if the caller's own student row is missing.
GuardianAttendancePortalServiceServiceOne ward's attendanceassertPortalAudienceGuardianAttendancePortalController—reads only404 on a non-ward.
AttendanceSchedulerSchedulerTicks both sweeps every minuteBullMQ@CronQueue.addnone (no DB writes)Caught and logged; never throws.
AttendanceQueueProcessorProcessorThe one worker on QueueName.ATTENDANCEReminder/Absence processorsBullMQdispatches by job namenone directlyThrows only on an unhandled job name.
AttendanceReminderProcessorInjectable handlerThe reminder sweep's full logicClock, Calendar, NotificationServiceAttendanceQueueProcessorClock, Calendar, Notificationwrites sessions/runtimeEvery branch is a logged skip; never throws.
AttendanceAbsenceProcessorInjectable handlerThe absence sweep's full logicClock, Calendar, NotificationServiceAttendanceQueueProcessorClock, Calendar, Notificationwrites guardian_alerted_atEvery branch is a logged skip; never throws.

Every file under apps/api/src/modules/attendance/ is represented above except pure DTO files (covered in the API doc) and dto/index.ts barrels, which re-export and contain no behavior.

16.2 UML and Architecture Diagram Pack

16.3 Code Flow Narrative — StudentAttendanceWriteService.bulkMark

  1. Entry point and caller. StudentAttendanceController.mark (admin PUT) or TeacherAttendancePortalService.markAttendance (portal, after assertTeacherStaffRow/assertOwnClass).
  2. DTO/command shape. BulkMarkAttendanceInput { classPublicId, date, expectedVersion, submissionId, entries, force?, forcedReason? }, plus BulkMarkAttendanceOptions { refuseIfCompleted?, requireToday? } supplied only by the portal.
  3. Auth/identity assumptions. actor: AuthUser is already authenticated by JwtAuthGuard; the admin path has already passed RoleGuard on StudentAttendance_CREATE/_UPDATE, the portal path has already passed assertTeacherStaffRow and assertOwnClass.
  4. Input normalization. None beyond DTO-level validation — every text field arrives already checked against the CHECK-matching regex.
  5. Validation order. Non-empty entries → no duplicate studentId → not a future date → (requireToday only) is today → (inside the transaction) date within the class's own session range → calendar gate → session open → idempotency → concurrency → (refuseIfCompleted only) not completed → every entry enrolled.
  6. Reads performed. classes/grades/sections (via resolveClass), academic_sessions (range check), academic_calendar_days (via CalendarDayService.require), student_attendance_sessions (open-or-find), student_attendance_session_runtime (idempotency), student_class_enrollments/students/users/student_attendance_records (via forSession, both for validation and as the pre-image for the log diff).
  7. Business decisions. Whether the write is a force-write (isForceWrite = !calendarDayState.attendanceExpected); whether the session was just created (created truthy) versus pre-existing (governs the expectedVersion === null branch); which entries upsert versus delete; whether the session should advance to in_progress.
  8. Writes performed. INSERT ... ON CONFLICT DO NOTHING on the session; one INSERT ... ON CONFLICT DO UPDATE covering every marked entry; one DELETE ... WHERE studentId IN (...) covering every unmarked entry; attendance_change_log insert (diffed); conditional UPDATE advancing session status; INSERT ... ON CONFLICT DO UPDATE on the runtime row's last_submission_id.
  9. Transactions. The entire method after the pre-transaction validation runs inside one this.db.transaction(async (tx) => ...) — thirteen steps, one commit.
  10. Cache behavior. None — this module has no cache layer.
  11. Jobs/events/notifications. None synchronous; the two sweeps that later read this state run on their own schedule.
  12. Response mapping. The transaction's return value is this.read.loadState(tx, resolvedClass, input.date) — always a fresh read inside the same transaction, never an echo of input.
  13. Error handling. Every refusal is a typed Nest exception carrying a named errorCode (see §13); none is a bare 500 except the practically-unreachable WRITE_RETURNED_NO_ROW.
  14. Logs/metrics/audit events. ActivityAuditInterceptor records the route call at the controller boundary; attendance_change_log records the domain change inside the same transaction as the state it describes.
  15. Known tradeoffs. The upsert-then-delete pair is exactly two statements regardless of roster size, by design (§16.6); the idempotency check trades a second SELECT for the guarantee that a retried submit never double-applies.

16.4 Data Layer Deep Dive

Covered field-by-field in §5.2–§5.6. The one recurring pattern worth restating here: three tables in this module (student_attendance_session_runtime, school_holiday_days — owned by the calendar module, and attendance_change_log) deliberately carry no version column and no bump_row_version trigger, and each has a distinct reason — the runtime table so a per-minute cron can never invalidate an operator's token (§5.3); the change log because it is append-only and a version column on a table nothing ever updates would be meaningless. bump_row_version itself is schema-level infrastructure (function created in migration 0012); this module's migration 0020 only attaches or deliberately withholds the trigger.

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
Unmarked is the absence of a row, never a stored statusSchema (status NOT NULL on both record tables), service logic (status: null deletes)The enum stays closed over real outcomes; a stored "unmarked" would let a display placeholder answer an existence question wrongly.— (a null entry always deletes)student-attendance.integration.spec.ts
A register's tally can never disagree with its listAttendanceRosterService.forSession + tally over the same query resultAn earlier design stored marked_count/total_students from two different populations, which drifted the moment a pupil transferred mid-day.—roster-ordering and completion tests in both integration specs
The bulk write is idempotent on submissionIdstudent_attendance_session_runtime.last_submission_id, checked before the concurrency checkA replayed submit (double-tap, proxy retry) must not look like a genuine conflict.— (200, no-op)"replaying the same submissionId writes nothing and returns 200"
expectedVersion is required on complete, reopen and resetDTO shape (CompleteAttendanceDto and its two subclasses)Reset is the module's most destructive path; a stale screen must not silently erase marks someone else just saved.ATTENDANCE_VERSION_CONFLICT"refuses to complete... then reopens", "reset deletes every record..."
A non-school day refuses marking unless force + forcedReasonStudentAttendanceWriteService.bulkMarkThe calendar is the source of truth for whether the school operates; an override must be explicit and explained.ATTENDANCE_NOT_A_SCHOOL_DAY"refuses a holiday without force, and succeeds with force"
The staff register is last-write-winsSchema (no session row for staff), StaffAttendanceService.bulkWriteA synthetic session row existing only to carry a token is more machinery than one administrator marking a staff register at a time warrants.—"marks staff attendance on a holiday successfully", PATCH-conflict test
attendance_change_log records the first marking, not only correctionsprevious_status/new_status both nullable, CHECK requiring at least one non-nullA log written only on correction shows an empty history for the common case — a pupil marked once and never changed.attendance_change_log_states_a_change"the first marking is logged with previousStatus: null"
attendance_change_log is append-onlyattendance_change_log_is_append_only() trigger, SQLSTATE 2F002The history a correction is audited against must not be rewritable by a later code change.2F002"refuses a direct UPDATE on attendance_change_log"
Both history routes tie-break on id DESC after changed_at DESCIndex + query constructionnow() is constant within a transaction; a bulk mark's rows share one changed_at exactly.—pagination-stability tests in both integration specs
An attendance-settings row can only subtract delivery, never add itCATEGORY_DEFAULTS.attendance in the preference resolver runs after this setting, unconditionallyattendance is not in UNSUPPRESSIBLE_CATEGORY; an administrator must not be able to SMS every parent past their own opt-out.—attendance-settings.integration.spec.ts
A missing attendance-settings row fails closedAttendanceSettingsService.list, both workers' readSettingsThe workers already fail closed on it; the read API must not claim a channel state that does not exist.ATTENDANCE_SETTINGS_NOT_FOUND—
The reminder/absence sweeps select over a window, never exact-minute equalityfirstPeriodStartMinute - 5 <= now < firstPeriodStartMinute; firstPeriodEndMinute <= nowA missed, delayed, or replica-split tick must be recovered by the next tick rather than losing that cohort permanently.—attendance-sweeps.integration.spec.ts
Every sweep send is exactly-once via a guarded UPDATE ... WHERE ... IS NULLreminder_sent_at, guardian_alerted_atTwo replicas or a replayed tick must not double-send.—"does not send twice for the same tick's cohort", "does not alert twice"
No seeded role holds StudentAttendance_*, StaffAttendance_* or AttendanceSettings_*seed-auth.ts (absence of grants)TEACHER_PERMISSIONS spreads STAFF_PERMISSIONS; granting either to staff would give every teacher the unscoped admin surface.— (a permission design decision, not a runtime check)permission-catalog.spec.ts / seed-auth.ts review

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Split the runtime row off the versioned sessionA per-minute cron must write scheduling facts without invalidating an operator's optimistic token.student_attendance_session_runtime, no version, no trigger.Attach bump_row_version uniformly to every table with a version-shaped need; guard the trigger with an IS DISTINCT FROM check.The uniform option ships a table that works until its first UPDATE from the cron, at which point every open register 409s at the worst possible minute; a guarded trigger still requires every future column addition to remember the guard.An extra table and an extra join on every write that needs both.If the runtime facts ever need their own optimistic concurrency (unlikely — they are entirely system-written).
No stored attendance countersTallies must never be able to disagree with the roster they describe.Derive every count from one AttendanceRosterService.forSession read, in memory.Maintain marked_count/total_students columns on the session, updated alongside each write.The counter design shipped once and broke: a mid-day transfer made marked_count > total_students permanently, and every maintaining write was a second UPDATE the version trigger counted as a second bump.An extra count(*) FILTER per read; negligible at class-roster scale (tens of rows).Never, at this module's scale — a genuinely large roster (hundreds+) would be the only reason to reconsider.
Idempotency via last_submission_id, separate from versionOptimistic concurrency alone cannot distinguish an honest retry from a genuine conflict — both arrive with a stale token.A client-generated submissionId, checked before the version check, short-circuits to a re-read.Rely on the client never double-submitting; make the upsert itself naturally idempotent and accept the version bump on replay.A naturally-idempotent upsert still bumps version, so a double-tap would make the SECOND legitimate edit from a different device 409 against a version the first (identical) submit had already advanced past.One extra column, one extra read per write.None identified.
Append-only change log with no FK on subject_id/changed_by_user_idHistory must survive the subject's (or the actor's) deletion, and must never be rewritable.Snapshotted names, no foreign key, a row-level trigger raising 2F002 on UPDATE/DELETE.A referential ON DELETE SET NULL; a soft "is this editable" convention with no trigger.A referential SET NULL still fires row triggers on the child, which would abort any DELETE FROM users that had ever touched attendance — worse than the coupling it tries to avoid. A convention with no trigger is not enforcement; "no service issues an UPDATE" held only until it didn't.The log can never be corrected once written — a genuine data-entry mistake in the log itself needs a NEW compensating row, never an edit.If a legitimate need for log correction emerges (none has).
Staff attendance is last-write-wins, students are optimistic-versionedStaff marking is one administrator at a time; student marking is a fanned-out classroom register a teacher and an office can both touch.No session row, no version, for staff; a full session + version for students.A synthetic staff "session" row purely to carry a token.The risk staff attendance faces (two admins editing one day's staff register simultaneously) is rare and single-office; manufacturing a session row to guard against it is more machinery than the risk warrants.A genuine simultaneous edit silently loses the earlier writer's change.If staff attendance becomes multi-actor (e.g. delegated to department heads) rather than one office.
The calendar gate applies to students, never to staffRefusing to record staff attendance on a holiday would make the register unable to describe the days it most needs to (exam days, staff-only working holidays).StaffAttendanceService.bulkWrite never calls CalendarDayService.require's attendanceExpected half.Apply the same gate to both, with force available to staff writers too.Staff frequently and legitimately work on days students do not; treating that as an override rather than the default would make the common case require a flag.A typo'd future date for staff attendance is caught by the future-date check alone, not by the calendar.None identified.
Sweeps select over a window with a persisted marker, never exact-minute equalityA missed, delayed, or replica-split cron tick must not permanently lose a cohort.[startMinute - 5, startMinute) window plus a guarded, idempotent UPDATE ... WHERE ... IS NULL stamp.Exact-minute equality, relying on the cron never being late.Exact equality drops a whole minute's cohort silently the moment a tick is delayed — with nothing in any log, because the tick that would have reported it never ran.A tick that fires within the window after the marker is already set does one extra, cheap SELECT/no-op UPDATE.None identified.
No seeded role holds any attendance permission codeTEACHER_PERMISSIONS spreads STAFF_PERMISSIONS and both roles carry scopeKind: "all".Withhold every StudentAttendance_*/StaffAttendance_*/AttendanceSettings_* grant from every seeded role.Grant StudentAttendance_* to staff, relying on the portal's own scoping to contain the blast radius.The admin routes these codes gate carry no class-teacher predicate at all — a teacher holding them could read any pupil's history or reset any class's day, regardless of what the portal itself does correctly.A school that wants a non-administrator to hold these codes must create a dedicated role for it.None identified — this is the module's intended shape.

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Reminder sweepServer log, grep -n "\[attendance\] reminder sweep"One log line per minute per active school day, candidates=N sent=M with M <= N.No log line for several minutes during school hours; candidates=0 for a class known to have a published timetable and a teacher.Check AttendanceScheduler's own error log first (enqueue failure); then check the settings row (GET /attendance/settings); then check the class's timetable publish state and class-teacher assignment.
Absence sweepServer log, grep -n "\[attendance\] absence sweep"candidates=0 most of the time; nonzero only shortly after a first period ends with unmarked-absent pupils.Consistently candidates=0 on a day known to have absences past first period.Check school_week_config.school_day_cutoff_minute (a too-early cutoff silently stops the sweep); check the settings row; check that student_attendance_session_runtime.first_period_end_minute was actually snapshotted for the session (requires the reminder sweep to have run first, or the register to have been opened by a mark).
Queue healthBull Board / BullMQ metrics on QueueName.ATTENDANCEJobs completing every minute, near-zero failed/delayed.Delayed or failed jobs accumulating.AttendanceQueueProcessor throws only on an unhandled job name (should be unreachable); a persistent failure points at a downstream error inside one of the two processors — read the server log for the specific exception, since both processors otherwise absorb their own domain skips.
Append-only triggerpsql, attempt UPDATE attendance_change_log SET reason = 'x' WHERE id = '...'Refused with SQLSTATE 2F002.Any successful UPDATE/DELETE against this table outside a test harness TRUNCATE.This should never happen against a correctly migrated database; if it does, the trigger itself has been dropped or altered — restore migration 0020.
Settings fail-closedGET /attendance/settings200 with both kinds.404 ATTENDANCE_SETTINGS_NOT_FOUND.The seed rows from migration 0020 are missing; re-run the seed insert for attendance_notification_settings.

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
Staff attendance last-write-winsStaffAttendanceService.bulkWriteTwo administrators editing the same day's staff register simultaneously silently lose the earlier write.Deliberate design tradeoff for a single-office, low-concurrency surface.No detection or warning if it does happen; the audit trail records both writes but nothing flags the collision.
CalendarDayService.sessionContaining selects .limit(2) but reads only the first rowcalendar-day.service.ts:40-51None under the current schema — academic_sessions_no_active_overlap guarantees at most one active session covers any date.The EXCLUDE constraint, owned by the calendar module.If that constraint were ever relaxed, this method would silently pick an arbitrary one of two overlapping sessions rather than surfacing the ambiguity. Noted as an observation, not fixed, in this documentation pass — see the final report.
Absence sweep batch capAttendanceAbsenceProcessor.findCandidates, LIMIT 500A morning with more than 500 newly-qualifying absences in one tick would defer the remainder to the next tick.ORDER BY id makes the deferral deterministic and lossless across ticks — nothing is skipped, only delayed by up to a minute.None material at any plausible single-school scale.
No cache layer for the staff and admin surfacesWhole module except the portal attendance summaryEvery read other than the two portal summary routes, including the overview's grouped queries, hits Postgres directly.Acceptable given per-class-per-day cardinality.A very large school reading the overview very frequently could reconsider this; not observed as a problem today.

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented or explicitly marked non-runtime (DTO/index barrels).
  • Every controller, service, provider, processor, scheduler, DTO, enum, and schema table is documented.
  • Every method with business behavior has a code-flow narrative (§16.3 for the central write; §6 and §7 for the rest).
  • Every table has field-level detail (§5.2).
  • Every index, constraint, relation, and delete behavior has rationale (§5.2, §5.6).
  • Every lifecycle/status transition has a state diagram and transition table (§7.2).
  • Every read/write/action/job flow has a sequence or flow diagram (§7).
  • Every business invariant is cataloged (§16.5).
  • Every cache key (none), queue job, and external call is documented (§8, §9).
  • Every architectural tradeoff is documented with alternatives and revisit triggers (§16.6).
  • Every operational failure mode has a runbook entry (§16.7).

18. Backend Completion Checklist

  • Module boundaries are documented (§2).
  • Every controller, service, DTO, schema file, job, and event is covered (§3, §4, §6, §9).
  • Every database table has a field table and relationship diagram (§5).
  • Every runtime flow has a diagram and branch notes (§7).
  • API, features/flows docs are linked (below).
  • No claim is made without a source file or documented source reference.

See Also

  • API doc: /docs/developer/attendance/api
  • Features and flows doc: /docs/developer/attendance/feature

On this page

Attendance Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tables and Collectionsstudent_attendance_sessionsstudent_attendance_session_runtimestudent_attendance_recordsstaff_attendance_recordsattendance_change_logattendance_notification_settings5.3 The Runtime Split, Precisely5.4 The Append-Only Trigger5.5 Relationship Diagram5.6 Index Rationale6. Services and Responsibilities6.1 SchoolClockService6.2 CalendarDayService6.3 AttendanceRosterService6.4 AttendanceLogService6.5 StudentAttendanceReadService6.6 StudentAttendanceWriteService6.7 StudentAttendanceLifecycleService6.8 AttendanceHistoryService6.9 AttendanceOverviewService6.10 StaffAttendanceService6.11 AttendanceSettingsService6.12 TeacherAttendancePortalService6.13 StudentAttendancePortalService6.14 GuardianAttendancePortalService6.14a PortalAttendanceSummaryService6.15 AttendanceReminderProcessor6.16 AttendanceAbsenceProcessor6.17 AttendanceScheduler / AttendanceQueueProcessor7. Runtime Flows7.1 Bulk mark / correct the student register7.2 Complete / reopen / reset7.3 Correct one record (PATCH .../:recordId)7.4 Staff register bulk write (last-write-wins)7.5 The teacher portal's register (reuse, not duplication)7.6 Student and guardian portal reads7.7 First-period reminder sweep7.8 Guardian absence-alert sweep7.9 History pagination — the shared shape, two cursor techniques8. Caching9. BullMQ, Schedulers, and Async Work10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error Handling14. Observability15. Testing and Validation16. Mandatory Backend Deep-Dive Pack16.1 Submodule Coverage Matrix16.2 UML and Architecture Diagram Pack16.3 Code Flow Narrative — StudentAttendanceWriteService.bulkMark16.4 Data Layer Deep Dive16.5 Business Logic and Invariant Catalog16.6 Tradeoffs, Alternatives, and ADR Notes16.7 Operational Runbook16.8 Backend Risk Register17. Zero-Omission Backend Checklist18. Backend Completion ChecklistSee Also