6.1 KiB
Staff Attendance Backend
Purpose
staff_attendance_records stores staff-level attendance entries per organization. This slice exposes
a filtered record list, an aggregated summary used by the attendance snapshot, and a scoped upsert
endpoint for manual office/staff attendance entry.
This is distinct from the student-level attendance models (attendance_sessions,
attendance_records) and from campus daily aggregates (campus_attendance_summaries).
Slice Files (by layer)
- Route:
src/routes/staff_attendance.ts(thin wiring;GET /records,GET /summary,PUT /records/:userId/:date). - Controller:
src/api/controllers/staff_attendance.controller.ts(custom — not the CRUD factory). - Service (BLL):
src/services/staff_attendance.ts. - Repository (DAL): queries run through
db.staff_attendance_recordsanddb.usersinside the service (no separatedb/api/staff_attendance.ts). - Model:
src/db/models/staff_attendance_records.ts. - Shared used:
services/shared/access.ts(assertAuthenticatedTenantUser,requireOrganizationId,requireUserId,hasFeaturePermission,campusDimensionScope,getRoleScope,getSchoolId),services/shared/validate.ts(clampLimit,optionalIsoDate),shared/constants/staff-attendance.ts(STAFF_ATTENDANCE_STATUSES,STAFF_ATTENDANCE_DEFAULT_LIMIT,STAFF_ATTENDANCE_MAX_LIMIT).
API
The slice is mounted at /api/staff_attendance; all routes require JWT authentication (the mount in
src/index.ts applies the jwt passport guard).
GET /api/staff_attendance/records->200{ rows, count }. Query parameters (all optional):startDateandendDate(ISOYYYY-MM-DD, validated viaoptionalIsoDate), andlimit(positive integer, defaulting toSTAFF_ATTENDANCE_DEFAULT_LIMIT= 90 and capped atSTAFF_ATTENDANCE_MAX_LIMIT= 366 viaclampLimit). Each row is the record DTO below. Rows are ordered byattendance_datedescending, thenuser_nameascending.GET /api/staff_attendance/summary->200{ staffCount, recordsCount, present, late, absent }. Accepts the samestartDate,endDate, andlimitquery parameters;limitis read from the filter type but the summary counts are computed with SQLCOUNTaggregates, not by limiting rows.PUT /api/staff_attendance/records/:userId/:date->200record DTO. Body:{ data: { status, note } }, wherestatusispresent,late, orabsent, andnoteis nullable/optional text. The route requiresFILL_ATTENDANCE.
Invalid startDate/endDate (non-ISO) or a non-positive / non-integer limit raises
ValidationError.
Access Rules
Enforced by visibilityScope in the service:
- A user who does NOT have
READ_STAFF_ATTENDANCE_REPORTSsees only their own records, scoped byuserId(requireUserId). - A user with
READ_STAFF_ATTENDANCE_REPORTSsees scope-filtered records: organization-wide for owner/superintendent, school campuses plus users directly assigned to that school for principal/registrar, and a single campus for director/campus scope. - A user with
FILL_ATTENDANCEcan upsert staff attendance only for staff users inside their effective scope: organization office users at organization scope, school office users at school scope, or campus users at campus/class scope. - Role-seeded permissions are only the baseline grants.
custom_permissionscan grant permissions andcustom_permissions_filtercan remove them for non-global users.
Both endpoints call assertAuthenticatedTenantUser first; a request without an authenticated user or
resolvable organization raises ForbiddenError.
Tenant Scope
- Every query is bound to the current user's organization (
requireOrganizationId). - Within the organization, visibility is narrowed to the user, their campus, their school, or the
whole tenant per the access rules above. The summary's
staffCountquery applies the same scope over internal-role users. For school scope, it includes users withschoolIdequal to the active school as well as users assigned to campuses under that school; for organization scope, it includes organization office, school, and campus staff.
Data Contract
Record DTO returned by GET /records (toRecordDto): id, date (from attendance_date),
status, note, user_name, user_role, organizationId, campusId, userId, createdAt,
updatedAt.
Summary DTO returned by GET /summary: staffCount (internal-role users in scope), recordsCount
(= present + late + absent), present, late, absent.
Model staff_attendance_records fields: id (UUID PK), attendance_date (DATEONLY, not null),
status (ENUM of STAFF_ATTENDANCE_STATUSES — present, late, absent; not null), note
(TEXT, nullable), user_name (TEXT, not null), user_role (TEXT, nullable), importHash
(unique, nullable), organizationId (UUID, not null), campusId (UUID, nullable), userId
(UUID, not null), createdById (UUID, not null), updatedById (UUID, nullable), plus
createdAt/updatedAt/deletedAt. The model is paranoid (soft delete) with freezeTableName.
Associations (belongsTo): organization, campus, user, createdBy, updatedBy.
Behavior / Notes
- The summary aggregates each status with separate SQL
COUNTqueries (run concurrently with the active-staff count viaPromise.all) rather than fetching and counting rows in JS, so totals are not truncated bylimit. dateFilterbuilds anattendance_daterange usingOp.gte/Op.lteonly for the provided bound(s); with neither bound it adds no date condition.- The list
limitdefaults and caps come from the staff-attendance constants, not the shared pagination helper.
Tests
src/services/staff_attendance.test.tsverifies school report scope includes both school-owned users and campuses under the school, and that school-scope office attendance upserts are scoped to school office users.
Related
- Frontend:
frontend/docs/staff-attendance-integration.md. - Related slices:
campus-attendance(campus daily aggregates), the student-levelattendance_sessions/attendance_recordsslices, andusers(active employee count, campus resolution).