Attendance Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Attendance.
Attendance Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Schema | packages/db/src/schema/school/attendance.ts | Six tables, three enums, every constraint, every index. |
| Migration | packages/db/src/migrations/0020_attendance_module.sql | Hand-written triggers, the append-only function, and the two seeded settings rows. |
| Shared services | apps/api/src/modules/attendance/shared/*.ts | Clock, calendar gate, roster, and change-log services shared by every surface. |
| Admin (student) | apps/api/src/modules/attendance/admin/*.ts, admin/dto/*.ts | Register read/write/lifecycle, overview, history. |
| Staff | apps/api/src/modules/attendance/staff/*.ts, staff/dto/*.ts | Staff register mark/correct/history. |
| Settings | apps/api/src/modules/attendance/settings/*.ts, settings/dto/*.ts | Notification-channel settings, subtractive by design. |
| Portal | apps/api/src/modules/attendance/portal/{teacher,student,guardian}/*.ts | Audience-asserted consumer surfaces mounted under /api/mobile. |
| Workers | apps/api/src/modules/attendance/workers/*.ts | The one BullMQ worker, its two handlers, and the cron that ticks them. |
| Queue contract | packages/jobs/src/index.ts (QueueName.ATTENDANCE, AttendanceJob, AttendanceSweepPayload) | Job names, payload shape, and the reasoning against carrying a minute. |
| Notifications | apps/api/src/modules/notification/templates/template-registry.ts | The two attendance notification kinds, their channels and variables. |
| Permissions | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | StudentAttendance, 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 allowlist | apps/api/src/common/authorization/role.guard.ts, apps/api/test/structure/route-permissions.spec.ts | The six portal handlers that carry no @Permissions(). |
| Error codes | apps/api/src/common/types/error-codes.ts | Every ATTENDANCE_* and STAFF_ATTENDANCE_* code and its condition. |
| Tests | apps/api/src/modules/attendance/__tests__/*.spec.ts | Behaviour 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 referencesclasses,gradesandsectionsby id and never writes to them. - Staff and student identity, including soft delete —
packages/db/src/schema/school/people.ts. Every read here treatsdeleted_atas 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 ofacademic_calendar_daysandacademic_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 readsclass_timetables.statusandclass_timeslotsto 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 callNotificationService.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 importsRoleModuleso the guard can resolveRoleService, but implements no guard logic. The portal surfaces declare no permission at all and are covered in §11.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| A pupil's marking for a class/date | student_attendance_records | One 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.status | The 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 submissionId | student_attendance_session_runtime | Carries 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 it | CalendarDayService over academic_calendar_days / academic_sessions | Never 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 register | Derived in memory from one AttendanceRosterService.forSession() read | There is no stored counter anywhere in this module — see §16.5. |
| A pupil's or staff member's attendance history | attendance_change_log | Append-only by trigger; records the first marking as well as every correction. |
| Which channels an attendance notification kind may use | attendance_notification_settings | Two seeded rows; a missing row fails the read closed rather than defaulting. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
AttendanceSharedModule | Shared, no controllers | attendance/shared/attendance-shared.module.ts | None | SchoolClockService, CalendarDayService, AttendanceRosterService, AttendanceLogService | All four | The 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. |
AttendanceAdminModule | Leaf, admin-facing | attendance/admin/attendance-admin.module.ts | StudentAttendanceController, AttendanceOverviewController, StudentAttendanceHistoryController | StudentAttendanceReadService, StudentAttendanceWriteService, StudentAttendanceLifecycleService, AttendanceOverviewService, AttendanceHistoryService | StudentAttendanceReadService, StudentAttendanceWriteService, StudentAttendanceLifecycleService | The 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. |
AttendanceStaffModule | Leaf, admin-facing | attendance/staff/attendance-staff.module.ts | StaffAttendanceController, StaffAttendanceHistoryController | StaffAttendanceService | None | The 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. |
AttendanceSettingsModule | Leaf, admin-facing | attendance/settings/attendance-settings.module.ts | AttendanceSettingsController | AttendanceSettingsService | None | Which channels the two notification kinds may use. Deliberately does not import AttendanceSharedModule — this surface never touches the clock, the calendar, or the change log. |
TeacherAttendancePortalModule | Leaf, consumer-facing | attendance/portal/teacher/teacher-attendance-portal.module.ts | TeacherAttendancePortalController | TeacherAttendancePortalService | None | The class teacher's own classes and register, mounted at /api/mobile/teacher/attendance. Imports AttendanceAdminModule to call its write/lifecycle services directly. |
StudentAttendancePortalModule | Leaf, consumer-facing | attendance/portal/student/student-attendance-portal.module.ts | StudentAttendancePortalController | StudentAttendancePortalService | None | A pupil's own attendance, mounted at /api/mobile/student/attendance. |
GuardianAttendancePortalModule | Leaf, consumer-facing | attendance/portal/guardian/guardian-attendance-portal.module.ts | GuardianAttendancePortalController | GuardianAttendancePortalService | None | One child's attendance, as read by their guardian, mounted at /api/mobile/guardian/children/:studentId/attendance. |
AttendanceWorkersModule | Worker, no controllers | attendance/workers/attendance-workers.module.ts | None | AttendanceQueueProcessor, AttendanceReminderProcessor, AttendanceAbsenceProcessor, AttendanceScheduler | None | Registers 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)| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/school-clock.service.ts | What "today" and "now" mean to this school. | SchoolClockService, SchoolNow | Reads 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, CalendarDayState | sessionContaining 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.ts | Who is on a class's roll, and what each of them was marked. | AttendanceRosterService, RosterEntry, AttendanceTally | One 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.ts | The attendance audit trail's one write path. | AttendanceLogService, AttendanceLogEntry | Diffs 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.ts | The status/session-status/subject-kind/notification-kind vocabulary. | ATTENDANCE_STATUS, ATTENDANCE_SESSION_STATUS, ATTENDANCE_SUBJECT_KIND, ATTENDANCE_NOTIFICATION_KINDS | Re-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.ts | Admin routes for the student register: read, bulk mark, per-pupil correction, complete/reopen/reset. | StudentAttendanceController | mark carries both StudentAttendance_CREATE and _UPDATE; correct carries only _UPDATE (student-attendance.controller.ts:34-44). |
admin/student-attendance-read.service.ts | Resolves a class, resolves (never creates) its session, and assembles the roster envelope every read and write path returns. | StudentAttendanceReadService, ResolvedAttendanceClass | resolveClass/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.ts | The bulk-write transaction behind both the admin PUT and the teacher portal PUT, and the per-record PATCH correction path. | StudentAttendanceWriteService, BulkMarkAttendanceInput, BulkMarkAttendanceOptions | Thirteen numbered steps in one transaction — see §16.3. |
admin/student-attendance-lifecycle.service.ts | The three session-level transitions: complete, reopen, reset. | StudentAttendanceLifecycleService, AttendanceSessionLifecycleInput | None 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.ts | A pupil's attendance change log. | StudentAttendanceHistoryController | Split from StudentAttendanceController so it carries its own Swagger tag. |
admin/attendance-history.service.ts | Keyset walk over attendance_change_log for one student. | AttendanceHistoryService, AttendanceHistoryPage | Orders 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.ts | The school-wide, one-row-per-class attendance state for a date. | AttendanceOverviewController, AttendanceOverviewService | Every 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.ts | Wires the three admin controllers and their five providers. | AttendanceAdminModule | Imports EnrollmentsCoreModule for ClassOccupancyService, the overview's one definition of class occupancy. |
staff/staff-attendance.controller.ts / staff-attendance-history.controller.ts | Admin routes for the staff register and its history. | StaffAttendanceController, StaffAttendanceHistoryController | Two 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.ts | List, bulk-write (last-write-wins), single-record patch, and keyset history for the staff register. | StaffAttendanceService, StaffAttendanceRow, StaffAttendanceTally | No calendar gate is ever consulted on this path (staff-attendance.service.ts:180-182). |
settings/attendance-settings.controller.ts / attendance-settings.service.ts | Read and update the two notification-channel settings rows. | AttendanceSettingsController, AttendanceSettingsService, AttendanceSettingRow | update() 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.ts | The class teacher's own classes and register. | TeacherAttendancePortalService | Scopes 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.ts | A pupil's own attendance record. | StudentAttendancePortalService | Resolves 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.ts | One child's attendance, as read by their guardian. | GuardianAttendancePortalService | The 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.ts | Session-to-date attendance totals shared by the pupil and guardian summary routes. | PortalAttendanceSummaryService | Reads only for an id the caller has already resolved; caches 60 s. |
portal/shared/portal-attendance-summary.dto.ts | Response shape of both summary routes. | PortalAttendanceSummaryDto | — |
workers/attendance.scheduler.ts | Ticks the two per-minute sweeps. | AttendanceScheduler | Builds 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.ts | The one @Processor on QueueName.ATTENDANCE. | AttendanceQueueProcessor | handlers is Record<AttendanceJob, …>, so an unhandled job name is a compile error (attendance-queue.processor.ts:31-45). |
workers/attendance-reminder.processor.ts | One tick of the first-period reminder sweep. | AttendanceReminderProcessor, SchoolNow (re-exported type) | A plain @Injectable(), not a @Processor — see §9. |
workers/attendance-absence.processor.ts | One tick of the guardian absence-alert sweep. | AttendanceAbsenceProcessor | Batches 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.sql5.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).
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK | — | The public identifier; no separate public_id. |
class_id | integer | No | — | composite FK below | classes.id | |
academic_session_id | integer | No | — | composite FK below | academic_sessions.id | Denormalised from the class and made provably equal to it by the composite FK, not by application logic (attendance.ts:95-100). |
attendance_date | date | No | — | unique w/ class_id | — | |
status | attendance_session_status | No | 'not_started' | CHECK below | — | |
completed_at | timestamptz | Yes | — | CHECK below | — | Non-null iff status = 'completed'. |
completed_by_user_id | uuid | Yes | — | FK set null | users.id | |
version | bigint | No | 0 | never indexed | — | The operator's optimistic-concurrency token, maintained only by bump_row_version. |
created_at / updated_at | timestamptz | No | now() | — | — |
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).
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
session_id | uuid | No | — | PK, FK student_attendance_sessions.id ON DELETE cascade. |
first_period_start_minute | smallint | Yes | — | 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_minute | smallint | Yes | — | Same snapshot. |
snapshot_taken_at | timestamptz | Yes | — | |
reminder_sent_at | timestamptz | Yes | — | Stamped with UPDATE ... WHERE reminder_sent_at IS NULL; the notification sends only if that reports one row. |
last_submission_id | uuid | Yes | — | The client-generated id of the last accepted bulk write — what tells an honest retry from a genuine conflict. |
created_at | timestamptz | No | now() |
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.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK | — | |
session_id | uuid | No | — | FK cascade, unique w/ student_id | student_attendance_sessions.id | |
student_id | uuid | No | — | FK restrict | students.id | restrict 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_id | uuid | Yes | — | FK set null | student_class_enrollments.id | Which enrolment this was taken against, for a pupil who transferred mid-year. |
status | attendance_status | No | — | — | — | NOT NULL — unmarked is the absence of a row, never a stored null. |
note | text | Yes | — | CHECK below | — | |
forced_reason | text | Yes | — | 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_at | timestamptz | Yes | — | partial index below | — | Per-record, not per-session — see §16.6 for why that placement is load-bearing. |
timetable_entry_id | uuid | Yes | — | FK set null | class_timetable_entries.id | The per-period seam; always NULL in this release, since attendance is daily, taken at first period. |
leave_request_id | uuid | Yes | — | no FK | — | The Leave Application seam; always NULL — that table does not exist yet. |
marked_by_user_id | uuid | Yes | — | FK set null | users.id | |
marked_at | timestamptz | No | now() | — | — | |
version | bigint | No | 0 | never indexed | — | |
created_at / updated_at | timestamptz | No | now() | — | — |
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).
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK | — | |
staff_id | uuid | No | — | FK restrict, unique w/ attendance_date | staff.id | |
attendance_date | date | No | — | — | — | |
academic_session_id | integer | No | — | FK restrict | academic_sessions.id | Resolved 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. |
status | attendance_status | No | — | — | — | |
note | text | Yes | — | CHECK below | — | |
leave_request_id | uuid | Yes | — | no FK | — | Same seam as the student table. |
marked_by_user_id | uuid | Yes | — | FK set null | users.id | |
marked_at | timestamptz | No | now() | — | — | |
version | bigint | No | 0 | never indexed | — | Used only by the single-record PATCH path — the bulk write is last-write-wins and never reads it. |
created_at / updated_at | timestamptz | No | now() | — | — |
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).
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK | — | Monotonic, so it correctly tie-breaks changed_at. |
subject_kind | attendance_subject_kind | No | — | — | — | student or staff. |
subject_id | uuid | No | — | no FK | — | students.id or staff.id. |
subject_name_snapshot | text | No | — | — | — | Snapshotted because the log outlives the person. |
class_id | integer | Yes | — | no FK | — | NULL for staff. |
class_label_snapshot | text | Yes | — | — | — | e.g. "Grade 5 A (Morning)" — what the screen renders when the class is gone. |
attendance_date | date | No | — | — | — | |
previous_status | attendance_status | Yes | — | CHECK below | — | NULL means "was unmarked" — this is what puts the FIRST marking in history, not only corrections. |
new_status | attendance_status | Yes | — | CHECK below | — | NULL means "reset to unmarked". |
reason | text | Yes | — | CHECK below | — | |
changed_by_user_id | uuid | Yes | — | no FK | — | No FK on purpose — see §16.6. |
changed_by_name_snapshot | text | No | — | — | — | |
changed_at | timestamptz | No | now() | — | — |
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.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
kind | text | No | — | PK, CHECK below | — | Matches TEMPLATE_REGISTRY. text, not an enum, so a third kind needs a migration rather than a silent ALTER TYPE. |
enabled | boolean | No | true | — | — | |
channels | jsonb | No | — | CHECK below | — | An array over NOTIFICATION_CHANNEL; membership validated in the service, not by the CHECK. |
updated_by_user_id | uuid | Yes | — | FK set null | users.id | |
version | bigint | No | 0 | never indexed | — | |
created_at / updated_at | timestamptz | No | now() | — | — |
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/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
student_attendance_sessions_class_date_unique | class_id, attendance_date | unique btree | One 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_idx | attendance_date, status | btree | The school-wide overview's per-date scan. | Small write overhead on every session status change. |
student_attendance_records_session_student_unique | session_id, student_id | unique btree | One record per pupil per register; the bulk write's ON CONFLICT target. | None. |
student_attendance_records_student_idx | student_id, id | btree | A pupil's own history/records as a keyset walk. | None material. |
student_attendance_records_pending_alert_idx | session_id | partial btree, WHERE status='absent' AND guardian_alerted_at IS NULL | The 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_unique | staff_id, attendance_date | unique btree | One record per person per day; the bulk write's ON CONFLICT target. | None. |
attendance_change_log_subject_idx | subject_kind, subject_id, changed_at DESC, id DESC | btree | Both history routes' keyset pagination. | The id DESC tiebreaker is required because now() ties within a transaction. |
academic_sessions_no_active_overlap | daterange(start_date,end_date,'[)') | GIST EXCLUDE, WHERE is_active | Guarantees 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
timezone(executor?) | Every write path, both processors | school_profile.timezone | — | — | — |
nowInZone(timezone, at?) | now(), and directly by the processors | — (pure Intl computation) | — | — | — |
now(executor?) | StudentAttendanceWriteService, StaffAttendanceService | school_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
sessionContaining(date, executor?) | Both write paths, AttendanceOverviewService, both processors, ClassesReadService.findOptions | academic_sessions | — | — | — |
requireWithinSession(academicSessionId, date, executor?) | StudentAttendanceReadService.loadState (so the GET, bulkMark's re-read and every lifecycle re-read), StudentAttendanceWriteService.bulkMark | academic_sessions | — | — | 422 ATTENDANCE_DATE_OUTSIDE_SESSION |
find(academicSessionId, date, executor?) | require, both processors | academic_calendar_days | — | — | — |
require(academicSessionId, date, executor?) | StudentAttendanceReadService.loadState, StudentAttendanceWriteService.bulkMark | academic_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
forSession(classId, sessionId, executor?) | Every student-attendance read and write path, the overview, the reminder/absence sweeps' class resolution | student_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
resolveClass(executor, classPublicId) | Every admin and teacher-portal entry point | classes, grades, sections | — | — | 404 CLASS_NOT_FOUND |
resolveClassById(executor, classId) | StudentAttendanceWriteService.patchRecord | same | — | — | 404 CLASS_NOT_FOUND |
findSession(executor, classId, date) | loadState, lifecycle's requireSession | student_attendance_sessions | — | — | — |
loadState(executor, resolvedClass, date) | getState, every write/lifecycle method's final re-read | calendar, 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
bulkMark(actor, input, options?) | StudentAttendanceController.mark, TeacherAttendancePortalService.markAttendance | classes, academic sessions, calendar, session, runtime, roster | student_attendance_sessions (insert/update), student_attendance_records (upsert/delete), attendance_change_log, student_attendance_session_runtime | none async — every effect is inside the one transaction | 422 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.correct | student_attendance_records, student_attendance_sessions, students, users | student_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
complete(actor, input) | StudentAttendanceController.complete, TeacherAttendancePortalService.completeAttendance | session, roster | student_attendance_sessions | — | 404 ATTENDANCE_SESSION_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT, ATTENDANCE_ALREADY_COMPLETED; 422 ATTENDANCE_INCOMPLETE_ROSTER |
reopen(_actor, input) | StudentAttendanceController.reopen | session | student_attendance_sessions | — | 404 ATTENDANCE_SESSION_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT, ATTENDANCE_NOT_COMPLETED |
reset(actor, input) | StudentAttendanceController.reset | session, roster | student_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
getHistory(studentId, query) | StudentAttendanceHistoryController.getHistory | attendance_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
getOverview(date) | AttendanceOverviewController.getOverview | academic_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
list(query) | StaffAttendanceController.list | staff, users, designations, departments, staff_attendance_records | — | — | — |
bulkWrite(actor, dto) | StaffAttendanceController.bulkWrite | staff, users, academic_sessions, staff_attendance_records | staff_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.patch | staff_attendance_records, staff, users | staff_attendance_records, attendance_change_log | — | 404 STAFF_ATTENDANCE_RECORD_NOT_FOUND; 409 ATTENDANCE_VERSION_CONFLICT |
history(staffId, query) | StaffAttendanceHistoryController.history | attendance_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
list() | AttendanceSettingsController.list | attendance_notification_settings | — | — | 404 ATTENDANCE_SETTINGS_NOT_FOUND |
update(actor, kind, dto) | AttendanceSettingsController.update | attendance_notification_settings | attendance_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
listMyClasses(actor) | TeacherAttendancePortalController.listMyClasses | staff (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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
myAttendance(actor, query) | StudentAttendancePortalController.myAttendance | students, 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
childAttendance(actor, studentId, query) | GuardianAttendancePortalController.childAttendance | guardians, 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
forStudent(studentId) | StudentAttendancePortalController.mySummary, GuardianAttendancePortalController.childSummary | academic_sessions, academic_calendar_days, student_class_enrollments, student_attendance_sessions, student_attendance_records; the school clock | None | Redis read, fire-and-forget Redis write on a miss | None of its own |
- Identity first. The controllers resolve the pupil before calling (
StudentAttendancePortalService.resolveOwnStudentIdfrom the token;GuardianAttendancePortalService.resolveChildIdthrough 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_dateto school-local today, or to the session'send_dateonce it has passed. When there is no current session, ortoprecedes 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) / 100throughpctStrict: 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.ratePctisnullwhen nothing is marked.unmarkedismax(expectedDays - marked, 0). - Cache. See §8.
6.15 AttendanceReminderProcessor
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
process(job) | AttendanceQueueProcessor | settings, calendar/session, classes/grades/sections, class_timetables, class_timeslots, student_attendance_sessions, student_attendance_session_runtime, staff | student_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
process(job) | AttendanceQueueProcessor | settings, school_week_config, calendar/session, school_profile, student_attendance_records joined through session/runtime/class/grade/section/student/user | student_attendance_records.guardian_alerted_at | NotificationService.send (to the pupil's guardians) | none thrown — every skip is a logged early return |
Full narrative in §7.8.
6.17 AttendanceScheduler / AttendanceQueueProcessor
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
AttendanceScheduler.enqueueReminderSweep() / .enqueueAbsenceSweep() | @Cron(EVERY_MINUTE) | — | BullMQ (attendanceQueue.add) | enqueues one job per bucket | caught 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.name | throws 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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | StudentAttendanceController.mark / TeacherAttendancePortalController.markAttendance | Receives the DTO, resolves the actor. | DTO validation (400). |
| 2 | StudentAttendanceWriteService.bulkMark | Rejects an empty payload and duplicate studentIds before opening a transaction. | 422 ATTENDANCE_EMPTY_PAYLOAD / ATTENDANCE_DUPLICATE_STUDENT. |
| 3 | same | Rejects a future date; the portal additionally rejects a non-today date. | 422 ATTENDANCE_DATE_IN_FUTURE / ATTENDANCE_DATE_NOT_TODAY. |
| 4 | CalendarDayService.requireWithinSession, inside the transaction | Confirms the date falls within the class's own academic session (mirrors the composite FK). | 422 ATTENDANCE_DATE_OUTSIDE_SESSION. |
| 5 | CalendarDayService.require | Confirms a calendar row exists, and whether attendance is expected. | 409 ACADEMIC_CALENDAR_NOT_GENERATED; 422 ATTENDANCE_NOT_A_SCHOOL_DAY unless force. |
| 6 | same | Opens the register without a no-op UPDATE. | 500 WRITE_RETURNED_NO_ROW if the re-select somehow finds nothing. |
| 7 | same | Idempotency: a repeated submissionId short-circuits to a re-read. | — (200, no write). |
| 8 | same | Optimistic concurrency on the session's version. | 409 ATTENDANCE_VERSION_CONFLICT. |
| 9 | AttendanceRosterService.forSession | Reads the live roster inside the transaction; every submitted studentId must be on it. | 422 ATTENDANCE_STUDENT_NOT_ENROLLED. |
| 10 | same | One upsert statement for marked entries, one delete for unmarked entries. | — |
| 11 | AttendanceLogService.append | Diffs against the pre-image roster; writes only real changes. | — |
| 12 | same | Advances not_started → in_progress guarded by a conditional UPDATE. | — |
| 13 | same | Stamps last_submission_id. | — |
| 14 | StudentAttendanceReadService.loadState | Re-reads the full envelope. | — |
7.2 Complete / reopen / reset
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No session row | The class/date was never opened | requireSession throws before any transition runs | 404 ATTENDANCE_SESSION_NOT_FOUND |
| Stale token | expectedVersion supplied and mismatched | Refused before any write | 409 ATTENDANCE_VERSION_CONFLICT |
| Complete an incomplete roster | Any live roster row has status: null | Refused | 422 ATTENDANCE_INCOMPLETE_ROSTER |
| Complete twice | session.status === 'completed' | Refused | 409 ATTENDANCE_ALREADY_COMPLETED |
| Reopen a register never completed | session.status !== 'completed' | Refused | 409 ATTENDANCE_NOT_COMPLETED |
| Reset | Any prior state | Deletes every record, logs one deletion per previously-marked pupil, clears the runtime's last_submission_id, returns to not_started | 200, 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 Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
portal:attendance-summary:shape:1-student:<studentId>-today:<YYYY-MM-DD> | CacheKeyUtil.build("portal:attendance-summary:", [...]) | PortalAttendanceSummaryDto | 60s | None. 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
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.ATTENDANCE | AttendanceJob.FIRST_PERIOD_REMINDER ("attendance.first_period_reminder") | AttendanceScheduler.enqueueReminderSweep (@Cron(EVERY_MINUTE)) | AttendanceQueueProcessor → AttendanceReminderProcessor | AttendanceSweepPayload { 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.ATTENDANCE | AttendanceJob.ABSENCE_ALERT ("attendance.absence_alert") | AttendanceScheduler.enqueueAbsenceSweep | AttendanceQueueProcessor → AttendanceAbsenceProcessor | AttendanceSweepPayload { correlationId? } | BullMQ defaults | Same 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).
| Handler | Control |
|---|---|
TeacherAttendancePortalController.listMyClasses | assertTeacherStaffRow 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.getAttendance | Same predicate, applied before loadState. |
TeacherAttendancePortalController.markAttendance | Same 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.completeAttendance | Same predicate. |
StudentAttendancePortalController.myAttendance | Takes no id at all; the pupil is students.user_id = actor.id, so there is no parameter that could name another child. |
GuardianAttendancePortalController.childAttendance | The 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 Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
ACADEMIC_CALENDAR_NOT_GENERATED | 409 | CalendarDayService.require | No academic_calendar_days row for this session/date. | Generate the calendar for the date before retrying. |
ATTENDANCE_NOT_A_SCHOOL_DAY | 422 | StudentAttendanceWriteService.bulkMark | Calendar 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_FOUND | 404 | Lifecycle service, patchRecord's session lookup | No register exists for the class/date. | The register must be opened (by a mark) before it can be transitioned. |
ATTENDANCE_RECORD_NOT_FOUND | 404 | patchRecord, loadRecordDto | The record id does not exist. | Reload the register. |
ATTENDANCE_DATE_IN_FUTURE | 422 | bulkMark, StaffAttendanceService.bulkWrite | date > SchoolClockService.now().isoDate. | Resend with a non-future date. |
ATTENDANCE_DATE_OUTSIDE_SESSION | 422 | CalendarDayService.requireWithinSession (register read, bulkMark and every lifecycle re-read), StaffAttendanceService.bulkWrite | Date falls outside the resolved academic session's range. | Resend with a date inside the class's academic session. |
ATTENDANCE_DATE_NOT_TODAY | 422 | bulkMark 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_TEACHER | 403 | TeacherAttendancePortalService.assertOwnClass | Caller is not classes.class_teacher_id for the addressed class. | Only that class's own teacher may act on this route. |
ATTENDANCE_STUDENT_NOT_ENROLLED | 422 | bulkMark | A submitted studentId has no active enrolment in the class. | Remove the pupil from the payload or verify the enrolment. |
ATTENDANCE_ALREADY_COMPLETED | 409 | bulkMark (refuseIfCompleted), LifecycleService.complete | The register is already completed. | Correct via PATCH .../:recordId, or reopen first. |
ATTENDANCE_NOT_COMPLETED | 409 | LifecycleService.reopen | The register is not completed. | Nothing to reopen. |
ATTENDANCE_INCOMPLETE_ROSTER | 422 | LifecycleService.complete | A live roster row has no record. | Mark every pupil before completing. |
ATTENDANCE_EMPTY_PAYLOAD | 422 | bulkMark, StaffAttendanceService.bulkWrite | entries is empty. | Submit at least one entry. |
ATTENDANCE_DUPLICATE_STUDENT | 422 | bulkMark, StaffAttendanceService.bulkWrite | The same person appears twice in one payload. | De-duplicate the payload. |
ATTENDANCE_VERSION_CONFLICT | 409 | bulkMark, lifecycle methods, patchRecord, StaffAttendanceService.patch, AttendanceSettingsService.update | expectedVersion does not match the current row. | Reload and retry with the current version. |
ATTENDANCE_SETTINGS_NOT_FOUND | 404 | AttendanceSettingsService.list/update | A seeded settings row is missing. | Operational issue; the migration seed must be restored. |
ATTENDANCE_SETTINGS_KIND_UNKNOWN | 404 | AttendanceSettingsService.update | :kind is not one of the two known kinds. | Use one of the two documented kinds. |
ATTENDANCE_CHANNEL_UNKNOWN | 422 | AttendanceSettingsService.update | A channel is not a known NOTIFICATION_CHANNEL, or repeats. | Send a de-duplicated, known-channel list. |
STAFF_ATTENDANCE_RECORD_NOT_FOUND | 404 | StaffAttendanceService.bulkWrite/patch | The staff id or record id does not exist. | Verify the id. |
PAGINATION_CURSOR_INVALID | 400 | Both history services' cursor decoders | The cursor query param is malformed. | Restart from page one (omit cursor). |
CLASS_NOT_FOUND | 404 | StudentAttendanceReadService.selectClassRow | The classPublicId/classId does not resolve. | Verify the class id. |
STUDENT_NOT_FOUND | 404 | Student/Guardian portal services | The 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_ROW | 500 | bulkMark | The session insert-then-reselect found nothing (unreachable in practice). | Retry; report if persistent. |
ACADEMIC_SESSION_LOCKED | 409 | Postgres 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
| Signal | Location | Purpose |
|---|---|---|
| Log | AttendanceScheduler (Logger.error on enqueue failure) | The only evidence of a failed enqueue, since the cron handler catches rather than throws. |
| Log | AttendanceReminderProcessor / 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. |
| Log | AttendanceQueueProcessor (Logger.error on an unhandled job name) | Should be unreachable given the Record<AttendanceJob, …> typing; kept as a backstop. |
| Audit | ActivityAuditInterceptor (application-global) | Records every mutating admin/staff route by its @Permissions() module/action. |
| Audit | attendance_change_log | The 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 Type | Files | Coverage |
|---|---|---|
| Integration | apps/api/src/modules/attendance/__tests__/student-attendance.integration.spec.ts | Roster 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. |
| Integration | apps/api/src/modules/attendance/__tests__/staff-attendance.integration.spec.ts | Create/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. |
| Integration | apps/api/src/modules/attendance/__tests__/attendance-portal.integration.spec.ts | Teacher 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. |
| Integration | apps/api/src/modules/attendance/__tests__/attendance-settings.integration.spec.ts | Both 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. |
| Integration | apps/api/src/modules/attendance/__tests__/attendance-sweeps.integration.spec.ts | Reminder 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:docs16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
SchoolClockService | Service | School-local date/minute/weekday | school_profile | Both write paths, both processors | — | reads school_profile | Falls back to Asia/Kathmandu if the row is absent; never throws. |
CalendarDayService | Service | Session-containing-date, calendar-row lookup, gate | academic_sessions, academic_calendar_days | Both write paths, overview, both processors | — | reads only | 409 on a missing calendar row (require). |
AttendanceRosterService | Service | The roster + tally population | student_class_enrollments, students, users, student_attendance_records | Every student-attendance surface | — | reads only | None — a query, not a gate. |
AttendanceLogService | Service | The one write path into attendance_change_log | — | Every write/lifecycle/staff-write path | — | writes attendance_change_log | None thrown; a no-op on an unchanged diff. |
StudentAttendanceReadService | Service | Class resolution, session lookup, envelope assembly | CalendarDayService, AttendanceRosterService | Every admin/teacher-portal entry point | Calendar, Roster | reads classes, grades, sections, student_attendance_sessions | 404 on an unresolvable class. |
StudentAttendanceWriteService | Service | The bulk-write transaction, the per-record PATCH | Read, Calendar, Roster, Log, Clock | Admin controller, teacher portal service | all of the above | writes sessions/records/runtime/log | See §13. |
StudentAttendanceLifecycleService | Service | complete/reopen/reset | Read, Roster, Log | Admin controller, teacher portal (complete only) | Read, Roster, Log | writes sessions/records/runtime/log | See §13. |
AttendanceHistoryService | Service | Student history keyset pagination | — | StudentAttendanceHistoryController | — | reads attendance_change_log | 400 on a malformed cursor. |
AttendanceOverviewService | Service | The school-wide date overview | CalendarDayService, ClassOccupancyService | AttendanceOverviewController | Calendar, Occupancy | reads only | Empty array when no session/no active classes. |
StaffAttendanceService | Service | Staff list/bulk-write/patch/history | Clock, Calendar, Log | Staff controllers | Clock, Calendar, Log | writes staff_attendance_records/log | See §13. |
AttendanceSettingsService | Service | Read/update the two settings rows | — | AttendanceSettingsController | — | writes attendance_notification_settings | 404 fail-closed on a missing row. |
TeacherAttendancePortalService | Service | Teacher's own classes/register, reusing admin services | assertTeacherStaffRow, AttendanceAdminModule's exports, ClassOccupancyService | TeacherAttendancePortalController | Read, Write, Lifecycle | (delegated) | 403 on a non-owned class, a caller not acting as teacher, or a caller with no live staff row. |
StudentAttendancePortalService | Service | Pupil's own attendance | assertPortalAudience | StudentAttendancePortalController | — | reads only | 404 if the caller's own student row is missing. |
GuardianAttendancePortalService | Service | One ward's attendance | assertPortalAudience | GuardianAttendancePortalController | — | reads only | 404 on a non-ward. |
AttendanceScheduler | Scheduler | Ticks both sweeps every minute | BullMQ | @Cron | Queue.add | none (no DB writes) | Caught and logged; never throws. |
AttendanceQueueProcessor | Processor | The one worker on QueueName.ATTENDANCE | Reminder/Absence processors | BullMQ | dispatches by job name | none directly | Throws only on an unhandled job name. |
AttendanceReminderProcessor | Injectable handler | The reminder sweep's full logic | Clock, Calendar, NotificationService | AttendanceQueueProcessor | Clock, Calendar, Notification | writes sessions/runtime | Every branch is a logged skip; never throws. |
AttendanceAbsenceProcessor | Injectable handler | The absence sweep's full logic | Clock, Calendar, NotificationService | AttendanceQueueProcessor | Clock, Calendar, Notification | writes guardian_alerted_at | Every 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
- Entry point and caller.
StudentAttendanceController.mark(adminPUT) orTeacherAttendancePortalService.markAttendance(portal, afterassertTeacherStaffRow/assertOwnClass). - DTO/command shape.
BulkMarkAttendanceInput { classPublicId, date, expectedVersion, submissionId, entries, force?, forcedReason? }, plusBulkMarkAttendanceOptions { refuseIfCompleted?, requireToday? }supplied only by the portal. - Auth/identity assumptions.
actor: AuthUseris already authenticated byJwtAuthGuard; the admin path has already passedRoleGuardonStudentAttendance_CREATE/_UPDATE, the portal path has already passedassertTeacherStaffRowandassertOwnClass. - Input normalization. None beyond DTO-level validation — every text field arrives already checked against the CHECK-matching regex.
- Validation order. Non-empty
entries→ no duplicatestudentId→ not a future date → (requireTodayonly) is today → (inside the transaction) date within the class's own session range → calendar gate → session open → idempotency → concurrency → (refuseIfCompletedonly) not completed → every entry enrolled. - Reads performed.
classes/grades/sections(viaresolveClass),academic_sessions(range check),academic_calendar_days(viaCalendarDayService.require),student_attendance_sessions(open-or-find),student_attendance_session_runtime(idempotency),student_class_enrollments/students/users/student_attendance_records(viaforSession, both for validation and as the pre-image for the log diff). - Business decisions. Whether the write is a force-write (
isForceWrite = !calendarDayState.attendanceExpected); whether the session was just created (createdtruthy) versus pre-existing (governs theexpectedVersion === nullbranch); which entries upsert versus delete; whether the session should advance toin_progress. - Writes performed.
INSERT ... ON CONFLICT DO NOTHINGon the session; oneINSERT ... ON CONFLICT DO UPDATEcovering every marked entry; oneDELETE ... WHERE studentId IN (...)covering every unmarked entry;attendance_change_loginsert (diffed); conditionalUPDATEadvancing session status;INSERT ... ON CONFLICT DO UPDATEon the runtime row'slast_submission_id. - Transactions. The entire method after the pre-transaction validation runs inside one
this.db.transaction(async (tx) => ...)— thirteen steps, one commit. - Cache behavior. None — this module has no cache layer.
- Jobs/events/notifications. None synchronous; the two sweeps that later read this state run on their own schedule.
- 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 ofinput. - Error handling. Every refusal is a typed Nest exception carrying a named
errorCode(see §13); none is a bare 500 except the practically-unreachableWRITE_RETURNED_NO_ROW. - Logs/metrics/audit events.
ActivityAuditInterceptorrecords the route call at the controller boundary;attendance_change_logrecords the domain change inside the same transaction as the state it describes. - 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
SELECTfor 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
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| Unmarked is the absence of a row, never a stored status | Schema (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 list | AttendanceRosterService.forSession + tally over the same query result | An 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 submissionId | student_attendance_session_runtime.last_submission_id, checked before the concurrency check | A 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 reset | DTO 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 + forcedReason | StudentAttendanceWriteService.bulkMark | The 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-wins | Schema (no session row for staff), StaffAttendanceService.bulkWrite | A 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 corrections | previous_status/new_status both nullable, CHECK requiring at least one non-null | A 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-only | attendance_change_log_is_append_only() trigger, SQLSTATE 2F002 | The 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 DESC | Index + query construction | now() 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 it | CATEGORY_DEFAULTS.attendance in the preference resolver runs after this setting, unconditionally | attendance 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 closed | AttendanceSettingsService.list, both workers' readSettings | The 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 equality | firstPeriodStartMinute - 5 <= now < firstPeriodStartMinute; firstPeriodEndMinute <= now | A 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 NULL | reminder_sent_at, guardian_alerted_at | Two 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
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Split the runtime row off the versioned session | A 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 counters | Tallies 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 version | Optimistic 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_id | History 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-versioned | Staff 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 staff | Refusing 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 equality | A 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 code | TEACHER_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
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Reminder sweep | Server 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 sweep | Server 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 health | Bull Board / BullMQ metrics on QueueName.ATTENDANCE | Jobs 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 trigger | psql, 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-closed | GET /attendance/settings | 200 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
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Staff attendance last-write-wins | StaffAttendanceService.bulkWrite | Two 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 row | calendar-day.service.ts:40-51 | None 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 cap | AttendanceAbsenceProcessor.findCandidates, LIMIT 500 | A 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 surfaces | Whole module except the portal attendance summary | Every 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