5.4 KiB
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_resultsinside the service (no separatedb/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), ornullif 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 (ornull).GET /api/personality_quiz_results/distribution->200. Optional querycampusId. 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 byuserId).upsertCurrentUserResultpersists 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 toREAD_PERSONALITY_REPORTS; otherwiseForbiddenError. Role-seeded permissions are only the baseline grants. The distribution response contains onlytypeandcountper group — no individual names or answers.custom_permissionscan grant the report permission andcustom_permissions_filtercan 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,
campusIdis set fromgetCampusId(the current user's direct campus, else the user'scampusId, elsenull);userId,createdById,updatedByIdcome 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.
distributionfilters by organization viagetOrganizationIdOrGlobal(global users see all orgs) and, when acampusIdquery value is provided, additionally by that campus.
Data Contract
- Mutation input (
PUT /me):personality_type(non-empty string) andquiz_answers(a non-array object whose values are all non-empty strings). Invalid input raisesValidationError. - On save,
personality_typeis trimmed and upper-cased;completed_atis 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 isparanoid(soft delete viadeletedAt) and usesfreezeTableName. - Associations:
belongsToorganizations (organization), campuses (campus), users (user,createdBy,updatedBy).
Behavior / Notes
upsertCurrentUserResultfirst checks whether the caller is acting in their own scope. If not, it skips persistence and returns the current saved result. Otherwise it runs insidewithTransaction: it looks up the existing row byorganizationId+userIdand updates it, otherwise creates a new one.getCurrentUserResultorders byupdatedAtdesc and returns the first match.distributiongroups bypersonality_typewith aCOUNT(id)aggregate, ordered by count desc;countin the response is the number of distinct types returned.
Tests
src/services/personal_scope_results.test.tsverifies 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).