# Safety Quiz Results Backend ## Purpose `safety_quiz_results` stores weekly safety/de-escalation quiz submissions per authenticated staff user. The backend owns tenant scope, user ownership, the user display-name snapshot, the product role snapshot, and persistence. Each submission is an append (create) — there is no update path. ## Slice Files (by layer) - Route: `src/routes/safety_quiz_results.ts` (thin wiring; `GET /`, `GET /me`, `GET /completion`, `POST /`). - Controller: `src/api/controllers/safety_quiz_results.controller.ts` (custom — not the CRUD factory). - Service (BLL): `src/services/safety_quiz_results.ts`. - Repository (DAL): queries run through `db.safety_quiz_results` inside the service (no separate `db/api/safety_quiz_results.ts`). - Model: `src/db/models/safety_quiz_results.ts`. - Shared used: `db/with-transaction.ts` (`withTransaction`); `shared/constants/pagination.ts` (`resolvePagination`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `getCampusId`, `assertAuthenticatedTenantUser`, `hasFeaturePermission`, `getDisplayName`); `shared/constants/roles.ts` (`ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`). ## API All routes require JWT authentication. Base path mounted at `/api/safety_quiz_results`. - `GET /api/safety_quiz_results` -> `200` `{ rows, count }`. Optional query `week_of`, plus `limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user (see Access Rules), ordered by `completed_at` desc. - `GET /api/safety_quiz_results/me` -> `200` `{ completed, result }`. Optional query `week_of`. Always reads only the authenticated user's own saved quiz result and is used for profile status and weekly notification state. - `GET /api/safety_quiz_results/completion` -> `200` `{ summary, rows }`. Optional query `week_of`. Requires report access and returns every staff user in the current scope with `complete` or `pending` status based on saved `safety_quiz_results` rows. - `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: }`. Returns the created result DTO. If the caller is a parent-scope user acting through a drilled child scope, the request is accepted as a no-op and returns `null`. ## Access Rules - All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`). - `create`: a staff user creates a result for themselves; ownership fields are filled from the authenticated user. - `create` persists only when the active scope is the user's own scope. Parent users drilled into a child school/campus/classroom can complete the quiz there, but the backend does not create reportable quiz rows for that child scope. - `list`: users with `READ_SAFETY_QUIZ_REPORTS` see scope-filtered results; everyone else sees only their own rows (filtered by `userId`). - `me`: ignores report access and only checks the authenticated user's own saved result rows. - `completion`: requires `READ_SAFETY_QUIZ_REPORTS` and joins staff users in the current scope with saved result rows. Pending rows are derived from missing database results, not frontend state. Role-seeded permissions are only the baseline grants. `custom_permissions` can grant the report permission and `custom_permissions_filter` can remove it for non-global users. ## Tenant Scope - Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org filter and see results across all organizations; regular users are bound to their organization. - On create, `campusId` is set from `getCampusId`; `userId`, `createdById`, `updatedById` come from the current user. - Drilled child scopes are not treated as the user's own scope for personal saves, even though list visibility can use the active scope for reports. ## Data Contract - Mutation input (`SafetyQuizInput`): `quiz_id`, `quiz_title`, `week_of` (non-empty strings); `score` and `total_questions` (integers); `answers` (an array of integers). Invalid input raises `ValidationError`. - On create the backend fills `user_name` from `getDisplayName(currentUser)` and `user_role` from the user's `app_role.name` (defaulting to `teacher`); `completed_at` is set to the current time. The frontend does not send name, role, or ownership fields. - DTO fields: `id`, `quiz_id`, `quiz_title`, `week_of`, `score`, `total_questions`, `answers`, `user_name`, `user_role`, `completed_at`, `organizationId`, `campusId`, `userId`, `createdAt`, `updatedAt`. - Model columns: `quiz_id`, `quiz_title`, `week_of`, `user_name`, `user_role` (all TEXT, not null); `score`, `total_questions` (INTEGER, not null); `answers` (JSONB, not null); `completed_at` (DATE, not null); `importHash` (unique); plus tenant/audit UUID columns (`organizationId`, `campusId`, `userId`, `createdById`, `updatedById`, all nullable). The model is `paranoid` (soft delete via `deletedAt`) and uses `freezeTableName`. - Associations: `belongsTo` organizations (`organization`), campuses (`campus`), users (`user`, `createdBy`, `updatedBy`). ## Behavior / Notes - `create` first checks whether the caller is acting in their own scope. If not, it skips persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields are persisted. - `list` is paginated with shared defaults (`resolvePagination`). - `completion` reports organization, school, campus, and class scope staff according to the active scope. Student, guardian, guest, and system roles are not completion subjects. ## Tests - `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child scopes do not create safety quiz result rows, that personal status reads from saved rows, and that completion reports include both completed and pending staff. ## Related - Frontend: `frontend/docs/safety-quiz-integration.md`. - Quiz content management: `backend/docs/content-catalog.md` (`safety-qbs-quiz` is organization-scoped). - Related slices: `personality-quiz-results.md`, `walkthrough-checkins.md`, `user-progress.md`.