From c397e97b9fb9aa45cda2f2ca9466045a51b32cf5 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 18 Jun 2026 18:36:33 +0200 Subject: [PATCH] added emotional intelligence quizzes CRUD --- backend/docs/content-catalog.md | 17 +- backend/docs/database-schema.md | 14 +- backend/docs/personality-quiz-results.md | 82 ++- backend/docs/safety-quiz-results.md | 14 +- .../personality_quiz_results.controller.ts | 20 + backend/src/db/initial-schema.ts | 2 +- ...extend-personality-quiz-results-history.ts | 116 ++++ ...ill-emotional-intelligence-quiz-content.ts | 96 ++++ .../src/db/models/personality_quiz_results.ts | 56 +- .../seeders/20260608103000-content-catalog.ts | 1 + .../content-catalog-seed-payloads.ts | 20 + .../src/routes/personality_quiz_results.ts | 9 + backend/src/services/content_catalog.test.ts | 154 ++++++ backend/src/services/content_catalog.ts | 28 + .../src/services/content_catalog_seed.test.ts | 55 ++ .../services/personal_scope_results.test.ts | 256 ++++++++- .../src/services/personality_quiz_results.ts | 418 ++++++++++++-- backend/src/services/safety_quiz_results.ts | 25 +- .../src/shared/constants/content-catalog.ts | 9 +- docs/backlog.md | 2 +- frontend/docs/content-catalog-integration.md | 4 +- .../docs/director-dashboard-integration.md | 13 +- frontend/docs/personality-catalog.md | 9 +- frontend/docs/personality-integration.md | 33 +- frontend/docs/safety-quiz-integration.md | 6 + frontend/docs/static-app-data.md | 2 +- frontend/docs/test-coverage.md | 3 +- frontend/docs/top-bar-integration.md | 7 +- .../src/business/content-catalog/hooks.ts | 60 +- .../src/business/director-dashboard/hooks.ts | 14 +- .../director-dashboard/selectors.test.ts | 116 +++- .../business/director-dashboard/selectors.ts | 97 +++- .../src/business/director-dashboard/types.ts | 14 +- .../personality/emotionalIntelligenceHooks.ts | 98 +++- .../src/business/personality/mappers.test.ts | 31 ++ frontend/src/business/personality/mappers.ts | 27 +- .../src/business/personality/queryHooks.ts | 52 +- .../business/personality/quizWorkflowHooks.ts | 3 +- frontend/src/business/personality/types.ts | 31 +- .../src/business/profile/selectors.test.ts | 83 +++ frontend/src/business/profile/selectors.ts | 108 ++++ frontend/src/business/top-bar/hooks.ts | 21 + .../src/business/top-bar/selectors.test.ts | 25 + frontend/src/business/top-bar/selectors.ts | 30 +- .../DirectorQuizResultsPanel.tsx | 14 +- .../emotional-intelligence/AssessmentTab.tsx | 136 ++++- .../EmotionalIntelligenceQuizEditorPanel.tsx | 523 ++++++++++++++++++ .../PersonalityQuizTab.tsx | 1 + .../frameworks/EmotionalIntelligence.tsx | 8 + .../components/frameworks/PersonalityQuiz.tsx | 4 + frontend/src/pages/ProfilePage.tsx | 76 ++- frontend/src/shared/api/personality.test.ts | 23 +- frontend/src/shared/api/personality.ts | 17 +- .../src/shared/constants/contentCatalog.ts | 1 + frontend/src/shared/constants/personality.ts | 11 +- .../src/shared/types/emotionalIntelligence.ts | 9 + frontend/src/shared/types/personality.ts | 65 ++- 57 files changed, 2957 insertions(+), 212 deletions(-) create mode 100644 backend/src/db/migrations/20260618130000-extend-personality-quiz-results-history.ts create mode 100644 backend/src/db/migrations/20260618131000-backfill-emotional-intelligence-quiz-content.ts create mode 100644 frontend/src/business/profile/selectors.test.ts create mode 100644 frontend/src/business/profile/selectors.ts create mode 100644 frontend/src/components/emotional-intelligence/EmotionalIntelligenceQuizEditorPanel.tsx diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index 643914d..d39dfd5 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -1,7 +1,7 @@ # Content Catalog Backend ## 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) - 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. - `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. +- `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 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. - 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. +- 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. 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 - `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. -- 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`. - Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads. ### 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 - 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. ## 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 - Frontend: `frontend/docs/content-catalog-integration.md`. diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index 02d4c34..fd15f92 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -919,13 +919,23 @@ _Relations:_ #### `personality_quiz_results` -Product-module personality quiz results. +Product-module Emotional Intelligence and Personality quiz completion history. | Column | Type | Null | Default | Notes | |---|---|---|---|---| | `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 | — | | +| `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 | — | | | `importHash` | varchar | yes | — | unique, audit | | `createdAt` | timestamptz | yes | — | audit | diff --git a/backend/docs/personality-quiz-results.md b/backend/docs/personality-quiz-results.md index 962ec46..77a704a 100644 --- a/backend/docs/personality-quiz-results.md +++ b/backend/docs/personality-quiz-results.md @@ -2,15 +2,15 @@ ## Purpose -`personality_quiz_results` stores each authenticated tenant user's current personality quiz -result (one row per user per organization) and exposes an aggregate distribution of personality -types for leadership reporting. The backend owns tenant scope, user ownership, the saved -personality type, and the answer snapshot. It does not write to user employment fields. +`personality_quiz_results` stores authenticated tenant users' Emotional Intelligence and +Personality quiz completion history. The backend owns tenant scope, user ownership, quiz kind, +quiz version identifiers, weekly EI completion windows, personality type snapshots, scores, and +answer snapshots. It does not write to user employment fields. ## Slice Files (by layer) -- Route: `src/routes/personality_quiz_results.ts` (thin wiring; `GET /me`, `PUT /me`, - `GET /distribution`). +- Route: `src/routes/personality_quiz_results.ts` (thin wiring; `GET /me`, `GET /me/history`, + `PUT /me`, `GET /distribution`, `GET /completion`). - Controller: `src/api/controllers/personality_quiz_results.controller.ts` (custom — not the CRUD factory). - 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`. -- `GET /api/personality_quiz_results/me` -> `200`. Returns the current user's saved result DTO - (most recently updated), or `null` if none exists. +- `GET /api/personality_quiz_results/me` -> `200`. Query `quiz_kind` can be + `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 - `{ data: { personality_type, quiz_answers } }`. Creates or updates the current user's result and - returns the saved DTO. If the caller is a parent-scope user acting through a drilled child scope, - the request is accepted as a no-op and returns the caller's currently saved result (or `null`). + `{ data: { quiz_kind, quiz_id, quiz_title, quiz_answers, total_questions, ... } }`. Creates a new + completion-history row and returns the saved DTO. If the caller is a parent-scope user acting + through a drilled child scope, the request is accepted as a no-op and returns `null`. - `GET /api/personality_quiz_results/distribution` -> `200`. Optional query `campusId`. Returns `{ rows: [{ type, count }], count }` (number of distinct personality types). Restricted to report roles. +- `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 -- `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 `userId`). - `upsertCurrentUserResult` persists only when the active scope is the user's own scope. Parent users drilled into a child school/campus/classroom can complete the UI flow there, but the backend does not create or update reportable quiz rows for that child scope. -- `distribution`: restricted to `READ_PERSONALITY_REPORTS`; otherwise +- `distribution` and `completion`: restricted to `READ_PERSONALITY_REPORTS`; otherwise `ForbiddenError`. Role-seeded permissions are only the baseline grants. The distribution response contains only `type` and `count` per group — no individual names or answers. `custom_permissions` can grant the report permission and @@ -64,33 +74,45 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu ## Data Contract -- Mutation input (`PUT /me`): `personality_type` (non-empty string) and `quiz_answers` (a non-array - object whose values are all non-empty strings). Invalid input raises `ValidationError`. -- On save, `personality_type` is trimmed and upper-cased; `completed_at` is set to the current - time. -- DTO fields: `id`, `personality_type`, `quiz_answers`, `completed_at`, `organizationId`, - `campusId`, `userId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. -- Model columns: `personality_type` (TEXT, not null), `quiz_answers` (JSONB, not null), - `completed_at` (DATE, not null), `importHash` (unique), plus tenant/audit UUID columns - (`organizationId`, `campusId`, `userId`, `createdById`, `updatedById`, all nullable). The model - is `paranoid` (soft delete via `deletedAt`) and uses `freezeTableName`. +- Mutation input (`PUT /me`): `quiz_kind`, `quiz_id`, `quiz_title`, `quiz_answers`, and + `total_questions`. Personality type submissions also require `personality_type`; EI submissions + may include `score`, `result_label`, and `result_payload`. Invalid input raises + `ValidationError`. +- On save, personality types are trimmed and upper-cased; `completed_at` is set to the current time. + EI self-assessment rows get `week_of` set to the current Sunday-start week. Personality type rows + keep `week_of = null` because the type quiz is not a weekly workflow. +- DTO fields: `id`, `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`, `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`, `createdBy`, `updatedBy`). ## Behavior / Notes - `upsertCurrentUserResult` first checks whether the caller is acting in their own scope. If not, - it skips persistence and returns the current saved result. Otherwise it runs inside - `withTransaction`: it looks up the existing row by - `organizationId` + `userId` and updates it, otherwise creates a new one. -- `getCurrentUserResult` orders by `updatedAt` desc and returns the first match. -- `distribution` groups by `personality_type` with a `COUNT(id)` aggregate, ordered by count desc; - `count` in the response is the number of distinct types returned. + it skips persistence and returns `null`. Otherwise it creates a new history row inside + `withTransaction`; old completions are never overwritten. +- `getCurrentUserResult` orders by `completed_at` desc and returns the latest match. For + `ei_self_assessment`, the query includes current `week_of`; for `personality_type`, it does not. +- `getCurrentUserHistory` returns the user's saved EI and personality completions across weeks and + 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 - `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 diff --git a/backend/docs/safety-quiz-results.md b/backend/docs/safety-quiz-results.md index a9b7d1b..964125e 100644 --- a/backend/docs/safety-quiz-results.md +++ b/backend/docs/safety-quiz-results.md @@ -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`). - `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 child school/campus/classroom can complete the quiz there, but the backend does not create 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 -- 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 `ValidationError`. - 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 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`). - `completion` reports organization, school, campus, and class scope staff according to the active 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 diff --git a/backend/src/api/controllers/personality_quiz_results.controller.ts b/backend/src/api/controllers/personality_quiz_results.controller.ts index 446982e..fc0e772 100644 --- a/backend/src/api/controllers/personality_quiz_results.controller.ts +++ b/backend/src/api/controllers/personality_quiz_results.controller.ts @@ -6,6 +6,18 @@ export async function getCurrentUserResult( res: Response, ): Promise { const payload = await PersonalityQuizResultsService.getCurrentUserResult( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function getCurrentUserHistory( + req: Request, + res: Response, +): Promise { + const payload = await PersonalityQuizResultsService.getCurrentUserHistory( + req.query, req.currentUser, ); res.status(200).send(payload); @@ -29,3 +41,11 @@ export async function distribution(req: Request, res: Response): Promise { ); res.status(200).send(payload); } + +export async function completion(req: Request, res: Response): Promise { + const payload = await PersonalityQuizResultsService.completion( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/db/initial-schema.ts b/backend/src/db/initial-schema.ts index 8660e59..c94f1b6 100644 --- a/backend/src/db/initial-schema.ts +++ b/backend/src/db/initial-schema.ts @@ -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 "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 "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 "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'; diff --git a/backend/src/db/migrations/20260618130000-extend-personality-quiz-results-history.ts b/backend/src/db/migrations/20260618130000-extend-personality-quiz-results-history.ts new file mode 100644 index 0000000..fc011db --- /dev/null +++ b/backend/src/db/migrations/20260618130000-extend-personality-quiz-results-history.ts @@ -0,0 +1,116 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +const TABLE = 'personality_quiz_results'; + +async function columnExists( + queryInterface: QueryInterface, + column: string, +): Promise { + 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[2], +): Promise { + 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'); + }, +}; diff --git a/backend/src/db/migrations/20260618131000-backfill-emotional-intelligence-quiz-content.ts b/backend/src/db/migrations/20260618131000-backfill-emotional-intelligence-quiz-content.ts new file mode 100644 index 0000000..87ace5c --- /dev/null +++ b/backend/src/db/migrations/20260618131000-backfill-emotional-intelligence-quiz-content.ts @@ -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 { + 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[] = []; + + 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}%` }, + }); + }, +}; diff --git a/backend/src/db/models/personality_quiz_results.ts b/backend/src/db/models/personality_quiz_results.ts index aee367a..5a7ec60 100644 --- a/backend/src/db/models/personality_quiz_results.ts +++ b/backend/src/db/models/personality_quiz_results.ts @@ -20,8 +20,18 @@ export class PersonalityQuizResults extends Model< InferCreationAttributes > { declare id: CreationOptional; - declare personality_type: string; + declare quiz_kind: string; + declare quiz_id: string; + declare quiz_title: string; + declare week_of: CreationOptional; + declare personality_type: CreationOptional; declare quiz_answers: unknown; + declare score: CreationOptional; + declare total_questions: number; + declare result_label: CreationOptional; + declare result_payload: unknown | null; + declare user_name: CreationOptional; + declare user_role: CreationOptional; declare completed_at: Date; declare importHash: CreationOptional; declare createdAt: CreationOptional; @@ -89,13 +99,57 @@ export default function (sequelize: Sequelize): typeof PersonalityQuizResults { primaryKey: true, }, personality_type: { + type: DataTypes.TEXT, + allowNull: true, + }, + quiz_kind: { type: DataTypes.TEXT, 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: { type: DataTypes.JSONB, 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: { type: DataTypes.DATE, allowNull: false, diff --git a/backend/src/db/seeders/20260608103000-content-catalog.ts b/backend/src/db/seeders/20260608103000-content-catalog.ts index 05da089..ab68b67 100644 --- a/backend/src/db/seeders/20260608103000-content-catalog.ts +++ b/backend/src/db/seeders/20260608103000-content-catalog.ts @@ -95,6 +95,7 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ { content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations }, { 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-personality-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligencePersonalityQuiz }, { 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-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics }, diff --git a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts index 9f818c0..a94d03e 100644 --- a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts +++ b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts @@ -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: '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: [ { 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' }, @@ -989,6 +1008,7 @@ export const CONTENT_CATALOG_DEFAULT_ROWS: ReadonlyArray<{ { content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations }, { 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-personality-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligencePersonalityQuiz }, { 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-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics }, diff --git a/backend/src/routes/personality_quiz_results.ts b/backend/src/routes/personality_quiz_results.ts index 2b60719..54890c4 100644 --- a/backend/src/routes/personality_quiz_results.ts +++ b/backend/src/routes/personality_quiz_results.ts @@ -22,6 +22,13 @@ const router = express.Router(); * responses: * 200: { description: Saved. } * 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: * get: * tags: [Quizzes] @@ -30,6 +37,7 @@ const router = express.Router(); * 200: { description: Distribution. } * 403: { $ref: '#/components/responses/ForbiddenError' } */ +router.get('/me/history', wrapAsync(personality_quiz_results.getCurrentUserHistory)); router.get('/me', wrapAsync(personality_quiz_results.getCurrentUserResult)); router.put( '/me', @@ -37,5 +45,6 @@ router.put( wrapAsync(personality_quiz_results.upsertCurrentUserResult), ); router.get('/distribution', wrapAsync(personality_quiz_results.distribution)); +router.get('/completion', wrapAsync(personality_quiz_results.completion)); export default router; diff --git a/backend/src/services/content_catalog.test.ts b/backend/src/services/content_catalog.test.ts index e803fd7..80a9922 100644 --- a/backend/src/services/content_catalog.test.ts +++ b/backend/src/services/content_catalog.test.ts @@ -196,4 +196,158 @@ describe('ContentCatalogService tenant scoping', () => { { 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 | 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 }) => { + 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 | 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 }) => { + 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']); + }); }); diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts index f863a96..ebcee3e 100644 --- a/backend/src/services/content_catalog.ts +++ b/backend/src/services/content_catalog.ts @@ -15,6 +15,8 @@ import { } from '@/services/shared/access'; import { CLASSROOM_SUPPORT_CONTENT_TYPE, + EI_ASSESSMENT_CONTENT_TYPE, + PERSONALITY_QUIZ_CONTENT_TYPE, SAFETY_QUIZ_CONTENT_TYPE, PER_TENANT_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES, @@ -76,6 +78,12 @@ interface ContentCatalogInput { 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) { const plain = record.get({ plain: true }); @@ -118,6 +126,8 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo ( contentType === CLASSROOM_SUPPORT_CONTENT_TYPE || contentType === SAFETY_QUIZ_CONTENT_TYPE + || contentType === EI_ASSESSMENT_CONTENT_TYPE + || contentType === PERSONALITY_QUIZ_CONTENT_TYPE ) && getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION ) { @@ -255,6 +265,7 @@ class ContentCatalogService { const record = await db.content_catalog.findOne({ where: { content_type: normalizedContentType, + active: true, ...tenantWhereFor(normalizedContentType, currentUser), }, transaction, @@ -264,6 +275,22 @@ class ContentCatalogService { 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( { payload, @@ -284,6 +311,7 @@ class ContentCatalogService { const record = await db.content_catalog.findOne({ where: { content_type: normalizedContentType, + active: true, ...tenantWhereFor(normalizedContentType, currentUser), }, transaction, diff --git a/backend/src/services/content_catalog_seed.test.ts b/backend/src/services/content_catalog_seed.test.ts index b74d426..3927865 100644 --- a/backend/src/services/content_catalog_seed.test.ts +++ b/backend/src/services/content_catalog_seed.test.ts @@ -114,4 +114,59 @@ describe('seedDefaultContentForTenant', () => { ], ); }); + + test('seeds Emotional Intelligence quizzes only at organization scope', async () => { + const createdRows: Array> = []; + + mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne); + mock.method(db.content_catalog, 'create', (async (payload: Record) => { + 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, + }, + ], + ); + }); }); diff --git a/backend/src/services/personal_scope_results.test.ts b/backend/src/services/personal_scope_results.test.ts index 2d3b1f8..ca5176a 100644 --- a/backend/src/services/personal_scope_results.test.ts +++ b/backend/src/services/personal_scope_results.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, mock, test } from 'node:test'; import assert from 'node:assert/strict'; +import { Op } from 'sequelize'; import db from '@/db/models'; import PersonalityQuizResultsService from '@/services/personality_quiz_results'; @@ -66,6 +67,85 @@ describe('personal result persistence while drilled into child scope', () => { 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 () => { let createCount = 0; 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( { + quiz_kind: 'personality_type', + quiz_id: 'personality-type', + quiz_title: 'Personality Type Quiz', personality_type: 'INFJ', quiz_answers: { 1: 'A' }, + total_questions: 1, }, parentUserDrilledIntoSchool(), ); @@ -86,29 +170,112 @@ describe('personal result persistence while drilled into child scope', () => { assert.equal(createCount, 0); }); + test('reads only the current weekly EI self-assessment result for the current user', async () => { + let capturedWhere: Record | null = null; + mock.method(db.personality_quiz_results, 'findOne', (async (options: { where: Record }) => { + 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; + 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 | null = null; + let capturedLimit: number | null = null; + mock.method(db.personality_quiz_results, 'findAll', (async (options: { + where: Record; + 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 () => { - mock.method(db.safety_quiz_results, 'findOne', (async () => ({ + let capturedWhere: Record | null = null; + mock.method(db.safety_quiz_results, 'findOne', (async (options: { where: Record }) => { + capturedWhere = options.where; + return { get: () => ({ id: 'result-1', quiz_id: 'qbs-weekly', - quiz_title: 'QBS Weekly', - week_of: '2026-06-15', - score: 4, - 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.findOne); + quiz_title: 'QBS Weekly', + week_of: '2026-06-14', + score: 4, + 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.findOne); const result = await SafetyQuizResultsService.me( - { week_of: '2026-06-15' }, + { week_of: '2026-06-17' }, createTestUser({ id: 'user-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.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 () => { @@ -185,4 +356,55 @@ describe('personal result persistence while drilled into child scope', () => { assert.equal(report.rows[0]?.status, 'complete'); 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 | null = null; + mock.method(db.personality_quiz_results, 'findAll', (async (options: { where: Record }) => { + 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; + const kindFilter = where[Op.or] as Array> | 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, + }); + }); }); diff --git a/backend/src/services/personality_quiz_results.ts b/backend/src/services/personality_quiz_results.ts index e8a7c5c..f964a3a 100644 --- a/backend/src/services/personality_quiz_results.ts +++ b/backend/src/services/personality_quiz_results.ts @@ -6,22 +6,101 @@ import ValidationError from '@/shared/errors/validation'; import { getOrganizationIdOrGlobal, getCampusId, + getSchoolId, + getClassId, assertAuthenticatedTenantUser, campusDimensionScope, hasFeaturePermission, isActingInOwnScope, + getDisplayName, + getRoleScope, + requireUserId, } from '@/services/shared/access'; 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 { Users } from '@/db/models/users'; import type { CurrentUser } from '@/db/api/types'; interface PersonalityInput { - personality_type: string; + quiz_kind?: string; + quiz_id: string; + quiz_title: string; + personality_type?: string | null; quiz_answers: unknown; + score?: number | null; + total_questions: number; + result_label?: string | null; + result_payload?: unknown; } interface PersonalityFilter { 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 { @@ -29,26 +108,123 @@ function assertValidResult(data: PersonalityInput): void { throw new ValidationError(); } + const quizKind = normalizeQuizKind(data.quiz_kind); if ( - typeof data.personality_type !== 'string' || - data.personality_type.trim().length === 0 || + typeof data.quiz_id !== 'string' || + data.quiz_id.trim().length === 0 || + typeof data.quiz_title !== 'string' || + data.quiz_title.trim().length === 0 || !data.quiz_answers || typeof data.quiz_answers !== 'object' || - Array.isArray(data.quiz_answers) + Array.isArray(data.quiz_answers) || + !Number.isInteger(data.total_questions) ) { throw new ValidationError(); } 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 ( - !answerValues.every( - (value) => typeof value === 'string' && value.trim().length > 0, - ) + data.score !== null && + data.score !== undefined && + !Number.isInteger(data.score) ) { 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 { + const byUserAndKind = new Map(); + 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) { if (!record) { return null; @@ -58,8 +234,18 @@ function toDto(record: PersonalityQuizResults | null) { return { 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, 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, organizationId: plain.organizationId, campusId: plain.campusId, @@ -72,23 +258,57 @@ function toDto(record: PersonalityQuizResults | null) { } class PersonalityQuizResultsService { - static async getCurrentUserResult(currentUser?: CurrentUser) { + static async getCurrentUserResult( + filter: PersonalityFilter, + currentUser?: CurrentUser, + ) { assertAuthenticatedTenantUser(currentUser); const organizationId = getOrganizationIdOrGlobal(currentUser); const orgFilter = organizationId ? { organizationId } : {}; + const quizKind = normalizeQuizKind(filter.quiz_kind); const record = await db.personality_quiz_results.findOne({ where: { ...orgFilter, - userId: currentUser?.id ?? null, + userId: requireUserId(currentUser), + quiz_kind: quizKind, + ...weekWhereForQuizKind(quizKind), }, - order: [['updatedAt', 'desc']], + order: [['completed_at', 'desc']], }); 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( data: PersonalityInput, currentUser?: CurrentUser, @@ -97,43 +317,39 @@ class PersonalityQuizResultsService { assertValidResult(data); if (!isActingInOwnScope(currentUser)) { - return this.getCurrentUserResult(currentUser); + return null; } const organizationId = getOrganizationIdOrGlobal(currentUser); - const orgFilter = organizationId ? { organizationId } : {}; - const where = { - ...orgFilter, - userId: currentUser?.id ?? null, - }; + const quizKind = normalizeQuizKind(data.quiz_kind); + const weekOf = quizKind === EI_SELF_ASSESSMENT_KIND ? currentWeekStartIso() : null; return withTransaction(async (transaction) => { - const existing = await db.personality_quiz_results.findOne({ - where, - transaction, - }); - 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, + 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(), organizationId, campusId: getCampusId(currentUser), userId: currentUser?.id ?? null, + createdById: currentUser?.id ?? null, updatedById: currentUser?.id ?? null, }; - let saved: PersonalityQuizResults; - if (existing) { - saved = await existing.update(payload, { transaction }); - } else { - saved = await db.personality_quiz_results.create( - { - ...payload, - createdById: currentUser?.id ?? null, - }, - { transaction }, - ); - } + const saved = await db.personality_quiz_results.create(payload, { + transaction, + }); return toDto(saved); }); @@ -158,12 +374,9 @@ class PersonalityQuizResultsService { const orgFilter = organizationId ? { organizationId } : {}; const rows = await db.personality_quiz_results.findAll({ - attributes: [ - 'personality_type', - [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count'], - ], where: { ...orgFilter, + quiz_kind: PERSONALITY_QUIZ_KIND, // School/campus isolation; an explicit campusId filter is intersected // (Op.and) with the scope so a school role cannot read another school. [Op.and]: [ @@ -171,16 +384,131 @@ class PersonalityQuizResultsService { ...(filter.campusId ? [{ campusId: filter.campusId }] : []), ], }, - group: ['personality_type'], - order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']], + order: [['completed_at', 'desc']], }); + const latestByUser = new Map(); + for (const row of rows) { + if (!row.userId || latestByUser.has(row.userId)) { + continue; + } + latestByUser.set(row.userId, row); + } + const counts = new Map(); + 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 { - rows: rows.map((row) => ({ - type: row.get('personality_type'), - count: Number(row.get('count')), - })), - count: rows.length, + rows: sortedRows, + count: sortedRows.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, }; } } diff --git a/backend/src/services/safety_quiz_results.ts b/backend/src/services/safety_quiz_results.ts index 111345d..2f23d2c 100644 --- a/backend/src/services/safety_quiz_results.ts +++ b/backend/src/services/safety_quiz_results.ts @@ -22,6 +22,7 @@ import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { SafetyQuizResults } from '@/db/models/safety_quiz_results'; import type { Users } from '@/db/models/users'; import type { CurrentUser } from '@/db/api/types'; +import { toWeekStartIso } from '@/shared/constants/week'; interface SafetyQuizInput { 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) { const plain = record.get({ plain: true }); @@ -186,6 +199,7 @@ class SafetyQuizResultsService { static async list(filter: SafetyQuizFilter, currentUser?: CurrentUser) { assertAuthenticatedTenantUser(currentUser); const { limit, offset } = resolvePagination(filter.limit, filter.page); + const weekOf = optionalWeekStart(filter.week_of); const organizationId = getOrganizationIdOrGlobal(currentUser); const orgFilter = organizationId ? { organizationId } : {}; @@ -199,7 +213,7 @@ class SafetyQuizResultsService { ) ? campusDimensionScope(currentUser) : { userId: currentUser?.id ?? null }), - ...(filter.week_of ? { week_of: filter.week_of } : {}), + ...(weekOf ? { week_of: weekOf } : {}), }, order: [['completed_at', 'desc']], limit, @@ -223,11 +237,12 @@ class SafetyQuizResultsService { const organizationId = getOrganizationIdOrGlobal(currentUser); return withTransaction(async (transaction) => { + const weekOf = requireWeekStart(data.week_of); const created = await db.safety_quiz_results.create( { quiz_id: data.quiz_id.trim(), quiz_title: data.quiz_title.trim(), - week_of: data.week_of.trim(), + week_of: weekOf, score: data.score, total_questions: data.total_questions, answers: data.answers, @@ -249,10 +264,11 @@ class SafetyQuizResultsService { static async me(filter: SafetyQuizFilter, currentUser?: CurrentUser) { assertAuthenticatedTenantUser(currentUser); + const weekOf = optionalWeekStart(filter.week_of); const result = await db.safety_quiz_results.findOne({ where: { userId: requireUserId(currentUser), - ...(filter.week_of ? { week_of: filter.week_of } : {}), + ...(weekOf ? { week_of: weekOf } : {}), }, order: [['completed_at', 'desc']], }); @@ -265,6 +281,7 @@ class SafetyQuizResultsService { static async completion(filter: SafetyQuizFilter, currentUser?: CurrentUser) { assertCanReadCompletion(currentUser); + const weekOf = optionalWeekStart(filter.week_of); const staffUsers = await db.users.findAll({ where: { disabled: false, @@ -290,7 +307,7 @@ class SafetyQuizResultsService { ? await db.safety_quiz_results.findAll({ where: { userId: userIds, - ...(filter.week_of ? { week_of: filter.week_of } : {}), + ...(weekOf ? { week_of: weekOf } : {}), }, order: [['completed_at', 'desc']], }) diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index 0d82940..5c2698b 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -4,6 +4,12 @@ export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies'; /** The safety/QBS quiz content type, owned and managed at organization scope. */ 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). */ export const ESA_CONTENT_TYPE = 'esa-funding-content'; @@ -28,11 +34,12 @@ export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ SAFETY_QUIZ_CONTENT_TYPE, CLASSROOM_SUPPORT_CONTENT_TYPE, + EI_ASSESSMENT_CONTENT_TYPE, + PERSONALITY_QUIZ_CONTENT_TYPE, 'regulation-zones', 'zones-of-regulation-page-content', 'sign-language-items', 'sign-language-page-content', - 'emotional-intelligence-assessment-questions', 'emotional-intelligence-weekly-topics', 'emotional-intelligence-growth-tips', 'emotional-intelligence-team-wellness-metrics', diff --git a/docs/backlog.md b/docs/backlog.md index 49aaed7..2c68cd5 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -24,7 +24,7 @@ Two scoping modes coexist: - **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. -- **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: diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index dc627b3..d5e912a 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -43,12 +43,12 @@ catalog only once the user types (see `top-bar-integration.md`). - dashboard quote, compliance items, and sign of the week - community organizations - 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 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`) ## Error Handling diff --git a/frontend/docs/director-dashboard-integration.md b/frontend/docs/director-dashboard-integration.md index 5983c38..f7d4ecf 100644 --- a/frontend/docs/director-dashboard-integration.md +++ b/frontend/docs/director-dashboard-integration.md @@ -2,7 +2,7 @@ ## 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 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/safetyQuizResults.ts` +- `frontend/src/shared/api/personality.ts` - `frontend/src/shared/api/staffAttendance.ts` - `frontend/src/shared/api/policyAcknowledgments.ts` @@ -36,10 +37,16 @@ Constants: - The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`. - 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. - 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. - Loading, empty, and error states are explicit. diff --git a/frontend/docs/personality-catalog.md b/frontend/docs/personality-catalog.md index a414fed..3693336 100644 --- a/frontend/docs/personality-catalog.md +++ b/frontend/docs/personality-catalog.md @@ -2,13 +2,12 @@ ## 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 `personalityStaticContent.ts`: -- `PERSONALITY_QUIZ_QUESTIONS` - `PERSONALITY_TYPES` - `PERSONALITY_QUIZ_FEATURES` - `PERSONALITY_WORKPLACE_CONTENT` @@ -17,14 +16,14 @@ Static emotional-intelligence personality quiz content lives in `frontend/src/sh - `calculateMBTI` - `getPersonalityType` -- static catalog types for quiz questions and personality descriptions +- static catalog types for quiz question payloads and personality descriptions ## 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` - DTO types: `frontend/src/shared/types/personality.ts` - 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. diff --git a/frontend/docs/personality-integration.md b/frontend/docs/personality-integration.md index fbecad8..543d24a 100644 --- a/frontend/docs/personality-integration.md +++ b/frontend/docs/personality-integration.md @@ -2,7 +2,7 @@ ## 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 View -> Business Logic -> API/Data Access -> Backend @@ -14,6 +14,7 @@ View layer: - `frontend/src/components/frameworks/EmotionalIntelligence.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/EmotionalIntelligenceTabs.tsx` - `frontend/src/components/emotional-intelligence/PersonalityDistributionPanel.tsx` @@ -45,13 +46,32 @@ API/data access layer: ## Behavior -- The current user's saved personality result loads from `GET /api/personality_quiz_results/me`. -- Quiz completion saves through `PUT /api/personality_quiz_results/me`. +- The current user's latest saved result for each quiz kind loads from + `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 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 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`. +- 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. - `EmotionalIntelligence.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 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. -- EI questions, topics, growth tips, MBTI dimensions, and workplace tips live in shared constants. -- Personality type directory records load from the backend content catalog. +- EI questions and Personality Type quiz questions are backend-owned organization content. + 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. ## 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. diff --git a/frontend/docs/safety-quiz-integration.md b/frontend/docs/safety-quiz-integration.md index bbd9808..3370cbb 100644 --- a/frontend/docs/safety-quiz-integration.md +++ b/frontend/docs/safety-quiz-integration.md @@ -61,7 +61,13 @@ Constants: - `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. - 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. +- 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. - Leadership dashboards derive QBS completion metrics and risk rows from the backend completion summary. diff --git a/frontend/docs/static-app-data.md b/frontend/docs/static-app-data.md index 9cbe13b..c1b12c0 100644 --- a/frontend/docs/static-app-data.md +++ b/frontend/docs/static-app-data.md @@ -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. - 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. -- 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. diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index fa30abd..09d0ed7 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -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/personality/mappers.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/selectors.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/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. diff --git a/frontend/docs/top-bar-integration.md b/frontend/docs/top-bar-integration.md index db2cac3..8cb821d 100644 --- a/frontend/docs/top-bar-integration.md +++ b/frontend/docs/top-bar-integration.md @@ -39,6 +39,11 @@ Shared config: - Notification nudges include the daily Emotional Zone reminder only when the user has explicit `ZONE_CHECKIN`; that personal workflow permission is not 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. - **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. @@ -46,7 +51,7 @@ Shared config: ## 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 + combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal). diff --git a/frontend/src/business/content-catalog/hooks.ts b/frontend/src/business/content-catalog/hooks.ts index 11b3fa7..5b41ab4 100644 --- a/frontend/src/business/content-catalog/hooks.ts +++ b/frontend/src/business/content-catalog/hooks.ts @@ -1,5 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; -import { getContentCatalog } from '@/shared/api/contentCatalog'; +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; +import { + createManagedContentCatalog, + deleteManagedContentCatalog, + getContentCatalog, + getManagedContentCatalog, + updateManagedContentCatalog, +} from '@/shared/api/contentCatalog'; import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog'; export function useContentCatalogPayload( @@ -23,3 +29,53 @@ export function useContentCatalogPayload( refresh: query.refetch, }; } + +export function useManagedContentCatalog(contentType: string) { + return useQuery({ + queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType], + queryFn: () => getManagedContentCatalog(contentType), + }); +} + +export function useSaveManagedContentCatalog( + contentType: string, + hasExistingContent: boolean, + getPayload: () => TPayload, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => ( + hasExistingContent + ? updateManagedContentCatalog(contentType, { payload: getPayload() }) + : createManagedContentCatalog({ + 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 { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType] }), + queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType] }), + ]); +} diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts index fe5bc13..8f74b0f 100644 --- a/frontend/src/business/director-dashboard/hooks.ts +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useFrameEntries } from '@/business/frame/hooks'; import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks'; +import { usePersonalityCompletion } from '@/business/personality/queryHooks'; import { useStaffAttendanceRecords, useStaffAttendanceSummary, @@ -10,6 +11,7 @@ import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks'; import { buildDirectorFramePreviews, buildDirectorOverviewCards, + buildDirectorQuizResults, buildDirectorRiskAreas, } from '@/business/director-dashboard/selectors'; import type { DirectorDashboardPage } from '@/business/director-dashboard/types'; @@ -32,11 +34,13 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); const frameEntriesQuery = useFrameEntries(); const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true); + const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true); const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const frameEntries = frameEntriesQuery.data ?? []; const quizRows = quizCompletionQuery.data?.rows ?? []; + const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null; const quizSummary = quizCompletionQuery.data?.summary ?? { totalStaff: 0, completedCount: 0, @@ -46,11 +50,13 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const attendanceRecords = staffAttendanceRecordsQuery.data ?? []; const isLoading = frameEntriesQuery.isLoading || quizCompletionQuery.isLoading + || emotionalIntelligenceCompletionQuery.isLoading || staffAttendanceRecordsQuery.isLoading || staffAttendanceSummaryQuery.isLoading || acknowledgmentReportQuery.isLoading; const error = frameEntriesQuery.error ?? quizCompletionQuery.error + ?? emotionalIntelligenceCompletionQuery.error ?? staffAttendanceRecordsQuery.error ?? staffAttendanceSummaryQuery.error ?? acknowledgmentReportQuery.error; @@ -65,10 +71,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { frameEntries, acknowledgmentReportQuery.data?.summary, ), - riskAreas: buildDirectorRiskAreas(attendanceRecords, quizSummary), + riskAreas: buildDirectorRiskAreas( + attendanceRecords, + quizSummary, + emotionalIntelligenceCompletion, + ), framePreviews: buildDirectorFramePreviews(frameEntries), quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, - quizResults: quizRows, + quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion), isLoading, error, setTimeRange: setTimeRangeState, diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts index 789fadd..d3ea8dc 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -3,12 +3,17 @@ import { describe, expect, it } from 'vitest'; import { buildDirectorFramePreviews, buildDirectorOverviewCards, + buildDirectorQuizResults, buildDirectorRiskAreas, calculateQuizCompletionRate, } from '@/business/director-dashboard/selectors'; import type { FrameEntryViewModel } from '@/business/frame/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( overrides: Partial = {}, @@ -51,6 +56,78 @@ function createFrameEntry(overrides: Partial = {}): FrameEn }; } +function createPersonalityCompletion( + overrides: Partial = {}, +): 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', () => { it('calculates quiz completion rate with empty staff protection', () => { expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0); @@ -103,6 +180,16 @@ describe('director dashboard selectors', () => { severity: 'high', 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', 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', () => { const longText = 'A'.repeat(70); const previews = buildDirectorFramePreviews([ diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts index 97a25f5..819dc6c 100644 --- a/frontend/src/business/director-dashboard/selectors.ts +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -15,9 +15,17 @@ import type { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policy import type { DirectorFramePreview, DirectorOverviewCard, + DirectorQuizResultRow, DirectorRiskArea, } 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( quizSummary: SafetyQuizCompletionSummary, @@ -87,9 +95,18 @@ export function buildDirectorOverviewCards( export function buildDirectorRiskAreas( attendanceRecords: readonly StaffAttendanceRecordViewModel[], quizSummary: SafetyQuizCompletionSummary, + emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, ): readonly DirectorRiskArea[] { const incompleteStaffCount = quizSummary.pendingCount; 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 [ { @@ -97,6 +114,16 @@ export function buildDirectorRiskAreas( severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium', 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`, 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( frameEntries: readonly FrameEntryViewModel[], ): readonly DirectorFramePreview[] { @@ -128,3 +193,33 @@ function truncatePreview(value: string): string { 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', + }); +} diff --git a/frontend/src/business/director-dashboard/types.ts b/frontend/src/business/director-dashboard/types.ts index 1d2185f..c0d5be7 100644 --- a/frontend/src/business/director-dashboard/types.ts +++ b/frontend/src/business/director-dashboard/types.ts @@ -3,13 +3,13 @@ import type { DirectorQuickActionConfig, } from '@/shared/constants/directorDashboard'; import type { ModuleId } from '@/shared/types/app'; -import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types'; export type DirectorDashboardTrend = 'up' | 'down'; export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low'; export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard'; export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald'; export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E'; +export type DirectorQuizResultStatus = 'complete' | 'pending'; export interface DirectorOverviewCard { readonly label: string; @@ -38,6 +38,16 @@ export interface DirectorFramePreview { 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 { /** Role-specific dashboard title (e.g. "Owner Dashboard"). */ readonly title: string; @@ -48,7 +58,7 @@ export interface DirectorDashboardPage { readonly riskAreas: readonly DirectorRiskArea[]; readonly framePreviews: readonly DirectorFramePreview[]; readonly quickActions: readonly DirectorQuickActionConfig[]; - readonly quizResults: readonly SafetyQuizComplianceRow[]; + readonly quizResults: readonly DirectorQuizResultRow[]; readonly isLoading: boolean; readonly error: unknown; readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void; diff --git a/frontend/src/business/personality/emotionalIntelligenceHooks.ts b/frontend/src/business/personality/emotionalIntelligenceHooks.ts index cdc1de7..c5a5f43 100644 --- a/frontend/src/business/personality/emotionalIntelligenceHooks.ts +++ b/frontend/src/business/personality/emotionalIntelligenceHooks.ts @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { useCurrentPersonalityResult, + usePersonalityCompletion, usePersonalityDistribution, useSaveCurrentPersonalityResult, } from '@/business/personality/queryHooks'; @@ -13,6 +14,7 @@ import { } from '@/business/personality/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality'; import { PERSONALITY_TYPES, PERSONALITY_WORKPLACE_CONTENT, @@ -20,6 +22,7 @@ import { import type { UserRole } from '@/shared/types/app'; import type { EmotionalIntelligenceQuestion, + EmotionalIntelligencePersonalityQuizContent, EmotionalIntelligenceTab, EmotionalIntelligenceTopic, EmotionalIntelligenceWeeklyFocus, @@ -29,16 +32,27 @@ import type { PersonalityDistributionDto } from '@/shared/types/personality'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; import { useScopeContext } from '@/shared/app/scope-context'; +import { usePermissions } from '@/shared/app/usePermissions'; const EMPTY_PERSONALITY_DISTRIBUTION: readonly PersonalityDistributionDto[] = []; -export function useEmotionalIntelligencePage(userRole: UserRole) { +export function useEmotionalIntelligencePage(_userRole: UserRole) { + const permissions = usePermissions(); const { ownTenant, selectedTenant } = useScopeContext(); + const activeTenant = selectedTenant ?? ownTenant; 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( CONTENT_CATALOG_TYPES.emotionalIntelligenceAssessmentQuestions, [], ); + const personalityQuizQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.emotionalIntelligencePersonalityQuiz, + null, + ); const weeklyTopicsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.emotionalIntelligenceWeeklyTopics, [], @@ -61,15 +75,38 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { const [answers, setAnswers] = useState([]); 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 canViewPersonalityDistribution = userRole === 'director' || userRole === 'superintendent'; const personalityDistributionQuery = usePersonalityDistribution(undefined, canViewPersonalityDistribution); + const personalityCompletionQuery = usePersonalityCompletion(canViewPersonalityDistribution); + const savedSelfAssessment = currentSelfAssessmentQuery.data; const savedPersonality = currentPersonalityQuery.data; const personalityResult = savedPersonality?.personalityType ?? null; - const savedAnswers = savedPersonality?.quizAnswers ?? null; + const savedAnswers = savedPersonality?.quizAnswers + ? Object.entries(savedPersonality.quizAnswers).reduce>((result, [key, value]) => { + if (typeof value === 'string') { + result[Number(key)] = value; + } + return result; + }, {}) + : 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 distributionTotal = totalPersonalityDistribution(distribution); const groupDistribution = useMemo( @@ -81,10 +118,13 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { const maxScore = assessmentQuestions.length * 4; const percentage = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0; const assessmentLevel = getEmotionalIntelligenceLevel(percentage); - const personalityError = currentPersonalityQuery.error ?? savePersonalityMutation.error; + const personalityError = currentSelfAssessmentQuery.error + ?? currentPersonalityQuery.error + ?? savePersonalityMutation.error; const distributionError = personalityDistributionQuery.error; const contentQueries = [ assessmentQuestionsQuery, + personalityQuizQuery, weeklyTopicsQuery, growthTipsQuery, teamWellnessMetricsQuery, @@ -105,6 +145,30 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { } 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>((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 = () => { @@ -120,8 +184,16 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { } await savePersonalityMutation.mutateAsync({ + quizKind: PERSONALITY_QUIZ_KINDS.personalityType, + quizId: personalityQuizQuery.payload?.id ?? 'personality-type', + quizTitle: personalityQuizQuery.payload?.title ?? 'Personality Type Quiz', personalityType: code, 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, percentage, assessmentLevel, + currentSelfAssessmentQuery, currentPersonalityQuery, + personalityCompletionQuery, personalityDistributionQuery, canViewPersonalityDistribution, + canManageQuizContent, savedPersonality, + savedSelfAssessment, + selfAssessmentResult, personalityResult, savedAnswers, savedDate, canPersistPersonalResults, isSaving: canPersistPersonalResults && savePersonalityMutation.isPending, - isLoadingSaved: canPersistPersonalResults && currentPersonalityQuery.isLoading, + isLoadingSaved: canPersistPersonalResults && ( + currentSelfAssessmentQuery.isLoading || + currentPersonalityQuery.isLoading + ), distribution, + personalityCompletion: personalityCompletionQuery.data ?? null, distributionLoading: personalityDistributionQuery.isLoading || personalityDistributionQuery.isFetching, distributionTotal, groupDistribution, errorMessage: getOptionalErrorMessage(personalityError), - distributionErrorMessage: getOptionalErrorMessage(distributionError), - isDirector: userRole === 'director', + distributionErrorMessage: getOptionalErrorMessage(distributionError ?? personalityCompletionQuery.error), + isDirector: canViewPersonalityDistribution, assessmentQuestions, + personalityQuiz: personalityQuizQuery.payload, weeklyTopics: weeklyTopicsQuery.payload, growthTips: growthTipsQuery.payload, teamWellnessMetrics: teamWellnessMetricsQuery.payload, diff --git a/frontend/src/business/personality/mappers.test.ts b/frontend/src/business/personality/mappers.test.ts index 5d7a548..e1831f3 100644 --- a/frontend/src/business/personality/mappers.test.ts +++ b/frontend/src/business/personality/mappers.test.ts @@ -5,6 +5,7 @@ import { } from '@/business/personality/mappers'; import type { PersonalityQuizSubmission } from '@/business/personality/types'; import type { PersonalityQuizResultDto } from '@/shared/types/personality'; +import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality'; describe('personality mappers', () => { 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', () => { const dto: PersonalityQuizResultDto = { id: 'personality-1', + quiz_kind: PERSONALITY_QUIZ_KINDS.personalityType, + quiz_id: 'personality-type', + quiz_title: 'Personality Type Quiz', personality_type: 'INFJ', quiz_answers: { '1': 'I', @@ -21,6 +25,13 @@ describe('personality mappers', () => { '3': 'F', '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', organizationId: 'org-1', campusId: 'campus-1', @@ -32,6 +43,9 @@ describe('personality mappers', () => { }; expect(toPersonalityQuizResultViewModel(dto)).toEqual({ + quizKind: PERSONALITY_QUIZ_KINDS.personalityType, + quizId: 'personality-type', + quizTitle: 'Personality Type Quiz', personalityType: 'INFJ', quizAnswers: { 1: 'I', @@ -39,12 +53,21 @@ describe('personality mappers', () => { 3: 'F', 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', }); }); it('maps quiz submission state back into the backend mutation DTO shape', () => { const submission: PersonalityQuizSubmission = { + quizKind: PERSONALITY_QUIZ_KINDS.personalityType, + quizId: 'personality-type', + quizTitle: 'Personality Type Quiz', personalityType: 'ESTP', quizAnswers: { 1: 'E', @@ -52,9 +75,13 @@ describe('personality mappers', () => { 3: 'T', 4: 'P', }, + totalQuestions: 4, }; expect(toPersonalityQuizResultMutationDto(submission)).toEqual({ + quiz_kind: PERSONALITY_QUIZ_KINDS.personalityType, + quiz_id: 'personality-type', + quiz_title: 'Personality Type Quiz', personality_type: 'ESTP', quiz_answers: { '1': 'E', @@ -62,6 +89,10 @@ describe('personality mappers', () => { '3': 'T', '4': 'P', }, + score: null, + total_questions: 4, + result_label: 'ESTP', + result_payload: undefined, }); }); }); diff --git a/frontend/src/business/personality/mappers.ts b/frontend/src/business/personality/mappers.ts index b58a6e8..331ba6e 100644 --- a/frontend/src/business/personality/mappers.ts +++ b/frontend/src/business/personality/mappers.ts @@ -1,4 +1,5 @@ import type { + PersonalityAnswerValue, PersonalityQuizResultDto, PersonalityQuizResultMutationDto, } from '@/shared/types/personality'; @@ -7,15 +8,15 @@ import type { PersonalityQuizSubmission, } from '@/business/personality/types'; -function toNumericAnswerMap(answers: Record): Record { - return Object.entries(answers).reduce>((result, [key, value]) => { +function toNumericAnswerMap(answers: Record): Record { + return Object.entries(answers).reduce>((result, [key, value]) => { result[Number(key)] = value; return result; }, {}); } -function toStringAnswerMap(answers: Record): Record { - return Object.entries(answers).reduce>((result, [key, value]) => { +function toStringAnswerMap(answers: Record): Record { + return Object.entries(answers).reduce>((result, [key, value]) => { result[key] = value; return result; }, {}); @@ -29,8 +30,17 @@ export function toPersonalityQuizResultViewModel( } return { + quizKind: dto.quiz_kind, + quizId: dto.quiz_id, + quizTitle: dto.quiz_title, + weekOf: dto.week_of, personalityType: dto.personality_type, 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, }; } @@ -39,7 +49,14 @@ export function toPersonalityQuizResultMutationDto( submission: PersonalityQuizSubmission, ): PersonalityQuizResultMutationDto { 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), + score: submission.score ?? null, + total_questions: submission.totalQuestions, + result_label: submission.resultLabel ?? submission.personalityType ?? null, + result_payload: submission.resultPayload, }; } diff --git a/frontend/src/business/personality/queryHooks.ts b/frontend/src/business/personality/queryHooks.ts index d56632f..81fcada 100644 --- a/frontend/src/business/personality/queryHooks.ts +++ b/frontend/src/business/personality/queryHooks.ts @@ -1,6 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { getCurrentPersonalityResult, + getPersonalityCompletion, + listCurrentPersonalityResultHistory, listPersonalityDistribution, saveCurrentPersonalityResult, } from '@/shared/api/personality'; @@ -9,30 +11,54 @@ import { toPersonalityQuizResultViewModel, } from '@/business/personality/mappers'; 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 { useInvalidatingMutation } from '@/shared/business/queryMutations'; -export function useCurrentPersonalityResult(enabled = true) { +export function useCurrentPersonalityResult(quizKind: PersonalityQuizKind, enabled = true) { return useQuery({ - queryKey: PERSONALITY_QUERY_KEYS.current, + queryKey: PERSONALITY_QUERY_KEYS.current(quizKind), enabled, queryFn: async () => { - const response = await getCurrentPersonalityResult(); + const response = await getCurrentPersonalityResult(quizKind); 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() { + const queryClient = useQueryClient(); + return useInvalidatingMutation({ mutationFn: (submission: PersonalityQuizSubmission) => saveCurrentPersonalityResult( toPersonalityQuizResultMutationDto(submission), ), - invalidateQueryKeys: [ - PERSONALITY_QUERY_KEYS.current, - PERSONALITY_QUERY_KEYS.distribution, - ], + invalidateQueryKey: PERSONALITY_QUERY_KEYS.distribution, + onSuccess: async (_data, submission) => { + 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)), }); } + +export function usePersonalityCompletion(enabled = true) { + return useQuery({ + queryKey: PERSONALITY_QUERY_KEYS.completion, + enabled, + queryFn: getPersonalityCompletion, + }); +} diff --git a/frontend/src/business/personality/quizWorkflowHooks.ts b/frontend/src/business/personality/quizWorkflowHooks.ts index 7039f44..c78c799 100644 --- a/frontend/src/business/personality/quizWorkflowHooks.ts +++ b/frontend/src/business/personality/quizWorkflowHooks.ts @@ -26,6 +26,7 @@ export function usePersonalityQuizWorkflow({ onResult, savedType, savedAnswers, + questions: inputQuestions, }: PersonalityQuizWorkflowInput) { const [started, setStarted] = useState(false); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -36,7 +37,7 @@ export function usePersonalityQuizWorkflow({ const [activeResultTab, setActiveResultTab] = useState('overview'); const [showSavedResult, setShowSavedResult] = useState(false); - const questions = PERSONALITY_QUIZ_QUESTIONS; + const questions = inputQuestions ?? PERSONALITY_QUIZ_QUESTIONS; const personalityTypes = PERSONALITY_TYPES; const totalQuestions = questions.length; const currentQuestion = questions[currentQuestionIndex]; diff --git a/frontend/src/business/personality/types.ts b/frontend/src/business/personality/types.ts index 9ae6283..cb3ec1f 100644 --- a/frontend/src/business/personality/types.ts +++ b/frontend/src/business/personality/types.ts @@ -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 { - readonly personalityType: string; - readonly quizAnswers: Record; + readonly quizKind: PersonalityQuizKind; + readonly quizId: string; + readonly quizTitle: string; + readonly weekOf: string | null; + readonly personalityType: string | null; + readonly quizAnswers: Record; + readonly score: number | null; + readonly totalQuestions: number; + readonly resultLabel: string | null; + readonly resultPayload: unknown | null; + readonly completedAt: string; readonly updatedAt: string; } export interface PersonalityQuizSubmission { - readonly personalityType: string; - readonly quizAnswers: Record; + readonly quizKind: PersonalityQuizKind; + readonly quizId: string; + readonly quizTitle: string; + readonly personalityType?: string | null; + readonly quizAnswers: Record; + readonly score?: number | null; + readonly totalQuestions: number; + readonly resultLabel?: string | null; + readonly resultPayload?: unknown; } export type PersonalityQuizResultTab = 'overview' | 'relationships' | 'language'; @@ -43,6 +63,7 @@ export interface PersonalityQuizWorkflowInput { readonly onResult?: (code: string, answers: Record) => void | Promise; readonly savedType?: string | null; readonly savedAnswers?: Record | null; + readonly questions?: readonly import('@/shared/constants/personalityCatalog').QuizQuestion[]; } export type PersonalityDirectoryFilterGroup = PersonalityDirectoryFilterGroupValue; diff --git a/frontend/src/business/profile/selectors.test.ts b/frontend/src/business/profile/selectors.test.ts new file mode 100644 index 0000000..2ea10b2 --- /dev/null +++ b/frontend/src/business/profile/selectors.test.ts @@ -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 { + 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' }, + ]); + }); +}); diff --git a/frontend/src/business/profile/selectors.ts b/frontend/src/business/profile/selectors.ts new file mode 100644 index 0000000..22f0740 --- /dev/null +++ b/frontend/src/business/profile/selectors.ts @@ -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', + }; +} diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index d7b4498..0419afb 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -18,12 +18,14 @@ import { import { getScopedModules } from '@/business/app-shell/selectors'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks'; +import { useCurrentPersonalityResult } from '@/business/personality/queryHooks'; import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { useSafetyProtocols } from '@/business/safety-protocols/hooks'; import { hasPermission } from '@/business/auth/permissions'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { MODULES } from '@/shared/constants/appData'; +import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality'; import type { ModuleId, SignItem, @@ -88,6 +90,23 @@ export function useTopBarPage({ const needsSafetyQuiz = canReceiveSafetyQuizNotification && !safetyQuizStatus.isLoading && 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 acknowledgedCommunicationEventIds = useMemo(() => new Set(), []); const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY'); @@ -101,6 +120,8 @@ export function useTopBarPage({ const notifications = buildTopBarNotifications({ needsZoneCheckIn, needsSafetyQuiz, + needsEiSelfAssessment, + needsPersonalityQuiz, communicationEvents: communicationEvents.data ?? [], acknowledgedCommunicationEventIds, handbookPolicies: handbookPolicies.data ?? [], diff --git a/frontend/src/business/top-bar/selectors.test.ts b/frontend/src/business/top-bar/selectors.test.ts index 7b10884..00ab7b6 100644 --- a/frontend/src/business/top-bar/selectors.test.ts +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -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', () => { expect(getTopBarInitials('Guest')).toBe('G'); expect(getTopBarInitials('Ada Lovelace')).toBe('AL'); diff --git a/frontend/src/business/top-bar/selectors.ts b/frontend/src/business/top-bar/selectors.ts index 04c4166..fac4dfb 100644 --- a/frontend/src/business/top-bar/selectors.ts +++ b/frontend/src/business/top-bar/selectors.ts @@ -39,15 +39,19 @@ export function countUnreadTopBarNotifications( const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; 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 - * backend notifications store yet). Currently surfaces a single nudge when an - * eligible user has not logged today's Zone. + * Builds the top-bar notification list from derived app state. Personal + * completion reminders are driven by backend-backed status queries; there is + * no persisted notifications store yet. */ export function buildTopBarNotifications(input: { readonly needsZoneCheckIn: boolean; readonly needsSafetyQuiz?: boolean; + readonly needsEiSelfAssessment?: boolean; + readonly needsPersonalityQuiz?: boolean; readonly communicationEvents?: readonly CommunicationEventDto[]; readonly acknowledgedCommunicationEventIds?: ReadonlySet; 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 ?? []) { if (input.acknowledgedCommunicationEventIds?.has(event.id)) { continue; diff --git a/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx index c5a8896..370fb01 100644 --- a/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx +++ b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx @@ -9,10 +9,10 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types'; +import type { DirectorQuizResultRow } from '@/business/director-dashboard/types'; interface DirectorQuizResultsPanelProps { - readonly results: readonly SafetyQuizComplianceRow[]; + readonly results: readonly DirectorQuizResultRow[]; } export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) { @@ -27,15 +27,17 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr Staff + Quiz Role - Score + Result Date {results.map((result) => ( - - {result.name} + + {result.staffName} + {result.quiz} {result.role} - {result.score} + {result.result} diff --git a/frontend/src/components/emotional-intelligence/AssessmentTab.tsx b/frontend/src/components/emotional-intelligence/AssessmentTab.tsx index 62461c0..2667c84 100644 --- a/frontend/src/components/emotional-intelligence/AssessmentTab.tsx +++ b/frontend/src/components/emotional-intelligence/AssessmentTab.tsx @@ -12,6 +12,7 @@ import { TrendingUp, Users, } from 'lucide-react'; +import type { ReactNode } from 'react'; import type { LucideIcon } from 'lucide-react'; import { StatePanel } from '@/components/ui/state-panel'; @@ -19,6 +20,7 @@ import type { EmotionalIntelligencePageActions, EmotionalIntelligencePageState, } from '@/components/emotional-intelligence/types'; +import type { PersonalityCompletionRowDto } from '@/shared/types/personality'; import type { EmotionalIntelligenceTopicIconId } from '@/shared/types/emotionalIntelligence'; import { getPersonalityType } from '@/shared/constants/personalityCatalog'; @@ -246,16 +248,47 @@ function AssessmentSidebar({ state, actions }: AssessmentTabProps) { - {state.isDirector && } + {state.canViewPersonalityDistribution && } ); } -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 (

