40227-vm/backend/docs/staff-attendance.md

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_records and db.users inside the service (no separate db/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): startDate and endDate (ISO YYYY-MM-DD, validated via optionalIsoDate), and limit (positive integer, defaulting to STAFF_ATTENDANCE_DEFAULT_LIMIT = 90 and capped at STAFF_ATTENDANCE_MAX_LIMIT = 366 via clampLimit). Each row is the record DTO below. Rows are ordered by attendance_date descending, then user_name ascending.
  • GET /api/staff_attendance/summary -> 200 { staffCount, recordsCount, present, late, absent }. Accepts the same startDate, endDate, and limit query parameters; limit is read from the filter type but the summary counts are computed with SQL COUNT aggregates, not by limiting rows.
  • PUT /api/staff_attendance/records/:userId/:date -> 200 record DTO. Body: { data: { status, note } }, where status is present, late, or absent, and note is nullable/optional text. The route requires FILL_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_REPORTS sees only their own records, scoped by userId (requireUserId).
  • A user with READ_STAFF_ATTENDANCE_REPORTS sees 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_ATTENDANCE can 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_permissions can grant permissions and custom_permissions_filter can 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 staffCount query applies the same scope over internal-role users. For school scope, it includes users with schoolId equal 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_STATUSESpresent, 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 COUNT queries (run concurrently with the active-staff count via Promise.all) rather than fetching and counting rows in JS, so totals are not truncated by limit.
  • dateFilter builds an attendance_date range using Op.gte / Op.lte only for the provided bound(s); with neither bound it adds no date condition.
  • The list limit defaults and caps come from the staff-attendance constants, not the shared pagination helper.

Tests

  • src/services/staff_attendance.test.ts verifies 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.
  • Frontend: frontend/docs/staff-attendance-integration.md.
  • Related slices: campus-attendance (campus daily aggregates), the student-level attendance_sessions / attendance_records slices, and users (active employee count, campus resolution).