40227-vm/backend/docs/safety-quiz-results.md
2026-06-18 10:09:11 +02:00

106 lines
6.0 KiB
Markdown

# 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: <SafetyQuizInput> }`.
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`.