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

5.4 KiB

Personality Quiz Results Backend

Purpose

personality_quiz_results stores each authenticated tenant user's current personality quiz result (one row per user per organization) and exposes an aggregate distribution of personality types for leadership reporting. The backend owns tenant scope, user ownership, the saved personality type, and the answer snapshot. It does not write to user employment fields.

Slice Files (by layer)

  • Route: src/routes/personality_quiz_results.ts (thin wiring; GET /me, PUT /me, GET /distribution).
  • Controller: src/api/controllers/personality_quiz_results.controller.ts (custom — not the CRUD factory).
  • Service (BLL): src/services/personality_quiz_results.ts.
  • Repository (DAL): queries run through db.personality_quiz_results inside the service (no separate db/api/personality_quiz_results.ts).
  • Model: src/db/models/personality_quiz_results.ts.
  • Shared used: db/with-transaction.ts (withTransaction); services/shared/access.ts (getOrganizationIdOrGlobal, getCampusId, assertAuthenticatedTenantUser, hasFeaturePermission); shared/errors/* (ForbiddenError, ValidationError).

API

All routes require JWT authentication. Base path mounted at /api/personality_quiz_results.

  • GET /api/personality_quiz_results/me -> 200. Returns the current user's saved result DTO (most recently updated), or null if none exists.
  • PUT /api/personality_quiz_results/me -> 200. Request body wrapped as { data: { personality_type, quiz_answers } }. Creates or updates the current user's result and returns the saved 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 the caller's currently saved result (or null).
  • GET /api/personality_quiz_results/distribution -> 200. Optional query campusId. Returns { rows: [{ type, count }], count } (number of distinct personality types). Restricted to report roles.

Access Rules

  • getCurrentUserResult / upsertCurrentUserResult: any authenticated tenant user (assertAuthenticatedTenantUser); each user reads and writes only their own result (filtered by userId).
  • upsertCurrentUserResult persists only when the active scope is the user's own scope. Parent users drilled into a child school/campus/classroom can complete the UI flow there, but the backend does not create or update reportable quiz rows for that child scope.
  • distribution: restricted to READ_PERSONALITY_REPORTS; otherwise ForbiddenError. Role-seeded permissions are only the baseline grants. The distribution response contains only type and count per group — no individual names or answers. 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 can see their results across organizations; regular users are bound to their org.
  • On upsert, campusId is set from getCampusId (the current user's direct campus, else the user's campusId, else null); 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 reads and reports use the active scope for visibility.
  • distribution filters by organization via getOrganizationIdOrGlobal (global users see all orgs) and, when a campusId query value is provided, additionally by that campus.

Data Contract

  • Mutation input (PUT /me): personality_type (non-empty string) and quiz_answers (a non-array object whose values are all non-empty strings). Invalid input raises ValidationError.
  • On save, personality_type is trimmed and upper-cased; completed_at is set to the current time.
  • DTO fields: id, personality_type, quiz_answers, completed_at, organizationId, campusId, userId, createdById, updatedById, createdAt, updatedAt.
  • Model columns: personality_type (TEXT, not null), quiz_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

  • upsertCurrentUserResult first checks whether the caller is acting in their own scope. If not, it skips persistence and returns the current saved result. Otherwise it runs inside withTransaction: it looks up the existing row by organizationId + userId and updates it, otherwise creates a new one.
  • getCurrentUserResult orders by updatedAt desc and returns the first match.
  • distribution groups by personality_type with a COUNT(id) aggregate, ordered by count desc; count in the response is the number of distinct types returned.

Tests

  • src/services/personal_scope_results.test.ts verifies that parent users drilled into child scopes do not create or update personality quiz rows.
  • Frontend: frontend/docs/personality-integration.md, frontend/docs/personality-catalog.md.
  • Related slices: safety-quiz-results.md, walkthrough-checkins.md, user-progress.md (similar per-user tenant-scoped result/progress pattern).