Attendance Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Attendance.
Attendance Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Backend doc | apps/fumadocs/content/docs/developer/attendance/backend.mdx, and the services/schema it documents | State model, transaction shapes, side effects, sweep logic. |
| API | Controllers under apps/api/src/modules/attendance/{admin,staff,settings,portal}/ | Route surface, actors, auth model. |
| Tests | apps/api/src/modules/attendance/__tests__/*.spec.ts | Confirmed edge-case behavior for every flow below. |
| Permissions | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts, apps/api/src/common/authorization/role.guard.ts | Which actor can reach which flow. |
2. Feature Summary
| Field | Value |
|---|---|
| Module | attendance |
| Submodule | Student register · Staff/teacher register · Notification settings · Teacher/Student/Guardian portal · Reminder and absence sweeps |
| Primary user value | A class teacher marks a daily register in seconds, an office sees the whole school's attendance state on one screen, a guardian and a pupil are told promptly when the pupil is marked absent, and every correction is preserved as a permanent, tamper-proof history. |
| Actors | Administrator/office staff (permissioned), class teacher (audience-asserted, no permission), pupil (audience-asserted), guardian (audience-asserted), the system (two BullMQ sweeps). |
| Main entry points | PUT/PATCH/POST /api/attendance/students*, PUT/PATCH /api/attendance/staff*, GET /api/attendance/overview, GET/PUT /api/attendance/settings*, /api/mobile/teacher/attendance*, /api/mobile/student/attendance, /api/mobile/guardian/children/:studentId/attendance, two @Cron(EVERY_MINUTE) sweeps. |
| Main outputs | The register envelope (session + roster + tally), the school-wide overview, keyset-paginated history pages, push/in-app/SMS/email notifications to a teacher or a guardian. |
| Related docs | API: /docs/developer/attendance/api. Backend: /docs/developer/attendance/backend. Dependency: /docs/developer/academic-calendar for the school-day gate this module consumes. |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
Administrator / office (StudentAttendance_*) | Read/mark/correct any class's register; complete/reopen/reset; read the school-wide overview; read any pupil's history. | Nothing is withheld from this permission's own scope — it is the unscoped admin surface. | JWT + StudentAttendance_READ/CREATE/UPDATE/DELETE. | Granted to no seeded role by default — see §12.3. A school creates a dedicated role holding it. |
Administrator / office (StaffAttendance_*) | Read/mark/correct the staff-and-teacher register on any day, holiday or not; read any staff member's history. | — | JWT + StaffAttendance_READ/CREATE/UPDATE. | Also granted to no seeded role by default. |
Administrator (AttendanceSettings_*) | Read and update which channels each of the two notification kinds may use. | Cannot force a channel a recipient has personally opted out of — the setting can only subtract. | JWT + AttendanceSettings_READ/UPDATE. | Also granted to no seeded role by default; stays with a dedicated administrator role. |
| Class teacher | List the classes they are the class teacher of; read, mark, and complete THEIR OWN class's register for TODAY only. | Mark a date other than today; mark a register once completed; correct a closed day; use force/forcedReason; act on a class they do not teach; reopen or reset a register; see another teacher's classes. | JWT; audience-asserted (assertTeacherStaffRow — the active role named teacher, plus a live staff row). No @Permissions(). | The "holiday override unreachable" rule: the portal DTO carries no force field, and forbidNonWhitelisted turns sending one into a 400. The panel's register screen (/dashboard/my-class/attendance) is gated on the screen-only code AttendanceView_READ, seeded to staff and teacher. The teacher role is granted when a staff member is created on a teaching designation; after a later designation change an administrator grants or revokes it by hand — see PATCH /api/staff/:id in the People API. |
| Pupil | Read their own attendance, date-ranged. | See another pupil's record; see who marked them; mark or correct anything. | JWT; audience-asserted (assertPortalAudience(actor, "student")). No @Permissions(). | Response carries no marked_by and no staff identity. The panel screen (/dashboard/my-attendance) is gated on the screen-only code AttendanceView_READ, which gates no API route. |
| Guardian | Read ONE named child's attendance, date-ranged — the child must be their ward. | See a non-ward's record (404, never 403); see who marked the child. | JWT; audience-asserted (assertPortalAudience(actor, "guardian")) plus a ward check. No @Permissions(). | A non-ward id answers exactly like a non-existent one — the id space is not an enumeration oracle. The panel screens (/dashboard/my-attendance, /dashboard/children/{studentId}/attendance) are gated on AttendanceView_READ, which gates no API route. A superadmin's permission list omits it, so neither screen appears in a superadmin's menu. |
| System (BullMQ sweeps) | Open a register lazily and send a reminder to the class teacher five minutes before first period; alert a pupil's guardians once first period has ended and they are marked absent. | Retry the same tick's cohort twice; send when settings are disabled or empty of channels; run outside school hours or on a non-school day. | Internal — runs inside the API process, no HTTP surface. | Every send is exactly-once via a guarded UPDATE ... WHERE ... IS NULL. |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Read a class's register for a date | Admin | Admin | GET /api/attendance/students | Calendar, session, roster | — | §8.1 |
| Bulk mark/unmark a class's register | Admin | Admin | PUT /api/attendance/students | Calendar, session, roster | Session, records, log, runtime | §8.2 |
| Correct one pupil's record | Admin | Admin | PATCH /api/attendance/students/:recordId | Record, session, subject | Record, log | §8.3 |
| Complete a register | Admin, Teacher | Admin, Teacher | POST /api/attendance/students/complete, POST /api/mobile/teacher/attendance/complete | Session, roster | Session | §8.4 |
| Reopen a completed register | Admin | Admin | POST /api/attendance/students/reopen | Session | Session | §8.5 |
| Reset (clear) a register | Admin | Admin | POST /api/attendance/students/reset | Session, roster | Records (deleted), log, session, runtime | §8.6 |
| Read a pupil's attendance history | Admin | Admin | GET /api/attendance/students/:studentId/history | Change log | — | §8.7 |
| Read the school-wide overview | Admin | Admin | GET /api/attendance/overview | Classes, sessions, records, runtime, timetables, timeslots | — | §8.8 |
| List the staff/teacher register for a date | Admin | Admin | GET /api/attendance/staff | Staff, records | — | §8.9 |
| Bulk mark the staff register | Admin | Admin | PUT /api/attendance/staff | Staff, existing records | Records, log | §8.10 |
| Correct one staff record | Admin | Admin | PATCH /api/attendance/staff/:recordId | Record | Record, log | §8.11 |
| Read a staff member's history | Admin | Admin | GET /api/attendance/staff/:staffId/history | Change log | — | §8.12 |
| Read both notification settings | Admin | Admin | GET /api/attendance/settings | Settings | — | §8.13 |
| Update one notification setting | Admin | Admin | PUT /api/attendance/settings/:kind | Settings | Settings | §8.14 |
| List my classes | Portal | Teacher | GET /api/mobile/teacher/attendance/classes | Classes, occupancy | — | §8.15 |
| Read my class's register | Portal | Teacher | GET /api/mobile/teacher/attendance | Calendar, session, roster | — | §8.16 |
| Mark my class's register (today only) | Portal | Teacher | PUT /api/mobile/teacher/attendance | Calendar, session, roster | Session, records, log, runtime | §8.17 |
| Complete my class's register | Portal | Teacher | POST /api/mobile/teacher/attendance/complete | Session, roster | Session | §8.18 |
| Read my own attendance | Portal | Pupil | GET /api/mobile/student/attendance | Records | — | §8.19 |
| Read my child's attendance | Portal | Guardian | GET /api/mobile/guardian/children/:studentId/attendance | Ward check, records | — | §8.20 |
| Read my attendance totals | Portal | Pupil | GET /api/mobile/student/attendance/summary | Current session, calendar, enrolments, records (60 s cache) | — | §8.21 |
| Read my child's attendance totals | Portal | Guardian | GET /api/mobile/guardian/children/:studentId/attendance/summary | Ward check, then as above | — | §8.22 |
| Send a first-period reminder | Worker | System | @Cron(EVERY_MINUTE) → AttendanceJob.FIRST_PERIOD_REMINDER | Settings, calendar, classes, timetables, timeslots, sessions, runtime | Session (lazy open), runtime | Backend §7.7 |
| Send a guardian absence alert | Worker | System | @Cron(EVERY_MINUTE) → AttendanceJob.ABSENCE_ALERT | Settings, calendar, school_week_config, records | guardian_alerted_at | Backend §7.8 |
5. User-Facing Flows
5.1 A class teacher marks today's register
Summary
A class teacher opens their attendance screen, sees the pupils who are on the roll today, marks each present, absent, late or excused, and submits. The system opens the register if this is the first submission of the day, applies the marks, and returns the updated register with a fresh tally.
Preconditions
- The teacher is authenticated and has a live
staffrow that is theclass_teacher_idof the class they are addressing. - The academic calendar has a row for today's date in the class's academic session (otherwise the very first read 409s, prompting the calendar to be generated).
- The date being marked is today, in the school's own timezone.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Teacher | Opens the attendance screen; the app calls GET /api/mobile/teacher/attendance/classes. | The teacher's own classes, with occupancy counts. | TeacherAttendancePortalController.listMyClasses |
| 2 | Teacher | Selects a class; the app calls GET /api/mobile/teacher/attendance. | The roster with every pupil, marked or not; session: null if never opened today. | TeacherAttendancePortalController.getAttendance |
| 3 | Teacher | Marks each pupil, submits PUT /api/mobile/teacher/attendance. | The register opens if needed, records are upserted/deleted, a change-log entry is written per real change. | TeacherAttendancePortalService.markAttendance → StudentAttendanceWriteService.bulkMark |
| 4 | Backend | Re-reads the full state. | Updated roster, tally, session status (advances to in_progress on the first record). | StudentAttendanceReadService.loadState |
| 5 | Teacher | Screen shows the confirmed marks and the running tally. | — | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Not the class teacher | classes.class_teacher_id !== staffId | Refused before any read/write. | 403 ATTENDANCE_NOT_CLASS_TEACHER |
| Date is not today | Portal write with date !== school's today | Refused. | 422 ATTENDANCE_DATE_NOT_TODAY |
| Register already completed | session.status === 'completed' and the portal's refuseIfCompleted option is set | Refused — correcting a closed day is the admin surface's job. | 409 ATTENDANCE_ALREADY_COMPLETED |
| Not a school day | Calendar classification has attendanceExpected: false | Refused; the portal DTO has no force field so a teacher cannot override it. | 422 ATTENDANCE_NOT_A_SCHOOL_DAY |
| Repeated submission (double-tap) | Same submissionId as the last accepted write | No-op; returns the current state. | 200, no new side effects |
| Concurrent edit | expectedVersion stale | Refused. | 409 ATTENDANCE_VERSION_CONFLICT |
| A pupil in the payload transferred out | studentId has no active enrolment in this class | Refused for the whole submission. | 422 ATTENDANCE_STUDENT_NOT_ENROLLED |
5.2 An administrator marks or corrects the register
Summary
An office administrator can do everything the teacher portal can, plus mark any date (past or, with force, a non-school day), correct a completed register per pupil, and run the three lifecycle transitions.
Preconditions
- The administrator holds
StudentAttendance_CREATE/_UPDATE/_DELETEas appropriate — held only by a role the school deliberately created for it.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | GET /api/attendance/students?classPublicId&date | The register envelope for any date. | StudentAttendanceController.getAttendance |
| 2 | Admin | PUT /api/attendance/students with force: true, forcedReason on a non-school day. | The register opens and marks are recorded despite the calendar's refusal. | StudentAttendanceController.mark |
| 3 | Admin | PATCH /api/attendance/students/:recordId to fix one pupil after the fact. | The record and the change log are updated; permitted even on a completed session. | StudentAttendanceController.correct |
| 4 | Admin | POST /api/attendance/students/complete once every pupil is marked. | Session becomes completed. | StudentAttendanceLifecycleService.complete |
| 5 | Admin | POST /api/attendance/students/reopen to correct in bulk. | Session returns to in_progress. | StudentAttendanceLifecycleService.reopen |
| 6 | Admin | POST /api/attendance/students/reset to clear a mismarked day entirely. | Every record for the class/date is deleted, one deletion logged per pupil, session returns to not_started. | StudentAttendanceLifecycleService.reset |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Force without a reason | force: true, forcedReason omitted | Accepted — forcedReason is optional even under force; the column is simply left null. | 200 |
| Non-school day, no force | attendanceExpected: false, force unset | Refused. | 422 ATTENDANCE_NOT_A_SCHOOL_DAY |
| No calendar row at all | The calendar has never been generated for this session/date | Refused before the force branch is even reached. | 409 ACADEMIC_CALENDAR_NOT_GENERATED |
| Future date | date > today | Refused, admin or teacher. | 422 ATTENDANCE_DATE_IN_FUTURE |
| Reset with a stale token | expectedVersion mismatched | Refused — the destructive path is guarded exactly like complete and reopen. | 409 ATTENDANCE_VERSION_CONFLICT |
| Reset on a register with nothing marked | Roster has zero marked pupils | Succeeds as a no-op beyond the session status write — nothing to delete, nothing to log. | 200 |
| Write against a locked year | The class's or record's academic session has been locked | Refused before any change reaches the row | 409 ACADEMIC_SESSION_LOCKED |
5.3 A pupil or guardian checks attendance
Summary
A pupil opens their own attendance history; a guardian opens one of their children's. Both see status, class, and note for a date range, with no staff identity attached.
Preconditions
- The caller holds an active role whose
scopeKindmatches (studentorguardian). - For a guardian, the addressed
studentIdmust actually be their ward.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Pupil | GET /api/mobile/student/attendance?from&to | Own attendance rows for the range, with Bikram Sambat dates. | StudentAttendancePortalController.myAttendance |
| 1' | Guardian | GET /api/mobile/guardian/children/:studentId/attendance?from&to | The named child's attendance rows, if a ward. | GuardianAttendancePortalController.childAttendance |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Not a ward | studentId is not this guardian's child | 404 — indistinguishable from a non-existent id. | 404 STUDENT_NOT_FOUND |
| Guardian's own soft-deleted profile | guardians.deleted_at set | Treated as not a ward. | 404 STUDENT_NOT_FOUND |
| Pupil's own row missing | students.user_id = actor.id finds nothing | Rare; the pupil session exists but their profile does not resolve. | 404 STUDENT_NOT_FOUND |
| No attendance in the range | Range genuinely has no records | Empty array, not an error. | 200 [] |
| A date the calendar cannot convert to Bikram Sambat | Outside BS 2000–2090 | bsYear/bsMonth/bsDay fall back to 0/0/0 rather than failing the whole page. | 200, degraded BS fields for that row only |
5.3a A pupil or guardian checks the session attendance totals
Summary
A pupil's or guardian's dashboard shows how many days the pupil has been expected, marked, present, late, absent and excused in the current academic session so far, with an attendance rate.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Pupil | GET /api/mobile/student/attendance/summary | Totals for the caller's own record; no id is taken from the request. | StudentAttendancePortalController.mySummary |
| 1' | Guardian | GET /api/mobile/guardian/children/:studentId/attendance/summary | Totals for the named child, if a ward. | GuardianAttendancePortalController.childSummary |
| 2 | System | Resolves identity, then reads the totals (cached for 60 seconds per pupil and day). | PortalAttendanceSummaryDto. | PortalAttendanceSummaryService.forStudent |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Not a ward | studentId is not this guardian's child, or the child's graduate access has ended | Indistinguishable from a non-existent id. | 404 STUDENT_NOT_FOUND |
| Wrong audience | Active role is not a pupil (or guardian) role | Refused before any lookup. | 403 PERMISSION_INSUFFICIENT |
| No current session | No academic session is flagged current | All counts 0; session name, bounds and rate null. | 200 |
| Session not started | Today is before the session start | All counts 0; bounds and rate null, session name set. | 200 |
| Nothing marked yet | marked is 0 | ratePct is null; every expected day is unmarked. | 200 |
| Redis slow or down | Cache read exceeds its 250 ms budget or fails | Totals are read from the database. | 200 |
A register mark or correction appears in the totals within 60 seconds.
5.4 An administrator manages the staff/teacher register
Summary
An office administrator marks the staff-and-teacher register for a date — including holidays and exam days, when staff still work — corrects one person, and reads history. There is no completion/reopen/reset cycle for staff; every write simply overwrites.
Preconditions
- The administrator holds
StaffAttendance_READ/CREATE/UPDATE.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | GET /api/attendance/staff?date&designationId | The full staff roster for the date, paginated, filterable by designation (the Teachers/Staff tabs). | StaffAttendanceController.list |
| 2 | Admin | PUT /api/attendance/staff | Every entry upserted or deleted, logged. Last write for the date wins. | StaffAttendanceController.bulkWrite |
| 3 | Admin | PATCH /api/attendance/staff/:recordId | One person's record corrected, keyed on that record's own version. | StaffAttendanceController.patch |
| 4 | Admin | GET /api/attendance/staff/:staffId/history | Keyset-paginated change history for that person. | StaffAttendanceHistoryController.history |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Marked on a holiday | Calendar says not a school day | Succeeds anyway — the calendar gate is never consulted for staff. | 200 |
status: null in the bulk payload | Explicit unmark | Deletes that person's record for the date. | 200 |
Duplicate staffId in one payload | Same person twice | Refused for the whole payload. | 422 ATTENDANCE_DUPLICATE_STUDENT |
| Two administrators submit the same day at once | No session/version to detect it | The later commit silently wins; both writes are individually logged. | 200 for both, no conflict surfaced |
| Stale PATCH | expectedVersion mismatched on the correction path | Refused — this path DOES have a real row and a real token. | 409 ATTENDANCE_VERSION_CONFLICT |
5.5 The system reminds a teacher and alerts a guardian
Summary
Every minute, two BullMQ jobs tick. Five minutes before a class's first period, if nobody has opened today's register yet, the class teacher gets a reminder. Once first period has ended, any pupil still marked absent triggers a guardian alert — once, ever, per pupil per day.
Preconditions
attendance_notification_settingshas an enabled row with at least one channel for the relevant kind.- The date is a school day with an active academic session covering it.
- A class must have a published timetable with a first
period-type timeslot on today's weekday to be reminded.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | System | Cron ticks every minute; two jobs enqueued with a per-minute jobId. | Neither can collapse into the other minute's tick. | AttendanceScheduler |
| 2 | Worker | AttendanceReminderProcessor finds classes in the [start-5, start) window with no reminder sent and a live class teacher. | Candidate list. | Backend §7.7 |
| 3 | Worker | Opens the register lazily, snapshots the first period, stamps reminder_sent_at guardedly, sends. | Teacher gets a push/in-app/SMS/email reminder. | NotificationService.send |
| 4 | Worker | AttendanceAbsenceProcessor scans absent, unalerted records whose session's first period has ended. | Candidate list, capped at 500, ordered by id. | Backend §7.8 |
| 5 | Worker | Stamps guardian_alerted_at guardedly, sends. | Guardian gets an alert naming the child. | NotificationService.send |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Settings row absent, disabled, or channel-less | Either kind | Sweep logs the skip and sends nothing. | No side effect |
| Tick delayed past the window | A missed or slow tick | The next tick's window still catches the cohort — a persisted marker, not exact-minute equality, is the control. | Recovered automatically |
| Class teacher NULL or soft-deleted | classes.class_teacher_id unset or the staff row is gone | That class is skipped and logged; no crash. | No reminder for that class |
| Register already completed | session.status === 'completed' | Skipped — nothing to remind about. | No reminder |
| Register marked during the lesson, not before | Absent records exist but the session-level "first period ended" moment already passed | Still alerts — the marker is per-record, not per-session, so a late-marked class still triggers the alert once its own record qualifies. | Alert sent |
| Past the school-day cutoff | now.minuteOfDay > school_week_config.school_day_cutoff_minute | Absence sweep stops entirely for the day. | No further alerts that day |
| More than 500 new absences in one tick | Large school, big rush | The remaining rows are picked up by the next tick, in deterministic id order. | Delayed by up to a minute, never lost |
6. Admin Flows
6.1 Create (mark)
PUT /api/attendance/students and PUT /api/attendance/staff both create records where none existed. Neither has a separate "create" verb from "update" — a register is opened lazily on the first mark.
6.2 List / Read detail
GET /api/attendance/students, GET /api/attendance/staff, GET /api/attendance/overview — see §8 in the API doc for full parameters.
6.3 Update (correct)
PATCH /api/attendance/students/:recordId and PATCH /api/attendance/staff/:recordId — both keyed on the record's own version, both permitted regardless of session/day state.
6.4 Delete
There is no per-record delete route. Deletion happens two ways: an entry with status: null in a bulk payload deletes that person's record; POST /api/attendance/students/reset deletes every record for a class/date at once.
6.5 Reorder
Not applicable — attendance has no orderable list.
6.6 Activate/deactivate
Not applicable to records. The closest analogue is attendance_notification_settings.enabled, covered in §6.9 below.
6.7 Soft delete / Restore
Not applicable to attendance records themselves — see §6.4 for the actual deletion semantics. Soft-deleted students/staff rows are read from, not written to, by this module (see backend doc §6.3).
6.8 Export/import
Not present in this module.
6.9 Notification settings — the module's own "configure" flow
AttendanceSettings_UPDATE holders can only ever narrow delivery — the per-recipient preference resolver runs afterwards and always wins in the restrictive direction.
6.10 Manual retry
There is no manual retry endpoint. A missed sweep tick recovers automatically on the next tick because both sweeps select over a window with a persisted marker rather than exact-minute equality.
7. Lifecycle and State Transitions
7.1 Student register session lifecycle
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
student_attendance_sessions | (none) | First PUT mark | not_started | Calendar allows it (or force) | Session row created |
student_attendance_sessions | not_started | First record actually written | in_progress | At least one record exists | Conditional UPDATE |
student_attendance_sessions | in_progress | POST .../complete | completed | Every live roster row has a record | completed_at, completed_by_user_id set |
student_attendance_sessions | completed | POST .../reopen | in_progress | expectedVersion matches | completed_at, completed_by_user_id cleared |
student_attendance_sessions | any | POST .../reset | not_started | expectedVersion matches | Every record deleted, one deletion logged per pupil, runtime's last_submission_id cleared |
7.2 Per-record status
A student_attendance_records/staff_attendance_records row has no internal lifecycle of its own — its status is one of present/absent/late/excused, and "unmarked" is the row's absence rather than a fifth value.
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Bulk mark (admin or teacher) | student_attendance_sessions, student_attendance_records, attendance_change_log, student_attendance_session_runtime | None | None | None | None | None |
| Correct one record | student_attendance_records, attendance_change_log | None | None | None | None | None |
| Complete/reopen | student_attendance_sessions | None | None | None | None | None |
| Reset | student_attendance_records (deleted), attendance_change_log, student_attendance_sessions, student_attendance_session_runtime | None | None | None | None | None |
| Staff bulk write / patch | staff_attendance_records, attendance_change_log | None | None | None | None | None |
| Update a setting | attendance_notification_settings | None | None | None | None | None |
| Reminder sweep tick | student_attendance_sessions (lazy open), student_attendance_session_runtime | None | Consumes AttendanceJob.FIRST_PERIOD_REMINDER | None | None | Push/in-app/SMS/email to the class teacher via NotificationService.send |
| Absence sweep tick | student_attendance_records.guardian_alerted_at | None | Consumes AttendanceJob.ABSENCE_ALERT | None | None | Push/in-app/SMS/email to the pupil's guardians |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Calendar not generated | Session/date has no academic_calendar_days row | Every read and write on that date 409s | Generate the calendar for the session first | CalendarDayService.require |
| Concurrent edit | Two operators read the same register, both submit | The second submit 409s | Reload and resubmit against the current version | ATTENDANCE_VERSION_CONFLICT |
| Double-tap / network retry | Client resends the exact same submission | No error, no duplicate effect | Nothing to do — it is already applied | submissionId idempotency |
| Missed cron tick | Scheduler enqueue fails, or a worker tick is delayed | No visible error to any user | The next tick's window recovers the cohort | Server log only — see backend doc §14 |
| Settings row missing | Migration seed not applied | GET /attendance/settings 404s; both sweeps silently send nothing | Restore the seed rows from migration 0020 | ATTENDANCE_SETTINGS_NOT_FOUND |
| Malformed pagination cursor | A hand-built or corrupted cursor query param | History page 400s | Restart from page one (omit cursor) | PAGINATION_CURSOR_INVALID |
11. Diagrams Required Per Module
- Actor capability diagram: §3, §4.
- High-level module flow diagram: §5.5's sequence diagram covers the fully automated path; §5.1/§5.2 cover the human-triggered ones.
- Sequence diagram for each major flow: §5.1–§5.5.
- State machine diagram for every lifecycle: §7.1, §7.2.
- Data side-effect diagram for write flows: §9, and §12.6 below.
- Error branch diagram for critical flows: §10, and the backend doc's error-branch table (§13 there).
12. Mandatory Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Bulk mark | Omitted pupil is left unchanged, never implicitly unmarked | Admin/teacher | PUT with entries not covering every pupil | Only the submitted pupils change | Partial upsert/delete, not a full-roster rewrite | student-attendance-write.service.ts:260-268 |
| Bulk mark | status: null in an entry is an explicit unmark | Admin/teacher | Entry with status: null | That pupil's record is deleted | DELETE ... studentId IN (...) | dto/student-attendance.dto.ts:53-57 |
| Bulk mark | Idempotent replay | Admin/teacher | Same submissionId resent | No visible change | Short-circuits to a re-read before the concurrency check | student-attendance-write.service.ts:202-212 |
| Bulk mark | Roll order, unrolled pupil last | Admin/teacher/system (overview) | Any roster read | Pupils appear in roll order, no-roll last | ORDER BY roll_number ASC NULLS LAST, admission_number, id | attendance-roster.service.ts:93-101 |
| Bulk mark | Force-marking a non-school day requires a stated reason column, kept separate from the note | Admin only | force: true | forced_reason populated, distinct from any operator note | Separate columns, separate length CHECKs | attendance.ts:234-241 |
| Complete | Refuses on ANY unmarked live pupil, including one added mid-day | Admin/teacher | POST .../complete | 422 until every current roll member has a record | Evaluated against a live roster read, never a stored count | student-attendance-lifecycle.service.ts:79-89 |
| Reset | Logs the deletion of EVERY previously-marked pupil, one row each | Admin | POST .../reset | History shows the reset as a series of newStatus: null entries | AttendanceLogService.append given the pre-image roster | student-attendance-lifecycle.service.ts:182-198 |
| History | The first marking is a history entry too | Admin | Any first-ever mark of a pupil | History is never empty for a marked pupil | previousStatus: null rows are written, not filtered out | attendance-log.service.ts:13-19 |
| History | Keyset pagination survives a same-timestamp tie | Admin | Bulk mark of many pupils | No row dropped or duplicated across a page boundary | (changed_at, id) cursor, truncated/rendered for precision | attendance-history.service.ts:29-41 |
| Overview | Schedule state distinguishes "no timetable" from "no period today" | Admin | GET /attendance/overview | Two different, informative states rather than one generic "not scheduled" | Two separate lookups (class_timetables.status, class_timeslots by weekday) | attendance-overview.service.ts:94-98 |
| Overview | Unalerted-absence count is exposed per class | Admin | GET /attendance/overview | The office can see, before any guardian complains, which classes have pending alerts | count(*) FILTER (WHERE status='absent' AND guardian_alerted_at IS NULL) | attendance-overview.service.ts:170 |
| Staff register | Holiday marking succeeds where student marking would refuse | Admin | PUT /attendance/staff on a non-school day | 200, recorded normally | The calendar's attendanceExpected half is never called on this path | staff-attendance.service.ts:180-182 |
| Staff register | Duplicate payload entry refused for the whole submission | Admin | Same staffId twice in one PUT | 422, nothing written | Checked before the transaction opens | staff-attendance.service.ts:194-204 |
| Settings | An update can only restrict delivery, never widen it | Admin | PUT /attendance/settings/:kind | Enabling email/sms here changes nothing for a recipient who never opted in | CATEGORY_DEFAULTS.attendance applies afterwards, unconditionally | dto/attendance-settings.dto.ts:14-31 |
| Teacher portal | force/forcedReason are unreachable, not merely unused | Teacher | Any PUT payload including those fields | 400 validation error | DTO omits the fields; forbidNonWhitelisted rejects extras | dto/teacher-attendance-portal.dto.ts:30-53 |
| Guardian/Student portal | No staff identity in the response | Pupil/Guardian | Any read | Response has no markedBy/staff name field at all | DTO enumerates fields by hand, never spreads the wider entity | dto/student-attendance-portal.dto.ts:16-38 |
| Sweeps | A class with no class teacher, or a soft-deleted one, is silently skipped, not erroring the whole tick | System | Reminder sweep | Every other eligible class is still processed | Per-candidate try, logged warn, loop continues | attendance-reminder.processor.ts:314-331 |
| Sweeps | Absence sweep batches at 500, ordered by id | System | A morning with unusually many absences | Remaining rows picked up next tick, none lost or duplicated | LIMIT 500 ORDER BY id | attendance-absence.processor.ts:30,229-230 |
12.2 Business Process Diagram Pack
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| Unmarked is a missing row, never a stored status | Keeps the enum closed over real outcomes; a placeholder status could answer "does this exist" wrongly | A pupil with no record shows a neutral "not yet marked" state, distinct from any real status | Schema (status NOT NULL), service (status: null deletes) | MarkAttendanceEntryDto.status is nullable and means delete | attendance.ts enum comment | student-attendance.integration.spec.ts |
| Calendar gate refuses a non-school-day mark unless forced | The academic calendar is the single source of truth for whether the school operates on a date | A teacher cannot accidentally mark a holiday; an administrator can, with a stated reason | StudentAttendanceWriteService.bulkMark | force/forcedReason on the admin DTO only | CalendarDayService.require | "refuses a holiday without force, and succeeds with force" |
| Completion requires every live roster row marked | A tally derived from an incomplete roster would misrepresent the day | Teachers must account for every pupil before closing the day | StudentAttendanceLifecycleService.complete | POST .../complete 422s otherwise | Evaluated against a live read, not a counter | "refuses to complete an incomplete roster..." |
| Teacher portal is today-only, not-completed-only | Correcting history is an administrative act, behind its own permission | A teacher cannot silently rewrite a closed day through an un-permissioned route | bulkMark's requireToday/refuseIfCompleted options | Portal PUT 422/409s outside today or once completed | Same transaction, different options | "refuses a non-today date on the teacher's write" |
| Staff register has no calendar gate | Staff legitimately work on holidays and exam days; the register must be able to describe those days | Admin can mark staff attendance any day without a workaround | StaffAttendanceService.bulkWrite never calls the gate's expectation half | No force field needed or present | — | "marks staff attendance on a holiday successfully" |
| No seeded role holds any attendance permission code | TEACHER_PERMISSIONS spreads STAFF_PERMISSIONS; both carry scopeKind: "all", so a grant to staff would give every teacher the unscoped admin surface | Teachers reach attendance only through the audience-asserted portal, never the admin routes | seed-auth.ts (absence of grants) | Admin routes 403 for a teacher session without a dedicated role | permission-catalog.ts comment | Permission catalogue and seed review |
A guardian's read never filters record_status | That column is an administrator's record-keeping toggle, not an existence signal; filtering it here would make a parent's read stricter than the pupil's own | A parent keeps seeing their child's attendance even if an unrelated record-keeping flag changes | GuardianAttendancePortalService.resolveWard | 404 only on a genuine non-ward, never on this toggle | Identity layer filters deleted_at only | Root CLAUDE.md's identity-vs-row-scope rule |
| Notification settings can only subtract | attendance is not an unsuppressible category; an administrator must not override a guardian's own opt-out at metered cost | A guardian's personal preference always wins over the school's channel setting | AttendanceSettingsService + the notification preference resolver | The settings screen must say this in words | CATEGORY_DEFAULTS.attendance applied unconditionally afterwards | attendance-settings.integration.spec.ts |
| Sweeps select over a window with a persisted marker | Exact-minute equality would drop a whole minute's cohort permanently on any delay | Nobody's reminder or alert is silently lost to a slow tick | Both processors' findCandidates | No client-visible contract — purely operational | Guarded UPDATE ... WHERE ... IS NULL | attendance-sweeps.integration.spec.ts |
| A locked academic year's register, staff record and student record can no longer change | The year's academic record is closed once locked; only an administrator reopening it can reintroduce a write | Every write route answers a single, named refusal instead of a constraint violation | Postgres trigger on the three tables (see the academic-sessions backend doc) | 409 ACADEMIC_SESSION_LOCKED on any affected route | assert_academic_session_unlocked / _via | — |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| The register is a set of changes, not a full-state rewrite | A teacher can mark ten of thirty pupils and come back later without re-sending everyone | Smaller payloads, and a partial submission cannot accidentally wipe unrelated marks | Require the full roster in every PUT | A client must track which pupils it has already sent, across screens | Low — the read endpoint always returns the current full state to reconcile against |
Idempotency via submissionId, not by making the write itself idempotent | A flaky connection's retry never double-applies or conflicts against itself | The concurrency check stays a simple, honest optimistic-lock comparison | Rely on the upsert's natural idempotency and accept a version bump on replay | One extra column, one extra read | None material |
| Staff attendance is last-write-wins | The common case — one administrator, one register — never has to think about a version token | No synthetic session row, no extra join, for a surface with genuinely low write contention | Give staff the same session+version machinery as students | A genuine simultaneous edit from two admins silently loses the earlier write | Low — this is a single-office, low-frequency surface |
| Force-marking is admin-only | A teacher cannot accidentally create attendance records for a day the school did not operate | Keeps the calendar's refusal meaningful on the surface most people use | Let any marker force | A holiday that genuinely needs recording (a special event day) requires an administrator, not the class teacher, to record it | Low — matches this module's actual authority model |
| No stored attendance counters anywhere | The numbers on screen can never contradict the list beneath them | Removes an entire class of drift bug this module already shipped once and fixed | Maintain running counters, updated per write | An extra count(*) FILTER per read | None at classroom/school scale |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Bulk mark | Empty state | entries: [] | Refused before any read | 422 ATTENDANCE_EMPTY_PAYLOAD | student-attendance-write.service.ts:92-97 |
| Bulk mark | First use | No session row exists yet for this class/date | Session opened lazily on the first mark | 200, session was null, now populated | student-attendance.integration.spec.ts |
| Bulk mark | Last item | The final unmarked pupil on the roll is marked | Session becomes eligible for complete | No error; complete now succeeds | student-attendance-lifecycle.service.ts:84-89 |
| Bulk mark | Duplicate action | Same submissionId twice | No-op | 200, unchanged state | "replaying the same submissionId writes nothing and returns 200" |
| Bulk mark | Concurrent action | Two operators, stale expectedVersion | Second submit refused | 409 ATTENDANCE_VERSION_CONFLICT | "refuses a stale expectedVersion as a conflict" |
| Bulk mark | Expired state | N/A — there is no session expiry in this module | — | — | — |
| Teacher portal | Permission mismatch | A staff session that is not a class teacher of the addressed class | Refused | 403 ATTENDANCE_NOT_CLASS_TEACHER | "refuses a teacher who is not the class teacher" |
| Guardian portal | Guest limitation | N/A — no guest access exists on this module; every route requires an authenticated, role-scoped session | — | — | — |
| Guardian portal | Missing dependency | The child is not (or no longer) this guardian's ward | 404 | 404 STUDENT_NOT_FOUND | "refuses a child who is not this guardian's ward" |
| History | Cache stale/miss | N/A — this module has no cache layer | — | — | — |
| History | Unsupported filter or sort | N/A — history takes only from/to/cursor/size, all validated | — | — | — |
| Reminder sweep | Queue failure | attendanceQueue.add throws | Logged, cron continues; the tick is simply skipped | Server log only | attendance.scheduler.ts:51-58 |
| Absence sweep | Cache stale/miss | N/A | — | — | — |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Bulk mark | classes, sessions, academic sessions, calendar, roster, runtime | sessions, records, log, runtime | None | None | classLabel, calendar, session, version, roster[], tally |
| Complete/reopen/reset | sessions, roster | sessions (+ records/log/runtime on reset) | None | None | Same envelope as bulk mark |
| Overview | classes, sessions, records, runtime, timetables, timeslots | None | None | None | One row per class: tallies, scheduleState, reminderSentAt, unalertedAbsences |
| History (either) | change log | None | None | None | data[] array, top-level nextCursor |
| Reminder sweep | settings, calendar, classes, timetables, timeslots, sessions, runtime, staff | sessions (lazy open), runtime | None | Consumes one job per tick | (no HTTP response — a notification) |
| Absence sweep | settings, school_week_config, calendar, records joined through session/runtime/class/student/user | guardian_alerted_at | None | Consumes one job per tick | (no HTTP response — a notification) |
12.7 Experience Quality Checklist
- The doc explains what the actor is trying to accomplish (§5).
- The doc explains what the backend does that the actor does not see (§12.1, §12.6).
- The doc covers every minor flow and branch (§12.1, §12.5).
- The doc includes user, admin, worker, and system flows (§5, §6, §5.5).
- The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4).
- The doc maps every flow to API routes and backend side effects (§4, §9, §12.6).
- The doc includes diagrams appropriate to each flow type (§5, §7, §12.2, §12.6).
- The doc covers all edge cases and failure recovery (§10, §12.5).
13. Completion Checklist
- Every feature, minor action, and submodule capability is listed (§4, §12.1).
- Every actor has allowed and forbidden behavior (§3).
- Every major and minor flow includes steps, branches, and diagrams (§5, §6).
- Every lifecycle has a transition table and state diagram (§7).
- Every flow links to the API and backend docs (below, and inline throughout).
- Dependencies on other modules are called out — the academic calendar (see below) shapes when attendance may be marked, and this doc documents only that dependency, not the calendar module itself.
See Also
- API doc:
/docs/developer/attendance/api - Backend doc:
/docs/developer/attendance/backend - Academic calendar (dependency — not documented here):
/docs/developer/academic-calendar
Academic Calendar API Reference
Complete API contracts for Academic Calendar, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Attendance Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Attendance.