added emotional intelligence quizzes CRUD
This commit is contained in:
parent
d79a618d4f
commit
c397e97b9f
@ -1,7 +1,7 @@
|
|||||||
# Content Catalog Backend
|
# Content Catalog Backend
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
`content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for editable/scoped domain/content records. Product-static catalogs such as personality quiz content and classroom-timer presets live in frontend constants instead. Authenticated reads return the active payload scoped to the current user, and authenticated management endpoints allow runtime configuration of catalog records.
|
`content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for editable/scoped domain/content records. Product-static catalogs such as personality type directory records and classroom-timer presets live in frontend constants instead. Authenticated reads return the active payload scoped to the current user, and authenticated management endpoints allow runtime configuration of catalog records.
|
||||||
|
|
||||||
## Slice Files (by layer)
|
## Slice Files (by layer)
|
||||||
- Routes:
|
- Routes:
|
||||||
@ -29,6 +29,7 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`.
|
|||||||
- All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited.
|
- All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited.
|
||||||
- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library.
|
- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library.
|
||||||
- `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz.
|
- `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz.
|
||||||
|
- `emotional-intelligence-assessment-questions` and `emotional-intelligence-personality-quiz` are organization-scoped catalogs. Organization users with `MANAGE_CONTENT_CATALOG` manage the active quiz content once for the organization; descendant scopes read and complete that organization-owned content.
|
||||||
|
|
||||||
## Tenant Scope
|
## Tenant Scope
|
||||||
Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns:
|
Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns:
|
||||||
@ -37,6 +38,7 @@ Content records can be tenant-scoped through nullable `organizationId`, `schoolI
|
|||||||
- School-scoped types read the caller's resolved school row.
|
- School-scoped types read the caller's resolved school row.
|
||||||
- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows.
|
- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows.
|
||||||
- `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload.
|
- `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload.
|
||||||
|
- Emotional Intelligence self-assessment and Personality Type quiz payloads are org-scoped for the same reason: each organization owns its active quiz versions and descendant scopes read the organization payloads.
|
||||||
- Shared/global types use all-null tenant ids.
|
- Shared/global types use all-null tenant ids.
|
||||||
|
|
||||||
Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors.
|
Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors.
|
||||||
@ -49,18 +51,19 @@ Management list excludes tenant-scoped content because those records are edited
|
|||||||
|
|
||||||
## Behavior / Notes
|
## Behavior / Notes
|
||||||
- `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created.
|
- `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created.
|
||||||
- `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`).
|
- `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the active row is missing. `delete` sets `active = false` then soft-deletes (`destroy`).
|
||||||
|
- Versioned quiz content types (`safety-qbs-quiz`, `emotional-intelligence-assessment-questions`, and `emotional-intelligence-personality-quiz`) preserve history on update: the current active row is marked inactive and a new active row is created. Reads and management fetches return only the active version, so old quiz versions remain stored but hidden from UI.
|
||||||
- `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record.
|
- `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record.
|
||||||
- For `classroom-strategies` and `safety-qbs-quiz`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage shared content and descendant scopes remain read-only.
|
- For `classroom-strategies`, `safety-qbs-quiz`, `emotional-intelligence-assessment-questions`, and `emotional-intelligence-personality-quiz`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage shared content and descendant scopes remain read-only.
|
||||||
- Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`.
|
- Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`.
|
||||||
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
|
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
|
||||||
|
|
||||||
### Seeded content types
|
### Seeded content types
|
||||||
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`.
|
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-personality-quiz`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`.
|
||||||
|
|
||||||
The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records.
|
The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. The migration `20260618131000-backfill-emotional-intelligence-quiz-content.ts` backfills missing Emotional Intelligence and Personality Type quiz content for existing organizations.
|
||||||
|
|
||||||
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies` and `safety-qbs-quiz`, school creation presets school-scoped content, and campus creation presets only per-tenant campus content. This keeps shared libraries and the weekly QBS quiz owned at the organization level.
|
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, and the Personality Type quiz; school creation presets school-scoped content; campus creation presets only per-tenant campus content. This keeps shared libraries and editable organization-wide quizzes owned at the organization level.
|
||||||
|
|
||||||
### Content authoring rules
|
### Content authoring rules
|
||||||
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
|
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
|
||||||
@ -68,7 +71,7 @@ New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`
|
|||||||
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs.
|
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for `classroom-strategies` and `safety-qbs-quiz`, and organization-only seeding for the preset Classroom Support library and QBS quiz.
|
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, and organization-only seeding for preset organization-owned content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
- Frontend: `frontend/docs/content-catalog-integration.md`.
|
- Frontend: `frontend/docs/content-catalog-integration.md`.
|
||||||
|
|||||||
@ -919,13 +919,23 @@ _Relations:_
|
|||||||
|
|
||||||
#### `personality_quiz_results`
|
#### `personality_quiz_results`
|
||||||
|
|
||||||
Product-module personality quiz results.
|
Product-module Emotional Intelligence and Personality quiz completion history.
|
||||||
|
|
||||||
| Column | Type | Null | Default | Notes |
|
| Column | Type | Null | Default | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `id` | uuid | no | UUIDV4 | PK |
|
| `id` | uuid | no | UUIDV4 | PK |
|
||||||
| `personality_type` | text | no | — | |
|
| `quiz_kind` | text | no | 'personality_type' | `personality_type` or `ei_self_assessment` |
|
||||||
|
| `quiz_id` | text | yes | — | source quiz/version id |
|
||||||
|
| `quiz_title` | text | yes | — | source quiz title snapshot |
|
||||||
|
| `week_of` | text | yes | — | Sunday-start week for weekly EI results |
|
||||||
|
| `personality_type` | text | yes | — | latest type snapshot for personality quiz rows |
|
||||||
| `quiz_answers` | jsonb | no | — | |
|
| `quiz_answers` | jsonb | no | — | |
|
||||||
|
| `score` | integer | yes | — | EI score or nullable for type quiz |
|
||||||
|
| `total_questions` | integer | yes | — | quiz question count snapshot |
|
||||||
|
| `result_label` | text | yes | — | EI level or personality type label |
|
||||||
|
| `result_payload` | jsonb | yes | — | structured result details |
|
||||||
|
| `user_name` | text | yes | — | user display name snapshot |
|
||||||
|
| `user_role` | text | yes | — | role snapshot |
|
||||||
| `completed_at` | timestamptz | no | — | |
|
| `completed_at` | timestamptz | no | — | |
|
||||||
| `importHash` | varchar | yes | — | unique, audit |
|
| `importHash` | varchar | yes | — | unique, audit |
|
||||||
| `createdAt` | timestamptz | yes | — | audit |
|
| `createdAt` | timestamptz | yes | — | audit |
|
||||||
|
|||||||
@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`personality_quiz_results` stores each authenticated tenant user's current personality quiz
|
`personality_quiz_results` stores authenticated tenant users' Emotional Intelligence and
|
||||||
result (one row per user per organization) and exposes an aggregate distribution of personality
|
Personality quiz completion history. The backend owns tenant scope, user ownership, quiz kind,
|
||||||
types for leadership reporting. The backend owns tenant scope, user ownership, the saved
|
quiz version identifiers, weekly EI completion windows, personality type snapshots, scores, and
|
||||||
personality type, and the answer snapshot. It does not write to user employment fields.
|
answer snapshots. It does not write to user employment fields.
|
||||||
|
|
||||||
## Slice Files (by layer)
|
## Slice Files (by layer)
|
||||||
|
|
||||||
- Route: `src/routes/personality_quiz_results.ts` (thin wiring; `GET /me`, `PUT /me`,
|
- Route: `src/routes/personality_quiz_results.ts` (thin wiring; `GET /me`, `GET /me/history`,
|
||||||
`GET /distribution`).
|
`PUT /me`, `GET /distribution`, `GET /completion`).
|
||||||
- Controller: `src/api/controllers/personality_quiz_results.controller.ts` (custom — not the CRUD
|
- Controller: `src/api/controllers/personality_quiz_results.controller.ts` (custom — not the CRUD
|
||||||
factory).
|
factory).
|
||||||
- Service (BLL): `src/services/personality_quiz_results.ts`.
|
- Service (BLL): `src/services/personality_quiz_results.ts`.
|
||||||
@ -26,25 +26,35 @@ personality type, and the answer snapshot. It does not write to user employment
|
|||||||
|
|
||||||
All routes require JWT authentication. Base path mounted at `/api/personality_quiz_results`.
|
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
|
- `GET /api/personality_quiz_results/me` -> `200`. Query `quiz_kind` can be
|
||||||
(most recently updated), or `null` if none exists.
|
`ei_self_assessment` or `personality_type`. Returns the current user's latest saved result DTO
|
||||||
|
for that kind, or `null` if none exists. EI self-assessment reads are limited to the current
|
||||||
|
Sunday-start week. Personality type reads use the user's latest historical result.
|
||||||
|
- `GET /api/personality_quiz_results/me/history` -> `200`. Optional query `quiz_kind` can be
|
||||||
|
`ei_self_assessment` or `personality_type`; optional `limit` defaults to 25 and is capped at 100.
|
||||||
|
Returns `{ rows, count }` for the current user's saved EI and personality quiz history ordered by
|
||||||
|
`completed_at` descending. This endpoint is used by the profile page so historical completions
|
||||||
|
remain visible after weekly EI windows roll forward.
|
||||||
- `PUT /api/personality_quiz_results/me` -> `200`. Request body wrapped as
|
- `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
|
`{ data: { quiz_kind, quiz_id, quiz_title, quiz_answers, total_questions, ... } }`. Creates a new
|
||||||
returns the saved DTO. If the caller is a parent-scope user acting through a drilled child scope,
|
completion-history row and returns the saved DTO. If the caller is a parent-scope user acting
|
||||||
the request is accepted as a no-op and returns the caller's currently saved result (or `null`).
|
through a drilled child scope, the request is accepted as a no-op and returns `null`.
|
||||||
- `GET /api/personality_quiz_results/distribution` -> `200`. Optional query `campusId`. Returns
|
- `GET /api/personality_quiz_results/distribution` -> `200`. Optional query `campusId`. Returns
|
||||||
`{ rows: [{ type, count }], count }` (number of distinct personality types). Restricted to report
|
`{ rows: [{ type, count }], count }` (number of distinct personality types). Restricted to report
|
||||||
roles.
|
roles.
|
||||||
|
- `GET /api/personality_quiz_results/completion` -> `200`. Optional query `quiz_kind`. Returns staff
|
||||||
|
completion rows with current-week EI self-assessment status and latest personality type status.
|
||||||
|
Restricted to `READ_PERSONALITY_REPORTS`.
|
||||||
|
|
||||||
## Access Rules
|
## Access Rules
|
||||||
|
|
||||||
- `getCurrentUserResult` / `upsertCurrentUserResult`: any authenticated tenant user
|
- `getCurrentUserResult` / `getCurrentUserHistory` / `upsertCurrentUserResult`: any authenticated tenant user
|
||||||
(`assertAuthenticatedTenantUser`); each user reads and writes only their own result (filtered by
|
(`assertAuthenticatedTenantUser`); each user reads and writes only their own result (filtered by
|
||||||
`userId`).
|
`userId`).
|
||||||
- `upsertCurrentUserResult` persists only when the active scope is the user's own scope. Parent
|
- `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
|
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.
|
does not create or update reportable quiz rows for that child scope.
|
||||||
- `distribution`: restricted to `READ_PERSONALITY_REPORTS`; otherwise
|
- `distribution` and `completion`: restricted to `READ_PERSONALITY_REPORTS`; otherwise
|
||||||
`ForbiddenError`. Role-seeded permissions are only the baseline grants. The distribution response contains
|
`ForbiddenError`. Role-seeded permissions are only the baseline grants. The distribution response contains
|
||||||
only `type` and `count` per group — no individual names or answers.
|
only `type` and `count` per group — no individual names or answers.
|
||||||
`custom_permissions` can grant the report permission and
|
`custom_permissions` can grant the report permission and
|
||||||
@ -64,33 +74,45 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu
|
|||||||
|
|
||||||
## Data Contract
|
## Data Contract
|
||||||
|
|
||||||
- Mutation input (`PUT /me`): `personality_type` (non-empty string) and `quiz_answers` (a non-array
|
- Mutation input (`PUT /me`): `quiz_kind`, `quiz_id`, `quiz_title`, `quiz_answers`, and
|
||||||
object whose values are all non-empty strings). Invalid input raises `ValidationError`.
|
`total_questions`. Personality type submissions also require `personality_type`; EI submissions
|
||||||
- On save, `personality_type` is trimmed and upper-cased; `completed_at` is set to the current
|
may include `score`, `result_label`, and `result_payload`. Invalid input raises
|
||||||
time.
|
`ValidationError`.
|
||||||
- DTO fields: `id`, `personality_type`, `quiz_answers`, `completed_at`, `organizationId`,
|
- On save, personality types are trimmed and upper-cased; `completed_at` is set to the current time.
|
||||||
`campusId`, `userId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`.
|
EI self-assessment rows get `week_of` set to the current Sunday-start week. Personality type rows
|
||||||
- Model columns: `personality_type` (TEXT, not null), `quiz_answers` (JSONB, not null),
|
keep `week_of = null` because the type quiz is not a weekly workflow.
|
||||||
`completed_at` (DATE, not null), `importHash` (unique), plus tenant/audit UUID columns
|
- DTO fields: `id`, `quiz_kind`, `quiz_id`, `quiz_title`, `personality_type`, `quiz_answers`,
|
||||||
(`organizationId`, `campusId`, `userId`, `createdById`, `updatedById`, all nullable). The model
|
`score`, `total_questions`, `result_label`, `result_payload`, `week_of`, `user_name`,
|
||||||
is `paranoid` (soft delete via `deletedAt`) and uses `freezeTableName`.
|
`user_role`, `completed_at`, `organizationId`, `campusId`, `userId`, `createdById`,
|
||||||
|
`updatedById`, `createdAt`, `updatedAt`.
|
||||||
|
- Model columns: `quiz_kind`, `quiz_id`, `quiz_title`, `personality_type`, `quiz_answers`, `score`,
|
||||||
|
`total_questions`, `result_label`, `result_payload`, `week_of`, `user_name`, `user_role`,
|
||||||
|
`completed_at`, `importHash`, 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`,
|
- Associations: `belongsTo` organizations (`organization`), campuses (`campus`), users (`user`,
|
||||||
`createdBy`, `updatedBy`).
|
`createdBy`, `updatedBy`).
|
||||||
|
|
||||||
## Behavior / Notes
|
## Behavior / Notes
|
||||||
|
|
||||||
- `upsertCurrentUserResult` first checks whether the caller is acting in their own scope. If not,
|
- `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
|
it skips persistence and returns `null`. Otherwise it creates a new history row inside
|
||||||
`withTransaction`: it looks up the existing row by
|
`withTransaction`; old completions are never overwritten.
|
||||||
`organizationId` + `userId` and updates it, otherwise creates a new one.
|
- `getCurrentUserResult` orders by `completed_at` desc and returns the latest match. For
|
||||||
- `getCurrentUserResult` orders by `updatedAt` desc and returns the first match.
|
`ei_self_assessment`, the query includes current `week_of`; for `personality_type`, it does not.
|
||||||
- `distribution` groups by `personality_type` with a `COUNT(id)` aggregate, ordered by count desc;
|
- `getCurrentUserHistory` returns the user's saved EI and personality completions across weeks and
|
||||||
`count` in the response is the number of distinct types returned.
|
quiz versions. It never exposes another user's rows and does not require report permissions.
|
||||||
|
- `distribution` keeps each user's latest personality type result, counts those current types, and
|
||||||
|
orders groups by count desc; `count` in the response is the number of distinct types returned.
|
||||||
|
- `completion` combines both categories: current-week EI self-assessment results and latest
|
||||||
|
all-time personality type results for each reportable staff user.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child
|
- `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child
|
||||||
scopes do not create or update personality quiz rows.
|
scopes do not create personality quiz rows, that current-user EI reads are week-scoped, that
|
||||||
|
profile history reads persisted rows for the current user, and that completion reporting combines
|
||||||
|
weekly EI with all-time personality type results.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,8 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
|
|||||||
|
|
||||||
- All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`).
|
- All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`).
|
||||||
- `create`: a staff user creates a result for themselves; ownership fields are filled from the
|
- `create`: a staff user creates a result for themselves; ownership fields are filled from the
|
||||||
authenticated user.
|
authenticated user. The submitted `week_of` date is normalized to the Sunday-start week before
|
||||||
|
storage so weekly notification, profile, completion, and dashboard reads use one canonical key.
|
||||||
- `create` persists only when the active scope is the user's own scope. Parent users drilled into a
|
- `create` persists only when the active scope is the user's own scope. Parent users drilled into a
|
||||||
child school/campus/classroom can complete the quiz there, but the backend does not create
|
child school/campus/classroom can complete the quiz there, but the backend does not create
|
||||||
reportable quiz rows for that child scope.
|
reportable quiz rows for that child scope.
|
||||||
@ -66,7 +67,8 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
|
|||||||
|
|
||||||
## Data Contract
|
## Data Contract
|
||||||
|
|
||||||
- Mutation input (`SafetyQuizInput`): `quiz_id`, `quiz_title`, `week_of` (non-empty strings);
|
- Mutation input (`SafetyQuizInput`): `quiz_id`, `quiz_title`, `week_of` (`YYYY-MM-DD`, normalized
|
||||||
|
to the Sunday-start week before storage);
|
||||||
`score` and `total_questions` (integers); `answers` (an array of integers). Invalid input raises
|
`score` and `total_questions` (integers); `answers` (an array of integers). Invalid input raises
|
||||||
`ValidationError`.
|
`ValidationError`.
|
||||||
- On create the backend fills `user_name` from `getDisplayName(currentUser)` and `user_role` from
|
- On create the backend fills `user_name` from `getDisplayName(currentUser)` and `user_role` from
|
||||||
@ -87,10 +89,16 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
|
|||||||
|
|
||||||
- `create` first checks whether the caller is acting in their own scope. If not, it skips
|
- `create` first checks whether the caller is acting in their own scope. If not, it skips
|
||||||
persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields
|
persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields
|
||||||
are persisted.
|
are persisted and `week_of` is canonicalized to Sunday week start.
|
||||||
- `list` is paginated with shared defaults (`resolvePagination`).
|
- `list` is paginated with shared defaults (`resolvePagination`).
|
||||||
- `completion` reports organization, school, campus, and class scope staff according to the active
|
- `completion` reports organization, school, campus, and class scope staff according to the active
|
||||||
scope. Student, guardian, guest, and system roles are not completion subjects.
|
scope. Student, guardian, guest, and system roles are not completion subjects.
|
||||||
|
- Result history is append-only. Retakes and future weekly submissions create additional rows;
|
||||||
|
current-week status, notifications, staff completion, and dashboards filter by the current
|
||||||
|
canonical `week_of` value.
|
||||||
|
- Quiz content is stored in `content_catalog` as `safety-qbs-quiz`. Organization managers edit only
|
||||||
|
the active version; updates preserve old quiz content by marking the previous row inactive and
|
||||||
|
inserting a new active row. Readers load only the active quiz payload.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,18 @@ export async function getCurrentUserResult(
|
|||||||
res: Response,
|
res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const payload = await PersonalityQuizResultsService.getCurrentUserResult(
|
const payload = await PersonalityQuizResultsService.getCurrentUserResult(
|
||||||
|
req.query,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUserHistory(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = await PersonalityQuizResultsService.getCurrentUserHistory(
|
||||||
|
req.query,
|
||||||
req.currentUser,
|
req.currentUser,
|
||||||
);
|
);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -29,3 +41,11 @@ export async function distribution(req: Request, res: Response): Promise<void> {
|
|||||||
);
|
);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function completion(req: Request, res: Response): Promise<void> {
|
||||||
|
const payload = await PersonalityQuizResultsService.completion(
|
||||||
|
req.query,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ DO 'BEGIN CREATE TYPE "public"."enum_messages_status" AS ENUM(''draft'', ''sched
|
|||||||
CREATE TABLE IF NOT EXISTS "messages" ("id" UUID , "subject" TEXT, "body" TEXT, "channel" "public"."enum_messages_channel", "audience" "public"."enum_messages_audience", "sent_at" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_messages_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "sent_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
CREATE TABLE IF NOT EXISTS "messages" ("id" UUID , "subject" TEXT, "body" TEXT, "channel" "public"."enum_messages_channel", "audience" "public"."enum_messages_audience", "sent_at" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_messages_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "sent_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
||||||
CREATE TABLE IF NOT EXISTS "organizations" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
CREATE TABLE IF NOT EXISTS "organizations" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
||||||
CREATE TABLE IF NOT EXISTS "permissions" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
CREATE TABLE IF NOT EXISTS "permissions" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
||||||
CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "personality_type" TEXT NOT NULL, "quiz_answers" JSONB NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "quiz_kind" TEXT NOT NULL DEFAULT 'personality_type', "quiz_id" TEXT NOT NULL DEFAULT 'personality-type', "quiz_title" TEXT NOT NULL DEFAULT 'Personality Type Quiz', "week_of" TEXT, "personality_type" TEXT, "quiz_answers" JSONB NOT NULL, "score" INTEGER, "total_questions" INTEGER NOT NULL DEFAULT 0, "result_label" TEXT, "result_payload" JSONB, "user_name" TEXT, "user_role" TEXT, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
||||||
CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
||||||
CREATE TABLE IF NOT EXISTS "safety_quiz_results" ("id" UUID , "quiz_id" TEXT NOT NULL, "quiz_title" TEXT NOT NULL, "week_of" TEXT NOT NULL, "score" INTEGER NOT NULL, "total_questions" INTEGER NOT NULL, "answers" JSONB NOT NULL, "user_name" TEXT NOT NULL, "user_role" TEXT NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
CREATE TABLE IF NOT EXISTS "safety_quiz_results" ("id" UUID , "quiz_id" TEXT NOT NULL, "quiz_title" TEXT NOT NULL, "week_of" TEXT NOT NULL, "score" INTEGER NOT NULL, "total_questions" INTEGER NOT NULL, "answers" JSONB NOT NULL, "user_name" TEXT NOT NULL, "user_role" TEXT NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
|
||||||
DO 'BEGIN CREATE TYPE "public"."enum_staff_attendance_records_status" AS ENUM(''present'', ''late'', ''absent''); EXCEPTION WHEN duplicate_object THEN null; END';
|
DO 'BEGIN CREATE TYPE "public"."enum_staff_attendance_records_status" AS ENUM(''present'', ''late'', ''absent''); EXCEPTION WHEN duplicate_object THEN null; END';
|
||||||
|
|||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { DataTypes, type QueryInterface } from 'sequelize';
|
||||||
|
|
||||||
|
const TABLE = 'personality_quiz_results';
|
||||||
|
|
||||||
|
async function columnExists(
|
||||||
|
queryInterface: QueryInterface,
|
||||||
|
column: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [results] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = '${TABLE}'
|
||||||
|
AND column_name = '${column}'
|
||||||
|
`);
|
||||||
|
return (results as unknown[]).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnIfMissing(
|
||||||
|
queryInterface: QueryInterface,
|
||||||
|
column: string,
|
||||||
|
definition: Parameters<QueryInterface['addColumn']>[2],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await columnExists(queryInterface, column))) {
|
||||||
|
await queryInterface.addColumn(TABLE, column, definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
await addColumnIfMissing(queryInterface, 'quiz_kind', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'personality_type',
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'quiz_id', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'personality-type',
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'quiz_title', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'Personality Type Quiz',
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'week_of', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'score', {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'total_questions', {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'result_label', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'result_payload', {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'user_name', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
await addColumnIfMissing(queryInterface, 'user_role', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE "${TABLE}"
|
||||||
|
SET
|
||||||
|
"result_label" = COALESCE("result_label", "personality_type"),
|
||||||
|
"total_questions" = CASE
|
||||||
|
WHEN "total_questions" > 0 THEN "total_questions"
|
||||||
|
WHEN jsonb_typeof("quiz_answers") = 'object' THEN (
|
||||||
|
SELECT count(*)::integer
|
||||||
|
FROM jsonb_object_keys("quiz_answers")
|
||||||
|
)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
WHERE "deletedAt" IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.changeColumn(TABLE, 'personality_type', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DELETE FROM "${TABLE}"
|
||||||
|
WHERE "personality_type" IS NULL
|
||||||
|
`);
|
||||||
|
await queryInterface.changeColumn(TABLE, 'personality_type', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
});
|
||||||
|
await queryInterface.removeColumn(TABLE, 'user_role');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'user_name');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'result_payload');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'result_label');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'total_questions');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'score');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'week_of');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'quiz_title');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'quiz_id');
|
||||||
|
await queryInterface.removeColumn(TABLE, 'quiz_kind');
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import { Op, QueryTypes, type QueryInterface } from 'sequelize';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import type { CreationAttributes } from 'sequelize';
|
||||||
|
import type { ContentCatalog } from '@/db/models/content_catalog';
|
||||||
|
import {
|
||||||
|
EI_ASSESSMENT_CONTENT_TYPE,
|
||||||
|
PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||||
|
} from '@/shared/constants/content-catalog';
|
||||||
|
import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
|
||||||
|
|
||||||
|
const BACKFILL_IMPORT_HASH_PREFIX = 'ei-quiz-content-org-backfill';
|
||||||
|
|
||||||
|
const orgQuizRows = [
|
||||||
|
{
|
||||||
|
content_type: EI_ASSESSMENT_CONTENT_TYPE,
|
||||||
|
payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content_type: PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||||
|
payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligencePersonalityQuiz,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function orgContentRowExists(
|
||||||
|
queryInterface: QueryInterface,
|
||||||
|
organizationId: string,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rows = await queryInterface.sequelize.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM content_catalog
|
||||||
|
WHERE content_type = :contentType
|
||||||
|
AND "deletedAt" IS NULL
|
||||||
|
AND "organizationId" = :organizationId
|
||||||
|
AND "schoolId" IS NULL
|
||||||
|
AND "campusId" IS NULL
|
||||||
|
AND "classId" IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { organizationId, contentType },
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
const organizations = await queryInterface.sequelize.query<{ id: string }>(
|
||||||
|
'SELECT id FROM organizations WHERE "deletedAt" IS NULL',
|
||||||
|
{ type: QueryTypes.SELECT },
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const rows: CreationAttributes<ContentCatalog>[] = [];
|
||||||
|
|
||||||
|
for (const organization of organizations) {
|
||||||
|
for (const contentRow of orgQuizRows) {
|
||||||
|
if (await orgContentRowExists(queryInterface, organization.id, contentRow.content_type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: uuid(),
|
||||||
|
content_type: contentRow.content_type,
|
||||||
|
payload: JSON.stringify(contentRow.payload),
|
||||||
|
active: true,
|
||||||
|
importHash: [
|
||||||
|
BACKFILL_IMPORT_HASH_PREFIX,
|
||||||
|
contentRow.content_type,
|
||||||
|
organization.id,
|
||||||
|
].join('-'),
|
||||||
|
organizationId: organization.id,
|
||||||
|
schoolId: null,
|
||||||
|
campusId: null,
|
||||||
|
classId: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('content_catalog', rows);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
await queryInterface.bulkDelete('content_catalog', {
|
||||||
|
importHash: { [Op.like]: `${BACKFILL_IMPORT_HASH_PREFIX}%` },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -20,8 +20,18 @@ export class PersonalityQuizResults extends Model<
|
|||||||
InferCreationAttributes<PersonalityQuizResults>
|
InferCreationAttributes<PersonalityQuizResults>
|
||||||
> {
|
> {
|
||||||
declare id: CreationOptional<string>;
|
declare id: CreationOptional<string>;
|
||||||
declare personality_type: string;
|
declare quiz_kind: string;
|
||||||
|
declare quiz_id: string;
|
||||||
|
declare quiz_title: string;
|
||||||
|
declare week_of: CreationOptional<string | null>;
|
||||||
|
declare personality_type: CreationOptional<string | null>;
|
||||||
declare quiz_answers: unknown;
|
declare quiz_answers: unknown;
|
||||||
|
declare score: CreationOptional<number | null>;
|
||||||
|
declare total_questions: number;
|
||||||
|
declare result_label: CreationOptional<string | null>;
|
||||||
|
declare result_payload: unknown | null;
|
||||||
|
declare user_name: CreationOptional<string | null>;
|
||||||
|
declare user_role: CreationOptional<string | null>;
|
||||||
declare completed_at: Date;
|
declare completed_at: Date;
|
||||||
declare importHash: CreationOptional<string | null>;
|
declare importHash: CreationOptional<string | null>;
|
||||||
declare createdAt: CreationOptional<Date>;
|
declare createdAt: CreationOptional<Date>;
|
||||||
@ -89,13 +99,57 @@ export default function (sequelize: Sequelize): typeof PersonalityQuizResults {
|
|||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
personality_type: {
|
personality_type: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
quiz_kind: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
defaultValue: 'personality_type',
|
||||||
|
},
|
||||||
|
quiz_id: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'personality-type',
|
||||||
|
},
|
||||||
|
quiz_title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'Personality Type Quiz',
|
||||||
|
},
|
||||||
|
week_of: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
},
|
},
|
||||||
quiz_answers: {
|
quiz_answers: {
|
||||||
type: DataTypes.JSONB,
|
type: DataTypes.JSONB,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
score: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
total_questions: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
result_label: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
result_payload: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
user_name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
user_role: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
completed_at: {
|
completed_at: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -95,6 +95,7 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([
|
|||||||
{ content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations },
|
{ content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations },
|
||||||
{ content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities },
|
{ content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities },
|
||||||
{ content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions },
|
{ content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions },
|
||||||
|
{ content_type: 'emotional-intelligence-personality-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligencePersonalityQuiz },
|
||||||
{ content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics },
|
{ content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics },
|
||||||
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
|
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
|
||||||
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
|
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
|
||||||
|
|||||||
@ -826,6 +826,25 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
|
|||||||
{ q: 'I recognize early signs of burnout in myself:', options: ['Only when it\'s severe', 'Sometimes too late', 'Usually in time to adjust', 'I proactively monitor my wellbeing'], scores: [1, 2, 3, 4] },
|
{ q: 'I recognize early signs of burnout in myself:', options: ['Only when it\'s severe', 'Sometimes too late', 'Usually in time to adjust', 'I proactively monitor my wellbeing'], scores: [1, 2, 3, 4] },
|
||||||
{ q: 'When communicating difficult information to parents, I:', options: ['Avoid it when possible', 'Get anxious beforehand', 'Prepare and stay factual', 'Balance empathy with clarity'], scores: [1, 2, 3, 4] },
|
{ q: 'When communicating difficult information to parents, I:', options: ['Avoid it when possible', 'Get anxious beforehand', 'Prepare and stay factual', 'Balance empathy with clarity'], scores: [1, 2, 3, 4] },
|
||||||
],
|
],
|
||||||
|
emotionalIntelligencePersonalityQuiz: {
|
||||||
|
id: 'personality-type',
|
||||||
|
title: 'Personality Type Quiz',
|
||||||
|
description: 'Discover how your personality type shapes work relationships and communication style.',
|
||||||
|
questions: [
|
||||||
|
{ id: 1, dimension: 'EI', question: 'During a staff meeting, you feel most energized when:', optionA: { text: 'Brainstorming ideas out loud with the group and building on others\' suggestions', value: 'E' }, optionB: { text: 'Listening carefully, then sharing a well-thought-out idea you\'ve been developing internally', value: 'I' } },
|
||||||
|
{ id: 2, dimension: 'EI', question: 'After a long, intense day with students, you recharge by:', optionA: { text: 'Chatting with colleagues, grabbing coffee together, or calling a friend', value: 'E' }, optionB: { text: 'Having quiet time alone - reading, walking, or just decompressing in silence', value: 'I' } },
|
||||||
|
{ id: 3, dimension: 'EI', question: 'When working on a new classroom strategy, you prefer to:', optionA: { text: 'Talk it through with a colleague or team to get immediate feedback', value: 'E' }, optionB: { text: 'Research and reflect on your own first before discussing with others', value: 'I' } },
|
||||||
|
{ id: 4, dimension: 'SN', question: 'When planning a lesson or activity, you tend to focus on:', optionA: { text: 'Concrete, step-by-step instructions and proven methods that have worked before', value: 'S' }, optionB: { text: 'The big picture concept and creative ways to make it engaging and meaningful', value: 'N' } },
|
||||||
|
{ id: 5, dimension: 'SN', question: 'When a new policy or procedure is introduced, you first want to know:', optionA: { text: 'The specific details - what exactly changes, when, and how it affects your daily routine', value: 'S' }, optionB: { text: 'The reasoning behind it - why the change matters and what the long-term vision is', value: 'N' } },
|
||||||
|
{ id: 6, dimension: 'SN', question: 'When observing a student\'s behavior, you naturally notice:', optionA: { text: 'Specific, observable details - what they said, did, and the exact sequence of events', value: 'S' }, optionB: { text: 'Patterns and underlying themes - what might be driving the behavior emotionally or socially', value: 'N' } },
|
||||||
|
{ id: 7, dimension: 'TF', question: 'When a colleague disagrees with your approach to a student situation, you:', optionA: { text: 'Present logical evidence and data to support why your approach is effective', value: 'T' }, optionB: { text: 'Consider their perspective empathetically and look for a solution that honors both viewpoints', value: 'F' } },
|
||||||
|
{ id: 8, dimension: 'TF', question: 'When making a decision about a student\'s behavior plan, you prioritize:', optionA: { text: 'Consistency, fairness, and what the data shows is most effective', value: 'T' }, optionB: { text: 'The student\'s emotional needs and how the plan will affect their sense of belonging', value: 'F' } },
|
||||||
|
{ id: 9, dimension: 'TF', question: 'When giving feedback to a colleague, you tend to:', optionA: { text: 'Be direct and specific about what needs improvement, focusing on outcomes', value: 'T' }, optionB: { text: 'Start with what\'s going well, then gently suggest areas for growth with encouragement', value: 'F' } },
|
||||||
|
{ id: 10, dimension: 'JP', question: 'Your ideal classroom or workspace is:', optionA: { text: 'Organized with clear systems, schedules posted, and materials in designated spots', value: 'J' }, optionB: { text: 'Flexible and adaptable - you know where things are even if it looks a bit creative', value: 'P' } },
|
||||||
|
{ id: 11, dimension: 'JP', question: 'When an unexpected schedule change happens during the school day, you:', optionA: { text: 'Feel a bit stressed and quickly create a new plan to stay on track', value: 'J' }, optionB: { text: 'Roll with it naturally and see it as an opportunity to be spontaneous', value: 'P' } },
|
||||||
|
{ id: 12, dimension: 'JP', question: 'When preparing for the next school week, you typically:', optionA: { text: 'Plan everything in advance - lessons, materials, and contingencies are all mapped out', value: 'J' }, optionB: { text: 'Have a general idea but prefer to stay flexible and adjust based on how the week unfolds', value: 'P' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
emotionalIntelligenceWeeklyTopics: [
|
emotionalIntelligenceWeeklyTopics: [
|
||||||
{ title: 'Stress Regulation', desc: 'Identify your stress triggers and build a personal regulation toolkit', iconId: 'shield', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
{ title: 'Stress Regulation', desc: 'Identify your stress triggers and build a personal regulation toolkit', iconId: 'shield', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||||
{ title: 'Conflict Response', desc: 'Move from reactive to responsive in difficult conversations', iconId: 'brain', color: 'bg-violet-500/10 text-violet-400 border-violet-500/20' },
|
{ title: 'Conflict Response', desc: 'Move from reactive to responsive in difficult conversations', iconId: 'brain', color: 'bg-violet-500/10 text-violet-400 border-violet-500/20' },
|
||||||
@ -989,6 +1008,7 @@ export const CONTENT_CATALOG_DEFAULT_ROWS: ReadonlyArray<{
|
|||||||
{ content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations },
|
{ content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations },
|
||||||
{ content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities },
|
{ content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities },
|
||||||
{ content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions },
|
{ content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions },
|
||||||
|
{ content_type: 'emotional-intelligence-personality-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligencePersonalityQuiz },
|
||||||
{ content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics },
|
{ content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics },
|
||||||
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
|
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
|
||||||
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
|
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
|
||||||
|
|||||||
@ -22,6 +22,13 @@ const router = express.Router();
|
|||||||
* responses:
|
* responses:
|
||||||
* 200: { description: Saved. }
|
* 200: { description: Saved. }
|
||||||
* 403: { $ref: '#/components/responses/ForbiddenError' }
|
* 403: { $ref: '#/components/responses/ForbiddenError' }
|
||||||
|
* /api/personality_quiz_results/me/history:
|
||||||
|
* get:
|
||||||
|
* tags: [Quizzes]
|
||||||
|
* summary: Get the current user's saved EI and personality quiz history
|
||||||
|
* responses:
|
||||||
|
* 200: { description: The user's saved results. }
|
||||||
|
* 401: { $ref: '#/components/responses/UnauthorizedError' }
|
||||||
* /api/personality_quiz_results/distribution:
|
* /api/personality_quiz_results/distribution:
|
||||||
* get:
|
* get:
|
||||||
* tags: [Quizzes]
|
* tags: [Quizzes]
|
||||||
@ -30,6 +37,7 @@ const router = express.Router();
|
|||||||
* 200: { description: Distribution. }
|
* 200: { description: Distribution. }
|
||||||
* 403: { $ref: '#/components/responses/ForbiddenError' }
|
* 403: { $ref: '#/components/responses/ForbiddenError' }
|
||||||
*/
|
*/
|
||||||
|
router.get('/me/history', wrapAsync(personality_quiz_results.getCurrentUserHistory));
|
||||||
router.get('/me', wrapAsync(personality_quiz_results.getCurrentUserResult));
|
router.get('/me', wrapAsync(personality_quiz_results.getCurrentUserResult));
|
||||||
router.put(
|
router.put(
|
||||||
'/me',
|
'/me',
|
||||||
@ -37,5 +45,6 @@ router.put(
|
|||||||
wrapAsync(personality_quiz_results.upsertCurrentUserResult),
|
wrapAsync(personality_quiz_results.upsertCurrentUserResult),
|
||||||
);
|
);
|
||||||
router.get('/distribution', wrapAsync(personality_quiz_results.distribution));
|
router.get('/distribution', wrapAsync(personality_quiz_results.distribution));
|
||||||
|
router.get('/completion', wrapAsync(personality_quiz_results.completion));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -196,4 +196,158 @@ describe('ContentCatalogService tenant scoping', () => {
|
|||||||
{ name: 'ForbiddenError' },
|
{ name: 'ForbiddenError' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects Emotional Intelligence quiz management outside organization scope', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => ContentCatalogService.findManagedByType(
|
||||||
|
'emotional-intelligence-assessment-questions',
|
||||||
|
createGlobalAccessUser({
|
||||||
|
app_role: {
|
||||||
|
name: ROLE_NAMES.SUPER_ADMIN,
|
||||||
|
scope: ROLE_SCOPES.SYSTEM,
|
||||||
|
globalAccess: true,
|
||||||
|
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG }],
|
||||||
|
},
|
||||||
|
activeScope: {
|
||||||
|
level: ROLE_SCOPES.CAMPUS,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
classId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ name: 'ForbiddenError' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versions QBS quiz content updates so prior weekly quiz payloads remain stored', async () => {
|
||||||
|
const commits: string[] = [];
|
||||||
|
const updatedRows: unknown[] = [];
|
||||||
|
const createdRows: unknown[] = [];
|
||||||
|
let capturedWhere: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
mock.method(db.sequelize, 'transaction', (async () => ({
|
||||||
|
commit: async () => { commits.push('commit'); },
|
||||||
|
rollback: async () => { commits.push('rollback'); },
|
||||||
|
})) as typeof db.sequelize.transaction);
|
||||||
|
|
||||||
|
mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record<string, unknown> }) => {
|
||||||
|
capturedWhere = options.where ?? null;
|
||||||
|
return {
|
||||||
|
update: async (data: unknown) => {
|
||||||
|
updatedRows.push(data);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
get: () => ({
|
||||||
|
id: 'catalog-old',
|
||||||
|
content_type: 'safety-qbs-quiz',
|
||||||
|
payload: { title: 'Old Quiz' },
|
||||||
|
updatedAt: new Date('2026-06-14T00:00:00Z'),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}) as unknown as typeof db.content_catalog.findOne);
|
||||||
|
|
||||||
|
mock.method(db.content_catalog, 'create', (async (data: unknown) => {
|
||||||
|
createdRows.push(data);
|
||||||
|
return {
|
||||||
|
get: () => ({
|
||||||
|
id: 'catalog-new',
|
||||||
|
content_type: 'safety-qbs-quiz',
|
||||||
|
payload: { title: 'New Quiz' },
|
||||||
|
updatedAt: new Date('2026-06-18T00:00:00Z'),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}) as unknown as typeof db.content_catalog.create);
|
||||||
|
|
||||||
|
const result = await ContentCatalogService.update(
|
||||||
|
'safety-qbs-quiz',
|
||||||
|
{ payload: { title: 'New Quiz' } },
|
||||||
|
organizationContentManager(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(capturedWhere, {
|
||||||
|
content_type: 'safety-qbs-quiz',
|
||||||
|
active: true,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
});
|
||||||
|
assert.deepEqual(updatedRows, [{ active: false }]);
|
||||||
|
assert.deepEqual(createdRows, [{
|
||||||
|
content_type: 'safety-qbs-quiz',
|
||||||
|
payload: { title: 'New Quiz' },
|
||||||
|
active: true,
|
||||||
|
importHash: null,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: null,
|
||||||
|
campusId: null,
|
||||||
|
classId: null,
|
||||||
|
}]);
|
||||||
|
assert.equal(result.id, 'catalog-new');
|
||||||
|
assert.deepEqual(commits, ['commit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versions Emotional Intelligence quiz content updates so old quiz payloads remain stored', async () => {
|
||||||
|
const commits: string[] = [];
|
||||||
|
const updatedRows: unknown[] = [];
|
||||||
|
const createdRows: unknown[] = [];
|
||||||
|
let capturedWhere: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
mock.method(db.sequelize, 'transaction', (async () => ({
|
||||||
|
commit: async () => { commits.push('commit'); },
|
||||||
|
rollback: async () => { commits.push('rollback'); },
|
||||||
|
})) as typeof db.sequelize.transaction);
|
||||||
|
|
||||||
|
mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record<string, unknown> }) => {
|
||||||
|
capturedWhere = options.where ?? null;
|
||||||
|
return {
|
||||||
|
update: async (data: unknown) => {
|
||||||
|
updatedRows.push(data);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
get: () => ({
|
||||||
|
id: 'catalog-old',
|
||||||
|
content_type: 'emotional-intelligence-personality-quiz',
|
||||||
|
payload: { title: 'Old Personality Quiz' },
|
||||||
|
updatedAt: new Date('2026-06-14T00:00:00Z'),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}) as unknown as typeof db.content_catalog.findOne);
|
||||||
|
|
||||||
|
mock.method(db.content_catalog, 'create', (async (data: unknown) => {
|
||||||
|
createdRows.push(data);
|
||||||
|
return {
|
||||||
|
get: () => ({
|
||||||
|
id: 'catalog-new',
|
||||||
|
content_type: 'emotional-intelligence-personality-quiz',
|
||||||
|
payload: { title: 'New Personality Quiz' },
|
||||||
|
updatedAt: new Date('2026-06-18T00:00:00Z'),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}) as unknown as typeof db.content_catalog.create);
|
||||||
|
|
||||||
|
const result = await ContentCatalogService.update(
|
||||||
|
'emotional-intelligence-personality-quiz',
|
||||||
|
{ payload: { title: 'New Personality Quiz' } },
|
||||||
|
organizationContentManager(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(capturedWhere, {
|
||||||
|
content_type: 'emotional-intelligence-personality-quiz',
|
||||||
|
active: true,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
});
|
||||||
|
assert.deepEqual(updatedRows, [{ active: false }]);
|
||||||
|
assert.deepEqual(createdRows, [{
|
||||||
|
content_type: 'emotional-intelligence-personality-quiz',
|
||||||
|
payload: { title: 'New Personality Quiz' },
|
||||||
|
active: true,
|
||||||
|
importHash: null,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: null,
|
||||||
|
campusId: null,
|
||||||
|
classId: null,
|
||||||
|
}]);
|
||||||
|
assert.equal(result.id, 'catalog-new');
|
||||||
|
assert.deepEqual(commits, ['commit']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import {
|
|||||||
} from '@/services/shared/access';
|
} from '@/services/shared/access';
|
||||||
import {
|
import {
|
||||||
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
||||||
|
EI_ASSESSMENT_CONTENT_TYPE,
|
||||||
|
PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||||
SAFETY_QUIZ_CONTENT_TYPE,
|
SAFETY_QUIZ_CONTENT_TYPE,
|
||||||
PER_TENANT_CONTENT_TYPES,
|
PER_TENANT_CONTENT_TYPES,
|
||||||
SCHOOL_SCOPED_CONTENT_TYPES,
|
SCHOOL_SCOPED_CONTENT_TYPES,
|
||||||
@ -76,6 +78,12 @@ interface ContentCatalogInput {
|
|||||||
importHash?: string | null;
|
importHash?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VERSIONED_CONTENT_TYPES = new Set([
|
||||||
|
SAFETY_QUIZ_CONTENT_TYPE,
|
||||||
|
EI_ASSESSMENT_CONTENT_TYPE,
|
||||||
|
PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||||
|
]);
|
||||||
|
|
||||||
function toContentCatalogDto(record: ContentCatalog) {
|
function toContentCatalogDto(record: ContentCatalog) {
|
||||||
const plain = record.get({ plain: true });
|
const plain = record.get({ plain: true });
|
||||||
|
|
||||||
@ -118,6 +126,8 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo
|
|||||||
(
|
(
|
||||||
contentType === CLASSROOM_SUPPORT_CONTENT_TYPE
|
contentType === CLASSROOM_SUPPORT_CONTENT_TYPE
|
||||||
|| contentType === SAFETY_QUIZ_CONTENT_TYPE
|
|| contentType === SAFETY_QUIZ_CONTENT_TYPE
|
||||||
|
|| contentType === EI_ASSESSMENT_CONTENT_TYPE
|
||||||
|
|| contentType === PERSONALITY_QUIZ_CONTENT_TYPE
|
||||||
)
|
)
|
||||||
&& getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION
|
&& getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION
|
||||||
) {
|
) {
|
||||||
@ -255,6 +265,7 @@ class ContentCatalogService {
|
|||||||
const record = await db.content_catalog.findOne({
|
const record = await db.content_catalog.findOne({
|
||||||
where: {
|
where: {
|
||||||
content_type: normalizedContentType,
|
content_type: normalizedContentType,
|
||||||
|
active: true,
|
||||||
...tenantWhereFor(normalizedContentType, currentUser),
|
...tenantWhereFor(normalizedContentType, currentUser),
|
||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
@ -264,6 +275,22 @@ class ContentCatalogService {
|
|||||||
throw new ValidationError('contentCatalogNotFound');
|
throw new ValidationError('contentCatalogNotFound');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (VERSIONED_CONTENT_TYPES.has(normalizedContentType)) {
|
||||||
|
await record.update({ active: false }, { transaction });
|
||||||
|
const version = await db.content_catalog.create(
|
||||||
|
{
|
||||||
|
content_type: normalizedContentType,
|
||||||
|
payload,
|
||||||
|
active: data.active !== false,
|
||||||
|
importHash: data.importHash || null,
|
||||||
|
...tenantStampFor(normalizedContentType, currentUser),
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
return toContentCatalogDto(version);
|
||||||
|
}
|
||||||
|
|
||||||
await record.update(
|
await record.update(
|
||||||
{
|
{
|
||||||
payload,
|
payload,
|
||||||
@ -284,6 +311,7 @@ class ContentCatalogService {
|
|||||||
const record = await db.content_catalog.findOne({
|
const record = await db.content_catalog.findOne({
|
||||||
where: {
|
where: {
|
||||||
content_type: normalizedContentType,
|
content_type: normalizedContentType,
|
||||||
|
active: true,
|
||||||
...tenantWhereFor(normalizedContentType, currentUser),
|
...tenantWhereFor(normalizedContentType, currentUser),
|
||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
|
|||||||
@ -114,4 +114,59 @@ describe('seedDefaultContentForTenant', () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('seeds Emotional Intelligence quizzes only at organization scope', async () => {
|
||||||
|
const createdRows: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne);
|
||||||
|
mock.method(db.content_catalog, 'create', (async (payload: Record<string, unknown>) => {
|
||||||
|
createdRows.push(payload);
|
||||||
|
return payload;
|
||||||
|
}) as typeof db.content_catalog.create);
|
||||||
|
|
||||||
|
await seedDefaultContentForTenant({
|
||||||
|
level: 'organization',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
});
|
||||||
|
await seedDefaultContentForTenant({
|
||||||
|
level: 'school',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
});
|
||||||
|
await seedDefaultContentForTenant({
|
||||||
|
level: 'campus',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const quizRows = createdRows.filter((row) =>
|
||||||
|
row.content_type === 'emotional-intelligence-assessment-questions' ||
|
||||||
|
row.content_type === 'emotional-intelligence-personality-quiz',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(quizRows.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
quizRows.map((row) => ({
|
||||||
|
content_type: row.content_type,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
schoolId: row.schoolId,
|
||||||
|
campusId: row.campusId,
|
||||||
|
})).sort((left, right) => String(left.content_type).localeCompare(String(right.content_type))),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
content_type: 'emotional-intelligence-assessment-questions',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: null,
|
||||||
|
campusId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content_type: 'emotional-intelligence-personality-quiz',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: null,
|
||||||
|
campusId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, mock, test } from 'node:test';
|
import { afterEach, describe, mock, test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
import db from '@/db/models';
|
import db from '@/db/models';
|
||||||
import PersonalityQuizResultsService from '@/services/personality_quiz_results';
|
import PersonalityQuizResultsService from '@/services/personality_quiz_results';
|
||||||
@ -66,6 +67,85 @@ describe('personal result persistence while drilled into child scope', () => {
|
|||||||
assert.equal(createCount, 0);
|
assert.equal(createCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes safety quiz result weeks to the Sunday week start on create', async () => {
|
||||||
|
const commits: string[] = [];
|
||||||
|
const createdRows: unknown[] = [];
|
||||||
|
|
||||||
|
mock.method(db.sequelize, 'transaction', (async () => ({
|
||||||
|
commit: async () => { commits.push('commit'); },
|
||||||
|
rollback: async () => { commits.push('rollback'); },
|
||||||
|
})) as typeof db.sequelize.transaction);
|
||||||
|
|
||||||
|
mock.method(db.safety_quiz_results, 'create', (async (data: unknown) => {
|
||||||
|
createdRows.push(data);
|
||||||
|
return {
|
||||||
|
get: () => ({
|
||||||
|
id: 'result-1',
|
||||||
|
quiz_id: 'qbs-weekly',
|
||||||
|
quiz_title: 'QBS Weekly',
|
||||||
|
week_of: '2026-06-14',
|
||||||
|
score: 3,
|
||||||
|
total_questions: 4,
|
||||||
|
answers: [0, 1, 2, 3],
|
||||||
|
user_name: 'Emily Johnson',
|
||||||
|
user_role: ROLE_NAMES.TEACHER,
|
||||||
|
completed_at: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}) as unknown as typeof db.safety_quiz_results.create);
|
||||||
|
|
||||||
|
const result = await SafetyQuizResultsService.create(
|
||||||
|
{
|
||||||
|
quiz_id: 'qbs-weekly',
|
||||||
|
quiz_title: 'QBS Weekly',
|
||||||
|
week_of: '2026-06-17',
|
||||||
|
score: 3,
|
||||||
|
total_questions: 4,
|
||||||
|
answers: [0, 1, 2, 3],
|
||||||
|
},
|
||||||
|
createTestUser({
|
||||||
|
id: 'user-1',
|
||||||
|
firstName: 'Emily',
|
||||||
|
lastName: 'Johnson',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
organizations: { id: 'org-1' },
|
||||||
|
campusId: 'campus-1',
|
||||||
|
app_role: {
|
||||||
|
name: ROLE_NAMES.TEACHER,
|
||||||
|
scope: ROLE_SCOPES.CAMPUS,
|
||||||
|
globalAccess: false,
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result?.week_of, '2026-06-14');
|
||||||
|
assert.deepEqual(createdRows, [{
|
||||||
|
quiz_id: 'qbs-weekly',
|
||||||
|
quiz_title: 'QBS Weekly',
|
||||||
|
week_of: '2026-06-14',
|
||||||
|
score: 3,
|
||||||
|
total_questions: 4,
|
||||||
|
answers: [0, 1, 2, 3],
|
||||||
|
user_name: 'Emily Johnson',
|
||||||
|
user_role: ROLE_NAMES.TEACHER,
|
||||||
|
completed_at: createdRows.length > 0
|
||||||
|
? (createdRows[0] as { completed_at: Date }).completed_at
|
||||||
|
: undefined,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdById: 'user-1',
|
||||||
|
updatedById: 'user-1',
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(commits, ['commit']);
|
||||||
|
});
|
||||||
|
|
||||||
test('does not upsert personality results for parent users in child scope', async () => {
|
test('does not upsert personality results for parent users in child scope', async () => {
|
||||||
let createCount = 0;
|
let createCount = 0;
|
||||||
mock.method(db.personality_quiz_results, 'findOne', (async () => null) as unknown as typeof db.personality_quiz_results.findOne);
|
mock.method(db.personality_quiz_results, 'findOne', (async () => null) as unknown as typeof db.personality_quiz_results.findOne);
|
||||||
@ -76,8 +156,12 @@ describe('personal result persistence while drilled into child scope', () => {
|
|||||||
|
|
||||||
const result = await PersonalityQuizResultsService.upsertCurrentUserResult(
|
const result = await PersonalityQuizResultsService.upsertCurrentUserResult(
|
||||||
{
|
{
|
||||||
|
quiz_kind: 'personality_type',
|
||||||
|
quiz_id: 'personality-type',
|
||||||
|
quiz_title: 'Personality Type Quiz',
|
||||||
personality_type: 'INFJ',
|
personality_type: 'INFJ',
|
||||||
quiz_answers: { 1: 'A' },
|
quiz_answers: { 1: 'A' },
|
||||||
|
total_questions: 1,
|
||||||
},
|
},
|
||||||
parentUserDrilledIntoSchool(),
|
parentUserDrilledIntoSchool(),
|
||||||
);
|
);
|
||||||
@ -86,29 +170,112 @@ describe('personal result persistence while drilled into child scope', () => {
|
|||||||
assert.equal(createCount, 0);
|
assert.equal(createCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reads only the current weekly EI self-assessment result for the current user', async () => {
|
||||||
|
let capturedWhere: Record<string | symbol, unknown> | null = null;
|
||||||
|
mock.method(db.personality_quiz_results, 'findOne', (async (options: { where: Record<string | symbol, unknown> }) => {
|
||||||
|
capturedWhere = options.where;
|
||||||
|
return null;
|
||||||
|
}) as unknown as typeof db.personality_quiz_results.findOne);
|
||||||
|
|
||||||
|
await PersonalityQuizResultsService.getCurrentUserResult(
|
||||||
|
{ quiz_kind: 'ei_self_assessment' },
|
||||||
|
createTestUser({
|
||||||
|
id: 'user-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
organizations: { id: 'org-1' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(capturedWhere);
|
||||||
|
const where = capturedWhere as Record<string | symbol, unknown>;
|
||||||
|
assert.equal(where.organizationId, 'org-1');
|
||||||
|
assert.equal(where.userId, 'user-1');
|
||||||
|
assert.equal(where.quiz_kind, 'ei_self_assessment');
|
||||||
|
assert.match(String(where.week_of), /^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads current user EI and personality result history from persisted results', async () => {
|
||||||
|
let capturedWhere: Record<string, unknown> | null = null;
|
||||||
|
let capturedLimit: number | null = null;
|
||||||
|
mock.method(db.personality_quiz_results, 'findAll', (async (options: {
|
||||||
|
where: Record<string, unknown>;
|
||||||
|
limit: number;
|
||||||
|
}) => {
|
||||||
|
capturedWhere = options.where;
|
||||||
|
capturedLimit = options.limit;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
get: () => ({
|
||||||
|
id: 'personality-1',
|
||||||
|
quiz_kind: 'personality_type',
|
||||||
|
quiz_id: 'personality-type',
|
||||||
|
quiz_title: 'Personality Type Quiz',
|
||||||
|
week_of: null,
|
||||||
|
personality_type: 'INFJ',
|
||||||
|
quiz_answers: { 1: 'I' },
|
||||||
|
score: null,
|
||||||
|
total_questions: 1,
|
||||||
|
result_label: 'INFJ',
|
||||||
|
result_payload: null,
|
||||||
|
user_name: 'Emily Johnson',
|
||||||
|
user_role: ROLE_NAMES.TEACHER,
|
||||||
|
completed_at: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}) as unknown as typeof db.personality_quiz_results.findAll);
|
||||||
|
|
||||||
|
const result = await PersonalityQuizResultsService.getCurrentUserHistory(
|
||||||
|
{},
|
||||||
|
createTestUser({
|
||||||
|
id: 'user-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
organizations: { id: 'org-1' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(capturedWhere, {
|
||||||
|
organizationId: 'org-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
assert.equal(capturedLimit, 25);
|
||||||
|
assert.equal(result.count, 1);
|
||||||
|
assert.equal(result.rows[0]?.quiz_kind, 'personality_type');
|
||||||
|
assert.equal(result.rows[0]?.personality_type, 'INFJ');
|
||||||
|
});
|
||||||
|
|
||||||
test('reads the current user safety quiz status from saved results', async () => {
|
test('reads the current user safety quiz status from saved results', async () => {
|
||||||
mock.method(db.safety_quiz_results, 'findOne', (async () => ({
|
let capturedWhere: Record<string, unknown> | null = null;
|
||||||
|
mock.method(db.safety_quiz_results, 'findOne', (async (options: { where: Record<string, unknown> }) => {
|
||||||
|
capturedWhere = options.where;
|
||||||
|
return {
|
||||||
get: () => ({
|
get: () => ({
|
||||||
id: 'result-1',
|
id: 'result-1',
|
||||||
quiz_id: 'qbs-weekly',
|
quiz_id: 'qbs-weekly',
|
||||||
quiz_title: 'QBS Weekly',
|
quiz_title: 'QBS Weekly',
|
||||||
week_of: '2026-06-15',
|
week_of: '2026-06-14',
|
||||||
score: 4,
|
score: 4,
|
||||||
total_questions: 4,
|
total_questions: 4,
|
||||||
answers: [0, 1, 2, 3],
|
answers: [0, 1, 2, 3],
|
||||||
user_name: 'Emily Johnson',
|
user_name: 'Emily Johnson',
|
||||||
user_role: ROLE_NAMES.TEACHER,
|
user_role: ROLE_NAMES.TEACHER,
|
||||||
completed_at: new Date('2026-06-17T12:00:00Z'),
|
completed_at: new Date('2026-06-17T12:00:00Z'),
|
||||||
organizationId: 'org-1',
|
organizationId: 'org-1',
|
||||||
campusId: 'campus-1',
|
campusId: 'campus-1',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
createdAt: new Date('2026-06-17T12:00:00Z'),
|
createdAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
updatedAt: new Date('2026-06-17T12:00:00Z'),
|
updatedAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
}),
|
}),
|
||||||
})) as unknown as typeof db.safety_quiz_results.findOne);
|
};
|
||||||
|
}) as unknown as typeof db.safety_quiz_results.findOne);
|
||||||
|
|
||||||
const result = await SafetyQuizResultsService.me(
|
const result = await SafetyQuizResultsService.me(
|
||||||
{ week_of: '2026-06-15' },
|
{ week_of: '2026-06-17' },
|
||||||
createTestUser({
|
createTestUser({
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
organizationId: 'org-1',
|
organizationId: 'org-1',
|
||||||
@ -118,6 +285,10 @@ describe('personal result persistence while drilled into child scope', () => {
|
|||||||
|
|
||||||
assert.equal(result.completed, true);
|
assert.equal(result.completed, true);
|
||||||
assert.equal(result.result?.id, 'result-1');
|
assert.equal(result.result?.id, 'result-1');
|
||||||
|
assert.deepEqual(capturedWhere, {
|
||||||
|
userId: 'user-1',
|
||||||
|
week_of: '2026-06-14',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('builds safety quiz completion rows for completed and pending staff', async () => {
|
test('builds safety quiz completion rows for completed and pending staff', async () => {
|
||||||
@ -185,4 +356,55 @@ describe('personal result persistence while drilled into child scope', () => {
|
|||||||
assert.equal(report.rows[0]?.status, 'complete');
|
assert.equal(report.rows[0]?.status, 'complete');
|
||||||
assert.equal(report.rows[1]?.status, 'pending');
|
assert.equal(report.rows[1]?.status, 'pending');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('builds EI completion from current-week self-assessment and all-time personality results', async () => {
|
||||||
|
mock.method(db.users, 'findAll', (async () => [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
firstName: 'Emily',
|
||||||
|
lastName: 'Johnson',
|
||||||
|
email: 'teacher@flatlogic.com',
|
||||||
|
app_role: { name: ROLE_NAMES.TEACHER },
|
||||||
|
},
|
||||||
|
]) as unknown as typeof db.users.findAll);
|
||||||
|
|
||||||
|
let capturedWhere: Record<string | symbol, unknown> | null = null;
|
||||||
|
mock.method(db.personality_quiz_results, 'findAll', (async (options: { where: Record<string | symbol, unknown> }) => {
|
||||||
|
capturedWhere = options.where;
|
||||||
|
return [];
|
||||||
|
}) as unknown as typeof db.personality_quiz_results.findAll);
|
||||||
|
|
||||||
|
const report = await PersonalityQuizResultsService.completion(
|
||||||
|
{},
|
||||||
|
createTestUser({
|
||||||
|
id: 'director-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
organizations: { id: 'org-1' },
|
||||||
|
campusId: 'campus-1',
|
||||||
|
app_role: {
|
||||||
|
name: ROLE_NAMES.DIRECTOR,
|
||||||
|
scope: ROLE_SCOPES.CAMPUS,
|
||||||
|
globalAccess: false,
|
||||||
|
permissions: [permission(FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS)],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(capturedWhere);
|
||||||
|
const where = capturedWhere as Record<string | symbol, unknown>;
|
||||||
|
const kindFilter = where[Op.or] as Array<Record<string, unknown>> | undefined;
|
||||||
|
assert.equal(where.organizationId, 'org-1');
|
||||||
|
assert.deepEqual(where.userId, { [Op.in]: ['user-1'] });
|
||||||
|
assert.equal(kindFilter?.[0]?.quiz_kind, 'personality_type');
|
||||||
|
assert.equal(kindFilter?.[1]?.quiz_kind, 'ei_self_assessment');
|
||||||
|
assert.match(String(kindFilter?.[1]?.week_of), /^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
assert.deepEqual(report.summary, {
|
||||||
|
totalStaff: 1,
|
||||||
|
completedCount: 0,
|
||||||
|
pendingCount: 1,
|
||||||
|
selfAssessmentCompletedCount: 0,
|
||||||
|
personalityCompletedCount: 0,
|
||||||
|
completionRate: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,22 +6,101 @@ import ValidationError from '@/shared/errors/validation';
|
|||||||
import {
|
import {
|
||||||
getOrganizationIdOrGlobal,
|
getOrganizationIdOrGlobal,
|
||||||
getCampusId,
|
getCampusId,
|
||||||
|
getSchoolId,
|
||||||
|
getClassId,
|
||||||
assertAuthenticatedTenantUser,
|
assertAuthenticatedTenantUser,
|
||||||
campusDimensionScope,
|
campusDimensionScope,
|
||||||
hasFeaturePermission,
|
hasFeaturePermission,
|
||||||
isActingInOwnScope,
|
isActingInOwnScope,
|
||||||
|
getDisplayName,
|
||||||
|
getRoleScope,
|
||||||
|
requireUserId,
|
||||||
} from '@/services/shared/access';
|
} from '@/services/shared/access';
|
||||||
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
||||||
|
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
|
||||||
|
import { toWeekStartIso } from '@/shared/constants/week';
|
||||||
import type { PersonalityQuizResults } from '@/db/models/personality_quiz_results';
|
import type { PersonalityQuizResults } from '@/db/models/personality_quiz_results';
|
||||||
|
import type { Users } from '@/db/models/users';
|
||||||
import type { CurrentUser } from '@/db/api/types';
|
import type { CurrentUser } from '@/db/api/types';
|
||||||
|
|
||||||
interface PersonalityInput {
|
interface PersonalityInput {
|
||||||
personality_type: string;
|
quiz_kind?: string;
|
||||||
|
quiz_id: string;
|
||||||
|
quiz_title: string;
|
||||||
|
personality_type?: string | null;
|
||||||
quiz_answers: unknown;
|
quiz_answers: unknown;
|
||||||
|
score?: number | null;
|
||||||
|
total_questions: number;
|
||||||
|
result_label?: string | null;
|
||||||
|
result_payload?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersonalityFilter {
|
interface PersonalityFilter {
|
||||||
campusId?: string;
|
campusId?: string;
|
||||||
|
quiz_kind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalityHistoryFilter {
|
||||||
|
quiz_kind?: string;
|
||||||
|
limit?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONALITY_QUIZ_KIND = 'personality_type';
|
||||||
|
const EI_SELF_ASSESSMENT_KIND = 'ei_self_assessment';
|
||||||
|
const DEFAULT_HISTORY_LIMIT = 25;
|
||||||
|
const MAX_HISTORY_LIMIT = 100;
|
||||||
|
const ALLOWED_QUIZ_KINDS = Object.freeze([
|
||||||
|
PERSONALITY_QUIZ_KIND,
|
||||||
|
EI_SELF_ASSESSMENT_KIND,
|
||||||
|
]);
|
||||||
|
const REPORT_STAFF_ROLES = Object.freeze([
|
||||||
|
ROLE_NAMES.OWNER,
|
||||||
|
ROLE_NAMES.SUPERINTENDENT,
|
||||||
|
ROLE_NAMES.PRINCIPAL,
|
||||||
|
ROLE_NAMES.REGISTRAR,
|
||||||
|
ROLE_NAMES.DIRECTOR,
|
||||||
|
ROLE_NAMES.OFFICE_MANAGER,
|
||||||
|
ROLE_NAMES.TEACHER,
|
||||||
|
ROLE_NAMES.SUPPORT_STAFF,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeQuizKind(value: unknown): string {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
return PERSONALITY_QUIZ_KIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!ALLOWED_QUIZ_KINDS.includes(normalized)) {
|
||||||
|
throw new ValidationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHistoryLimit(value: unknown): number {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return DEFAULT_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number(value);
|
||||||
|
if (!Number.isInteger(limit) || limit < 1) {
|
||||||
|
throw new ValidationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(limit, MAX_HISTORY_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductRole(currentUser?: CurrentUser): string {
|
||||||
|
return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentWeekStartIso(): string {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
return toWeekStartIso(today) ?? today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekWhereForQuizKind(quizKind: string): { week_of?: string } {
|
||||||
|
return quizKind === EI_SELF_ASSESSMENT_KIND ? { week_of: currentWeekStartIso() } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertValidResult(data: PersonalityInput): void {
|
function assertValidResult(data: PersonalityInput): void {
|
||||||
@ -29,26 +108,123 @@ function assertValidResult(data: PersonalityInput): void {
|
|||||||
throw new ValidationError();
|
throw new ValidationError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quizKind = normalizeQuizKind(data.quiz_kind);
|
||||||
if (
|
if (
|
||||||
typeof data.personality_type !== 'string' ||
|
typeof data.quiz_id !== 'string' ||
|
||||||
data.personality_type.trim().length === 0 ||
|
data.quiz_id.trim().length === 0 ||
|
||||||
|
typeof data.quiz_title !== 'string' ||
|
||||||
|
data.quiz_title.trim().length === 0 ||
|
||||||
!data.quiz_answers ||
|
!data.quiz_answers ||
|
||||||
typeof data.quiz_answers !== 'object' ||
|
typeof data.quiz_answers !== 'object' ||
|
||||||
Array.isArray(data.quiz_answers)
|
Array.isArray(data.quiz_answers) ||
|
||||||
|
!Number.isInteger(data.total_questions)
|
||||||
) {
|
) {
|
||||||
throw new ValidationError();
|
throw new ValidationError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const answerValues = Object.values(data.quiz_answers);
|
const answerValues = Object.values(data.quiz_answers);
|
||||||
|
if (answerValues.length === 0) {
|
||||||
|
throw new ValidationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quizKind === PERSONALITY_QUIZ_KIND) {
|
||||||
|
if (
|
||||||
|
typeof data.personality_type !== 'string' ||
|
||||||
|
data.personality_type.trim().length === 0 ||
|
||||||
|
!answerValues.every(
|
||||||
|
(value) => typeof value === 'string' && value.trim().length > 0,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ValidationError();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!answerValues.every(
|
data.score !== null &&
|
||||||
(value) => typeof value === 'string' && value.trim().length > 0,
|
data.score !== undefined &&
|
||||||
)
|
!Number.isInteger(data.score)
|
||||||
) {
|
) {
|
||||||
throw new ValidationError();
|
throw new ValidationError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function staffScopeWhere(currentUser?: CurrentUser) {
|
||||||
|
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
||||||
|
const base = organizationId ? { organizationId } : {};
|
||||||
|
const scope = getRoleScope(currentUser);
|
||||||
|
|
||||||
|
if (scope === ROLE_SCOPES.SCHOOL) {
|
||||||
|
const schoolId = getSchoolId(currentUser);
|
||||||
|
if (!schoolId) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const campuses = await db.campuses.findAll({
|
||||||
|
attributes: ['id'],
|
||||||
|
where: {
|
||||||
|
schoolId,
|
||||||
|
...(organizationId ? { organizationId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const campusIds = campuses.map((campus) => campus.id);
|
||||||
|
const classes = campusIds.length > 0
|
||||||
|
? await db.classes.findAll({
|
||||||
|
attributes: ['id'],
|
||||||
|
where: {
|
||||||
|
campusId: campusIds,
|
||||||
|
...(organizationId ? { organizationId } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const classIds = classes.map((classroom) => classroom.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
[Op.or]: [
|
||||||
|
{ schoolId },
|
||||||
|
...(campusIds.length > 0 ? [{ campusId: { [Op.in]: campusIds } }] : []),
|
||||||
|
...(classIds.length > 0 ? [{ classId: { [Op.in]: classIds } }] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === ROLE_SCOPES.CAMPUS) {
|
||||||
|
const campusId = getCampusId(currentUser);
|
||||||
|
return campusId ? { ...base, campusId } : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === ROLE_SCOPES.CLASS) {
|
||||||
|
const classId = getClassId(currentUser);
|
||||||
|
return classId ? { ...base, classId } : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayNameOf(user: Users): string {
|
||||||
|
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||||
|
|| user.email
|
||||||
|
|| 'Staff Member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestResultsByUserAndKind(
|
||||||
|
results: readonly PersonalityQuizResults[],
|
||||||
|
): ReadonlyMap<string, PersonalityQuizResults> {
|
||||||
|
const byUserAndKind = new Map<string, PersonalityQuizResults>();
|
||||||
|
for (const result of results) {
|
||||||
|
const userId = result.userId;
|
||||||
|
if (!userId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${userId}:${result.quiz_kind}`;
|
||||||
|
if (byUserAndKind.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byUserAndKind.set(key, result);
|
||||||
|
}
|
||||||
|
return byUserAndKind;
|
||||||
|
}
|
||||||
|
|
||||||
function toDto(record: PersonalityQuizResults | null) {
|
function toDto(record: PersonalityQuizResults | null) {
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
@ -58,8 +234,18 @@ function toDto(record: PersonalityQuizResults | null) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: plain.id,
|
id: plain.id,
|
||||||
|
quiz_kind: plain.quiz_kind,
|
||||||
|
quiz_id: plain.quiz_id,
|
||||||
|
quiz_title: plain.quiz_title,
|
||||||
|
week_of: plain.week_of,
|
||||||
personality_type: plain.personality_type,
|
personality_type: plain.personality_type,
|
||||||
quiz_answers: plain.quiz_answers,
|
quiz_answers: plain.quiz_answers,
|
||||||
|
score: plain.score,
|
||||||
|
total_questions: plain.total_questions,
|
||||||
|
result_label: plain.result_label,
|
||||||
|
result_payload: plain.result_payload,
|
||||||
|
user_name: plain.user_name,
|
||||||
|
user_role: plain.user_role,
|
||||||
completed_at: plain.completed_at,
|
completed_at: plain.completed_at,
|
||||||
organizationId: plain.organizationId,
|
organizationId: plain.organizationId,
|
||||||
campusId: plain.campusId,
|
campusId: plain.campusId,
|
||||||
@ -72,23 +258,57 @@ function toDto(record: PersonalityQuizResults | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PersonalityQuizResultsService {
|
class PersonalityQuizResultsService {
|
||||||
static async getCurrentUserResult(currentUser?: CurrentUser) {
|
static async getCurrentUserResult(
|
||||||
|
filter: PersonalityFilter,
|
||||||
|
currentUser?: CurrentUser,
|
||||||
|
) {
|
||||||
assertAuthenticatedTenantUser(currentUser);
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
|
|
||||||
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
||||||
const orgFilter = organizationId ? { organizationId } : {};
|
const orgFilter = organizationId ? { organizationId } : {};
|
||||||
|
const quizKind = normalizeQuizKind(filter.quiz_kind);
|
||||||
|
|
||||||
const record = await db.personality_quiz_results.findOne({
|
const record = await db.personality_quiz_results.findOne({
|
||||||
where: {
|
where: {
|
||||||
...orgFilter,
|
...orgFilter,
|
||||||
userId: currentUser?.id ?? null,
|
userId: requireUserId(currentUser),
|
||||||
|
quiz_kind: quizKind,
|
||||||
|
...weekWhereForQuizKind(quizKind),
|
||||||
},
|
},
|
||||||
order: [['updatedAt', 'desc']],
|
order: [['completed_at', 'desc']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return toDto(record);
|
return toDto(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getCurrentUserHistory(
|
||||||
|
filter: PersonalityHistoryFilter,
|
||||||
|
currentUser?: CurrentUser,
|
||||||
|
) {
|
||||||
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
|
|
||||||
|
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
||||||
|
const orgFilter = organizationId ? { organizationId } : {};
|
||||||
|
const quizKindFilter = filter.quiz_kind
|
||||||
|
? { quiz_kind: normalizeQuizKind(filter.quiz_kind) }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const rows = await db.personality_quiz_results.findAll({
|
||||||
|
where: {
|
||||||
|
...orgFilter,
|
||||||
|
...quizKindFilter,
|
||||||
|
userId: requireUserId(currentUser),
|
||||||
|
},
|
||||||
|
order: [['completed_at', 'desc']],
|
||||||
|
limit: parseHistoryLimit(filter.limit),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.map(toDto),
|
||||||
|
count: rows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static async upsertCurrentUserResult(
|
static async upsertCurrentUserResult(
|
||||||
data: PersonalityInput,
|
data: PersonalityInput,
|
||||||
currentUser?: CurrentUser,
|
currentUser?: CurrentUser,
|
||||||
@ -97,43 +317,39 @@ class PersonalityQuizResultsService {
|
|||||||
assertValidResult(data);
|
assertValidResult(data);
|
||||||
|
|
||||||
if (!isActingInOwnScope(currentUser)) {
|
if (!isActingInOwnScope(currentUser)) {
|
||||||
return this.getCurrentUserResult(currentUser);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
||||||
const orgFilter = organizationId ? { organizationId } : {};
|
const quizKind = normalizeQuizKind(data.quiz_kind);
|
||||||
const where = {
|
const weekOf = quizKind === EI_SELF_ASSESSMENT_KIND ? currentWeekStartIso() : null;
|
||||||
...orgFilter,
|
|
||||||
userId: currentUser?.id ?? null,
|
|
||||||
};
|
|
||||||
return withTransaction(async (transaction) => {
|
return withTransaction(async (transaction) => {
|
||||||
const existing = await db.personality_quiz_results.findOne({
|
|
||||||
where,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
personality_type: data.personality_type.trim().toUpperCase(),
|
quiz_kind: quizKind,
|
||||||
|
quiz_id: data.quiz_id.trim(),
|
||||||
|
quiz_title: data.quiz_title.trim(),
|
||||||
|
week_of: weekOf,
|
||||||
|
personality_type: data.personality_type
|
||||||
|
? data.personality_type.trim().toUpperCase()
|
||||||
|
: null,
|
||||||
quiz_answers: data.quiz_answers,
|
quiz_answers: data.quiz_answers,
|
||||||
|
score: data.score ?? null,
|
||||||
|
total_questions: data.total_questions,
|
||||||
|
result_label: data.result_label?.trim() || data.personality_type?.trim().toUpperCase() || null,
|
||||||
|
result_payload: data.result_payload ?? null,
|
||||||
|
user_name: getDisplayName(currentUser),
|
||||||
|
user_role: getProductRole(currentUser),
|
||||||
completed_at: new Date(),
|
completed_at: new Date(),
|
||||||
organizationId,
|
organizationId,
|
||||||
campusId: getCampusId(currentUser),
|
campusId: getCampusId(currentUser),
|
||||||
userId: currentUser?.id ?? null,
|
userId: currentUser?.id ?? null,
|
||||||
|
createdById: currentUser?.id ?? null,
|
||||||
updatedById: currentUser?.id ?? null,
|
updatedById: currentUser?.id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let saved: PersonalityQuizResults;
|
const saved = await db.personality_quiz_results.create(payload, {
|
||||||
if (existing) {
|
transaction,
|
||||||
saved = await existing.update(payload, { transaction });
|
});
|
||||||
} else {
|
|
||||||
saved = await db.personality_quiz_results.create(
|
|
||||||
{
|
|
||||||
...payload,
|
|
||||||
createdById: currentUser?.id ?? null,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return toDto(saved);
|
return toDto(saved);
|
||||||
});
|
});
|
||||||
@ -158,12 +374,9 @@ class PersonalityQuizResultsService {
|
|||||||
const orgFilter = organizationId ? { organizationId } : {};
|
const orgFilter = organizationId ? { organizationId } : {};
|
||||||
|
|
||||||
const rows = await db.personality_quiz_results.findAll({
|
const rows = await db.personality_quiz_results.findAll({
|
||||||
attributes: [
|
|
||||||
'personality_type',
|
|
||||||
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count'],
|
|
||||||
],
|
|
||||||
where: {
|
where: {
|
||||||
...orgFilter,
|
...orgFilter,
|
||||||
|
quiz_kind: PERSONALITY_QUIZ_KIND,
|
||||||
// School/campus isolation; an explicit campusId filter is intersected
|
// School/campus isolation; an explicit campusId filter is intersected
|
||||||
// (Op.and) with the scope so a school role cannot read another school.
|
// (Op.and) with the scope so a school role cannot read another school.
|
||||||
[Op.and]: [
|
[Op.and]: [
|
||||||
@ -171,16 +384,131 @@ class PersonalityQuizResultsService {
|
|||||||
...(filter.campusId ? [{ campusId: filter.campusId }] : []),
|
...(filter.campusId ? [{ campusId: filter.campusId }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
group: ['personality_type'],
|
order: [['completed_at', 'desc']],
|
||||||
order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']],
|
|
||||||
});
|
});
|
||||||
|
const latestByUser = new Map<string, PersonalityQuizResults>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.userId || latestByUser.has(row.userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
latestByUser.set(row.userId, row);
|
||||||
|
}
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const row of latestByUser.values()) {
|
||||||
|
if (!row.personality_type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts.set(row.personality_type, (counts.get(row.personality_type) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const sortedRows = [...counts.entries()]
|
||||||
|
.sort(([, countA], [, countB]) => countB - countA)
|
||||||
|
.map(([type, count]) => ({ type, count }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: rows.map((row) => ({
|
rows: sortedRows,
|
||||||
type: row.get('personality_type'),
|
count: sortedRows.length,
|
||||||
count: Number(row.get('count')),
|
};
|
||||||
})),
|
}
|
||||||
count: rows.length,
|
|
||||||
|
static async completion(filter: PersonalityFilter, currentUser?: CurrentUser) {
|
||||||
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
|
if (
|
||||||
|
!hasFeaturePermission(
|
||||||
|
currentUser,
|
||||||
|
FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffUsers = await db.users.findAll({
|
||||||
|
where: {
|
||||||
|
disabled: false,
|
||||||
|
...(await staffScopeWhere(currentUser)),
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.roles,
|
||||||
|
as: 'app_role',
|
||||||
|
required: true,
|
||||||
|
where: { name: REPORT_STAFF_ROLES },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [
|
||||||
|
['lastName', 'asc'],
|
||||||
|
['firstName', 'asc'],
|
||||||
|
['email', 'asc'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const userIds = staffUsers.map((user) => user.id);
|
||||||
|
const quizKinds = filter.quiz_kind
|
||||||
|
? [normalizeQuizKind(filter.quiz_kind)]
|
||||||
|
: [EI_SELF_ASSESSMENT_KIND, PERSONALITY_QUIZ_KIND];
|
||||||
|
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
||||||
|
const orgFilter = organizationId ? { organizationId } : {};
|
||||||
|
const resultWhere = filter.quiz_kind
|
||||||
|
? {
|
||||||
|
...orgFilter,
|
||||||
|
userId: { [Op.in]: userIds },
|
||||||
|
quiz_kind: quizKinds[0],
|
||||||
|
...weekWhereForQuizKind(quizKinds[0]),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...orgFilter,
|
||||||
|
userId: { [Op.in]: userIds },
|
||||||
|
[Op.or]: [
|
||||||
|
{ quiz_kind: PERSONALITY_QUIZ_KIND },
|
||||||
|
{
|
||||||
|
quiz_kind: EI_SELF_ASSESSMENT_KIND,
|
||||||
|
week_of: currentWeekStartIso(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const results = userIds.length > 0
|
||||||
|
? await db.personality_quiz_results.findAll({
|
||||||
|
where: resultWhere,
|
||||||
|
order: [['completed_at', 'desc']],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const resultByUserAndKind = latestResultsByUserAndKind(results);
|
||||||
|
const rows = staffUsers.map((user) => {
|
||||||
|
const selfAssessment = resultByUserAndKind.get(`${user.id}:${EI_SELF_ASSESSMENT_KIND}`) ?? null;
|
||||||
|
const personality = resultByUserAndKind.get(`${user.id}:${PERSONALITY_QUIZ_KIND}`) ?? null;
|
||||||
|
const completedKinds: string[] = [];
|
||||||
|
if (selfAssessment) {
|
||||||
|
completedKinds.push(EI_SELF_ASSESSMENT_KIND);
|
||||||
|
}
|
||||||
|
if (personality) {
|
||||||
|
completedKinds.push(PERSONALITY_QUIZ_KIND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
name: displayNameOf(user),
|
||||||
|
email: user.email,
|
||||||
|
role: user.app_role?.name ?? null,
|
||||||
|
status: completedKinds.length >= quizKinds.length ? 'complete' : 'pending',
|
||||||
|
completedKinds,
|
||||||
|
selfAssessment: selfAssessment ? toDto(selfAssessment) : null,
|
||||||
|
personality: personality ? toDto(personality) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const selfAssessmentCompletedCount = rows.filter((row) => row.selfAssessment).length;
|
||||||
|
const personalityCompletedCount = rows.filter((row) => row.personality).length;
|
||||||
|
const completedCount = rows.filter((row) => row.status === 'complete').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
totalStaff: rows.length,
|
||||||
|
completedCount,
|
||||||
|
pendingCount: Math.max(rows.length - completedCount, 0),
|
||||||
|
selfAssessmentCompletedCount,
|
||||||
|
personalityCompletedCount,
|
||||||
|
completionRate: rows.length > 0
|
||||||
|
? Math.round((completedCount / rows.length) * 100)
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
|||||||
import type { SafetyQuizResults } from '@/db/models/safety_quiz_results';
|
import type { SafetyQuizResults } from '@/db/models/safety_quiz_results';
|
||||||
import type { Users } from '@/db/models/users';
|
import type { Users } from '@/db/models/users';
|
||||||
import type { CurrentUser } from '@/db/api/types';
|
import type { CurrentUser } from '@/db/api/types';
|
||||||
|
import { toWeekStartIso } from '@/shared/constants/week';
|
||||||
|
|
||||||
interface SafetyQuizInput {
|
interface SafetyQuizInput {
|
||||||
quiz_id: string;
|
quiz_id: string;
|
||||||
@ -75,6 +76,18 @@ function assertValidResult(data: SafetyQuizInput): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireWeekStart(value: string): string {
|
||||||
|
const weekStart = toWeekStartIso(value);
|
||||||
|
if (!weekStart) {
|
||||||
|
throw new ValidationError();
|
||||||
|
}
|
||||||
|
return weekStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalWeekStart(value?: string): string | undefined {
|
||||||
|
return value ? requireWeekStart(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function toDto(record: SafetyQuizResults) {
|
function toDto(record: SafetyQuizResults) {
|
||||||
const plain = record.get({ plain: true });
|
const plain = record.get({ plain: true });
|
||||||
|
|
||||||
@ -186,6 +199,7 @@ class SafetyQuizResultsService {
|
|||||||
static async list(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
static async list(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
||||||
assertAuthenticatedTenantUser(currentUser);
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
||||||
|
const weekOf = optionalWeekStart(filter.week_of);
|
||||||
|
|
||||||
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
||||||
const orgFilter = organizationId ? { organizationId } : {};
|
const orgFilter = organizationId ? { organizationId } : {};
|
||||||
@ -199,7 +213,7 @@ class SafetyQuizResultsService {
|
|||||||
)
|
)
|
||||||
? campusDimensionScope(currentUser)
|
? campusDimensionScope(currentUser)
|
||||||
: { userId: currentUser?.id ?? null }),
|
: { userId: currentUser?.id ?? null }),
|
||||||
...(filter.week_of ? { week_of: filter.week_of } : {}),
|
...(weekOf ? { week_of: weekOf } : {}),
|
||||||
},
|
},
|
||||||
order: [['completed_at', 'desc']],
|
order: [['completed_at', 'desc']],
|
||||||
limit,
|
limit,
|
||||||
@ -223,11 +237,12 @@ class SafetyQuizResultsService {
|
|||||||
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
const organizationId = getOrganizationIdOrGlobal(currentUser);
|
||||||
|
|
||||||
return withTransaction(async (transaction) => {
|
return withTransaction(async (transaction) => {
|
||||||
|
const weekOf = requireWeekStart(data.week_of);
|
||||||
const created = await db.safety_quiz_results.create(
|
const created = await db.safety_quiz_results.create(
|
||||||
{
|
{
|
||||||
quiz_id: data.quiz_id.trim(),
|
quiz_id: data.quiz_id.trim(),
|
||||||
quiz_title: data.quiz_title.trim(),
|
quiz_title: data.quiz_title.trim(),
|
||||||
week_of: data.week_of.trim(),
|
week_of: weekOf,
|
||||||
score: data.score,
|
score: data.score,
|
||||||
total_questions: data.total_questions,
|
total_questions: data.total_questions,
|
||||||
answers: data.answers,
|
answers: data.answers,
|
||||||
@ -249,10 +264,11 @@ class SafetyQuizResultsService {
|
|||||||
|
|
||||||
static async me(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
static async me(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
||||||
assertAuthenticatedTenantUser(currentUser);
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
|
const weekOf = optionalWeekStart(filter.week_of);
|
||||||
const result = await db.safety_quiz_results.findOne({
|
const result = await db.safety_quiz_results.findOne({
|
||||||
where: {
|
where: {
|
||||||
userId: requireUserId(currentUser),
|
userId: requireUserId(currentUser),
|
||||||
...(filter.week_of ? { week_of: filter.week_of } : {}),
|
...(weekOf ? { week_of: weekOf } : {}),
|
||||||
},
|
},
|
||||||
order: [['completed_at', 'desc']],
|
order: [['completed_at', 'desc']],
|
||||||
});
|
});
|
||||||
@ -265,6 +281,7 @@ class SafetyQuizResultsService {
|
|||||||
|
|
||||||
static async completion(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
static async completion(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
||||||
assertCanReadCompletion(currentUser);
|
assertCanReadCompletion(currentUser);
|
||||||
|
const weekOf = optionalWeekStart(filter.week_of);
|
||||||
const staffUsers = await db.users.findAll({
|
const staffUsers = await db.users.findAll({
|
||||||
where: {
|
where: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@ -290,7 +307,7 @@ class SafetyQuizResultsService {
|
|||||||
? await db.safety_quiz_results.findAll({
|
? await db.safety_quiz_results.findAll({
|
||||||
where: {
|
where: {
|
||||||
userId: userIds,
|
userId: userIds,
|
||||||
...(filter.week_of ? { week_of: filter.week_of } : {}),
|
...(weekOf ? { week_of: weekOf } : {}),
|
||||||
},
|
},
|
||||||
order: [['completed_at', 'desc']],
|
order: [['completed_at', 'desc']],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,12 @@ export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies';
|
|||||||
/** The safety/QBS quiz content type, owned and managed at organization scope. */
|
/** The safety/QBS quiz content type, owned and managed at organization scope. */
|
||||||
export const SAFETY_QUIZ_CONTENT_TYPE = 'safety-qbs-quiz';
|
export const SAFETY_QUIZ_CONTENT_TYPE = 'safety-qbs-quiz';
|
||||||
|
|
||||||
|
/** EI self-assessment questions, owned and managed at organization scope. */
|
||||||
|
export const EI_ASSESSMENT_CONTENT_TYPE = 'emotional-intelligence-assessment-questions';
|
||||||
|
|
||||||
|
/** Personality type quiz content, owned and managed at organization scope. */
|
||||||
|
export const PERSONALITY_QUIZ_CONTENT_TYPE = 'emotional-intelligence-personality-quiz';
|
||||||
|
|
||||||
/** ESA funding content — school-scoped (rules depend on the school's locale). */
|
/** ESA funding content — school-scoped (rules depend on the school's locale). */
|
||||||
export const ESA_CONTENT_TYPE = 'esa-funding-content';
|
export const ESA_CONTENT_TYPE = 'esa-funding-content';
|
||||||
|
|
||||||
@ -28,11 +34,12 @@ export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
|||||||
export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||||
SAFETY_QUIZ_CONTENT_TYPE,
|
SAFETY_QUIZ_CONTENT_TYPE,
|
||||||
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
||||||
|
EI_ASSESSMENT_CONTENT_TYPE,
|
||||||
|
PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||||
'regulation-zones',
|
'regulation-zones',
|
||||||
'zones-of-regulation-page-content',
|
'zones-of-regulation-page-content',
|
||||||
'sign-language-items',
|
'sign-language-items',
|
||||||
'sign-language-page-content',
|
'sign-language-page-content',
|
||||||
'emotional-intelligence-assessment-questions',
|
|
||||||
'emotional-intelligence-weekly-topics',
|
'emotional-intelligence-weekly-topics',
|
||||||
'emotional-intelligence-growth-tips',
|
'emotional-intelligence-growth-tips',
|
||||||
'emotional-intelligence-team-wellness-metrics',
|
'emotional-intelligence-team-wellness-metrics',
|
||||||
|
|||||||
@ -24,7 +24,7 @@ Two scoping modes coexist:
|
|||||||
|
|
||||||
- **Aggregate / subtree:** statistics and attendance roll up through class → campus → school → org.
|
- **Aggregate / subtree:** statistics and attendance roll up through class → campus → school → org.
|
||||||
- **Owned content:** FRAME, policy documents, walkthrough content, and per-tenant catalog rows are owned by the current tenant. Class-scoped users read/write campus-level content; class scope is reserved for roster/attendance-style data.
|
- **Owned content:** FRAME, policy documents, walkthrough content, and per-tenant catalog rows are owned by the current tenant. Class-scoped users read/write campus-level content; class scope is reserved for roster/attendance-style data.
|
||||||
- **Catalog-specific scope:** `content_catalog` also has school-scoped, org-scoped, and shared/global content types. Truly-global personality and classroom-timer catalogs live in frontend constants, not DB rows.
|
- **Catalog-specific scope:** `content_catalog` also has school-scoped, org-scoped, and shared/global content types. Organization-owned quiz content and classroom support strategies live in DB rows; product-static personality directory and classroom-timer catalogs live in frontend constants.
|
||||||
|
|
||||||
Important page/content rules:
|
Important page/content rules:
|
||||||
|
|
||||||
|
|||||||
@ -43,12 +43,12 @@ catalog only once the user types (see `top-bar-integration.md`).
|
|||||||
- dashboard quote, compliance items, and sign of the week
|
- dashboard quote, compliance items, and sign of the week
|
||||||
- community organizations
|
- community organizations
|
||||||
- vocational opportunities
|
- vocational opportunities
|
||||||
- emotional intelligence assessment content, weekly focus, and team content
|
- emotional intelligence assessment content, personality quiz questions, weekly focus, and team content
|
||||||
- ESA funding content
|
- ESA funding content
|
||||||
|
|
||||||
Product-static content that intentionally does **not** use content catalog:
|
Product-static content that intentionally does **not** use content catalog:
|
||||||
|
|
||||||
- personality quiz questions, personality type directory, quiz intro feature cards, and workplace sidebar content (`frontend/src/shared/constants/personalityStaticContent.ts`)
|
- personality type directory, quiz intro feature cards, and workplace sidebar content (`frontend/src/shared/constants/personalityStaticContent.ts`)
|
||||||
- classroom timer backgrounds, built-in sound metadata, presets, and tips (`frontend/src/shared/constants/classroomTimerContent.ts`)
|
- classroom timer backgrounds, built-in sound metadata, presets, and tips (`frontend/src/shared/constants/classroomTimerContent.ts`)
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Director dashboard follows the frontend three-layer architecture and aggregates backend-backed FRAME, QBS safety quiz, staff attendance, and policy acknowledgment data.
|
Director dashboard follows the frontend three-layer architecture and aggregates backend-backed FRAME, quiz completion, staff attendance, and policy acknowledgment data.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
View -> Business Logic -> API/Data Access -> Backend
|
View -> Business Logic -> API/Data Access -> Backend
|
||||||
@ -25,6 +25,7 @@ API/data access layer:
|
|||||||
|
|
||||||
- `frontend/src/shared/api/frame.ts`
|
- `frontend/src/shared/api/frame.ts`
|
||||||
- `frontend/src/shared/api/safetyQuizResults.ts`
|
- `frontend/src/shared/api/safetyQuizResults.ts`
|
||||||
|
- `frontend/src/shared/api/personality.ts`
|
||||||
- `frontend/src/shared/api/staffAttendance.ts`
|
- `frontend/src/shared/api/staffAttendance.ts`
|
||||||
- `frontend/src/shared/api/policyAcknowledgments.ts`
|
- `frontend/src/shared/api/policyAcknowledgments.ts`
|
||||||
|
|
||||||
@ -36,10 +37,16 @@ Constants:
|
|||||||
|
|
||||||
- The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`.
|
- The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`.
|
||||||
- FRAME entries load through `useFrameEntries`.
|
- FRAME entries load through `useFrameEntries`.
|
||||||
- Safety quiz results load through `useSafetyQuizResults`.
|
- QBS safety quiz completion loads through `useSafetyQuizResults`.
|
||||||
|
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
|
||||||
- Staff attendance records and summary load through staff attendance business hooks.
|
- Staff attendance records and summary load through staff attendance business hooks.
|
||||||
- Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`.
|
- Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`.
|
||||||
- Overview metrics, risk areas, and FRAME previews are derived in business selectors.
|
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
|
||||||
|
- The dashboard quiz results table combines Behavior Management, EI Self-Assessment, and
|
||||||
|
Personality Type Quiz rows. EI self-assessment rows reflect the current Sunday-start week;
|
||||||
|
personality type rows reflect each user's latest saved type.
|
||||||
|
- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI self-assessment
|
||||||
|
pending counts, low-risk Personality Type pending counts, and attendance risk.
|
||||||
- View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives.
|
- View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives.
|
||||||
- Loading, empty, and error states are explicit.
|
- Loading, empty, and error states are explicit.
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Static emotional-intelligence personality quiz content lives in `frontend/src/shared/constants/personalityStaticContent.ts`. Pure catalog types and helper functions live in `frontend/src/shared/constants/personalityCatalog.ts`.
|
Static emotional-intelligence personality type directory content lives in `frontend/src/shared/constants/personalityStaticContent.ts`. Organization-owned EI self-assessment and Personality Type quiz questions are loaded from the backend content catalog. Pure catalog types and helper functions live in `frontend/src/shared/constants/personalityCatalog.ts`.
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
`personalityStaticContent.ts`:
|
`personalityStaticContent.ts`:
|
||||||
|
|
||||||
- `PERSONALITY_QUIZ_QUESTIONS`
|
|
||||||
- `PERSONALITY_TYPES`
|
- `PERSONALITY_TYPES`
|
||||||
- `PERSONALITY_QUIZ_FEATURES`
|
- `PERSONALITY_QUIZ_FEATURES`
|
||||||
- `PERSONALITY_WORKPLACE_CONTENT`
|
- `PERSONALITY_WORKPLACE_CONTENT`
|
||||||
@ -17,14 +16,14 @@ Static emotional-intelligence personality quiz content lives in `frontend/src/sh
|
|||||||
|
|
||||||
- `calculateMBTI`
|
- `calculateMBTI`
|
||||||
- `getPersonalityType`
|
- `getPersonalityType`
|
||||||
- static catalog types for quiz questions and personality descriptions
|
- static catalog types for quiz question payloads and personality descriptions
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
||||||
This file is product-static catalog content. Persisted user personality results remain in:
|
This file is product-static catalog content for the type directory and shared helpers. Persisted user personality results remain in:
|
||||||
|
|
||||||
- API layer: `frontend/src/shared/api/personality.ts`
|
- API layer: `frontend/src/shared/api/personality.ts`
|
||||||
- DTO types: `frontend/src/shared/types/personality.ts`
|
- DTO types: `frontend/src/shared/types/personality.ts`
|
||||||
- Business layer: `frontend/src/business/personality/`
|
- Business layer: `frontend/src/business/personality/`
|
||||||
|
|
||||||
Do not store user answers, quiz results, or tenant-owned personality data in the static catalog.
|
Do not store user answers, quiz results, tenant-owned quiz questions, or tenant-owned personality data in the static catalog.
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
EI/personality result loading, saving, and aggregate distribution follow the frontend three-layer architecture.
|
EI/personality content, result loading, saving, completion reporting, and aggregate distribution follow the frontend three-layer architecture.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
View -> Business Logic -> API/Data Access -> Backend
|
View -> Business Logic -> API/Data Access -> Backend
|
||||||
@ -14,6 +14,7 @@ View layer:
|
|||||||
|
|
||||||
- `frontend/src/components/frameworks/EmotionalIntelligence.tsx`
|
- `frontend/src/components/frameworks/EmotionalIntelligence.tsx`
|
||||||
- `frontend/src/components/emotional-intelligence/AssessmentTab.tsx`
|
- `frontend/src/components/emotional-intelligence/AssessmentTab.tsx`
|
||||||
|
- `frontend/src/components/emotional-intelligence/EmotionalIntelligenceQuizEditorPanel.tsx`
|
||||||
- `frontend/src/components/emotional-intelligence/EmotionalIntelligenceHeader.tsx`
|
- `frontend/src/components/emotional-intelligence/EmotionalIntelligenceHeader.tsx`
|
||||||
- `frontend/src/components/emotional-intelligence/EmotionalIntelligenceTabs.tsx`
|
- `frontend/src/components/emotional-intelligence/EmotionalIntelligenceTabs.tsx`
|
||||||
- `frontend/src/components/emotional-intelligence/PersonalityDistributionPanel.tsx`
|
- `frontend/src/components/emotional-intelligence/PersonalityDistributionPanel.tsx`
|
||||||
@ -45,13 +46,32 @@ API/data access layer:
|
|||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- The current user's saved personality result loads from `GET /api/personality_quiz_results/me`.
|
- The current user's latest saved result for each quiz kind loads from
|
||||||
- Quiz completion saves through `PUT /api/personality_quiz_results/me`.
|
`GET /api/personality_quiz_results/me`.
|
||||||
|
- The profile page loads the current user's persisted EI and personality history from
|
||||||
|
`GET /api/personality_quiz_results/me/history`, ordered by completion date. This keeps previous
|
||||||
|
EI weekly results and personality type completions visible after weekly EI windows roll forward.
|
||||||
|
- EI self-assessment and personality quiz content load from backend content catalog rows. Existing
|
||||||
|
quiz content is preset for each organization, and organization-scope users with
|
||||||
|
`MANAGE_CONTENT_CATALOG` manage it from the Emotional Intelligence page.
|
||||||
|
- Quiz completion saves through `PUT /api/personality_quiz_results/me`. The database is the single
|
||||||
|
source of truth for completion badges, profile history, completion reporting, and notifications.
|
||||||
- Saved-result loading, saved-result badges, and result mutations are enabled
|
- Saved-result loading, saved-result badges, and result mutations are enabled
|
||||||
only in the user's own scope. A parent user drilled into a child tenant may
|
only in the user's own scope. A parent user drilled into a child tenant may
|
||||||
complete the quiz for immediate review, but the UI does not claim that the
|
complete the quiz for immediate review, but the UI does not claim that the
|
||||||
result was saved and the backend does not create reportable child-scope rows.
|
result was saved and the backend does not create reportable child-scope rows.
|
||||||
- Director and superintendent aggregate distribution loads from `GET /api/personality_quiz_results/distribution`.
|
- Director and superintendent aggregate distribution loads from `GET /api/personality_quiz_results/distribution`.
|
||||||
|
- Team wellness completion reporting loads from `GET /api/personality_quiz_results/completion`.
|
||||||
|
The report combines current-week EI self-assessment results and latest personality type results,
|
||||||
|
grouped by quiz category. Personality rows show each user's current type.
|
||||||
|
- EI self-assessment is a weekly workflow. Notifications for missing EI completion are evaluated
|
||||||
|
for the current Sunday-start week. The Personality Type quiz and Personality Directory are not
|
||||||
|
weekly workflows and do not reset weekly.
|
||||||
|
- The user profile page renders Behavior Management, EI Self-Assessment, and Personality Type Quiz
|
||||||
|
results in one quiz-results table. The EI/personality rows come from the persisted history
|
||||||
|
endpoint; the QBS row comes from the safety quiz status endpoint.
|
||||||
|
- Leadership dashboards render the same quiz categories in one quiz-results table and include EI
|
||||||
|
self-assessment and Personality Type pending counts in the risk list.
|
||||||
- Backend errors are surfaced as UI error states instead of being swallowed.
|
- Backend errors are surfaced as UI error states instead of being swallowed.
|
||||||
- `EmotionalIntelligence.tsx` is a thin composition wrapper.
|
- `EmotionalIntelligence.tsx` is a thin composition wrapper.
|
||||||
- `PersonalityQuiz.tsx` is a thin composition wrapper.
|
- `PersonalityQuiz.tsx` is a thin composition wrapper.
|
||||||
@ -60,10 +80,11 @@ API/data access layer:
|
|||||||
- Personality quiz flow state, saved-result hydration, result tab state, progress calculation, relationship tips, workplace language strengths, and result formatting live in `frontend/src/business/personality/`.
|
- Personality quiz flow state, saved-result hydration, result tab state, progress calculation, relationship tips, workplace language strengths, and result formatting live in `frontend/src/business/personality/`.
|
||||||
- Personality directory search, group filtering, expanded type state, and active detail section state live in `frontend/src/business/personality/`.
|
- Personality directory search, group filtering, expanded type state, and active detail section state live in `frontend/src/business/personality/`.
|
||||||
- Personality business hooks are split by workflow: backend query/mutation hooks, directory workflow, EI page workflow, and quiz workflow. Imports use the workflow-specific files directly; there is no legacy re-export surface.
|
- Personality business hooks are split by workflow: backend query/mutation hooks, directory workflow, EI page workflow, and quiz workflow. Imports use the workflow-specific files directly; there is no legacy re-export surface.
|
||||||
- EI questions, topics, growth tips, MBTI dimensions, and workplace tips live in shared constants.
|
- EI questions and Personality Type quiz questions are backend-owned organization content.
|
||||||
- Personality type directory records load from the backend content catalog.
|
Personality type directory records, dimensions, and workplace tips are product-static and are not
|
||||||
|
weekly-updated quiz content.
|
||||||
- The frontend does not write personality type to user employment fields.
|
- The frontend does not write personality type to user employment fields.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Focused personality selector tests cover distribution totals/grouping, EI level thresholds, saved-date formatting, quiz progress, dimension progress, type breakdowns, relationship tips, communication strengths, communication growth guidance, and personality directory filtering. These tests also guard the S/J and S/P grouping behavior.
|
Focused personality selector and mapper tests cover distribution totals/grouping, EI level thresholds, saved-date formatting, quiz progress, dimension progress, type breakdowns, relationship tips, communication strengths, communication growth guidance, personality directory filtering, persisted result mapping, profile history API wiring, and completion API wiring. Profile and director-dashboard selector tests cover the unified Behavior Management / EI Self-Assessment / Personality Type quiz result tables. Top-bar selector tests cover EI self-assessment and personality quiz completion reminders. These tests also guard the S/J and S/P grouping behavior.
|
||||||
|
|||||||
@ -61,7 +61,13 @@ Constants:
|
|||||||
- `QBSSafety.tsx` is a thin wrapper that calls `useSafetyQuizPage` and renders focused safety quiz view components.
|
- `QBSSafety.tsx` is a thin wrapper that calls `useSafetyQuizPage` and renders focused safety quiz view components.
|
||||||
- Quiz score, progress, result feedback, and compliance summary are derived in business selectors.
|
- Quiz score, progress, result feedback, and compliance summary are derived in business selectors.
|
||||||
- The "current week" key (`getCurrentSafetyQuizWeek`) uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — consistent with the dashboard hero and F.R.A.M.E.
|
- The "current week" key (`getCurrentSafetyQuizWeek`) uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — consistent with the dashboard hero and F.R.A.M.E.
|
||||||
|
- The backend also normalizes submitted `week_of` values to the same Sunday-start key before saving,
|
||||||
|
so weekly status, notifications, completion, and dashboard metrics all read the current canonical
|
||||||
|
week.
|
||||||
- Weekly focus and key reminders are backend content payload fields, not frontend constants.
|
- Weekly focus and key reminders are backend content payload fields, not frontend constants.
|
||||||
|
- QBS quiz content reads only the active `safety-qbs-quiz` catalog row. When organization content
|
||||||
|
managers update the quiz, the backend stores a new active row and keeps previous quiz versions
|
||||||
|
inactive for history.
|
||||||
- Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives.
|
- Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives.
|
||||||
- Leadership dashboards derive QBS completion metrics and risk rows from the backend completion
|
- Leadership dashboards derive QBS completion metrics and risk rows from the backend completion
|
||||||
summary.
|
summary.
|
||||||
|
|||||||
@ -18,4 +18,4 @@ The file contains only non-secret frontend configuration and static UI assets:
|
|||||||
- Move newly persisted workflows to typed backend APIs and business hooks.
|
- Move newly persisted workflows to typed backend APIs and business hooks.
|
||||||
- Further domain split is allowed when UI configuration becomes large enough to justify its own shared constant file.
|
- Further domain split is allowed when UI configuration becomes large enough to justify its own shared constant file.
|
||||||
- Campus records and branding are backend-owned and are loaded through `GET /api/public/campuses`; frontend campus helpers must not define campus rows, names, mascot labels, descriptions, or per-campus branding.
|
- Campus records and branding are backend-owned and are loaded through `GET /api/public/campuses`; frontend campus helpers must not define campus rows, names, mascot labels, descriptions, or per-campus branding.
|
||||||
- Editable/scoped product content catalogs are backend-owned and are loaded through authenticated `GET /api/content-catalog/read/:contentType`. Truly global static catalogs, such as classroom-timer presets and personality quiz content, live in dedicated shared constant files.
|
- Editable/scoped product content catalogs are backend-owned and are loaded through authenticated `GET /api/content-catalog/read/:contentType`. Truly global static catalogs, such as classroom-timer presets and the personality type directory, live in dedicated shared constant files.
|
||||||
|
|||||||
@ -40,6 +40,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and
|
|||||||
- `frontend/src/business/zone-checkin/selectors.test.ts`
|
- `frontend/src/business/zone-checkin/selectors.test.ts`
|
||||||
- `frontend/src/business/personality/mappers.test.ts`
|
- `frontend/src/business/personality/mappers.test.ts`
|
||||||
- `frontend/src/business/personality/selectors.test.ts`
|
- `frontend/src/business/personality/selectors.test.ts`
|
||||||
|
- `frontend/src/business/profile/selectors.test.ts`
|
||||||
- `frontend/src/business/policies/mappers.test.ts`
|
- `frontend/src/business/policies/mappers.test.ts`
|
||||||
- `frontend/src/business/policies/selectors.test.ts`
|
- `frontend/src/business/policies/selectors.test.ts`
|
||||||
- `frontend/src/business/safety-protocols/mappers.test.ts`
|
- `frontend/src/business/safety-protocols/mappers.test.ts`
|
||||||
@ -79,7 +80,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and
|
|||||||
- `frontend/src/hooks/usePermissions.test.tsx`
|
- `frontend/src/hooks/usePermissions.test.tsx`
|
||||||
- `frontend/src/components/sign-in-modal/SignInForm.test.tsx`
|
- `frontend/src/components/sign-in-modal/SignInForm.test.tsx`
|
||||||
|
|
||||||
These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance.
|
These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, QBS/EI/personality completion reminders, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, unified profile quiz result rows, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance.
|
||||||
|
|
||||||
The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support and explicit personal-workflow exclusions.
|
The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support and explicit personal-workflow exclusions.
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,11 @@ Shared config:
|
|||||||
- Notification nudges include the daily Emotional Zone reminder only when the
|
- Notification nudges include the daily Emotional Zone reminder only when the
|
||||||
user has explicit `ZONE_CHECKIN`; that personal workflow permission is not
|
user has explicit `ZONE_CHECKIN`; that personal workflow permission is not
|
||||||
implied by `globalAccess`.
|
implied by `globalAccess`.
|
||||||
|
- Weekly quiz reminders include the QBS safety quiz and EI self-assessment when
|
||||||
|
eligible users have not completed the current week. The Personality Type quiz
|
||||||
|
reminder is a one-time completion nudge because that quiz does not reset
|
||||||
|
weekly. These reminders are derived from backend-backed status queries, not
|
||||||
|
frontend constants.
|
||||||
- Selectors handle initials, campus label fallback, shared role labels, and unread notification count.
|
- Selectors handle initials, campus label fallback, shared role labels, and unread notification count.
|
||||||
- **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (permission- and scope-filtered via `getScopedModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for modules available in the current effective scope) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here.
|
- **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (permission- and scope-filtered via `getScopedModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for modules available in the current effective scope) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here.
|
||||||
- View components receive a prepared page model and do not call API/data access modules.
|
- View components receive a prepared page model and do not call API/data access modules.
|
||||||
@ -46,7 +51,7 @@ Shared config:
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `business/top-bar/selectors.test.ts` (notification builder + zones `href`),
|
- `business/top-bar/selectors.test.ts` (notification builder, QBS/EI/personality quiz reminders + zones `href`),
|
||||||
`business/top-bar/search.test.ts` (module permission-filtering + content matching +
|
`business/top-bar/search.test.ts` (module permission-filtering + content matching +
|
||||||
combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal).
|
combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal).
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||||
import { getContentCatalog } from '@/shared/api/contentCatalog';
|
import {
|
||||||
|
createManagedContentCatalog,
|
||||||
|
deleteManagedContentCatalog,
|
||||||
|
getContentCatalog,
|
||||||
|
getManagedContentCatalog,
|
||||||
|
updateManagedContentCatalog,
|
||||||
|
} from '@/shared/api/contentCatalog';
|
||||||
import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog';
|
import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog';
|
||||||
|
|
||||||
export function useContentCatalogPayload<TPayload>(
|
export function useContentCatalogPayload<TPayload>(
|
||||||
@ -23,3 +29,53 @@ export function useContentCatalogPayload<TPayload>(
|
|||||||
refresh: query.refetch,
|
refresh: query.refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useManagedContentCatalog<TPayload>(contentType: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType],
|
||||||
|
queryFn: () => getManagedContentCatalog<TPayload>(contentType),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveManagedContentCatalog<TPayload>(
|
||||||
|
contentType: string,
|
||||||
|
hasExistingContent: boolean,
|
||||||
|
getPayload: () => TPayload,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => (
|
||||||
|
hasExistingContent
|
||||||
|
? updateManagedContentCatalog<TPayload>(contentType, { payload: getPayload() })
|
||||||
|
: createManagedContentCatalog<TPayload>({
|
||||||
|
content_type: contentType,
|
||||||
|
payload: getPayload(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateContentCatalogQueries(queryClient, contentType);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteManagedContentCatalog(contentType: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => deleteManagedContentCatalog(contentType),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateContentCatalogQueries(queryClient, contentType);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invalidateContentCatalogQueries(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useFrameEntries } from '@/business/frame/hooks';
|
import { useFrameEntries } from '@/business/frame/hooks';
|
||||||
import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks';
|
import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks';
|
||||||
|
import { usePersonalityCompletion } from '@/business/personality/queryHooks';
|
||||||
import {
|
import {
|
||||||
useStaffAttendanceRecords,
|
useStaffAttendanceRecords,
|
||||||
useStaffAttendanceSummary,
|
useStaffAttendanceSummary,
|
||||||
@ -10,6 +11,7 @@ import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
|
|||||||
import {
|
import {
|
||||||
buildDirectorFramePreviews,
|
buildDirectorFramePreviews,
|
||||||
buildDirectorOverviewCards,
|
buildDirectorOverviewCards,
|
||||||
|
buildDirectorQuizResults,
|
||||||
buildDirectorRiskAreas,
|
buildDirectorRiskAreas,
|
||||||
} from '@/business/director-dashboard/selectors';
|
} from '@/business/director-dashboard/selectors';
|
||||||
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
||||||
@ -32,11 +34,13 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
||||||
const frameEntriesQuery = useFrameEntries();
|
const frameEntriesQuery = useFrameEntries();
|
||||||
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
|
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
|
||||||
|
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
|
||||||
const staffAttendanceRecordsQuery = useStaffAttendanceRecords();
|
const staffAttendanceRecordsQuery = useStaffAttendanceRecords();
|
||||||
const staffAttendanceSummaryQuery = useStaffAttendanceSummary();
|
const staffAttendanceSummaryQuery = useStaffAttendanceSummary();
|
||||||
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
||||||
const frameEntries = frameEntriesQuery.data ?? [];
|
const frameEntries = frameEntriesQuery.data ?? [];
|
||||||
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
||||||
|
const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null;
|
||||||
const quizSummary = quizCompletionQuery.data?.summary ?? {
|
const quizSummary = quizCompletionQuery.data?.summary ?? {
|
||||||
totalStaff: 0,
|
totalStaff: 0,
|
||||||
completedCount: 0,
|
completedCount: 0,
|
||||||
@ -46,11 +50,13 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
const attendanceRecords = staffAttendanceRecordsQuery.data ?? [];
|
const attendanceRecords = staffAttendanceRecordsQuery.data ?? [];
|
||||||
const isLoading = frameEntriesQuery.isLoading
|
const isLoading = frameEntriesQuery.isLoading
|
||||||
|| quizCompletionQuery.isLoading
|
|| quizCompletionQuery.isLoading
|
||||||
|
|| emotionalIntelligenceCompletionQuery.isLoading
|
||||||
|| staffAttendanceRecordsQuery.isLoading
|
|| staffAttendanceRecordsQuery.isLoading
|
||||||
|| staffAttendanceSummaryQuery.isLoading
|
|| staffAttendanceSummaryQuery.isLoading
|
||||||
|| acknowledgmentReportQuery.isLoading;
|
|| acknowledgmentReportQuery.isLoading;
|
||||||
const error = frameEntriesQuery.error
|
const error = frameEntriesQuery.error
|
||||||
?? quizCompletionQuery.error
|
?? quizCompletionQuery.error
|
||||||
|
?? emotionalIntelligenceCompletionQuery.error
|
||||||
?? staffAttendanceRecordsQuery.error
|
?? staffAttendanceRecordsQuery.error
|
||||||
?? staffAttendanceSummaryQuery.error
|
?? staffAttendanceSummaryQuery.error
|
||||||
?? acknowledgmentReportQuery.error;
|
?? acknowledgmentReportQuery.error;
|
||||||
@ -65,10 +71,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
frameEntries,
|
frameEntries,
|
||||||
acknowledgmentReportQuery.data?.summary,
|
acknowledgmentReportQuery.data?.summary,
|
||||||
),
|
),
|
||||||
riskAreas: buildDirectorRiskAreas(attendanceRecords, quizSummary),
|
riskAreas: buildDirectorRiskAreas(
|
||||||
|
attendanceRecords,
|
||||||
|
quizSummary,
|
||||||
|
emotionalIntelligenceCompletion,
|
||||||
|
),
|
||||||
framePreviews: buildDirectorFramePreviews(frameEntries),
|
framePreviews: buildDirectorFramePreviews(frameEntries),
|
||||||
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
||||||
quizResults: quizRows,
|
quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion),
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
setTimeRange: setTimeRangeState,
|
setTimeRange: setTimeRangeState,
|
||||||
|
|||||||
@ -3,12 +3,17 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
buildDirectorFramePreviews,
|
buildDirectorFramePreviews,
|
||||||
buildDirectorOverviewCards,
|
buildDirectorOverviewCards,
|
||||||
|
buildDirectorQuizResults,
|
||||||
buildDirectorRiskAreas,
|
buildDirectorRiskAreas,
|
||||||
calculateQuizCompletionRate,
|
calculateQuizCompletionRate,
|
||||||
} from '@/business/director-dashboard/selectors';
|
} from '@/business/director-dashboard/selectors';
|
||||||
import type { FrameEntryViewModel } from '@/business/frame/types';
|
import type { FrameEntryViewModel } from '@/business/frame/types';
|
||||||
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
||||||
import type { SafetyQuizCompletionSummary } from '@/business/safety-quiz/types';
|
import type {
|
||||||
|
SafetyQuizCompletionSummary,
|
||||||
|
SafetyQuizComplianceRow,
|
||||||
|
} from '@/business/safety-quiz/types';
|
||||||
|
import type { PersonalityCompletionDto } from '@/shared/types/personality';
|
||||||
|
|
||||||
function createAttendanceRecord(
|
function createAttendanceRecord(
|
||||||
overrides: Partial<StaffAttendanceRecordViewModel> = {},
|
overrides: Partial<StaffAttendanceRecordViewModel> = {},
|
||||||
@ -51,6 +56,78 @@ function createFrameEntry(overrides: Partial<FrameEntryViewModel> = {}): FrameEn
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPersonalityCompletion(
|
||||||
|
overrides: Partial<PersonalityCompletionDto> = {},
|
||||||
|
): PersonalityCompletionDto {
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
totalStaff: 2,
|
||||||
|
completedCount: 1,
|
||||||
|
pendingCount: 1,
|
||||||
|
selfAssessmentCompletedCount: 1,
|
||||||
|
personalityCompletedCount: 1,
|
||||||
|
completionRate: 50,
|
||||||
|
},
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Ava Lee',
|
||||||
|
email: 'ava@example.test',
|
||||||
|
role: 'Teacher',
|
||||||
|
status: 'complete',
|
||||||
|
completedKinds: ['ei_self_assessment', 'personality_type'],
|
||||||
|
selfAssessment: {
|
||||||
|
id: 'ei-1',
|
||||||
|
quiz_kind: 'ei_self_assessment',
|
||||||
|
quiz_id: 'ei-weekly',
|
||||||
|
quiz_title: 'EI Self-Assessment',
|
||||||
|
week_of: '2026-06-14',
|
||||||
|
personality_type: null,
|
||||||
|
quiz_answers: {},
|
||||||
|
score: 14,
|
||||||
|
total_questions: 8,
|
||||||
|
result_label: 'Developing Awareness',
|
||||||
|
result_payload: null,
|
||||||
|
user_name: 'Ava Lee',
|
||||||
|
user_role: 'Teacher',
|
||||||
|
completed_at: '2026-06-18T10:00:00.000Z',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdById: null,
|
||||||
|
updatedById: null,
|
||||||
|
createdAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
personality: {
|
||||||
|
id: 'personality-1',
|
||||||
|
quiz_kind: 'personality_type',
|
||||||
|
quiz_id: 'personality-type',
|
||||||
|
quiz_title: 'Personality Type Quiz',
|
||||||
|
week_of: null,
|
||||||
|
personality_type: 'ENFP',
|
||||||
|
quiz_answers: {},
|
||||||
|
score: null,
|
||||||
|
total_questions: 0,
|
||||||
|
result_label: 'ENFP',
|
||||||
|
result_payload: null,
|
||||||
|
user_name: 'Ava Lee',
|
||||||
|
user_role: 'Teacher',
|
||||||
|
completed_at: '2026-06-18T10:00:00.000Z',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdById: null,
|
||||||
|
updatedById: null,
|
||||||
|
createdAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('director dashboard selectors', () => {
|
describe('director dashboard selectors', () => {
|
||||||
it('calculates quiz completion rate with empty staff protection', () => {
|
it('calculates quiz completion rate with empty staff protection', () => {
|
||||||
expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0);
|
expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0);
|
||||||
@ -103,6 +180,16 @@ describe('director dashboard selectors', () => {
|
|||||||
severity: 'high',
|
severity: 'high',
|
||||||
module: 'qbs',
|
module: 'qbs',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
issue: "0 staff haven't completed EI self-assessment",
|
||||||
|
severity: 'low',
|
||||||
|
module: 'ei',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
issue: "0 staff haven't completed personality type quiz",
|
||||||
|
severity: 'low',
|
||||||
|
module: 'ei',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
issue: '4 absences recorded this period',
|
issue: '4 absences recorded this period',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
@ -111,6 +198,33 @@ describe('director dashboard selectors', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('combines safety, EI assessment, and personality quiz results in one list', () => {
|
||||||
|
const rows = buildDirectorQuizResults(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Ava Lee',
|
||||||
|
role: 'Teacher',
|
||||||
|
status: 'complete',
|
||||||
|
score: '3/5',
|
||||||
|
date: 'Jun 18',
|
||||||
|
} satisfies SafetyQuizComplianceRow,
|
||||||
|
],
|
||||||
|
createPersonalityCompletion(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.quiz)).toEqual([
|
||||||
|
'Behavior Management',
|
||||||
|
'EI Self-Assessment',
|
||||||
|
'Personality Type Quiz',
|
||||||
|
]);
|
||||||
|
expect(rows.map((row) => row.result)).toEqual([
|
||||||
|
'3/5',
|
||||||
|
'Developing Awareness (14/32)',
|
||||||
|
'ENFP',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('limits and truncates FRAME previews', () => {
|
it('limits and truncates FRAME previews', () => {
|
||||||
const longText = 'A'.repeat(70);
|
const longText = 'A'.repeat(70);
|
||||||
const previews = buildDirectorFramePreviews([
|
const previews = buildDirectorFramePreviews([
|
||||||
|
|||||||
@ -15,9 +15,17 @@ import type { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policy
|
|||||||
import type {
|
import type {
|
||||||
DirectorFramePreview,
|
DirectorFramePreview,
|
||||||
DirectorOverviewCard,
|
DirectorOverviewCard,
|
||||||
|
DirectorQuizResultRow,
|
||||||
DirectorRiskArea,
|
DirectorRiskArea,
|
||||||
} from '@/business/director-dashboard/types';
|
} from '@/business/director-dashboard/types';
|
||||||
import type { SafetyQuizCompletionSummary } from '@/business/safety-quiz/types';
|
import type {
|
||||||
|
SafetyQuizCompletionSummary,
|
||||||
|
SafetyQuizComplianceRow,
|
||||||
|
} from '@/business/safety-quiz/types';
|
||||||
|
import type {
|
||||||
|
PersonalityCompletionDto,
|
||||||
|
PersonalityQuizResultDto,
|
||||||
|
} from '@/shared/types/personality';
|
||||||
|
|
||||||
export function calculateQuizCompletionRate(
|
export function calculateQuizCompletionRate(
|
||||||
quizSummary: SafetyQuizCompletionSummary,
|
quizSummary: SafetyQuizCompletionSummary,
|
||||||
@ -87,9 +95,18 @@ export function buildDirectorOverviewCards(
|
|||||||
export function buildDirectorRiskAreas(
|
export function buildDirectorRiskAreas(
|
||||||
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
|
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
|
||||||
quizSummary: SafetyQuizCompletionSummary,
|
quizSummary: SafetyQuizCompletionSummary,
|
||||||
|
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
||||||
): readonly DirectorRiskArea[] {
|
): readonly DirectorRiskArea[] {
|
||||||
const incompleteStaffCount = quizSummary.pendingCount;
|
const incompleteStaffCount = quizSummary.pendingCount;
|
||||||
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
|
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
|
||||||
|
const selfAssessmentPendingCount = emotionalIntelligenceCompletion?.summary
|
||||||
|
? emotionalIntelligenceCompletion.summary.totalStaff
|
||||||
|
- emotionalIntelligenceCompletion.summary.selfAssessmentCompletedCount
|
||||||
|
: 0;
|
||||||
|
const personalityPendingCount = emotionalIntelligenceCompletion?.summary
|
||||||
|
? emotionalIntelligenceCompletion.summary.totalStaff
|
||||||
|
- emotionalIntelligenceCompletion.summary.personalityCompletedCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -97,6 +114,16 @@ export function buildDirectorRiskAreas(
|
|||||||
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
|
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
|
||||||
module: 'qbs',
|
module: 'qbs',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`,
|
||||||
|
severity: 'low',
|
||||||
|
module: 'ei',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
issue: `${personalityPendingCount} staff haven't completed personality type quiz`,
|
||||||
|
severity: 'low',
|
||||||
|
module: 'ei',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
issue: `${absenceCount} absences recorded this period`,
|
issue: `${absenceCount} absences recorded this period`,
|
||||||
severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low',
|
severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low',
|
||||||
@ -105,6 +132,44 @@ export function buildDirectorRiskAreas(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDirectorQuizResults(
|
||||||
|
safetyRows: readonly SafetyQuizComplianceRow[],
|
||||||
|
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
||||||
|
): readonly DirectorQuizResultRow[] {
|
||||||
|
const behaviorRows = safetyRows.map((row): DirectorQuizResultRow => ({
|
||||||
|
id: `${row.userId}-behavior-management`,
|
||||||
|
staffName: row.name,
|
||||||
|
role: row.role,
|
||||||
|
quiz: 'Behavior Management',
|
||||||
|
result: row.score,
|
||||||
|
date: row.date,
|
||||||
|
status: row.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const emotionalIntelligenceRows = emotionalIntelligenceCompletion?.rows.flatMap((row) => [
|
||||||
|
{
|
||||||
|
id: `${row.userId}-ei-self-assessment`,
|
||||||
|
staffName: row.name,
|
||||||
|
role: row.role ?? 'Staff',
|
||||||
|
quiz: 'EI Self-Assessment',
|
||||||
|
result: formatPersonalityQuizResult(row.selfAssessment, 'Pending'),
|
||||||
|
date: formatPersonalityQuizDate(row.selfAssessment),
|
||||||
|
status: row.selfAssessment ? 'complete' as const : 'pending' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${row.userId}-personality-type`,
|
||||||
|
staffName: row.name,
|
||||||
|
role: row.role ?? 'Staff',
|
||||||
|
quiz: 'Personality Type Quiz',
|
||||||
|
result: formatPersonalityQuizResult(row.personality, 'Pending'),
|
||||||
|
date: formatPersonalityQuizDate(row.personality),
|
||||||
|
status: row.personality ? 'complete' as const : 'pending' as const,
|
||||||
|
},
|
||||||
|
]) ?? [];
|
||||||
|
|
||||||
|
return [...behaviorRows, ...emotionalIntelligenceRows];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDirectorFramePreviews(
|
export function buildDirectorFramePreviews(
|
||||||
frameEntries: readonly FrameEntryViewModel[],
|
frameEntries: readonly FrameEntryViewModel[],
|
||||||
): readonly DirectorFramePreview[] {
|
): readonly DirectorFramePreview[] {
|
||||||
@ -128,3 +193,33 @@ function truncatePreview(value: string): string {
|
|||||||
|
|
||||||
return `${value.slice(0, DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH)}...`;
|
return `${value.slice(0, DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPersonalityQuizResult(
|
||||||
|
result: PersonalityQuizResultDto | null,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
if (!result) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.personality_type) {
|
||||||
|
return result.personality_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.result_label && result.score !== null) {
|
||||||
|
return `${result.result_label} (${result.score}/${result.total_questions * 4})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.result_label ?? 'Completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPersonalityQuizDate(result: PersonalityQuizResultDto | null): string {
|
||||||
|
if (!result) {
|
||||||
|
return 'Not completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(result.completed_at).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import type {
|
|||||||
DirectorQuickActionConfig,
|
DirectorQuickActionConfig,
|
||||||
} from '@/shared/constants/directorDashboard';
|
} from '@/shared/constants/directorDashboard';
|
||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types';
|
|
||||||
|
|
||||||
export type DirectorDashboardTrend = 'up' | 'down';
|
export type DirectorDashboardTrend = 'up' | 'down';
|
||||||
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
||||||
export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard';
|
export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard';
|
||||||
export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald';
|
export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald';
|
||||||
export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E';
|
export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E';
|
||||||
|
export type DirectorQuizResultStatus = 'complete' | 'pending';
|
||||||
|
|
||||||
export interface DirectorOverviewCard {
|
export interface DirectorOverviewCard {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@ -38,6 +38,16 @@ export interface DirectorFramePreview {
|
|||||||
readonly sections: readonly DirectorFrameSectionPreview[];
|
readonly sections: readonly DirectorFrameSectionPreview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectorQuizResultRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly staffName: string;
|
||||||
|
readonly role: string;
|
||||||
|
readonly quiz: string;
|
||||||
|
readonly result: string;
|
||||||
|
readonly date: string;
|
||||||
|
readonly status: DirectorQuizResultStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirectorDashboardPage {
|
export interface DirectorDashboardPage {
|
||||||
/** Role-specific dashboard title (e.g. "Owner Dashboard"). */
|
/** Role-specific dashboard title (e.g. "Owner Dashboard"). */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
@ -48,7 +58,7 @@ export interface DirectorDashboardPage {
|
|||||||
readonly riskAreas: readonly DirectorRiskArea[];
|
readonly riskAreas: readonly DirectorRiskArea[];
|
||||||
readonly framePreviews: readonly DirectorFramePreview[];
|
readonly framePreviews: readonly DirectorFramePreview[];
|
||||||
readonly quickActions: readonly DirectorQuickActionConfig[];
|
readonly quickActions: readonly DirectorQuickActionConfig[];
|
||||||
readonly quizResults: readonly SafetyQuizComplianceRow[];
|
readonly quizResults: readonly DirectorQuizResultRow[];
|
||||||
readonly isLoading: boolean;
|
readonly isLoading: boolean;
|
||||||
readonly error: unknown;
|
readonly error: unknown;
|
||||||
readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void;
|
readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
|
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
|
||||||
import {
|
import {
|
||||||
useCurrentPersonalityResult,
|
useCurrentPersonalityResult,
|
||||||
|
usePersonalityCompletion,
|
||||||
usePersonalityDistribution,
|
usePersonalityDistribution,
|
||||||
useSaveCurrentPersonalityResult,
|
useSaveCurrentPersonalityResult,
|
||||||
} from '@/business/personality/queryHooks';
|
} from '@/business/personality/queryHooks';
|
||||||
@ -13,6 +14,7 @@ import {
|
|||||||
} from '@/business/personality/selectors';
|
} from '@/business/personality/selectors';
|
||||||
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
||||||
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
|
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
|
||||||
|
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
||||||
import {
|
import {
|
||||||
PERSONALITY_TYPES,
|
PERSONALITY_TYPES,
|
||||||
PERSONALITY_WORKPLACE_CONTENT,
|
PERSONALITY_WORKPLACE_CONTENT,
|
||||||
@ -20,6 +22,7 @@ import {
|
|||||||
import type { UserRole } from '@/shared/types/app';
|
import type { UserRole } from '@/shared/types/app';
|
||||||
import type {
|
import type {
|
||||||
EmotionalIntelligenceQuestion,
|
EmotionalIntelligenceQuestion,
|
||||||
|
EmotionalIntelligencePersonalityQuizContent,
|
||||||
EmotionalIntelligenceTab,
|
EmotionalIntelligenceTab,
|
||||||
EmotionalIntelligenceTopic,
|
EmotionalIntelligenceTopic,
|
||||||
EmotionalIntelligenceWeeklyFocus,
|
EmotionalIntelligenceWeeklyFocus,
|
||||||
@ -29,16 +32,27 @@ import type { PersonalityDistributionDto } from '@/shared/types/personality';
|
|||||||
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
||||||
import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState';
|
import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState';
|
||||||
import { useScopeContext } from '@/shared/app/scope-context';
|
import { useScopeContext } from '@/shared/app/scope-context';
|
||||||
|
import { usePermissions } from '@/shared/app/usePermissions';
|
||||||
|
|
||||||
const EMPTY_PERSONALITY_DISTRIBUTION: readonly PersonalityDistributionDto[] = [];
|
const EMPTY_PERSONALITY_DISTRIBUTION: readonly PersonalityDistributionDto[] = [];
|
||||||
|
|
||||||
export function useEmotionalIntelligencePage(userRole: UserRole) {
|
export function useEmotionalIntelligencePage(_userRole: UserRole) {
|
||||||
|
const permissions = usePermissions();
|
||||||
const { ownTenant, selectedTenant } = useScopeContext();
|
const { ownTenant, selectedTenant } = useScopeContext();
|
||||||
|
const activeTenant = selectedTenant ?? ownTenant;
|
||||||
const canPersistPersonalResults = canPersistPersonalScopeResults(ownTenant, selectedTenant);
|
const canPersistPersonalResults = canPersistPersonalScopeResults(ownTenant, selectedTenant);
|
||||||
|
const canManageQuizContent = permissions.has('MANAGE_CONTENT_CATALOG')
|
||||||
|
&& ownTenant?.level === 'organization'
|
||||||
|
&& activeTenant?.level === 'organization';
|
||||||
|
const canViewPersonalityDistribution = permissions.has('READ_PERSONALITY_REPORTS');
|
||||||
const assessmentQuestionsQuery = useContentCatalogPayload<readonly EmotionalIntelligenceQuestion[]>(
|
const assessmentQuestionsQuery = useContentCatalogPayload<readonly EmotionalIntelligenceQuestion[]>(
|
||||||
CONTENT_CATALOG_TYPES.emotionalIntelligenceAssessmentQuestions,
|
CONTENT_CATALOG_TYPES.emotionalIntelligenceAssessmentQuestions,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const personalityQuizQuery = useContentCatalogPayload<EmotionalIntelligencePersonalityQuizContent | null>(
|
||||||
|
CONTENT_CATALOG_TYPES.emotionalIntelligencePersonalityQuiz,
|
||||||
|
null,
|
||||||
|
);
|
||||||
const weeklyTopicsQuery = useContentCatalogPayload<readonly EmotionalIntelligenceTopic[]>(
|
const weeklyTopicsQuery = useContentCatalogPayload<readonly EmotionalIntelligenceTopic[]>(
|
||||||
CONTENT_CATALOG_TYPES.emotionalIntelligenceWeeklyTopics,
|
CONTENT_CATALOG_TYPES.emotionalIntelligenceWeeklyTopics,
|
||||||
[],
|
[],
|
||||||
@ -61,15 +75,38 @@ export function useEmotionalIntelligencePage(userRole: UserRole) {
|
|||||||
const [answers, setAnswers] = useState<readonly number[]>([]);
|
const [answers, setAnswers] = useState<readonly number[]>([]);
|
||||||
const [assessmentComplete, setAssessmentComplete] = useState(false);
|
const [assessmentComplete, setAssessmentComplete] = useState(false);
|
||||||
|
|
||||||
const currentPersonalityQuery = useCurrentPersonalityResult(canPersistPersonalResults);
|
const currentSelfAssessmentQuery = useCurrentPersonalityResult(
|
||||||
|
PERSONALITY_QUIZ_KINDS.selfAssessment,
|
||||||
|
canPersistPersonalResults,
|
||||||
|
);
|
||||||
|
const currentPersonalityQuery = useCurrentPersonalityResult(
|
||||||
|
PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
canPersistPersonalResults,
|
||||||
|
);
|
||||||
const savePersonalityMutation = useSaveCurrentPersonalityResult();
|
const savePersonalityMutation = useSaveCurrentPersonalityResult();
|
||||||
const canViewPersonalityDistribution = userRole === 'director' || userRole === 'superintendent';
|
|
||||||
const personalityDistributionQuery = usePersonalityDistribution(undefined, canViewPersonalityDistribution);
|
const personalityDistributionQuery = usePersonalityDistribution(undefined, canViewPersonalityDistribution);
|
||||||
|
const personalityCompletionQuery = usePersonalityCompletion(canViewPersonalityDistribution);
|
||||||
|
|
||||||
|
const savedSelfAssessment = currentSelfAssessmentQuery.data;
|
||||||
const savedPersonality = currentPersonalityQuery.data;
|
const savedPersonality = currentPersonalityQuery.data;
|
||||||
const personalityResult = savedPersonality?.personalityType ?? null;
|
const personalityResult = savedPersonality?.personalityType ?? null;
|
||||||
const savedAnswers = savedPersonality?.quizAnswers ?? null;
|
const savedAnswers = savedPersonality?.quizAnswers
|
||||||
|
? Object.entries(savedPersonality.quizAnswers).reduce<Record<number, string>>((result, [key, value]) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
result[Number(key)] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, {})
|
||||||
|
: null;
|
||||||
const savedDate = savedPersonality?.updatedAt ?? null;
|
const savedDate = savedPersonality?.updatedAt ?? null;
|
||||||
|
const selfAssessmentResult = savedSelfAssessment
|
||||||
|
? {
|
||||||
|
score: savedSelfAssessment.score,
|
||||||
|
totalQuestions: savedSelfAssessment.totalQuestions,
|
||||||
|
resultLabel: savedSelfAssessment.resultLabel,
|
||||||
|
updatedAt: savedSelfAssessment.updatedAt,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const distribution = personalityDistributionQuery.data ?? EMPTY_PERSONALITY_DISTRIBUTION;
|
const distribution = personalityDistributionQuery.data ?? EMPTY_PERSONALITY_DISTRIBUTION;
|
||||||
const distributionTotal = totalPersonalityDistribution(distribution);
|
const distributionTotal = totalPersonalityDistribution(distribution);
|
||||||
const groupDistribution = useMemo(
|
const groupDistribution = useMemo(
|
||||||
@ -81,10 +118,13 @@ export function useEmotionalIntelligencePage(userRole: UserRole) {
|
|||||||
const maxScore = assessmentQuestions.length * 4;
|
const maxScore = assessmentQuestions.length * 4;
|
||||||
const percentage = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
|
const percentage = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
|
||||||
const assessmentLevel = getEmotionalIntelligenceLevel(percentage);
|
const assessmentLevel = getEmotionalIntelligenceLevel(percentage);
|
||||||
const personalityError = currentPersonalityQuery.error ?? savePersonalityMutation.error;
|
const personalityError = currentSelfAssessmentQuery.error
|
||||||
|
?? currentPersonalityQuery.error
|
||||||
|
?? savePersonalityMutation.error;
|
||||||
const distributionError = personalityDistributionQuery.error;
|
const distributionError = personalityDistributionQuery.error;
|
||||||
const contentQueries = [
|
const contentQueries = [
|
||||||
assessmentQuestionsQuery,
|
assessmentQuestionsQuery,
|
||||||
|
personalityQuizQuery,
|
||||||
weeklyTopicsQuery,
|
weeklyTopicsQuery,
|
||||||
growthTipsQuery,
|
growthTipsQuery,
|
||||||
teamWellnessMetricsQuery,
|
teamWellnessMetricsQuery,
|
||||||
@ -105,6 +145,30 @@ export function useEmotionalIntelligencePage(userRole: UserRole) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAssessmentComplete(true);
|
setAssessmentComplete(true);
|
||||||
|
if (canPersistPersonalResults) {
|
||||||
|
const nextTotalScore = nextAnswers.reduce((sum, score) => sum + score, 0);
|
||||||
|
const nextMaxScore = assessmentQuestions.length * 4;
|
||||||
|
const nextPercentage = nextMaxScore > 0 ? Math.round((nextTotalScore / nextMaxScore) * 100) : 0;
|
||||||
|
const nextLevel = getEmotionalIntelligenceLevel(nextPercentage);
|
||||||
|
const quizAnswers = nextAnswers.reduce<Record<number, number>>((result, score, index) => {
|
||||||
|
result[index + 1] = score;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
void savePersonalityMutation.mutateAsync({
|
||||||
|
quizKind: PERSONALITY_QUIZ_KINDS.selfAssessment,
|
||||||
|
quizId: 'ei-self-assessment',
|
||||||
|
quizTitle: 'EI Self-Assessment',
|
||||||
|
quizAnswers,
|
||||||
|
score: nextTotalScore,
|
||||||
|
totalQuestions: assessmentQuestions.length,
|
||||||
|
resultLabel: nextLevel.label,
|
||||||
|
resultPayload: {
|
||||||
|
percentage: nextPercentage,
|
||||||
|
maxScore: nextMaxScore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetAssessment = () => {
|
const resetAssessment = () => {
|
||||||
@ -120,8 +184,16 @@ export function useEmotionalIntelligencePage(userRole: UserRole) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await savePersonalityMutation.mutateAsync({
|
await savePersonalityMutation.mutateAsync({
|
||||||
|
quizKind: PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
quizId: personalityQuizQuery.payload?.id ?? 'personality-type',
|
||||||
|
quizTitle: personalityQuizQuery.payload?.title ?? 'Personality Type Quiz',
|
||||||
personalityType: code,
|
personalityType: code,
|
||||||
quizAnswers,
|
quizAnswers,
|
||||||
|
totalQuestions: personalityQuizQuery.payload?.questions.length ?? Object.keys(quizAnswers).length,
|
||||||
|
resultLabel: code,
|
||||||
|
resultPayload: {
|
||||||
|
dimensions: code.split(''),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -136,24 +208,34 @@ export function useEmotionalIntelligencePage(userRole: UserRole) {
|
|||||||
maxScore,
|
maxScore,
|
||||||
percentage,
|
percentage,
|
||||||
assessmentLevel,
|
assessmentLevel,
|
||||||
|
currentSelfAssessmentQuery,
|
||||||
currentPersonalityQuery,
|
currentPersonalityQuery,
|
||||||
|
personalityCompletionQuery,
|
||||||
personalityDistributionQuery,
|
personalityDistributionQuery,
|
||||||
canViewPersonalityDistribution,
|
canViewPersonalityDistribution,
|
||||||
|
canManageQuizContent,
|
||||||
savedPersonality,
|
savedPersonality,
|
||||||
|
savedSelfAssessment,
|
||||||
|
selfAssessmentResult,
|
||||||
personalityResult,
|
personalityResult,
|
||||||
savedAnswers,
|
savedAnswers,
|
||||||
savedDate,
|
savedDate,
|
||||||
canPersistPersonalResults,
|
canPersistPersonalResults,
|
||||||
isSaving: canPersistPersonalResults && savePersonalityMutation.isPending,
|
isSaving: canPersistPersonalResults && savePersonalityMutation.isPending,
|
||||||
isLoadingSaved: canPersistPersonalResults && currentPersonalityQuery.isLoading,
|
isLoadingSaved: canPersistPersonalResults && (
|
||||||
|
currentSelfAssessmentQuery.isLoading ||
|
||||||
|
currentPersonalityQuery.isLoading
|
||||||
|
),
|
||||||
distribution,
|
distribution,
|
||||||
|
personalityCompletion: personalityCompletionQuery.data ?? null,
|
||||||
distributionLoading: personalityDistributionQuery.isLoading || personalityDistributionQuery.isFetching,
|
distributionLoading: personalityDistributionQuery.isLoading || personalityDistributionQuery.isFetching,
|
||||||
distributionTotal,
|
distributionTotal,
|
||||||
groupDistribution,
|
groupDistribution,
|
||||||
errorMessage: getOptionalErrorMessage(personalityError),
|
errorMessage: getOptionalErrorMessage(personalityError),
|
||||||
distributionErrorMessage: getOptionalErrorMessage(distributionError),
|
distributionErrorMessage: getOptionalErrorMessage(distributionError ?? personalityCompletionQuery.error),
|
||||||
isDirector: userRole === 'director',
|
isDirector: canViewPersonalityDistribution,
|
||||||
assessmentQuestions,
|
assessmentQuestions,
|
||||||
|
personalityQuiz: personalityQuizQuery.payload,
|
||||||
weeklyTopics: weeklyTopicsQuery.payload,
|
weeklyTopics: weeklyTopicsQuery.payload,
|
||||||
growthTips: growthTipsQuery.payload,
|
growthTips: growthTipsQuery.payload,
|
||||||
teamWellnessMetrics: teamWellnessMetricsQuery.payload,
|
teamWellnessMetrics: teamWellnessMetricsQuery.payload,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
} from '@/business/personality/mappers';
|
} from '@/business/personality/mappers';
|
||||||
import type { PersonalityQuizSubmission } from '@/business/personality/types';
|
import type { PersonalityQuizSubmission } from '@/business/personality/types';
|
||||||
import type { PersonalityQuizResultDto } from '@/shared/types/personality';
|
import type { PersonalityQuizResultDto } from '@/shared/types/personality';
|
||||||
|
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
||||||
|
|
||||||
describe('personality mappers', () => {
|
describe('personality mappers', () => {
|
||||||
it('maps a null backend result to a null view model', () => {
|
it('maps a null backend result to a null view model', () => {
|
||||||
@ -14,6 +15,9 @@ describe('personality mappers', () => {
|
|||||||
it('maps backend quiz result DTO fields into the frontend view model shape', () => {
|
it('maps backend quiz result DTO fields into the frontend view model shape', () => {
|
||||||
const dto: PersonalityQuizResultDto = {
|
const dto: PersonalityQuizResultDto = {
|
||||||
id: 'personality-1',
|
id: 'personality-1',
|
||||||
|
quiz_kind: PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
quiz_id: 'personality-type',
|
||||||
|
quiz_title: 'Personality Type Quiz',
|
||||||
personality_type: 'INFJ',
|
personality_type: 'INFJ',
|
||||||
quiz_answers: {
|
quiz_answers: {
|
||||||
'1': 'I',
|
'1': 'I',
|
||||||
@ -21,6 +25,13 @@ describe('personality mappers', () => {
|
|||||||
'3': 'F',
|
'3': 'F',
|
||||||
'4': 'J',
|
'4': 'J',
|
||||||
},
|
},
|
||||||
|
score: null,
|
||||||
|
total_questions: 4,
|
||||||
|
result_label: 'INFJ',
|
||||||
|
result_payload: null,
|
||||||
|
week_of: null,
|
||||||
|
user_name: 'Emily Johnson',
|
||||||
|
user_role: 'teacher',
|
||||||
completed_at: '2026-06-08T08:00:00.000Z',
|
completed_at: '2026-06-08T08:00:00.000Z',
|
||||||
organizationId: 'org-1',
|
organizationId: 'org-1',
|
||||||
campusId: 'campus-1',
|
campusId: 'campus-1',
|
||||||
@ -32,6 +43,9 @@ describe('personality mappers', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(toPersonalityQuizResultViewModel(dto)).toEqual({
|
expect(toPersonalityQuizResultViewModel(dto)).toEqual({
|
||||||
|
quizKind: PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
quizId: 'personality-type',
|
||||||
|
quizTitle: 'Personality Type Quiz',
|
||||||
personalityType: 'INFJ',
|
personalityType: 'INFJ',
|
||||||
quizAnswers: {
|
quizAnswers: {
|
||||||
1: 'I',
|
1: 'I',
|
||||||
@ -39,12 +53,21 @@ describe('personality mappers', () => {
|
|||||||
3: 'F',
|
3: 'F',
|
||||||
4: 'J',
|
4: 'J',
|
||||||
},
|
},
|
||||||
|
score: null,
|
||||||
|
totalQuestions: 4,
|
||||||
|
resultLabel: 'INFJ',
|
||||||
|
resultPayload: null,
|
||||||
|
weekOf: null,
|
||||||
|
completedAt: '2026-06-08T08:00:00.000Z',
|
||||||
updatedAt: '2026-06-08T09:00:00.000Z',
|
updatedAt: '2026-06-08T09:00:00.000Z',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps quiz submission state back into the backend mutation DTO shape', () => {
|
it('maps quiz submission state back into the backend mutation DTO shape', () => {
|
||||||
const submission: PersonalityQuizSubmission = {
|
const submission: PersonalityQuizSubmission = {
|
||||||
|
quizKind: PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
quizId: 'personality-type',
|
||||||
|
quizTitle: 'Personality Type Quiz',
|
||||||
personalityType: 'ESTP',
|
personalityType: 'ESTP',
|
||||||
quizAnswers: {
|
quizAnswers: {
|
||||||
1: 'E',
|
1: 'E',
|
||||||
@ -52,9 +75,13 @@ describe('personality mappers', () => {
|
|||||||
3: 'T',
|
3: 'T',
|
||||||
4: 'P',
|
4: 'P',
|
||||||
},
|
},
|
||||||
|
totalQuestions: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toPersonalityQuizResultMutationDto(submission)).toEqual({
|
expect(toPersonalityQuizResultMutationDto(submission)).toEqual({
|
||||||
|
quiz_kind: PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
quiz_id: 'personality-type',
|
||||||
|
quiz_title: 'Personality Type Quiz',
|
||||||
personality_type: 'ESTP',
|
personality_type: 'ESTP',
|
||||||
quiz_answers: {
|
quiz_answers: {
|
||||||
'1': 'E',
|
'1': 'E',
|
||||||
@ -62,6 +89,10 @@ describe('personality mappers', () => {
|
|||||||
'3': 'T',
|
'3': 'T',
|
||||||
'4': 'P',
|
'4': 'P',
|
||||||
},
|
},
|
||||||
|
score: null,
|
||||||
|
total_questions: 4,
|
||||||
|
result_label: 'ESTP',
|
||||||
|
result_payload: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
PersonalityAnswerValue,
|
||||||
PersonalityQuizResultDto,
|
PersonalityQuizResultDto,
|
||||||
PersonalityQuizResultMutationDto,
|
PersonalityQuizResultMutationDto,
|
||||||
} from '@/shared/types/personality';
|
} from '@/shared/types/personality';
|
||||||
@ -7,15 +8,15 @@ import type {
|
|||||||
PersonalityQuizSubmission,
|
PersonalityQuizSubmission,
|
||||||
} from '@/business/personality/types';
|
} from '@/business/personality/types';
|
||||||
|
|
||||||
function toNumericAnswerMap(answers: Record<string, string>): Record<number, string> {
|
function toNumericAnswerMap(answers: Record<string, PersonalityAnswerValue>): Record<number, PersonalityAnswerValue> {
|
||||||
return Object.entries(answers).reduce<Record<number, string>>((result, [key, value]) => {
|
return Object.entries(answers).reduce<Record<number, PersonalityAnswerValue>>((result, [key, value]) => {
|
||||||
result[Number(key)] = value;
|
result[Number(key)] = value;
|
||||||
return result;
|
return result;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStringAnswerMap(answers: Record<number, string>): Record<string, string> {
|
function toStringAnswerMap(answers: Record<number, PersonalityAnswerValue>): Record<string, PersonalityAnswerValue> {
|
||||||
return Object.entries(answers).reduce<Record<string, string>>((result, [key, value]) => {
|
return Object.entries(answers).reduce<Record<string, PersonalityAnswerValue>>((result, [key, value]) => {
|
||||||
result[key] = value;
|
result[key] = value;
|
||||||
return result;
|
return result;
|
||||||
}, {});
|
}, {});
|
||||||
@ -29,8 +30,17 @@ export function toPersonalityQuizResultViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
quizKind: dto.quiz_kind,
|
||||||
|
quizId: dto.quiz_id,
|
||||||
|
quizTitle: dto.quiz_title,
|
||||||
|
weekOf: dto.week_of,
|
||||||
personalityType: dto.personality_type,
|
personalityType: dto.personality_type,
|
||||||
quizAnswers: toNumericAnswerMap(dto.quiz_answers),
|
quizAnswers: toNumericAnswerMap(dto.quiz_answers),
|
||||||
|
score: dto.score,
|
||||||
|
totalQuestions: dto.total_questions,
|
||||||
|
resultLabel: dto.result_label,
|
||||||
|
resultPayload: dto.result_payload,
|
||||||
|
completedAt: dto.completed_at,
|
||||||
updatedAt: dto.updatedAt,
|
updatedAt: dto.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -39,7 +49,14 @@ export function toPersonalityQuizResultMutationDto(
|
|||||||
submission: PersonalityQuizSubmission,
|
submission: PersonalityQuizSubmission,
|
||||||
): PersonalityQuizResultMutationDto {
|
): PersonalityQuizResultMutationDto {
|
||||||
return {
|
return {
|
||||||
personality_type: submission.personalityType,
|
quiz_kind: submission.quizKind,
|
||||||
|
quiz_id: submission.quizId,
|
||||||
|
quiz_title: submission.quizTitle,
|
||||||
|
personality_type: submission.personalityType ?? null,
|
||||||
quiz_answers: toStringAnswerMap(submission.quizAnswers),
|
quiz_answers: toStringAnswerMap(submission.quizAnswers),
|
||||||
|
score: submission.score ?? null,
|
||||||
|
total_questions: submission.totalQuestions,
|
||||||
|
result_label: submission.resultLabel ?? submission.personalityType ?? null,
|
||||||
|
result_payload: submission.resultPayload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
getCurrentPersonalityResult,
|
getCurrentPersonalityResult,
|
||||||
|
getPersonalityCompletion,
|
||||||
|
listCurrentPersonalityResultHistory,
|
||||||
listPersonalityDistribution,
|
listPersonalityDistribution,
|
||||||
saveCurrentPersonalityResult,
|
saveCurrentPersonalityResult,
|
||||||
} from '@/shared/api/personality';
|
} from '@/shared/api/personality';
|
||||||
@ -9,30 +11,54 @@ import {
|
|||||||
toPersonalityQuizResultViewModel,
|
toPersonalityQuizResultViewModel,
|
||||||
} from '@/business/personality/mappers';
|
} from '@/business/personality/mappers';
|
||||||
import type { PersonalityQuizSubmission } from '@/business/personality/types';
|
import type { PersonalityQuizSubmission } from '@/business/personality/types';
|
||||||
import { PERSONALITY_QUERY_KEYS } from '@/shared/constants/personality';
|
import {
|
||||||
|
PERSONALITY_QUERY_KEYS,
|
||||||
|
type PersonalityQuizKind,
|
||||||
|
} from '@/shared/constants/personality';
|
||||||
|
import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
|
||||||
import { getApiListRows } from '@/shared/business/apiListRows';
|
import { getApiListRows } from '@/shared/business/apiListRows';
|
||||||
import { useInvalidatingMutation } from '@/shared/business/queryMutations';
|
import { useInvalidatingMutation } from '@/shared/business/queryMutations';
|
||||||
|
|
||||||
export function useCurrentPersonalityResult(enabled = true) {
|
export function useCurrentPersonalityResult(quizKind: PersonalityQuizKind, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: PERSONALITY_QUERY_KEYS.current,
|
queryKey: PERSONALITY_QUERY_KEYS.current(quizKind),
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await getCurrentPersonalityResult();
|
const response = await getCurrentPersonalityResult(quizKind);
|
||||||
return toPersonalityQuizResultViewModel(response);
|
return toPersonalityQuizResultViewModel(response);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCurrentPersonalityResultHistory(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PERSONALITY_QUERY_KEYS.history,
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await listCurrentPersonalityResultHistory();
|
||||||
|
return response.rows
|
||||||
|
.map(toPersonalityQuizResultViewModel)
|
||||||
|
.filter((result): result is PersonalityQuizResultViewModel => result !== null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSaveCurrentPersonalityResult() {
|
export function useSaveCurrentPersonalityResult() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useInvalidatingMutation({
|
return useInvalidatingMutation({
|
||||||
mutationFn: (submission: PersonalityQuizSubmission) => saveCurrentPersonalityResult(
|
mutationFn: (submission: PersonalityQuizSubmission) => saveCurrentPersonalityResult(
|
||||||
toPersonalityQuizResultMutationDto(submission),
|
toPersonalityQuizResultMutationDto(submission),
|
||||||
),
|
),
|
||||||
invalidateQueryKeys: [
|
invalidateQueryKey: PERSONALITY_QUERY_KEYS.distribution,
|
||||||
PERSONALITY_QUERY_KEYS.current,
|
onSuccess: async (_data, submission) => {
|
||||||
PERSONALITY_QUERY_KEYS.distribution,
|
await Promise.all([
|
||||||
],
|
queryClient.invalidateQueries({ queryKey: PERSONALITY_QUERY_KEYS.current(submission.quizKind) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: PERSONALITY_QUERY_KEYS.history }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: PERSONALITY_QUERY_KEYS.distribution }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: PERSONALITY_QUERY_KEYS.completion }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,3 +69,11 @@ export function usePersonalityDistribution(campusId?: string, enabled = true) {
|
|||||||
queryFn: () => getApiListRows(listPersonalityDistribution(campusId)),
|
queryFn: () => getApiListRows(listPersonalityDistribution(campusId)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePersonalityCompletion(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PERSONALITY_QUERY_KEYS.completion,
|
||||||
|
enabled,
|
||||||
|
queryFn: getPersonalityCompletion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export function usePersonalityQuizWorkflow({
|
|||||||
onResult,
|
onResult,
|
||||||
savedType,
|
savedType,
|
||||||
savedAnswers,
|
savedAnswers,
|
||||||
|
questions: inputQuestions,
|
||||||
}: PersonalityQuizWorkflowInput) {
|
}: PersonalityQuizWorkflowInput) {
|
||||||
const [started, setStarted] = useState(false);
|
const [started, setStarted] = useState(false);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
@ -36,7 +37,7 @@ export function usePersonalityQuizWorkflow({
|
|||||||
const [activeResultTab, setActiveResultTab] = useState<PersonalityQuizResultTab>('overview');
|
const [activeResultTab, setActiveResultTab] = useState<PersonalityQuizResultTab>('overview');
|
||||||
const [showSavedResult, setShowSavedResult] = useState(false);
|
const [showSavedResult, setShowSavedResult] = useState(false);
|
||||||
|
|
||||||
const questions = PERSONALITY_QUIZ_QUESTIONS;
|
const questions = inputQuestions ?? PERSONALITY_QUIZ_QUESTIONS;
|
||||||
const personalityTypes = PERSONALITY_TYPES;
|
const personalityTypes = PERSONALITY_TYPES;
|
||||||
const totalQuestions = questions.length;
|
const totalQuestions = questions.length;
|
||||||
const currentQuestion = questions[currentQuestionIndex];
|
const currentQuestion = questions[currentQuestionIndex];
|
||||||
|
|||||||
@ -1,14 +1,34 @@
|
|||||||
import type { PersonalityDirectoryFilterGroup as PersonalityDirectoryFilterGroupValue } from '@/shared/constants/personality';
|
import type {
|
||||||
|
PersonalityDirectoryFilterGroup as PersonalityDirectoryFilterGroupValue,
|
||||||
|
PersonalityQuizKind,
|
||||||
|
} from '@/shared/constants/personality';
|
||||||
|
import type { PersonalityAnswerValue } from '@/shared/types/personality';
|
||||||
|
|
||||||
export interface PersonalityQuizResultViewModel {
|
export interface PersonalityQuizResultViewModel {
|
||||||
readonly personalityType: string;
|
readonly quizKind: PersonalityQuizKind;
|
||||||
readonly quizAnswers: Record<number, string>;
|
readonly quizId: string;
|
||||||
|
readonly quizTitle: string;
|
||||||
|
readonly weekOf: string | null;
|
||||||
|
readonly personalityType: string | null;
|
||||||
|
readonly quizAnswers: Record<number, PersonalityAnswerValue>;
|
||||||
|
readonly score: number | null;
|
||||||
|
readonly totalQuestions: number;
|
||||||
|
readonly resultLabel: string | null;
|
||||||
|
readonly resultPayload: unknown | null;
|
||||||
|
readonly completedAt: string;
|
||||||
readonly updatedAt: string;
|
readonly updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonalityQuizSubmission {
|
export interface PersonalityQuizSubmission {
|
||||||
readonly personalityType: string;
|
readonly quizKind: PersonalityQuizKind;
|
||||||
readonly quizAnswers: Record<number, string>;
|
readonly quizId: string;
|
||||||
|
readonly quizTitle: string;
|
||||||
|
readonly personalityType?: string | null;
|
||||||
|
readonly quizAnswers: Record<number, PersonalityAnswerValue>;
|
||||||
|
readonly score?: number | null;
|
||||||
|
readonly totalQuestions: number;
|
||||||
|
readonly resultLabel?: string | null;
|
||||||
|
readonly resultPayload?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersonalityQuizResultTab = 'overview' | 'relationships' | 'language';
|
export type PersonalityQuizResultTab = 'overview' | 'relationships' | 'language';
|
||||||
@ -43,6 +63,7 @@ export interface PersonalityQuizWorkflowInput {
|
|||||||
readonly onResult?: (code: string, answers: Record<number, string>) => void | Promise<void>;
|
readonly onResult?: (code: string, answers: Record<number, string>) => void | Promise<void>;
|
||||||
readonly savedType?: string | null;
|
readonly savedType?: string | null;
|
||||||
readonly savedAnswers?: Record<number, string> | null;
|
readonly savedAnswers?: Record<number, string> | null;
|
||||||
|
readonly questions?: readonly import('@/shared/constants/personalityCatalog').QuizQuestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersonalityDirectoryFilterGroup = PersonalityDirectoryFilterGroupValue;
|
export type PersonalityDirectoryFilterGroup = PersonalityDirectoryFilterGroupValue;
|
||||||
|
|||||||
83
frontend/src/business/profile/selectors.test.ts
Normal file
83
frontend/src/business/profile/selectors.test.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildProfileQuizResultRows } from '@/business/profile/selectors';
|
||||||
|
import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
|
||||||
|
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
||||||
|
|
||||||
|
function createSafetyResult(): SafetyQuizResultDto {
|
||||||
|
return {
|
||||||
|
id: 'safety-1',
|
||||||
|
quiz_id: 'qbs-weekly',
|
||||||
|
quiz_title: 'Behavior Management Review',
|
||||||
|
week_of: '2026-06-14',
|
||||||
|
score: 3,
|
||||||
|
total_questions: 5,
|
||||||
|
answers: [0, 1, 2],
|
||||||
|
user_name: 'Emily Johnson',
|
||||||
|
user_role: 'teacher',
|
||||||
|
completed_at: '2026-06-18T10:00:00.000Z',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersonalityResult(
|
||||||
|
overrides: Partial<PersonalityQuizResultViewModel> = {},
|
||||||
|
): PersonalityQuizResultViewModel {
|
||||||
|
return {
|
||||||
|
quizKind: 'personality_type',
|
||||||
|
quizId: 'personality-type',
|
||||||
|
quizTitle: 'Personality Type Quiz',
|
||||||
|
weekOf: null,
|
||||||
|
personalityType: 'ENFP',
|
||||||
|
quizAnswers: {},
|
||||||
|
score: null,
|
||||||
|
totalQuestions: 0,
|
||||||
|
resultLabel: 'ENFP',
|
||||||
|
resultPayload: null,
|
||||||
|
completedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('profile selectors', () => {
|
||||||
|
it('combines safety, EI assessment, and personality quiz results', () => {
|
||||||
|
const rows = buildProfileQuizResultRows(createSafetyResult(), [
|
||||||
|
createPersonalityResult({
|
||||||
|
quizKind: 'ei_self_assessment',
|
||||||
|
quizId: 'ei-self-assessment',
|
||||||
|
quizTitle: 'EI Self-Assessment',
|
||||||
|
personalityType: null,
|
||||||
|
score: 14,
|
||||||
|
totalQuestions: 8,
|
||||||
|
resultLabel: 'Developing Awareness',
|
||||||
|
}),
|
||||||
|
createPersonalityResult(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.quiz)).toEqual([
|
||||||
|
'Behavior Management Review',
|
||||||
|
'EI Self-Assessment',
|
||||||
|
'Personality Type Quiz',
|
||||||
|
]);
|
||||||
|
expect(rows.map((row) => row.result)).toEqual([
|
||||||
|
'3/5',
|
||||||
|
'Developing Awareness (14/32)',
|
||||||
|
'ENFP',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds pending rows for missing quiz results', () => {
|
||||||
|
const rows = buildProfileQuizResultRows(null, []);
|
||||||
|
|
||||||
|
expect(rows).toMatchObject([
|
||||||
|
{ quiz: 'Behavior Management', result: 'Pending', status: 'pending' },
|
||||||
|
{ quiz: 'EI Self-Assessment', result: 'Pending', status: 'pending' },
|
||||||
|
{ quiz: 'Personality Type Quiz', result: 'Pending', status: 'pending' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
frontend/src/business/profile/selectors.ts
Normal file
108
frontend/src/business/profile/selectors.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
||||||
|
import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
|
||||||
|
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
||||||
|
|
||||||
|
export interface ProfileQuizResultRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly quiz: string;
|
||||||
|
readonly category: string;
|
||||||
|
readonly result: string;
|
||||||
|
readonly completed: string;
|
||||||
|
readonly status: 'complete' | 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProfileQuizResultRows(
|
||||||
|
safetyQuizResult: SafetyQuizResultDto | null,
|
||||||
|
personalityResults: readonly PersonalityQuizResultViewModel[],
|
||||||
|
): readonly ProfileQuizResultRow[] {
|
||||||
|
const rows: ProfileQuizResultRow[] = [
|
||||||
|
toProfileSafetyQuizRow(safetyQuizResult),
|
||||||
|
...personalityResults.map(toProfilePersonalityQuizRow),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!hasPersonalityResult(personalityResults, PERSONALITY_QUIZ_KINDS.selfAssessment)) {
|
||||||
|
rows.push(toProfilePendingPersonalityQuizRow(PERSONALITY_QUIZ_KINDS.selfAssessment));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPersonalityResult(personalityResults, PERSONALITY_QUIZ_KINDS.personalityType)) {
|
||||||
|
rows.push(toProfilePendingPersonalityQuizRow(PERSONALITY_QUIZ_KINDS.personalityType));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPersonalityResult(
|
||||||
|
results: readonly PersonalityQuizResultViewModel[],
|
||||||
|
quizKind: PersonalityQuizResultViewModel['quizKind'],
|
||||||
|
): boolean {
|
||||||
|
return results.some((result) => result.quizKind === quizKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonalityResultSummary(result: PersonalityQuizResultViewModel): string {
|
||||||
|
if (result.quizKind === PERSONALITY_QUIZ_KINDS.personalityType) {
|
||||||
|
return result.personalityType ?? result.resultLabel ?? 'Completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = result.resultLabel ?? 'Completed';
|
||||||
|
if (result.score === null) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label} (${result.score}/${result.totalQuestions * 4})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultCategoryLabel(result: PersonalityQuizResultViewModel): string {
|
||||||
|
return result.quizKind === PERSONALITY_QUIZ_KINDS.personalityType
|
||||||
|
? 'Personality type'
|
||||||
|
: 'EI self-assessment';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfileSafetyQuizRow(result: SafetyQuizResultDto | null): ProfileQuizResultRow {
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
id: 'behavior-management-pending',
|
||||||
|
quiz: 'Behavior Management',
|
||||||
|
category: 'QBS safety quiz',
|
||||||
|
result: 'Pending',
|
||||||
|
completed: 'Not completed',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${result.id}-behavior-management`,
|
||||||
|
quiz: result.quiz_title,
|
||||||
|
category: 'QBS safety quiz',
|
||||||
|
result: `${result.score}/${result.total_questions}`,
|
||||||
|
completed: new Date(result.completed_at).toLocaleDateString(),
|
||||||
|
status: 'complete',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfilePersonalityQuizRow(result: PersonalityQuizResultViewModel): ProfileQuizResultRow {
|
||||||
|
return {
|
||||||
|
id: `${result.quizKind}-${result.quizId}-${result.completedAt}`,
|
||||||
|
quiz: result.quizTitle,
|
||||||
|
category: getResultCategoryLabel(result),
|
||||||
|
result: getPersonalityResultSummary(result),
|
||||||
|
completed: new Date(result.completedAt).toLocaleDateString(),
|
||||||
|
status: 'complete',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfilePendingPersonalityQuizRow(
|
||||||
|
quizKind: typeof PERSONALITY_QUIZ_KINDS.selfAssessment | typeof PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
): ProfileQuizResultRow {
|
||||||
|
return {
|
||||||
|
id: `${quizKind}-pending`,
|
||||||
|
quiz: quizKind === PERSONALITY_QUIZ_KINDS.personalityType
|
||||||
|
? 'Personality Type Quiz'
|
||||||
|
: 'EI Self-Assessment',
|
||||||
|
category: quizKind === PERSONALITY_QUIZ_KINDS.personalityType
|
||||||
|
? 'Personality type'
|
||||||
|
: 'EI self-assessment',
|
||||||
|
result: 'Pending',
|
||||||
|
completed: 'Not completed',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -18,12 +18,14 @@ import {
|
|||||||
import { getScopedModules } from '@/business/app-shell/selectors';
|
import { getScopedModules } from '@/business/app-shell/selectors';
|
||||||
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
|
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
|
||||||
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
||||||
|
import { useCurrentPersonalityResult } from '@/business/personality/queryHooks';
|
||||||
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||||
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
||||||
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
||||||
import { hasPermission } from '@/business/auth/permissions';
|
import { hasPermission } from '@/business/auth/permissions';
|
||||||
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
|
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
|
||||||
import { MODULES } from '@/shared/constants/appData';
|
import { MODULES } from '@/shared/constants/appData';
|
||||||
|
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
||||||
import type {
|
import type {
|
||||||
ModuleId,
|
ModuleId,
|
||||||
SignItem,
|
SignItem,
|
||||||
@ -88,6 +90,23 @@ export function useTopBarPage({
|
|||||||
const needsSafetyQuiz = canReceiveSafetyQuizNotification
|
const needsSafetyQuiz = canReceiveSafetyQuizNotification
|
||||||
&& !safetyQuizStatus.isLoading
|
&& !safetyQuizStatus.isLoading
|
||||||
&& safetyQuizStatus.data?.completed !== true;
|
&& safetyQuizStatus.data?.completed !== true;
|
||||||
|
const canReceiveEmotionalIntelligenceNotifications = canPersistPersonalResults
|
||||||
|
&& hasPermission(user, 'TAKE_QUIZ')
|
||||||
|
&& accessibleModuleIds.has('ei');
|
||||||
|
const selfAssessmentStatus = useCurrentPersonalityResult(
|
||||||
|
PERSONALITY_QUIZ_KINDS.selfAssessment,
|
||||||
|
canReceiveEmotionalIntelligenceNotifications,
|
||||||
|
);
|
||||||
|
const personalityQuizStatus = useCurrentPersonalityResult(
|
||||||
|
PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
canReceiveEmotionalIntelligenceNotifications,
|
||||||
|
);
|
||||||
|
const needsEiSelfAssessment = canReceiveEmotionalIntelligenceNotifications
|
||||||
|
&& !selfAssessmentStatus.isLoading
|
||||||
|
&& !selfAssessmentStatus.data;
|
||||||
|
const needsPersonalityQuiz = canReceiveEmotionalIntelligenceNotifications
|
||||||
|
&& !personalityQuizStatus.isLoading
|
||||||
|
&& !personalityQuizStatus.data;
|
||||||
const communicationEvents = useCommunicationEvents();
|
const communicationEvents = useCommunicationEvents();
|
||||||
const acknowledgedCommunicationEventIds = useMemo(() => new Set<string>(), []);
|
const acknowledgedCommunicationEventIds = useMemo(() => new Set<string>(), []);
|
||||||
const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY');
|
const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY');
|
||||||
@ -101,6 +120,8 @@ export function useTopBarPage({
|
|||||||
const notifications = buildTopBarNotifications({
|
const notifications = buildTopBarNotifications({
|
||||||
needsZoneCheckIn,
|
needsZoneCheckIn,
|
||||||
needsSafetyQuiz,
|
needsSafetyQuiz,
|
||||||
|
needsEiSelfAssessment,
|
||||||
|
needsPersonalityQuiz,
|
||||||
communicationEvents: communicationEvents.data ?? [],
|
communicationEvents: communicationEvents.data ?? [],
|
||||||
acknowledgedCommunicationEventIds,
|
acknowledgedCommunicationEventIds,
|
||||||
handbookPolicies: handbookPolicies.data ?? [],
|
handbookPolicies: handbookPolicies.data ?? [],
|
||||||
|
|||||||
@ -79,6 +79,31 @@ describe('top bar selectors', () => {
|
|||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces EI self-assessment and personality quiz completion reminders', () => {
|
||||||
|
const reminders = buildTopBarNotifications({
|
||||||
|
needsZoneCheckIn: false,
|
||||||
|
needsEiSelfAssessment: true,
|
||||||
|
needsPersonalityQuiz: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reminders).toEqual([
|
||||||
|
{
|
||||||
|
id: 'ei-self-assessment',
|
||||||
|
text: "You haven't completed your EI self-assessment",
|
||||||
|
time: 'This week',
|
||||||
|
unread: true,
|
||||||
|
href: APP_ROUTE_PATHS.ei,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ei-personality-quiz',
|
||||||
|
text: "You haven't completed your personality type quiz",
|
||||||
|
time: 'Once',
|
||||||
|
unread: true,
|
||||||
|
href: APP_ROUTE_PATHS.ei,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('builds initials from display names', () => {
|
it('builds initials from display names', () => {
|
||||||
expect(getTopBarInitials('Guest')).toBe('G');
|
expect(getTopBarInitials('Guest')).toBe('G');
|
||||||
expect(getTopBarInitials('Ada Lovelace')).toBe('AL');
|
expect(getTopBarInitials('Ada Lovelace')).toBe('AL');
|
||||||
|
|||||||
@ -39,15 +39,19 @@ export function countUnreadTopBarNotifications(
|
|||||||
|
|
||||||
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
|
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
|
||||||
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly';
|
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly';
|
||||||
|
const EI_SELF_ASSESSMENT_NOTIFICATION_ID = 'ei-self-assessment';
|
||||||
|
const EI_PERSONALITY_QUIZ_NOTIFICATION_ID = 'ei-personality-quiz';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the top-bar notification list from derived app state (there is no
|
* Builds the top-bar notification list from derived app state. Personal
|
||||||
* backend notifications store yet). Currently surfaces a single nudge when an
|
* completion reminders are driven by backend-backed status queries; there is
|
||||||
* eligible user has not logged today's Zone.
|
* no persisted notifications store yet.
|
||||||
*/
|
*/
|
||||||
export function buildTopBarNotifications(input: {
|
export function buildTopBarNotifications(input: {
|
||||||
readonly needsZoneCheckIn: boolean;
|
readonly needsZoneCheckIn: boolean;
|
||||||
readonly needsSafetyQuiz?: boolean;
|
readonly needsSafetyQuiz?: boolean;
|
||||||
|
readonly needsEiSelfAssessment?: boolean;
|
||||||
|
readonly needsPersonalityQuiz?: boolean;
|
||||||
readonly communicationEvents?: readonly CommunicationEventDto[];
|
readonly communicationEvents?: readonly CommunicationEventDto[];
|
||||||
readonly acknowledgedCommunicationEventIds?: ReadonlySet<string>;
|
readonly acknowledgedCommunicationEventIds?: ReadonlySet<string>;
|
||||||
readonly handbookPolicies?: readonly PolicyViewModel[];
|
readonly handbookPolicies?: readonly PolicyViewModel[];
|
||||||
@ -76,6 +80,26 @@ export function buildTopBarNotifications(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.needsEiSelfAssessment) {
|
||||||
|
notifications.push({
|
||||||
|
id: EI_SELF_ASSESSMENT_NOTIFICATION_ID,
|
||||||
|
text: "You haven't completed your EI self-assessment",
|
||||||
|
time: 'This week',
|
||||||
|
unread: true,
|
||||||
|
href: APP_ROUTE_PATHS.ei,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.needsPersonalityQuiz) {
|
||||||
|
notifications.push({
|
||||||
|
id: EI_PERSONALITY_QUIZ_NOTIFICATION_ID,
|
||||||
|
text: "You haven't completed your personality type quiz",
|
||||||
|
time: 'Once',
|
||||||
|
unread: true,
|
||||||
|
href: APP_ROUTE_PATHS.ei,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const event of input.communicationEvents ?? []) {
|
for (const event of input.communicationEvents ?? []) {
|
||||||
if (input.acknowledgedCommunicationEventIds?.has(event.id)) {
|
if (input.acknowledgedCommunicationEventIds?.has(event.id)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types';
|
import type { DirectorQuizResultRow } from '@/business/director-dashboard/types';
|
||||||
|
|
||||||
interface DirectorQuizResultsPanelProps {
|
interface DirectorQuizResultsPanelProps {
|
||||||
readonly results: readonly SafetyQuizComplianceRow[];
|
readonly results: readonly DirectorQuizResultRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) {
|
export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) {
|
||||||
@ -27,15 +27,17 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||||
<TableHead className="h-auto p-3 text-left text-gray-500">Staff</TableHead>
|
<TableHead className="h-auto p-3 text-left text-gray-500">Staff</TableHead>
|
||||||
|
<TableHead className="h-auto p-3 text-left text-gray-500">Quiz</TableHead>
|
||||||
<TableHead className="h-auto p-3 text-center text-gray-500">Role</TableHead>
|
<TableHead className="h-auto p-3 text-center text-gray-500">Role</TableHead>
|
||||||
<TableHead className="h-auto p-3 text-center text-gray-500">Score</TableHead>
|
<TableHead className="h-auto p-3 text-center text-gray-500">Result</TableHead>
|
||||||
<TableHead className="h-auto p-3 text-center text-gray-500">Date</TableHead>
|
<TableHead className="h-auto p-3 text-center text-gray-500">Date</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{results.map((result) => (
|
{results.map((result) => (
|
||||||
<TableRow key={result.userId} className="border-t border-gray-50">
|
<TableRow key={result.id} className="border-t border-gray-50">
|
||||||
<TableCell className="p-3 font-medium text-gray-700">{result.name}</TableCell>
|
<TableCell className="p-3 font-medium text-gray-700">{result.staffName}</TableCell>
|
||||||
|
<TableCell className="p-3 text-sm text-gray-600">{result.quiz}</TableCell>
|
||||||
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.role}</TableCell>
|
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.role}</TableCell>
|
||||||
<TableCell className="p-3 text-center">
|
<TableCell className="p-3 text-center">
|
||||||
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${
|
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${
|
||||||
@ -43,7 +45,7 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr
|
|||||||
? 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
: 'bg-amber-100 text-amber-700'
|
: 'bg-amber-100 text-amber-700'
|
||||||
}`}>
|
}`}>
|
||||||
{result.score}
|
{result.result}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 text-center text-xs text-gray-400">
|
<TableCell className="p-3 text-center text-xs text-gray-400">
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { StatePanel } from '@/components/ui/state-panel';
|
import { StatePanel } from '@/components/ui/state-panel';
|
||||||
@ -19,6 +20,7 @@ import type {
|
|||||||
EmotionalIntelligencePageActions,
|
EmotionalIntelligencePageActions,
|
||||||
EmotionalIntelligencePageState,
|
EmotionalIntelligencePageState,
|
||||||
} from '@/components/emotional-intelligence/types';
|
} from '@/components/emotional-intelligence/types';
|
||||||
|
import type { PersonalityCompletionRowDto } from '@/shared/types/personality';
|
||||||
import type { EmotionalIntelligenceTopicIconId } from '@/shared/types/emotionalIntelligence';
|
import type { EmotionalIntelligenceTopicIconId } from '@/shared/types/emotionalIntelligence';
|
||||||
import { getPersonalityType } from '@/shared/constants/personalityCatalog';
|
import { getPersonalityType } from '@/shared/constants/personalityCatalog';
|
||||||
|
|
||||||
@ -246,16 +248,47 @@ function AssessmentSidebar({ state, actions }: AssessmentTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.isDirector && <TeamWellnessCard metrics={state.teamWellnessMetrics} />}
|
{state.canViewPersonalityDistribution && <TeamWellnessCard state={state} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TeamWellnessCard({ metrics }: { readonly metrics: readonly EmotionalIntelligencePageState['teamWellnessMetrics'][number][] }) {
|
function TeamWellnessCard({ state }: { readonly state: EmotionalIntelligencePageState }) {
|
||||||
|
const summary = state.personalityCompletion?.summary;
|
||||||
|
const metrics = summary
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Both quizzes complete',
|
||||||
|
value: `${summary.completionRate}%`,
|
||||||
|
color: 'text-emerald-400',
|
||||||
|
bar: 'bg-emerald-500',
|
||||||
|
width: `${summary.completionRate}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'EI self-assessment',
|
||||||
|
value: `${summary.selfAssessmentCompletedCount}/${summary.totalStaff}`,
|
||||||
|
color: 'text-pink-400',
|
||||||
|
bar: 'bg-pink-500',
|
||||||
|
width: summary.totalStaff > 0
|
||||||
|
? `${Math.round((summary.selfAssessmentCompletedCount / summary.totalStaff) * 100)}%`
|
||||||
|
: '0%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Personality quiz',
|
||||||
|
value: `${summary.personalityCompletedCount}/${summary.totalStaff}`,
|
||||||
|
color: 'text-violet-400',
|
||||||
|
bar: 'bg-violet-500',
|
||||||
|
width: summary.totalStaff > 0
|
||||||
|
? `${Math.round((summary.personalityCompletedCount / summary.totalStaff) * 100)}%`
|
||||||
|
: '0%',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: state.teamWellnessMetrics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
<h3 className="font-semibold text-white mb-3 flex items-center gap-2"><Users size={16} className="text-violet-400" /> Team Wellness (Aggregated)</h3>
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2"><Users size={16} className="text-violet-400" /> Team Wellness (Aggregated)</h3>
|
||||||
<p className="text-[10px] text-slate-500 mb-3">No individual emotional data shown</p>
|
<p className="text-[10px] text-slate-500 mb-3">Aggregated completion only. Individual emotional answers stay private.</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{metrics.map((item) => (
|
{metrics.map((item) => (
|
||||||
<div key={item.label}>
|
<div key={item.label}>
|
||||||
@ -270,6 +303,103 @@ function TeamWellnessCard({ metrics }: { readonly metrics: readonly EmotionalInt
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{state.personalityCompletion && (
|
||||||
|
<QuizResultsByCategory rows={state.personalityCompletion.rows} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuizResultsByCategory({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
readonly rows: readonly PersonalityCompletionRowDto[];
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 border-t border-slate-700/50 pt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-white">Quiz Results</h4>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Grouped by quiz category. Emotional answers are not shown.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<QuizResultCategory
|
||||||
|
title="Weekly EI Self-Assessment"
|
||||||
|
tone="pink"
|
||||||
|
rows={rows}
|
||||||
|
renderResult={(row) => {
|
||||||
|
if (!row.selfAssessment) {
|
||||||
|
return <span className="text-amber-300">Pending</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-pink-300">
|
||||||
|
{row.selfAssessment.result_label ?? 'Completed'}
|
||||||
|
{typeof row.selfAssessment.score === 'number'
|
||||||
|
? ` (${row.selfAssessment.score}/${row.selfAssessment.total_questions})`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<QuizResultCategory
|
||||||
|
title="Personality Type"
|
||||||
|
tone="violet"
|
||||||
|
rows={rows}
|
||||||
|
renderResult={(row) => {
|
||||||
|
if (!row.personality?.personality_type) {
|
||||||
|
return <span className="text-amber-300">Pending</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-violet-300">
|
||||||
|
{row.personality.personality_type}
|
||||||
|
{row.personality.result_label && row.personality.result_label !== row.personality.personality_type
|
||||||
|
? ` · ${row.personality.result_label}`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuizResultCategory({
|
||||||
|
title,
|
||||||
|
tone,
|
||||||
|
rows,
|
||||||
|
renderResult,
|
||||||
|
}: {
|
||||||
|
readonly title: string;
|
||||||
|
readonly tone: 'pink' | 'violet';
|
||||||
|
readonly rows: readonly PersonalityCompletionRowDto[];
|
||||||
|
readonly renderResult: (row: PersonalityCompletionRowDto) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const toneClass = tone === 'pink' ? 'text-pink-300' : 'text-violet-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-700/40 bg-slate-900/30 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-slate-700/40">
|
||||||
|
<h5 className={`text-xs font-semibold ${toneClass}`}>{title}</h5>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-700/30">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={`${title}-${row.userId}`} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-white truncate">{row.name}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 truncate">{row.role ?? 'Staff'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-semibold text-right shrink-0">
|
||||||
|
{renderResult(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,523 @@
|
|||||||
|
import { ChevronDown, Plus, Settings, Trash2 } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { NativeSelect } from '@/components/ui/native-select';
|
||||||
|
import { StatePanel } from '@/components/ui/state-panel';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
useDeleteManagedContentCatalog,
|
||||||
|
useManagedContentCatalog,
|
||||||
|
useSaveManagedContentCatalog,
|
||||||
|
} from '@/business/content-catalog/hooks';
|
||||||
|
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
|
||||||
|
import { PERSONALITY_QUIZ_QUESTIONS } from '@/shared/constants/personalityStaticContent';
|
||||||
|
import type { QuizQuestion } from '@/shared/constants/personalityCatalog';
|
||||||
|
import type {
|
||||||
|
EmotionalIntelligencePersonalityQuizContent,
|
||||||
|
EmotionalIntelligenceQuestion,
|
||||||
|
} from '@/shared/types/emotionalIntelligence';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
readonly assessmentQuestions: readonly EmotionalIntelligenceQuestion[];
|
||||||
|
readonly personalityQuiz: EmotionalIntelligencePersonalityQuizContent | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClassName = 'border-slate-700/60 bg-slate-950/70 text-slate-100 placeholder:text-slate-500';
|
||||||
|
const sectionClassName = 'rounded-xl border border-slate-700/50 bg-slate-950/30 p-4 space-y-4';
|
||||||
|
const iconButtonClassName = 'h-9 w-9 border border-slate-700/60 text-slate-300 hover:bg-slate-700/40';
|
||||||
|
|
||||||
|
const blankAssessmentQuestion = (): EmotionalIntelligenceQuestion => ({
|
||||||
|
q: '',
|
||||||
|
options: ['', '', '', ''],
|
||||||
|
scores: [1, 2, 3, 4],
|
||||||
|
});
|
||||||
|
|
||||||
|
const blankPersonalityQuestion = (id: number): QuizQuestion => ({
|
||||||
|
id,
|
||||||
|
dimension: 'EI',
|
||||||
|
question: '',
|
||||||
|
optionA: { text: '', value: 'E' },
|
||||||
|
optionB: { text: '', value: 'I' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPersonalityQuiz: EmotionalIntelligencePersonalityQuizContent = {
|
||||||
|
id: 'personality-type',
|
||||||
|
title: 'Personality Type Quiz',
|
||||||
|
description: 'Discover your MBTI type and workplace communication style.',
|
||||||
|
questions: PERSONALITY_QUIZ_QUESTIONS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmotionalIntelligenceQuizEditorPanel({
|
||||||
|
assessmentQuestions,
|
||||||
|
personalityQuiz,
|
||||||
|
}: Props) {
|
||||||
|
const draftKey = useMemo(() => JSON.stringify({
|
||||||
|
assessmentQuestions,
|
||||||
|
personalityQuiz,
|
||||||
|
}), [assessmentQuestions, personalityQuiz]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmotionalIntelligenceQuizEditorDraft
|
||||||
|
key={draftKey}
|
||||||
|
assessmentQuestions={assessmentQuestions}
|
||||||
|
personalityQuiz={personalityQuiz}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmotionalIntelligenceQuizEditorDraft({
|
||||||
|
assessmentQuestions,
|
||||||
|
personalityQuiz,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [savedMessage, setSavedMessage] = useState<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [assessmentDraft, setAssessmentDraft] = useState<readonly EmotionalIntelligenceQuestion[]>(
|
||||||
|
assessmentQuestions.length > 0 ? assessmentQuestions : [blankAssessmentQuestion()],
|
||||||
|
);
|
||||||
|
const [personalityDraft, setPersonalityDraft] = useState<EmotionalIntelligencePersonalityQuizContent>(
|
||||||
|
personalityQuiz ?? defaultPersonalityQuiz,
|
||||||
|
);
|
||||||
|
const assessmentType = CONTENT_CATALOG_TYPES.emotionalIntelligenceAssessmentQuestions;
|
||||||
|
const personalityType = CONTENT_CATALOG_TYPES.emotionalIntelligencePersonalityQuiz;
|
||||||
|
const assessmentManagedQuery = useManagedContentCatalog<readonly EmotionalIntelligenceQuestion[]>(assessmentType);
|
||||||
|
const personalityManagedQuery = useManagedContentCatalog<EmotionalIntelligencePersonalityQuizContent>(personalityType);
|
||||||
|
const isLoading = assessmentManagedQuery.isLoading || personalityManagedQuery.isLoading;
|
||||||
|
|
||||||
|
const saveAssessmentMutation = useSaveManagedContentCatalog(
|
||||||
|
assessmentType,
|
||||||
|
Boolean(assessmentManagedQuery.data),
|
||||||
|
() => assessmentDraft,
|
||||||
|
);
|
||||||
|
const savePersonalityMutation = useSaveManagedContentCatalog(
|
||||||
|
personalityType,
|
||||||
|
Boolean(personalityManagedQuery.data),
|
||||||
|
() => personalityDraft,
|
||||||
|
);
|
||||||
|
const deleteAssessmentMutation = useDeleteManagedContentCatalog(assessmentType);
|
||||||
|
const deletePersonalityMutation = useDeleteManagedContentCatalog(personalityType);
|
||||||
|
const disabled = isLoading ||
|
||||||
|
saveAssessmentMutation.isPending ||
|
||||||
|
savePersonalityMutation.isPending ||
|
||||||
|
deleteAssessmentMutation.isPending ||
|
||||||
|
deletePersonalityMutation.isPending;
|
||||||
|
|
||||||
|
const assessmentValid = useMemo(() => (
|
||||||
|
assessmentDraft.length > 0 &&
|
||||||
|
assessmentDraft.every((question) => (
|
||||||
|
question.q.trim().length > 0 &&
|
||||||
|
question.options.length === 4 &&
|
||||||
|
question.scores.length === 4 &&
|
||||||
|
question.options.every((option) => option.trim().length > 0) &&
|
||||||
|
question.scores.every((score) => Number.isInteger(score))
|
||||||
|
))
|
||||||
|
), [assessmentDraft]);
|
||||||
|
const personalityValid = useMemo(() => (
|
||||||
|
personalityDraft.title.trim().length > 0 &&
|
||||||
|
personalityDraft.description.trim().length > 0 &&
|
||||||
|
personalityDraft.questions.length > 0 &&
|
||||||
|
personalityDraft.questions.every((question) => (
|
||||||
|
question.question.trim().length > 0 &&
|
||||||
|
question.optionA.text.trim().length > 0 &&
|
||||||
|
question.optionB.text.trim().length > 0
|
||||||
|
))
|
||||||
|
), [personalityDraft]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
className="rounded-2xl border border-slate-700/40 bg-slate-800/40"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 border-b border-slate-700/40 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
||||||
|
aria-label={open ? 'Collapse EI quiz editor' : 'Expand EI quiz editor'}
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-500/15 text-blue-300">
|
||||||
|
<Settings size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-white">EI Quiz Content Editor</span>
|
||||||
|
<span className="block text-xs text-slate-400">Edit self-assessment and personality quiz content.</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={`ml-auto text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent className="space-y-4 p-4">
|
||||||
|
{errorMessage && (
|
||||||
|
<StatePanel tone="red" role="alert" size="inline">{errorMessage}</StatePanel>
|
||||||
|
)}
|
||||||
|
{savedMessage && (
|
||||||
|
<StatePanel tone="cyan" size="inline">{savedMessage}</StatePanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className={sectionClassName}>
|
||||||
|
<EditorSectionHeader
|
||||||
|
title="EI Self-Assessment"
|
||||||
|
description="Reflective scored questions for personal emotional intelligence results."
|
||||||
|
onSave={() => {
|
||||||
|
if (!assessmentValid) {
|
||||||
|
setErrorMessage('Complete every EI assessment question, option, and score before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void saveAssessment();
|
||||||
|
}}
|
||||||
|
onDelete={() => void deleteAssessment()}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{assessmentDraft.map((question, questionIndex) => (
|
||||||
|
<div key={questionIndex} className="rounded-xl border border-slate-700/50 bg-slate-900/50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-100">Question {questionIndex + 1}</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove EI question ${questionIndex + 1}`}
|
||||||
|
onClick={() => setAssessmentDraft((draft) => draft.filter((_, index) => index !== questionIndex))}
|
||||||
|
disabled={disabled || assessmentDraft.length <= 1}
|
||||||
|
className={iconButtonClassName}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={question.q}
|
||||||
|
onChange={(event) => updateAssessmentQuestion(questionIndex, { q: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${inputClassName} min-h-20`}
|
||||||
|
placeholder="Question text"
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{question.options.map((option, optionIndex) => (
|
||||||
|
<div key={optionIndex} className="grid grid-cols-[1fr_80px] gap-2">
|
||||||
|
<Input
|
||||||
|
value={option}
|
||||||
|
onChange={(event) => updateAssessmentOption(questionIndex, optionIndex, event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder={`Option ${optionIndex + 1}`}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={question.scores[optionIndex] ?? 0}
|
||||||
|
onChange={(event) => updateAssessmentScore(questionIndex, optionIndex, Number(event.target.value))}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
aria-label={`Score for EI option ${optionIndex + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
leadingIcon={<Plus size={14} />}
|
||||||
|
onClick={() => setAssessmentDraft((draft) => [...draft, blankAssessmentQuestion()])}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
|
||||||
|
>
|
||||||
|
Add EI Question
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={sectionClassName}>
|
||||||
|
<EditorSectionHeader
|
||||||
|
title="Personality Type Quiz"
|
||||||
|
description="MBTI-style questions used to calculate personality type."
|
||||||
|
onSave={() => {
|
||||||
|
if (!personalityValid) {
|
||||||
|
setErrorMessage('Complete the personality quiz title, description, and all question text before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void savePersonality();
|
||||||
|
}}
|
||||||
|
onDelete={() => void deletePersonality()}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
value={personalityDraft.title}
|
||||||
|
onChange={(event) => updatePersonalityDraft({ title: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="Quiz title"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={personalityDraft.id}
|
||||||
|
onChange={(event) => updatePersonalityDraft({ id: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="Quiz id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={personalityDraft.description}
|
||||||
|
onChange={(event) => updatePersonalityDraft({ description: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${inputClassName} min-h-20`}
|
||||||
|
placeholder="Quiz description"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{personalityDraft.questions.map((question, questionIndex) => (
|
||||||
|
<div key={question.id} className="rounded-xl border border-slate-700/50 bg-slate-900/50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-100">Question {questionIndex + 1}</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove personality question ${questionIndex + 1}`}
|
||||||
|
onClick={() => removePersonalityQuestion(questionIndex)}
|
||||||
|
disabled={disabled || personalityDraft.questions.length <= 1}
|
||||||
|
className={iconButtonClassName}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[140px_1fr]">
|
||||||
|
<NativeSelect
|
||||||
|
value={question.dimension}
|
||||||
|
onChange={(event) => updatePersonalityQuestion(questionIndex, { dimension: event.target.value as QuizQuestion['dimension'] })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
aria-label={`Dimension for personality question ${questionIndex + 1}`}
|
||||||
|
>
|
||||||
|
<option value="EI">E / I</option>
|
||||||
|
<option value="SN">S / N</option>
|
||||||
|
<option value="TF">T / F</option>
|
||||||
|
<option value="JP">J / P</option>
|
||||||
|
</NativeSelect>
|
||||||
|
<Textarea
|
||||||
|
value={question.question}
|
||||||
|
onChange={(event) => updatePersonalityQuestion(questionIndex, { question: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${inputClassName} min-h-20`}
|
||||||
|
placeholder="Question text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<PersonalityOptionFields
|
||||||
|
label="Option A"
|
||||||
|
text={question.optionA.text}
|
||||||
|
value={question.optionA.value}
|
||||||
|
disabled={disabled}
|
||||||
|
onTextChange={(text) => updatePersonalityQuestion(questionIndex, { optionA: { ...question.optionA, text } })}
|
||||||
|
onValueChange={(value) => updatePersonalityQuestion(questionIndex, { optionA: { ...question.optionA, value } })}
|
||||||
|
/>
|
||||||
|
<PersonalityOptionFields
|
||||||
|
label="Option B"
|
||||||
|
text={question.optionB.text}
|
||||||
|
value={question.optionB.value}
|
||||||
|
disabled={disabled}
|
||||||
|
onTextChange={(text) => updatePersonalityQuestion(questionIndex, { optionB: { ...question.optionB, text } })}
|
||||||
|
onValueChange={(value) => updatePersonalityQuestion(questionIndex, { optionB: { ...question.optionB, value } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
leadingIcon={<Plus size={14} />}
|
||||||
|
onClick={() => addPersonalityQuestion()}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
|
||||||
|
>
|
||||||
|
Add Personality Question
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateAssessmentQuestion(index: number, patch: Partial<EmotionalIntelligenceQuestion>) {
|
||||||
|
setAssessmentDraft((draft) => draft.map((question, questionIndex) => (
|
||||||
|
questionIndex === index ? { ...question, ...patch } : question
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAssessment() {
|
||||||
|
try {
|
||||||
|
await saveAssessmentMutation.mutateAsync();
|
||||||
|
setSavedMessage('EI self-assessment saved.');
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('EI self-assessment could not be saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePersonality() {
|
||||||
|
try {
|
||||||
|
await savePersonalityMutation.mutateAsync();
|
||||||
|
setSavedMessage('Personality quiz saved.');
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('Personality quiz could not be saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAssessment() {
|
||||||
|
try {
|
||||||
|
await deleteAssessmentMutation.mutateAsync();
|
||||||
|
setAssessmentDraft([blankAssessmentQuestion()]);
|
||||||
|
setSavedMessage('EI self-assessment deleted.');
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('EI self-assessment could not be deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePersonality() {
|
||||||
|
try {
|
||||||
|
await deletePersonalityMutation.mutateAsync();
|
||||||
|
setPersonalityDraft(defaultPersonalityQuiz);
|
||||||
|
setSavedMessage('Personality quiz deleted.');
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('Personality quiz could not be deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssessmentOption(questionIndex: number, optionIndex: number, value: string) {
|
||||||
|
setAssessmentDraft((draft) => draft.map((question, index) => {
|
||||||
|
if (index !== questionIndex) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
const options = [...question.options];
|
||||||
|
options[optionIndex] = value;
|
||||||
|
return { ...question, options };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssessmentScore(questionIndex: number, optionIndex: number, value: number) {
|
||||||
|
setAssessmentDraft((draft) => draft.map((question, index) => {
|
||||||
|
if (index !== questionIndex) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
const scores = [...question.scores];
|
||||||
|
scores[optionIndex] = value;
|
||||||
|
return { ...question, scores };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePersonalityDraft(patch: Partial<EmotionalIntelligencePersonalityQuizContent>) {
|
||||||
|
setPersonalityDraft((draft) => ({ ...draft, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePersonalityQuestion(index: number, patch: Partial<QuizQuestion>) {
|
||||||
|
setPersonalityDraft((draft) => ({
|
||||||
|
...draft,
|
||||||
|
questions: draft.questions.map((question, questionIndex) => (
|
||||||
|
questionIndex === index ? { ...question, ...patch } : question
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPersonalityQuestion() {
|
||||||
|
setPersonalityDraft((draft) => ({
|
||||||
|
...draft,
|
||||||
|
questions: [...draft.questions, blankPersonalityQuestion(draft.questions.length + 1)],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePersonalityQuestion(index: number) {
|
||||||
|
setPersonalityDraft((draft) => ({
|
||||||
|
...draft,
|
||||||
|
questions: draft.questions.filter((_, questionIndex) => questionIndex !== index),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorSectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly onSave: () => void;
|
||||||
|
readonly onDelete: () => void;
|
||||||
|
readonly disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">{title}</h3>
|
||||||
|
<p className="text-xs text-slate-400">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" onClick={onSave} disabled={disabled} className="bg-blue-600 text-white hover:bg-blue-500">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-red-500/30 bg-red-500/10 text-red-200 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalityOptionFields({
|
||||||
|
label,
|
||||||
|
text,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onTextChange,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
readonly label: string;
|
||||||
|
readonly text: string;
|
||||||
|
readonly value: string;
|
||||||
|
readonly disabled: boolean;
|
||||||
|
readonly onTextChange: (value: string) => void;
|
||||||
|
readonly onValueChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[1fr_80px] gap-2">
|
||||||
|
<Input
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => onTextChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder={label}
|
||||||
|
/>
|
||||||
|
<NativeSelect
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onValueChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
aria-label={`${label} value`}
|
||||||
|
>
|
||||||
|
{['E', 'I', 'S', 'N', 'T', 'F', 'J', 'P'].map((letter) => (
|
||||||
|
<option key={letter} value={letter}>{letter}</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ export function PersonalityQuizTab({ state, actions }: PersonalityQuizTabProps)
|
|||||||
<PersonalityQuiz
|
<PersonalityQuiz
|
||||||
onViewDirectory={() => actions.setActiveTab('directory')}
|
onViewDirectory={() => actions.setActiveTab('directory')}
|
||||||
onResult={actions.handlePersonalityResult}
|
onResult={actions.handlePersonalityResult}
|
||||||
|
questions={state.personalityQuiz?.questions}
|
||||||
savedType={state.personalityResult}
|
savedType={state.personalityResult}
|
||||||
savedAnswers={state.savedAnswers}
|
savedAnswers={state.savedAnswers}
|
||||||
savedDate={state.savedDate}
|
savedDate={state.savedDate}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { AssessmentTab } from '@/components/emotional-intelligence/AssessmentTab';
|
import { AssessmentTab } from '@/components/emotional-intelligence/AssessmentTab';
|
||||||
|
import { EmotionalIntelligenceQuizEditorPanel } from '@/components/emotional-intelligence/EmotionalIntelligenceQuizEditorPanel';
|
||||||
import { EmotionalIntelligenceHeader } from '@/components/emotional-intelligence/EmotionalIntelligenceHeader';
|
import { EmotionalIntelligenceHeader } from '@/components/emotional-intelligence/EmotionalIntelligenceHeader';
|
||||||
import { EmotionalIntelligenceTabs } from '@/components/emotional-intelligence/EmotionalIntelligenceTabs';
|
import { EmotionalIntelligenceTabs } from '@/components/emotional-intelligence/EmotionalIntelligenceTabs';
|
||||||
import { PersonalityDistributionPanel } from '@/components/emotional-intelligence/PersonalityDistributionPanel';
|
import { PersonalityDistributionPanel } from '@/components/emotional-intelligence/PersonalityDistributionPanel';
|
||||||
@ -27,6 +28,13 @@ const EmotionalIntelligence = ({ userRole }: EIProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{state.canManageQuizContent && (
|
||||||
|
<EmotionalIntelligenceQuizEditorPanel
|
||||||
|
assessmentQuestions={state.assessmentQuestions}
|
||||||
|
personalityQuiz={state.personalityQuiz}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{state.personalityResult && !state.isLoadingSaved && state.activeTab === 'assessment' && (
|
{state.personalityResult && !state.isLoadingSaved && state.activeTab === 'assessment' && (
|
||||||
<SavedPersonalityBanner
|
<SavedPersonalityBanner
|
||||||
personalityResult={state.personalityResult}
|
personalityResult={state.personalityResult}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { usePersonalityQuizWorkflow } from '@/business/personality/quizWorkflowHooks';
|
import { usePersonalityQuizWorkflow } from '@/business/personality/quizWorkflowHooks';
|
||||||
import { PersonalityQuizView } from '@/components/personality-quiz/PersonalityQuizView';
|
import { PersonalityQuizView } from '@/components/personality-quiz/PersonalityQuizView';
|
||||||
|
import type { QuizQuestion } from '@/shared/constants/personalityCatalog';
|
||||||
|
|
||||||
interface PersonalityQuizProps {
|
interface PersonalityQuizProps {
|
||||||
readonly onViewDirectory: () => void;
|
readonly onViewDirectory: () => void;
|
||||||
@ -9,6 +10,7 @@ interface PersonalityQuizProps {
|
|||||||
readonly savedDate?: string | null;
|
readonly savedDate?: string | null;
|
||||||
readonly isSaving?: boolean;
|
readonly isSaving?: boolean;
|
||||||
readonly canPersistResult?: boolean;
|
readonly canPersistResult?: boolean;
|
||||||
|
readonly questions?: readonly QuizQuestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PersonalityQuiz({
|
export default function PersonalityQuiz({
|
||||||
@ -19,11 +21,13 @@ export default function PersonalityQuiz({
|
|||||||
savedDate,
|
savedDate,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
canPersistResult = true,
|
canPersistResult = true,
|
||||||
|
questions,
|
||||||
}: PersonalityQuizProps) {
|
}: PersonalityQuizProps) {
|
||||||
const workflow = usePersonalityQuizWorkflow({
|
const workflow = usePersonalityQuizWorkflow({
|
||||||
onResult,
|
onResult,
|
||||||
savedType,
|
savedType,
|
||||||
savedAnswers,
|
savedAnswers,
|
||||||
|
questions,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { KeyRound, Loader2, ShieldCheck, UserCircle } from 'lucide-react';
|
import { ClipboardList, KeyRound, Loader2, UserCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -8,13 +8,23 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { ModuleHeader } from '@/components/ui/module-header';
|
import { ModuleHeader } from '@/components/ui/module-header';
|
||||||
import { NativeSelect } from '@/components/ui/native-select';
|
import { NativeSelect } from '@/components/ui/native-select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '@/contexts/useAuth';
|
||||||
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
|
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
|
||||||
import { getAuthRoleLabel } from '@/business/auth/selectors';
|
import { getAuthRoleLabel } from '@/business/auth/selectors';
|
||||||
import { changePassword, updateOwnProfile, updateOrganization } from '@/business/profile/api';
|
import { changePassword, updateOwnProfile, updateOrganization } from '@/business/profile/api';
|
||||||
|
import { buildProfileQuizResultRows } from '@/business/profile/selectors';
|
||||||
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
||||||
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
|
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
|
||||||
import { ImageUpload } from '@/components/common/ImageUpload';
|
import { ImageUpload } from '@/components/common/ImageUpload';
|
||||||
|
import { useCurrentPersonalityResultHistory } from '@/business/personality/queryHooks';
|
||||||
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||||
|
|
||||||
interface StatusMessage {
|
interface StatusMessage {
|
||||||
@ -59,6 +69,7 @@ export default function ProfilePage() {
|
|||||||
const { user, profile, refreshUser } = useAuth();
|
const { user, profile, refreshUser } = useAuth();
|
||||||
const capabilitiesQuery = useIamCapabilities();
|
const capabilitiesQuery = useIamCapabilities();
|
||||||
const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user));
|
const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user));
|
||||||
|
const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user));
|
||||||
|
|
||||||
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
|
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
|
||||||
const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
|
const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
|
||||||
@ -97,6 +108,14 @@ export default function ProfilePage() {
|
|||||||
return parts.length > 0 ? parts.join(' › ') : 'Platform';
|
return parts.length > 0 ? parts.join(' › ') : 'Platform';
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const quizResultRows = useMemo(
|
||||||
|
() => buildProfileQuizResultRows(
|
||||||
|
safetyQuizStatus.data?.result ?? null,
|
||||||
|
personalityHistoryStatus.data ?? [],
|
||||||
|
),
|
||||||
|
[personalityHistoryStatus.data, safetyQuizStatus.data?.result],
|
||||||
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -347,31 +366,46 @@ export default function ProfilePage() {
|
|||||||
<Card className={profileCardClassName}>
|
<Card className={profileCardClassName}>
|
||||||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||||||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||||||
<ShieldCheck size={16} />
|
<ClipboardList size={16} />
|
||||||
QBS safety quiz
|
Quiz results
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 md:px-5">
|
<CardContent className="px-4 pb-4 md:px-5">
|
||||||
<div className={`${formPanelClassName} mt-6`}>
|
<div className={`${formPanelClassName} mt-6`}>
|
||||||
{safetyQuizStatus.isLoading ? (
|
{safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading ? (
|
||||||
<p className="text-sm text-slate-300">Loading quiz status...</p>
|
<p className="text-sm text-slate-300">Loading quiz results...</p>
|
||||||
) : safetyQuizStatus.data?.result ? (
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Latest quiz"
|
|
||||||
value={safetyQuizStatus.data.result.quiz_title}
|
|
||||||
/>
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Score"
|
|
||||||
value={`${safetyQuizStatus.data.result.score}/${safetyQuizStatus.data.result.total_questions}`}
|
|
||||||
/>
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Completed"
|
|
||||||
value={new Date(safetyQuizStatus.data.result.completed_at).toLocaleDateString()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-300">No QBS safety quiz result is saved yet.</p>
|
<div className="overflow-hidden rounded-lg border border-slate-700/70">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Quiz</TableHead>
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Result</TableHead>
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Completed</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{quizResultRows.map((result) => (
|
||||||
|
<TableRow
|
||||||
|
key={result.id}
|
||||||
|
className="border-slate-800/80 hover:bg-slate-800/20"
|
||||||
|
>
|
||||||
|
<TableCell className="p-3">
|
||||||
|
<p className="font-semibold text-slate-100">{result.quiz}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 text-slate-300">{result.category}</TableCell>
|
||||||
|
<TableCell className={`p-3 font-semibold ${
|
||||||
|
result.status === 'complete' ? 'text-slate-100' : 'text-amber-300'
|
||||||
|
}`}>
|
||||||
|
{result.result}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 text-slate-300">{result.completed}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
getPersonalityCompletion,
|
||||||
getCurrentPersonalityResult,
|
getCurrentPersonalityResult,
|
||||||
|
listCurrentPersonalityResultHistory,
|
||||||
listPersonalityDistribution,
|
listPersonalityDistribution,
|
||||||
saveCurrentPersonalityResult,
|
saveCurrentPersonalityResult,
|
||||||
} from '@/shared/api/personality';
|
} from '@/shared/api/personality';
|
||||||
import { apiRequest } from '@/shared/api/httpClient';
|
import { apiRequest } from '@/shared/api/httpClient';
|
||||||
import type { PersonalityQuizResultMutationDto } from '@/shared/types/personality';
|
import type { PersonalityQuizResultMutationDto } from '@/shared/types/personality';
|
||||||
|
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
||||||
|
|
||||||
vi.mock('@/shared/api/httpClient', () => ({
|
vi.mock('@/shared/api/httpClient', () => ({
|
||||||
apiRequest: vi.fn(),
|
apiRequest: vi.fn(),
|
||||||
@ -19,18 +22,28 @@ describe('personality API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads the current user personality result through the me endpoint', () => {
|
it('loads the current user personality result through the me endpoint', () => {
|
||||||
void getCurrentPersonalityResult();
|
void getCurrentPersonalityResult(PERSONALITY_QUIZ_KINDS.personalityType);
|
||||||
|
|
||||||
expect(apiRequestMock).toHaveBeenCalledWith('/personality_quiz_results/me');
|
expect(apiRequestMock).toHaveBeenCalledWith('/personality_quiz_results/me?quiz_kind=personality_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the current user personality result history through the me history endpoint', () => {
|
||||||
|
void listCurrentPersonalityResultHistory();
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith('/personality_quiz_results/me/history');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves the current user personality result with PUT body wrapped in data', () => {
|
it('saves the current user personality result with PUT body wrapped in data', () => {
|
||||||
const request: PersonalityQuizResultMutationDto = {
|
const request: PersonalityQuizResultMutationDto = {
|
||||||
|
quiz_kind: PERSONALITY_QUIZ_KINDS.personalityType,
|
||||||
|
quiz_id: 'personality-type',
|
||||||
|
quiz_title: 'Personality Type Quiz',
|
||||||
personality_type: 'INFJ',
|
personality_type: 'INFJ',
|
||||||
quiz_answers: {
|
quiz_answers: {
|
||||||
'1': 'I',
|
'1': 'I',
|
||||||
'2': 'N',
|
'2': 'N',
|
||||||
},
|
},
|
||||||
|
total_questions: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
void saveCurrentPersonalityResult(request);
|
void saveCurrentPersonalityResult(request);
|
||||||
@ -52,4 +65,10 @@ describe('personality API', () => {
|
|||||||
|
|
||||||
expect(apiRequestMock).toHaveBeenCalledWith('/personality_quiz_results/distribution?campusId=campus-1');
|
expect(apiRequestMock).toHaveBeenCalledWith('/personality_quiz_results/distribution?campusId=campus-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads the personality completion report', () => {
|
||||||
|
void getPersonalityCompletion();
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith('/personality_quiz_results/completion');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,14 +2,23 @@ import { apiRequest } from '@/shared/api/httpClient';
|
|||||||
import { ApiListResponse } from '@/shared/types/api';
|
import { ApiListResponse } from '@/shared/types/api';
|
||||||
import type {
|
import type {
|
||||||
PersonalityDistributionDto,
|
PersonalityDistributionDto,
|
||||||
|
PersonalityCompletionDto,
|
||||||
|
PersonalityQuizResultHistoryDto,
|
||||||
PersonalityQuizResultDto,
|
PersonalityQuizResultDto,
|
||||||
PersonalityQuizResultMutationDto,
|
PersonalityQuizResultMutationDto,
|
||||||
} from '@/shared/types/personality';
|
} from '@/shared/types/personality';
|
||||||
|
import type { PersonalityQuizKind } from '@/shared/constants/personality';
|
||||||
|
|
||||||
const PERSONALITY_RESULTS_PATH = '/personality_quiz_results';
|
const PERSONALITY_RESULTS_PATH = '/personality_quiz_results';
|
||||||
|
|
||||||
export function getCurrentPersonalityResult(): Promise<PersonalityQuizResultDto | null> {
|
export function getCurrentPersonalityResult(quizKind: PersonalityQuizKind): Promise<PersonalityQuizResultDto | null> {
|
||||||
return apiRequest<PersonalityQuizResultDto | null>(`${PERSONALITY_RESULTS_PATH}/me`);
|
return apiRequest<PersonalityQuizResultDto | null>(
|
||||||
|
`${PERSONALITY_RESULTS_PATH}/me?${new URLSearchParams({ quiz_kind: quizKind }).toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCurrentPersonalityResultHistory(): Promise<PersonalityQuizResultHistoryDto> {
|
||||||
|
return apiRequest<PersonalityQuizResultHistoryDto>(`${PERSONALITY_RESULTS_PATH}/me/history`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveCurrentPersonalityResult(
|
export function saveCurrentPersonalityResult(
|
||||||
@ -30,3 +39,7 @@ export function listPersonalityDistribution(campusId?: string): Promise<ApiListR
|
|||||||
`${PERSONALITY_RESULTS_PATH}/distribution?${new URLSearchParams({ campusId }).toString()}`,
|
`${PERSONALITY_RESULTS_PATH}/distribution?${new URLSearchParams({ campusId }).toString()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPersonalityCompletion(): Promise<PersonalityCompletionDto> {
|
||||||
|
return apiRequest<PersonalityCompletionDto>(`${PERSONALITY_RESULTS_PATH}/completion`);
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export const CONTENT_CATALOG_TYPES = {
|
|||||||
communityOrganizations: 'community-organizations',
|
communityOrganizations: 'community-organizations',
|
||||||
vocationalOpportunities: 'vocational-opportunities',
|
vocationalOpportunities: 'vocational-opportunities',
|
||||||
emotionalIntelligenceAssessmentQuestions: 'emotional-intelligence-assessment-questions',
|
emotionalIntelligenceAssessmentQuestions: 'emotional-intelligence-assessment-questions',
|
||||||
|
emotionalIntelligencePersonalityQuiz: 'emotional-intelligence-personality-quiz',
|
||||||
emotionalIntelligenceWeeklyTopics: 'emotional-intelligence-weekly-topics',
|
emotionalIntelligenceWeeklyTopics: 'emotional-intelligence-weekly-topics',
|
||||||
emotionalIntelligenceGrowthTips: 'emotional-intelligence-growth-tips',
|
emotionalIntelligenceGrowthTips: 'emotional-intelligence-growth-tips',
|
||||||
emotionalIntelligenceTeamWellnessMetrics: 'emotional-intelligence-team-wellness-metrics',
|
emotionalIntelligenceTeamWellnessMetrics: 'emotional-intelligence-team-wellness-metrics',
|
||||||
|
|||||||
@ -1,8 +1,17 @@
|
|||||||
export const PERSONALITY_QUERY_KEYS = {
|
export const PERSONALITY_QUERY_KEYS = {
|
||||||
current: ['personality', 'current'],
|
current: (quizKind: PersonalityQuizKind) => ['personality', 'current', quizKind] as const,
|
||||||
|
history: ['personality', 'history'],
|
||||||
distribution: ['personality', 'distribution'],
|
distribution: ['personality', 'distribution'],
|
||||||
|
completion: ['personality', 'completion'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const PERSONALITY_QUIZ_KINDS = {
|
||||||
|
selfAssessment: 'ei_self_assessment',
|
||||||
|
personalityType: 'personality_type',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PersonalityQuizKind = typeof PERSONALITY_QUIZ_KINDS[keyof typeof PERSONALITY_QUIZ_KINDS];
|
||||||
|
|
||||||
export const PERSONALITY_DIRECTORY_FILTER_GROUPS = [
|
export const PERSONALITY_DIRECTORY_FILTER_GROUPS = [
|
||||||
'all',
|
'all',
|
||||||
'analysts',
|
'analysts',
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { QuizQuestion } from '@/shared/constants/personalityCatalog';
|
||||||
|
|
||||||
export type EmotionalIntelligenceTab = 'assessment' | 'personality' | 'directory';
|
export type EmotionalIntelligenceTab = 'assessment' | 'personality' | 'directory';
|
||||||
|
|
||||||
export type EmotionalIntelligenceTopicIconId = 'shield' | 'brain' | 'heart' | 'eye';
|
export type EmotionalIntelligenceTopicIconId = 'shield' | 'brain' | 'heart' | 'eye';
|
||||||
@ -39,6 +41,13 @@ export type PersonalityWorkplaceContent = {
|
|||||||
readonly workplaceTips: readonly string[];
|
readonly workplaceTips: readonly string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EmotionalIntelligencePersonalityQuizContent = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly questions: readonly QuizQuestion[];
|
||||||
|
};
|
||||||
|
|
||||||
export type TeamWellnessMetric = {
|
export type TeamWellnessMetric = {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly value: string;
|
readonly value: string;
|
||||||
|
|||||||
@ -1,23 +1,74 @@
|
|||||||
|
import type { PersonalityQuizKind } from '@/shared/constants/personality';
|
||||||
|
|
||||||
|
export type PersonalityAnswerValue = string | number;
|
||||||
|
|
||||||
export interface PersonalityQuizResultDto {
|
export interface PersonalityQuizResultDto {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly personality_type: string;
|
readonly quiz_kind: PersonalityQuizKind;
|
||||||
readonly quiz_answers: Record<string, string>;
|
readonly quiz_id: string;
|
||||||
|
readonly quiz_title: string;
|
||||||
|
readonly week_of: string | null;
|
||||||
|
readonly personality_type: string | null;
|
||||||
|
readonly quiz_answers: Record<string, PersonalityAnswerValue>;
|
||||||
|
readonly score: number | null;
|
||||||
|
readonly total_questions: number;
|
||||||
|
readonly result_label: string | null;
|
||||||
|
readonly result_payload: unknown | null;
|
||||||
|
readonly user_name: string | null;
|
||||||
|
readonly user_role: string | null;
|
||||||
readonly completed_at: string;
|
readonly completed_at: string;
|
||||||
readonly organizationId: string;
|
readonly organizationId: string | null;
|
||||||
readonly campusId: string | null;
|
readonly campusId: string | null;
|
||||||
readonly userId: string;
|
readonly userId: string | null;
|
||||||
readonly createdById: string;
|
readonly createdById: string | null;
|
||||||
readonly updatedById: string | null;
|
readonly updatedById: string | null;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt: string;
|
readonly updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonalityQuizResultMutationDto {
|
export interface PersonalityQuizResultMutationDto {
|
||||||
readonly personality_type: string;
|
readonly quiz_kind: PersonalityQuizKind;
|
||||||
readonly quiz_answers: Record<string, string>;
|
readonly quiz_id: string;
|
||||||
|
readonly quiz_title: string;
|
||||||
|
readonly personality_type?: string | null;
|
||||||
|
readonly quiz_answers: Record<string, PersonalityAnswerValue>;
|
||||||
|
readonly score?: number | null;
|
||||||
|
readonly total_questions: number;
|
||||||
|
readonly result_label?: string | null;
|
||||||
|
readonly result_payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalityQuizResultHistoryDto {
|
||||||
|
readonly rows: readonly PersonalityQuizResultDto[];
|
||||||
|
readonly count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonalityDistributionDto {
|
export interface PersonalityDistributionDto {
|
||||||
readonly type: string;
|
readonly type: string;
|
||||||
readonly count: number;
|
readonly count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PersonalityCompletionSummaryDto {
|
||||||
|
readonly totalStaff: number;
|
||||||
|
readonly completedCount: number;
|
||||||
|
readonly pendingCount: number;
|
||||||
|
readonly selfAssessmentCompletedCount: number;
|
||||||
|
readonly personalityCompletedCount: number;
|
||||||
|
readonly completionRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalityCompletionRowDto {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly email: string | null;
|
||||||
|
readonly role: string | null;
|
||||||
|
readonly status: 'complete' | 'pending';
|
||||||
|
readonly completedKinds: readonly PersonalityQuizKind[];
|
||||||
|
readonly selfAssessment: PersonalityQuizResultDto | null;
|
||||||
|
readonly personality: PersonalityQuizResultDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalityCompletionDto {
|
||||||
|
readonly summary: PersonalityCompletionSummaryDto;
|
||||||
|
readonly rows: readonly PersonalityCompletionRowDto[];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user