40227-vm/backend/docs/safety-quiz-results.md

4.8 KiB

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 /, 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.
  • 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). 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).

Tests

  • src/services/personal_scope_results.test.ts verifies that parent users drilled into child scopes do not create safety quiz result rows.
  • Frontend: frontend/docs/safety-quiz-integration.md.
  • Related slices: personality-quiz-results.md, walkthrough-checkins.md, user-progress.md.