Team Wellness (Aggregated)

-

No individual emotional data shown

+

Aggregated completion only. Individual emotional answers stay private.

{metrics.map((item) => (
@@ -270,6 +303,103 @@ function TeamWellnessCard({ metrics }: { readonly metrics: readonly EmotionalInt
))}
+ {state.personalityCompletion && ( + + )} +
+ ); +} + +function QuizResultsByCategory({ + rows, +}: { + readonly rows: readonly PersonalityCompletionRowDto[]; +}) { + if (rows.length === 0) { + return null; + } + + return ( +
+
+

Quiz Results

+

+ Grouped by quiz category. Emotional answers are not shown. +

+
+ { + if (!row.selfAssessment) { + return Pending; + } + + return ( + + {row.selfAssessment.result_label ?? 'Completed'} + {typeof row.selfAssessment.score === 'number' + ? ` (${row.selfAssessment.score}/${row.selfAssessment.total_questions})` + : ''} + + ); + }} + /> + { + if (!row.personality?.personality_type) { + return Pending; + } + + return ( + + {row.personality.personality_type} + {row.personality.result_label && row.personality.result_label !== row.personality.personality_type + ? ` · ${row.personality.result_label}` + : ''} + + ); + }} + /> +
+ ); +} + +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 ( +
+
+
{title}
+
+
+ {rows.map((row) => ( +
+
+

{row.name}

+

{row.role ?? 'Staff'}

+
+
+ {renderResult(row)} +
+
+ ))} +
); } diff --git a/frontend/src/components/emotional-intelligence/EmotionalIntelligenceQuizEditorPanel.tsx b/frontend/src/components/emotional-intelligence/EmotionalIntelligenceQuizEditorPanel.tsx new file mode 100644 index 0000000..cbe615f --- /dev/null +++ b/frontend/src/components/emotional-intelligence/EmotionalIntelligenceQuizEditorPanel.tsx @@ -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 ( + + ); +} + +function EmotionalIntelligenceQuizEditorDraft({ + assessmentQuestions, + personalityQuiz, +}: Props) { + const [open, setOpen] = useState(false); + const [savedMessage, setSavedMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [assessmentDraft, setAssessmentDraft] = useState( + assessmentQuestions.length > 0 ? assessmentQuestions : [blankAssessmentQuestion()], + ); + const [personalityDraft, setPersonalityDraft] = useState( + personalityQuiz ?? defaultPersonalityQuiz, + ); + const assessmentType = CONTENT_CATALOG_TYPES.emotionalIntelligenceAssessmentQuestions; + const personalityType = CONTENT_CATALOG_TYPES.emotionalIntelligencePersonalityQuiz; + const assessmentManagedQuery = useManagedContentCatalog(assessmentType); + const personalityManagedQuery = useManagedContentCatalog(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 ( + +
+ + + +
+ + + {errorMessage && ( + {errorMessage} + )} + {savedMessage && ( + {savedMessage} + )} + +
+ { + if (!assessmentValid) { + setErrorMessage('Complete every EI assessment question, option, and score before saving.'); + return; + } + void saveAssessment(); + }} + onDelete={() => void deleteAssessment()} + disabled={disabled} + /> + +
+ {assessmentDraft.map((question, questionIndex) => ( +
+
+

Question {questionIndex + 1}

+ +
+