40227-vm/backend/docs/staff-attendance.md
2026-06-10 18:27:19 +02:00

108 lines
5.7 KiB
Markdown

# Staff Attendance Backend
## Purpose
`staff_attendance_records` stores staff-level attendance entries per organization. This slice is a
read-only reporting surface: it exposes a filtered record list and an aggregated summary used by the
attendance snapshot and the director dashboard. It does not write, import, or generate records.
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`).
- 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.staff` 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`, `hasRoleAccess`, `campusScope`), `services/shared/validate.ts` (`clampLimit`,
`optionalIsoDate`), `shared/constants/staff-attendance.ts`
(`STAFF_ATTENDANCE_STATUSES`, `STAFF_ATTENDANCE_REPORT_ROLE_NAMES`,
`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`, `STAFF_ATTENDANCE_DEFAULT_LIMIT`,
`STAFF_ATTENDANCE_MAX_LIMIT`), `shared/constants/staff.ts` (`STAFF_STATUSES`).
## 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.
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 hold a report role (`STAFF_ATTENDANCE_REPORT_ROLE_NAMES`) sees only their own
records, scoped by `userId` (`requireUserId`).
- A user who holds a report role sees campus-scoped records via `campusScope`: tenant-wide roles
(`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`) or users with `globalAccess` see all organization
records; other report-role users are restricted to their own campus (`campusId` from their staff
profile, else unrestricted if no campus resolves).
- `STAFF_ATTENDANCE_REPORT_ROLE_NAMES` = the generated roles Super Administrator, Administrator,
Platform Owner, Tenant Director, Campus Manager.
- `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` = Super Administrator, Administrator, Platform Owner.
- `globalAccess` on the user's app role grants access in any role check (`hasRoleAccess`).
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, or the whole tenant per
the access rules above. The summary's `staffCount` query applies the same `campusScope` over the
`staff` table (active staff only).
## 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` (active staff in scope, from the `staff` table
filtered by `STAFF_STATUSES.ACTIVE`), `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 `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
None yet (no `staff_attendance` unit/e2e test in `src/`).
## Related
- Frontend: `frontend/docs/staff-attendance-integration.md`.
- Related slices: `campus-attendance` (campus daily aggregates), the student-level
`attendance_sessions` / `attendance_records` slices, and `staff` (active staff count, campus
resolution).