# Campus Attendance Backend ## Purpose The campus attendance slice owns campus attendance system links (`campus_attendance_config`) and manually entered daily campus attendance summaries (`campus_attendance_summaries`), both scoped per organization. The backend is the source of truth for these records. The UI works with daily aggregate totals, not student-level attendance sessions; student-level data remains in the separate generated `attendance_sessions` / `attendance_records` models and is not handled here. ## Slice Files (by layer) - Route: `src/routes/campus_attendance.ts` (thin wiring; `GET /configs`, `PUT /configs/:campusKey`, `GET /summaries`, `PUT /summaries/:campusKey/:date`). Mounted at `/api/campus_attendance` behind the `authenticated` middleware in `src/index.ts`. - Controller: `src/api/controllers/campus_attendance.controller.ts` (custom — `listConfigs`, `upsertConfig`, `listSummaries`, `upsertSummary`). - Service (BLL): `src/services/campus_attendance.ts` (+ `src/services/campus_attendance.types.ts`). Contains its own validation, scope resolution, and DTO mappers. - Repository (DAL): queries run through `db.campus_attendance_config` and `db.campus_attendance_summaries` inside the service (no separate `db/api` file). - Models: `src/db/models/campus_attendance_config.ts`, `src/db/models/campus_attendance_summaries.ts`. - Shared used: `db/with-transaction.ts`, `services/shared/access.ts`, `services/shared/validate.ts` (`clampLimit`, `requiredIsoDate`), `shared/constants/campus-attendance.ts` (role lists, limits, `normalizeCampusKey`, `getProductRole`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. ## API All routes require JWT authentication. - `GET /api/campus_attendance/configs` -> `200` `{ rows, count }`. `rows` are config DTOs. Optional query `campusKey`; supports `limit` and `page` via `resolvePagination`. - `PUT /api/campus_attendance/configs/:campusKey` -> `200` the upserted config DTO. Body is `req.body.data` with optional `attendance_link`. - `GET /api/campus_attendance/summaries` -> `200` `{ rows, count }`. `rows` are summary DTOs. Optional query `campusKey`, `startDate`, `endDate` (ISO `YYYY-MM-DD`), `limit`. - `PUT /api/campus_attendance/summaries/:campusKey/:date` -> `200` the upserted summary DTO. `:date` must be an ISO date. Body is `req.body.data`. Config DTO fields: `id`, `campus_key`, `attendance_link`, `updated_by_label`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_enrolled`, `total_present`, `total_absent`, `total_tardy`, `attendance_percentage` (number), `recorded_by_label`, `notes`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. ## Access Rules - All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`). - Mutations (`PUT` config / summary) additionally require `FILL_ATTENDANCE` (`assertCanManageCampusAttendance`). Global-access users still pass through the shared global-access permission bypass. - Campus-key access (`assertCanAccessCampusKey`): organization/global active scope may access any campus key in the active organization; school scope may access campus keys under the active school; campus/class scope may access only the current campus key. A mismatch or missing campus key throws `ForbiddenError`. - The frontend does not send organization, campus UUID, creator, updater, or label fields. The backend derives them from the authenticated user (`requireOrganizationId`, `getCampusId`, `getDisplayName`, `currentUser.id`). - Organization and school attendance entry still writes a campus-level summary. The aggregate screens choose a scoped campus and call the same `PUT /summaries/:campusKey/:date` endpoint; organization/school totals are read-time aggregates, not separate rows. ## Tenant Scope - Every read and write filters by `organizationId: requireOrganizationId(currentUser)`. - `campusKeyScope` resolves the `campus_key` filter: a requested `campusKey` is access-checked then applied; organization/global active scope with no requested key sees all campus keys in the active organization; school scope with no requested key sees all campus keys under the active school; campus/class scope is restricted to the current campus key, and users with no derivable campus key are rejected with `ForbiddenError`. - On upsert, the existing-row lookup keys on `organizationId` + `campus_key` (config) or `organizationId` + `campus_key` + `attendance_date` (summary). ## Data Contract - Config mutation input (`ConfigInput`): optional `attendance_link` (stored as trimmed text or `null`). - Summary mutation input (`SummaryInput`): `total_enrolled`, `total_present`, `total_absent` are required non-negative integers; `total_tardy` is optional (defaults to `0`); `notes` optional text. Validation requires `total_enrolled > 0` and that present/absent/tardy each do not exceed enrolled. - `attendance_percentage` is computed by the backend as `(total_present / total_enrolled) * 100`, stored as `DECIMAL(5,2)` and returned as a number. - Models: both tables carry `organizationId` (not null), nullable `campusId`, `createdById` (not null), nullable `updatedById`, `paranoid` soft deletes, and `belongsTo` associations to `organizations`, `campuses`, and `users` (createdBy, updatedBy). `campus_attendance_config` adds `attendance_link` and `updated_by_label`; `campus_attendance_summaries` adds `attendance_date` (DATEONLY), the four totals, `attendance_percentage`, `recorded_by_label`, and `notes`. - List pagination: configs use `resolvePagination(limit, page)`; summaries use `clampLimit(limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT=120, CAMPUS_ATTENDANCE_MAX_LIMIT=366)` and have no offset paging. ## Behavior / Notes - Both upserts run inside `withTransaction`: find existing row, then `update` it or `create` a new one (setting `createdById` on create). - Config list orders by `campus_key asc`; summary list orders by `attendance_date desc`, then `campus_key asc`. - Summary date range filtering uses `requiredIsoDate` on `startDate`/`endDate` and applies `Op.gte` / `Op.lte` on `attendance_date`. - Invalid campus keys, dates, or summary payloads throw `ValidationError`; access failures throw `ForbiddenError`. ## Source-of-truth contract (Workstream 12) Per the customer decision (2026-06-11), the **source of truth for campus attendance is manual entry by the `office_manager`** (and the higher campus/tenant roles), via the `PUT` config/summary endpoints guarded by the `FILL_ATTENDANCE` permission. There is no automatic derivation from student-level records. **Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path; until then manual entry is the only writer. No frontend-only attendance source exists — every UI value traces to a `campus_attendance_summaries` row. ## Tests - `src/api/controllers/campus_attendance.controller.test.ts` covers controller delegation. - `src/services/campus_attendance.test.ts` covers active campus drill-down access by resolving campus UUID to the stored campus key. ## Related - Frontend: `frontend/docs/campus-attendance-integration.md`.