40227-vm/backend/docs/personality-quiz-results.md
2026-06-10 18:27:19 +02:00

90 lines
4.7 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 staff profile records.
## 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`, `hasRoleAccess`);
`shared/constants/personality.ts` (`PERSONALITY_REPORT_ROLE_NAMES`); `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.
- `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`).
- `distribution`: restricted to `PERSONALITY_REPORT_ROLE_NAMES` (`Super Administrator`,
`Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) or any role with
`globalAccess`, enforced by `hasRoleAccess`; otherwise `ForbiddenError`. The distribution
response contains only `type` and `count` per group — no individual names or answers.
## 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 staff profile's campus, else the
user's `campusId`, else `null`); `userId`, `createdById`, `updatedById` come from the current
user.
- `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` 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
None yet (no `personality_quiz_results` unit/e2e test in `src/`).
## 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).