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

100 lines
5.4 KiB
Markdown

# 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.
## Related
- 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).