# 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_admin`,
  `system_admin`, `owner`, `superintendent`, `director`) 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).
