diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index 3d12329..643914d 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -28,6 +28,7 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`. - `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`. - 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. ## Tenant Scope Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns: @@ -35,6 +36,7 @@ Content records can be tenant-scoped through nullable `organizationId`, `schoolI - Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level. - 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. - 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,7 +51,7 @@ Management list excludes tenant-scoped content because those records are edited - `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`). - `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record. -- For `classroom-strategies`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage the shared strategy library and descendant scopes remain read-only. +- 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. - 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. @@ -58,7 +60,7 @@ The seeder (`20260608103000-content-catalog.ts`) loads the following `content_ty 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. -New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, school creation presets school-scoped content, and campus creation presets only per-tenant campus content. This keeps the Classroom Support library shared at the organization level. +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. ### Content authoring rules - Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. @@ -66,7 +68,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 organization-only seeding for the preset Classroom Support library. +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. ## Related - Frontend: `frontend/docs/content-catalog-integration.md`. diff --git a/backend/docs/safety-quiz-results.md b/backend/docs/safety-quiz-results.md index a56726e..a9b7d1b 100644 --- a/backend/docs/safety-quiz-results.md +++ b/backend/docs/safety-quiz-results.md @@ -8,7 +8,8 @@ role snapshot, and persistence. Each submission is an append (create) — there ## Slice Files (by layer) -- Route: `src/routes/safety_quiz_results.ts` (thin wiring; `GET /`, `POST /`). +- Route: `src/routes/safety_quiz_results.ts` (thin wiring; `GET /`, `GET /me`, + `GET /completion`, `POST /`). - Controller: `src/api/controllers/safety_quiz_results.controller.ts` (custom — not the CRUD factory). - Service (BLL): `src/services/safety_quiz_results.ts`. @@ -27,6 +28,12 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re - `GET /api/safety_quiz_results` -> `200` `{ rows, count }`. Optional query `week_of`, plus `limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user (see Access Rules), ordered by `completed_at` desc. +- `GET /api/safety_quiz_results/me` -> `200` `{ completed, result }`. Optional query `week_of`. + Always reads only the authenticated user's own saved quiz result and is used for profile status + and weekly notification state. +- `GET /api/safety_quiz_results/completion` -> `200` `{ summary, rows }`. Optional query + `week_of`. Requires report access and returns every staff user in the current scope with + `complete` or `pending` status based on saved `safety_quiz_results` rows. - `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: }`. Returns the created result 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`. @@ -41,6 +48,9 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re reportable quiz rows for that child scope. - `list`: users with `READ_SAFETY_QUIZ_REPORTS` see scope-filtered results; everyone else sees only their own rows (filtered by `userId`). +- `me`: ignores report access and only checks the authenticated user's own saved result rows. +- `completion`: requires `READ_SAFETY_QUIZ_REPORTS` and joins staff users in the current scope with + saved result rows. Pending rows are derived from missing database results, not frontend state. Role-seeded permissions are only the baseline grants. `custom_permissions` can grant the report permission and `custom_permissions_filter` can remove it for non-global users. @@ -79,13 +89,17 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields are persisted. - `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. ## Tests - `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child - scopes do not create safety quiz result rows. + scopes do not create safety quiz result rows, that personal status reads from saved rows, and that + completion reports include both completed and pending staff. ## Related - Frontend: `frontend/docs/safety-quiz-integration.md`. +- Quiz content management: `backend/docs/content-catalog.md` (`safety-qbs-quiz` is organization-scoped). - Related slices: `personality-quiz-results.md`, `walkthrough-checkins.md`, `user-progress.md`. diff --git a/backend/src/api/controllers/safety_quiz_results.controller.ts b/backend/src/api/controllers/safety_quiz_results.controller.ts index 295ad80..71e3224 100644 --- a/backend/src/api/controllers/safety_quiz_results.controller.ts +++ b/backend/src/api/controllers/safety_quiz_results.controller.ts @@ -9,6 +9,22 @@ export async function list(req: Request, res: Response): Promise { res.status(200).send(payload); } +export async function me(req: Request, res: Response): Promise { + const payload = await SafetyQuizResultsService.me( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function completion(req: Request, res: Response): Promise { + const payload = await SafetyQuizResultsService.completion( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + export async function create(req: Request, res: Response): Promise { const payload = await SafetyQuizResultsService.create( req.body.data, diff --git a/backend/src/db/migrations/20260618120000-grant-registrar-take-quiz.ts b/backend/src/db/migrations/20260618120000-grant-registrar-take-quiz.ts new file mode 100644 index 0000000..e7ca116 --- /dev/null +++ b/backend/src/db/migrations/20260618120000-grant-registrar-take-quiz.ts @@ -0,0 +1,73 @@ +import { v4 as uuid } from 'uuid'; +import type { QueryInterface } from 'sequelize'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; + +function isIdRow(value: unknown): value is { id: string } { + return ( + value !== null + && typeof value === 'object' + && 'id' in value + && typeof value.id === 'string' + ); +} + +function firstId(value: unknown): string | null { + if (!Array.isArray(value)) { + return null; + } + const row = value.find(isIdRow); + return row?.id ?? null; +} + +export default { + up: async (queryInterface: QueryInterface) => { + const now = new Date(); + const [permissionRows] = await queryInterface.sequelize.query( + 'SELECT "id" FROM "permissions" WHERE "name" = :name LIMIT 1', + { replacements: { name: FEATURE_PERMISSIONS.TAKE_QUIZ } }, + ); + let permissionId = firstId(permissionRows); + + if (!permissionId) { + permissionId = uuid(); + await queryInterface.bulkInsert('permissions', [{ + id: permissionId, + name: FEATURE_PERMISSIONS.TAKE_QUIZ, + createdAt: now, + updatedAt: now, + }]); + } + + const [roleRows] = await queryInterface.sequelize.query( + 'SELECT "id" FROM "roles" WHERE "name" = :name LIMIT 1', + { replacements: { name: ROLE_NAMES.REGISTRAR } }, + ); + const roleId = firstId(roleRows); + if (!roleId) { + return; + } + + const [existingRows] = await queryInterface.sequelize.query( + `SELECT 1 FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId + LIMIT 1`, + { replacements: { roleId, permissionId } }, + ); + + if (Array.isArray(existingRows) && existingRows.length > 0) { + return; + } + + await queryInterface.bulkInsert('rolesPermissionsPermissions', [{ + createdAt: now, + updatedAt: now, + roles_permissionsId: roleId, + permissionId, + }]); + }, + + down: async () => { + // Keep permission grants on rollback; permissions may be assigned manually. + }, +}; diff --git a/backend/src/db/migrations/20260618123000-safety-quiz-org-scoped.ts b/backend/src/db/migrations/20260618123000-safety-quiz-org-scoped.ts new file mode 100644 index 0000000..b1aa773 --- /dev/null +++ b/backend/src/db/migrations/20260618123000-safety-quiz-org-scoped.ts @@ -0,0 +1,21 @@ +import type { QueryInterface } from 'sequelize'; +import { SAFETY_QUIZ_CONTENT_TYPE } from '@/shared/constants/content-catalog'; + +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.sequelize.query( + ` + UPDATE content_catalog + SET active = false, "deletedAt" = NOW(), "updatedAt" = NOW() + WHERE content_type = :contentType + AND "deletedAt" IS NULL + AND ("schoolId" IS NOT NULL OR "campusId" IS NOT NULL OR "classId" IS NOT NULL) + `, + { replacements: { contentType: SAFETY_QUIZ_CONTENT_TYPE } }, + ); + }, + + down: async () => { + // Do not recreate formerly duplicated school/campus quiz rows on rollback. + }, +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index 57e0582..546bc0e 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -87,8 +87,8 @@ export const EXTERNAL_ROLES: readonly RoleName[] = [ * `student` gets external pages; `guardian` gets external pages plus parent comms. */ export const MODULE_PERMISSIONS_BY_ROLE: Partial> = { - // Registrar: read every product surface across the school for audit, but no - // action permissions (no fill-attendance/quiz/ack/zone, no audio manage). + // Registrar: read every product surface across the school for audit, and + // complete required staff safety training, but no operational write actions. [ROLE_NAMES.REGISTRAR]: [ ...MODULE_READ_ALL_STAFF, ...MODULE_READ_INSTRUCTIONAL, @@ -98,6 +98,7 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial { { name: 'ForbiddenError' }, ); }); + + test('rejects QBS quiz management outside organization scope', async () => { + await assert.rejects( + () => ContentCatalogService.findManagedByType( + 'safety-qbs-quiz', + 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' }, + ); + }); }); diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts index 279b530..f863a96 100644 --- a/backend/src/services/content_catalog.ts +++ b/backend/src/services/content_catalog.ts @@ -15,6 +15,7 @@ import { } from '@/services/shared/access'; import { CLASSROOM_SUPPORT_CONTENT_TYPE, + SAFETY_QUIZ_CONTENT_TYPE, PER_TENANT_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES, ORG_SCOPED_CONTENT_TYPES, @@ -114,7 +115,10 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo } if ( - contentType === CLASSROOM_SUPPORT_CONTENT_TYPE + ( + contentType === CLASSROOM_SUPPORT_CONTENT_TYPE + || contentType === SAFETY_QUIZ_CONTENT_TYPE + ) && getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION ) { throw new ForbiddenError(); diff --git a/backend/src/services/content_catalog_seed.test.ts b/backend/src/services/content_catalog_seed.test.ts index c5a083b..b74d426 100644 --- a/backend/src/services/content_catalog_seed.test.ts +++ b/backend/src/services/content_catalog_seed.test.ts @@ -72,4 +72,46 @@ describe('seedDefaultContentForTenant', () => { assert.equal(classroomRows[0]?.active, true); assert.ok(Array.isArray(classroomRows[0]?.payload)); }); + + test('seeds safety quiz content 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 safetyQuizRows = createdRows.filter((row) => + row.content_type === 'safety-qbs-quiz', + ); + + assert.equal(safetyQuizRows.length, 1); + assert.deepEqual( + safetyQuizRows.map((row) => ({ + organizationId: row.organizationId, + schoolId: row.schoolId, + campusId: row.campusId, + })), + [ + { organizationId: 'org-1', schoolId: null, campusId: null }, + ], + ); + }); }); diff --git a/backend/src/services/content_catalog_seed.ts b/backend/src/services/content_catalog_seed.ts index 43f70e2..bd4f45b 100644 --- a/backend/src/services/content_catalog_seed.ts +++ b/backend/src/services/content_catalog_seed.ts @@ -25,9 +25,10 @@ interface OwnerStamp { /** * The owning-tenant ids a content type takes when the given tenant level is * created — or `null` if this type is not preset at this level. Per-tenant - * safety quiz exists at org/school/campus; dashboard + parent templates only at - * org/school/campus; org-scoped only at org; school-scoped only at school; truly - * global types are seeded once (with no tenant) when the first org is created. + * org-scoped content such as the safety quiz exists only at organization level; + * dashboard + parent templates exist at org/school/campus; school-scoped only + * at school; truly global types are seeded once (with no tenant) when the first + * org is created. */ function stampForLevel( contentType: string, diff --git a/backend/src/services/personal_scope_results.test.ts b/backend/src/services/personal_scope_results.test.ts index 4e2c8f9..2d3b1f8 100644 --- a/backend/src/services/personal_scope_results.test.ts +++ b/backend/src/services/personal_scope_results.test.ts @@ -85,4 +85,104 @@ describe('personal result persistence while drilled into child scope', () => { assert.equal(result, null); assert.equal(createCount, 0); }); + + test('reads the current user safety quiz status from saved results', async () => { + mock.method(db.safety_quiz_results, 'findOne', (async () => ({ + 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); + + const result = await SafetyQuizResultsService.me( + { week_of: '2026-06-15' }, + createTestUser({ + id: 'user-1', + organizationId: 'org-1', + organizations: { id: 'org-1' }, + }), + ); + + assert.equal(result.completed, true); + assert.equal(result.result?.id, 'result-1'); + }); + + test('builds safety quiz completion rows for completed and pending staff', async () => { + mock.method(db.users, 'findAll', (async () => [ + { + id: 'user-1', + firstName: 'Emily', + lastName: 'Johnson', + email: 'teacher@flatlogic.com', + app_role: { name: ROLE_NAMES.TEACHER }, + }, + { + id: 'user-2', + firstName: 'Marcus', + lastName: 'Davis', + email: 'support@flatlogic.com', + app_role: { name: ROLE_NAMES.SUPPORT_STAFF }, + }, + ]) as unknown as typeof db.users.findAll); + mock.method(db.safety_quiz_results, 'findAll', (async () => [ + { + userId: 'user-1', + 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.findAll); + + const report = await SafetyQuizResultsService.completion( + { week_of: '2026-06-15' }, + 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_SAFETY_QUIZ_REPORTS)], + }, + }), + ); + + assert.deepEqual(report.summary, { + totalStaff: 2, + completedCount: 1, + pendingCount: 1, + completionRate: 50, + }); + assert.equal(report.rows[0]?.status, 'complete'); + assert.equal(report.rows[1]?.status, 'pending'); + }); }); diff --git a/backend/src/services/safety_quiz_results.ts b/backend/src/services/safety_quiz_results.ts index f7a1b0d..111345d 100644 --- a/backend/src/services/safety_quiz_results.ts +++ b/backend/src/services/safety_quiz_results.ts @@ -1,19 +1,26 @@ +import { Op } from 'sequelize'; import db from '@/db/models'; import { withTransaction } from '@/db/with-transaction'; import { resolvePagination } from '@/shared/constants/pagination'; import ValidationError from '@/shared/errors/validation'; +import ForbiddenError from '@/shared/errors/forbidden'; import { getOrganizationIdOrGlobal, getCampusId, + getSchoolId, + getClassId, assertAuthenticatedTenantUser, campusDimensionScope, hasFeaturePermission, getDisplayName, isActingInOwnScope, + requireUserId, + getRoleScope, } from '@/services/shared/access'; -import { ROLE_NAMES } from '@/shared/constants/roles'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; 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'; interface SafetyQuizInput { @@ -32,6 +39,16 @@ interface SafetyQuizFilter { } const REQUIRED_STRINGS = ['quiz_id', 'quiz_title', 'week_of'] as const; +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 getProductRole(currentUser?: CurrentUser): string { return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER; @@ -80,6 +97,91 @@ function toDto(record: SafetyQuizResults) { }; } +function assertCanReadCompletion(currentUser?: CurrentUser): void { + assertAuthenticatedTenantUser(currentUser); + if ( + hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS, + ) + ) { + return; + } + throw new ForbiddenError(); +} + +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 latestResultByUser( + results: readonly SafetyQuizResults[], +): ReadonlyMap { + const byUser = new Map(); + for (const result of results) { + const userId = result.userId; + if (!userId || byUser.has(userId)) { + continue; + } + byUser.set(userId, result); + } + return byUser; +} + class SafetyQuizResultsService { static async list(filter: SafetyQuizFilter, currentUser?: CurrentUser) { assertAuthenticatedTenantUser(currentUser); @@ -144,6 +246,81 @@ class SafetyQuizResultsService { return toDto(created); }); } + + static async me(filter: SafetyQuizFilter, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const result = await db.safety_quiz_results.findOne({ + where: { + userId: requireUserId(currentUser), + ...(filter.week_of ? { week_of: filter.week_of } : {}), + }, + order: [['completed_at', 'desc']], + }); + + return { + completed: Boolean(result), + result: result ? toDto(result) : null, + }; + } + + static async completion(filter: SafetyQuizFilter, currentUser?: CurrentUser) { + assertCanReadCompletion(currentUser); + 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 results = userIds.length > 0 + ? await db.safety_quiz_results.findAll({ + where: { + userId: userIds, + ...(filter.week_of ? { week_of: filter.week_of } : {}), + }, + order: [['completed_at', 'desc']], + }) + : []; + const resultByUser = latestResultByUser(results); + const rows = staffUsers.map((user) => { + const result = resultByUser.get(user.id); + return { + userId: user.id, + name: displayNameOf(user), + email: user.email, + role: user.app_role?.name ?? null, + status: result ? 'complete' : 'pending', + result: result ? toDto(result) : null, + }; + }); + const completedCount = rows.filter((row) => row.status === 'complete').length; + + return { + summary: { + totalStaff: rows.length, + completedCount, + pendingCount: Math.max(rows.length - completedCount, 0), + completionRate: rows.length > 0 + ? Math.round((completedCount / rows.length) * 100) + : 0, + }, + rows, + }; + } } export default SafetyQuizResultsService; diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index 409c657..0d82940 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -1,7 +1,7 @@ /** Classroom Support — org-scoped and managed from organization scope. */ export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies'; -/** The safety/QBS quiz content type, dedicated per tenant (org/school/campus). */ +/** The safety/QBS quiz content type, owned and managed at organization scope. */ export const SAFETY_QUIZ_CONTENT_TYPE = 'safety-qbs-quiz'; /** ESA funding content — school-scoped (rules depend on the school's locale). */ @@ -9,11 +9,10 @@ export const ESA_CONTENT_TYPE = 'esa-funding-content'; /** * **Per-tenant** content types (read/written at the user's own tenant level via - * `getOwnTenant`; preset at organization + school + campus levels). The safety - * quiz, dashboard, and parent templates span org/school/campus. + * `getOwnTenant`; preset at organization + school + campus levels). Dashboard + * and parent templates span org/school/campus. */ export const PER_TENANT_CONTENT_TYPES: ReadonlySet = new Set([ - SAFETY_QUIZ_CONTENT_TYPE, 'dashboard-sign-of-week', 'dashboard-teacher-images', 'dashboard-encouraging-quotes', @@ -27,6 +26,7 @@ export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ /** **Org-scoped** content types (one per organization; preset at org creation). */ export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ + SAFETY_QUIZ_CONTENT_TYPE, CLASSROOM_SUPPORT_CONTENT_TYPE, 'regulation-zones', 'zones-of-regulation-page-content', diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index aaa8a96..dc627b3 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -104,6 +104,8 @@ The seeded e2e suite first verifies this minimum set through `GET /api/content-c QBS quiz title, weekly focus, key reminders, questions, answer choices, correct answers, and explanations are part of the `safety-qbs-quiz` content catalog payload. The frontend renders this payload and does not keep quiz content or reminder copy in shared constants. +`safety-qbs-quiz` is organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` can manage the quiz through the QBS safety editor; school, campus, and classroom users read the organization-owned payload and only complete the quiz. + ## Editable Classroom Strategy Content Classroom strategy titles, descriptions, images, categories, age groups, regulation zones, and implementation tips are part of the `classroom-strategies` content catalog payload. The frontend may keep filter labels and style tokens, but it must not keep strategy records or implementation copy in shared constants. diff --git a/frontend/docs/safety-quiz-integration.md b/frontend/docs/safety-quiz-integration.md index ca4b39c..bbd9808 100644 --- a/frontend/docs/safety-quiz-integration.md +++ b/frontend/docs/safety-quiz-integration.md @@ -40,25 +40,34 @@ Constants: ## Behavior - Quiz submission uses `POST /api/safety_quiz_results`. -- Behavior Management is available at organization, campus, and class effective +- Behavior Management is available at organization, school, campus, and class effective tiers when the user has `READ_QBS`. -- Result-saving UI and mutation are enabled only in the user's own scope. A - parent user drilled into a child tenant can complete the quiz for immediate - feedback, but no "saved" badge is shown and no reportable child-scope result is - created. -- Staff completion and director dashboard rows load from `GET /api/safety_quiz_results`. +- Result-saving UI and mutation are enabled only in the user's own non-organization scope. A + parent user drilled into a child tenant can complete the quiz for immediate feedback, but no + "saved" badge is shown and no reportable child-scope result is created. +- The notification dropdown uses `GET /api/safety_quiz_results/me` for the current week. School, + campus, and class staff with `TAKE_QUIZ` see a reminder until their saved database result exists. +- Staff completion and leadership dashboard rows load from `GET /api/safety_quiz_results/completion`; + pending status is derived from missing saved rows in the backend response. - QBS quiz content loads from `GET /api/content-catalog/read/safety-qbs-quiz`. -- Directors and superintendents can edit the QBS quiz content through the authenticated content catalog endpoint `PUT /api/content-catalog/safety-qbs-quiz`. -- Editable QBS quiz payloads are JSON-validated in the business layer before saving. -- Compliance views render empty and error states explicitly instead of substituting static staff rows. +- Organization-scope content managers can add, update, and delete the QBS quiz and key reminders + through a form-based editor backed by the authenticated content catalog endpoints. The editor is + hidden outside the user's own organization scope, and the backend enforces the same rule. +- Editable QBS quiz payloads are typed and validated in the business layer before saving. +- Compliance views render completed and pending staff from the backend report instead of + substituting static staff rows. - Result ownership is derived by the backend from the authenticated session. +- User profile renders the latest saved QBS result from `GET /api/safety_quiz_results/me`. - `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. - Weekly focus and key reminders are backend content payload fields, not frontend constants. - Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives. -- Director dashboard derives QBS completion metrics and risk rows in business selectors. +- Leadership dashboards derive QBS completion metrics and risk rows from the backend completion + summary. -## Remaining Related Work +## Preset Content -If compliance needs pending/overdue rows for all staff, add a backend summary endpoint that joins staff membership with submitted results. Do not recreate pending staff rows in frontend static data. +The default `safety-qbs-quiz` payload is seeded from the backend content catalog defaults for new +organizations. School, campus, and classroom scopes read the organization-owned payload. The +frontend does not keep fallback quiz questions or reminder copy in constants. diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts index abb2d93..641632f 100644 --- a/frontend/src/business/app-shell/selectors.test.ts +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -96,11 +96,11 @@ describe('app-shell selectors', () => { expect(getScopedModules(scopedModules, classroomUser, 'class', false).map((m) => m.id)).toEqual(['classroom']); }); - it('makes Behavior Management available at organization, campus, and class scopes', () => { + it('makes Behavior Management available from organization through class scopes', () => { const behaviorUser = user(['READ_QBS']); expect(getScopedModules(scopedModules, behaviorUser, 'organization', false).map((m) => m.id)).toEqual(['qbs']); - expect(getScopedModules(scopedModules, behaviorUser, 'school', false).map((m) => m.id)).toEqual([]); + expect(getScopedModules(scopedModules, behaviorUser, 'school', false).map((m) => m.id)).toEqual(['qbs']); expect(getScopedModules(scopedModules, behaviorUser, 'campus', false).map((m) => m.id)).toEqual(['qbs']); expect(getScopedModules(scopedModules, behaviorUser, 'class', false).map((m) => m.id)).toEqual(['qbs']); }); @@ -119,7 +119,7 @@ describe('app-shell selectors', () => { expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'organization', false)).toBe(true); expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'school', false)).toBe(true); expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'organization', false)).toBe(true); - expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'school', false)).toBe(false); + expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'school', false)).toBe(true); expect(canAccessScopedModuleRoute(scopedModules, '/zones-of-regulation', user(['READ_ZONES']), 'class', false)).toBe(true); }); diff --git a/frontend/src/business/app-shell/selectors.ts b/frontend/src/business/app-shell/selectors.ts index f5174bf..0aba7eb 100644 --- a/frontend/src/business/app-shell/selectors.ts +++ b/frontend/src/business/app-shell/selectors.ts @@ -34,7 +34,7 @@ const MODULE_SCOPE_TIERS: Partial> = { class: ['class'], classroom: ['organization', 'school', 'campus', 'class'], timer: ['class'], - qbs: ['organization', 'campus', 'class'], + qbs: ['organization', 'school', 'campus', 'class'], // Leadership dashboard: each leader sees it at their own tier (owner/ // superintendent → organization, principal/registrar → school, director → // campus). Never shown via drill-down (see getScopedModules). diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts index d2a8d0e..fe5bc13 100644 --- a/frontend/src/business/director-dashboard/hooks.ts +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useFrameEntries } from '@/business/frame/hooks'; -import { useSafetyQuizResults } from '@/business/safety-quiz/hooks'; +import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks'; import { useStaffAttendanceRecords, useStaffAttendanceSummary, @@ -21,6 +21,7 @@ import { useAuth } from '@/shared/app/useAuth'; import { getLeadershipDashboardName } from '@/business/app-shell/selectors'; import { getActiveTenant } from '@/business/scope/selectors'; import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; +import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; export function useDirectorDashboardPage(): DirectorDashboardPage { const { user, profile } = useAuth(); @@ -28,22 +29,28 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const title = getLeadershipDashboardName(role); const scopeLabel = getActiveTenant(user)?.name ?? ''; const [timeRange, setTimeRangeState] = useState('month'); + const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); const frameEntriesQuery = useFrameEntries(); - const quizResultsQuery = useSafetyQuizResults(); + const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true); const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const frameEntries = frameEntriesQuery.data ?? []; - const quizResults = quizResultsQuery.data ?? []; + const quizRows = quizCompletionQuery.data?.rows ?? []; + const quizSummary = quizCompletionQuery.data?.summary ?? { + totalStaff: 0, + completedCount: 0, + pendingCount: 0, + completionRate: 0, + }; const attendanceRecords = staffAttendanceRecordsQuery.data ?? []; - const staffCount = staffAttendanceSummaryQuery.data?.staffCount ?? 0; const isLoading = frameEntriesQuery.isLoading - || quizResultsQuery.isLoading + || quizCompletionQuery.isLoading || staffAttendanceRecordsQuery.isLoading || staffAttendanceSummaryQuery.isLoading || acknowledgmentReportQuery.isLoading; const error = frameEntriesQuery.error - ?? quizResultsQuery.error + ?? quizCompletionQuery.error ?? staffAttendanceRecordsQuery.error ?? staffAttendanceSummaryQuery.error ?? acknowledgmentReportQuery.error; @@ -54,15 +61,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { timeRange, overviewCards: buildDirectorOverviewCards( attendanceRecords, - quizResults, + quizSummary, frameEntries, - staffCount, acknowledgmentReportQuery.data?.summary, ), - riskAreas: buildDirectorRiskAreas(attendanceRecords, quizResults, staffCount), + riskAreas: buildDirectorRiskAreas(attendanceRecords, quizSummary), framePreviews: buildDirectorFramePreviews(frameEntries), quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, - quizResults, + quizResults: quizRows, 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 84fbff5..789fadd 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -8,7 +8,7 @@ import { } from '@/business/director-dashboard/selectors'; import type { FrameEntryViewModel } from '@/business/frame/types'; import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; -import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import type { SafetyQuizCompletionSummary } from '@/business/safety-quiz/types'; function createAttendanceRecord( overrides: Partial = {}, @@ -24,23 +24,12 @@ function createAttendanceRecord( }; } -function createQuizResult(overrides: Partial = {}): SafetyQuizResultDto { +function createQuizSummary(overrides: Partial = {}): SafetyQuizCompletionSummary { return { - id: 'quiz-1', - quiz_id: 'qbs', - quiz_title: 'QBS Safety', - week_of: '2026-06-01', - score: 5, - total_questions: 5, - answers: [0, 1, 2], - user_name: 'Ava Lee', - user_role: 'teacher', - completed_at: '2026-06-01T09:00:00.000Z', - organizationId: 'org-1', - campusId: 'campus-1', - userId: 'user-1', - createdAt: '2026-06-01T09:00:00.000Z', - updatedAt: '2026-06-01T09:00:00.000Z', + completedCount: 1, + pendingCount: 1, + totalStaff: 2, + completionRate: 50, ...overrides, }; } @@ -64,8 +53,8 @@ function createFrameEntry(overrides: Partial = {}): FrameEn describe('director dashboard selectors', () => { it('calculates quiz completion rate with empty staff protection', () => { - expect(calculateQuizCompletionRate([createQuizResult()], 0)).toBe(0); - expect(calculateQuizCompletionRate([createQuizResult()], 4)).toBe(25); + expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0); + expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 25 }))).toBe(25); }); it('builds overview cards from backend-backed records', () => { @@ -74,9 +63,8 @@ describe('director dashboard selectors', () => { createAttendanceRecord({ id: 'present', status: 'present' }), createAttendanceRecord({ id: 'absent', status: 'absent' }), ], - [createQuizResult()], + createQuizSummary(), [createFrameEntry()], - 2, { scope: 'campus', totalDocuments: 2, @@ -106,8 +94,7 @@ describe('director dashboard selectors', () => { createAttendanceRecord({ id: '3', status: 'absent' }), createAttendanceRecord({ id: '4', status: 'absent' }), ], - [createQuizResult()], - 6, + createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }), ); expect(risks).toEqual([ diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts index e2d9b0e..97a25f5 100644 --- a/frontend/src/business/director-dashboard/selectors.ts +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -11,34 +11,28 @@ import { } from '@/business/staff-attendance/selectors'; import type { FrameEntryViewModel } from '@/business/frame/types'; import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; -import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; import type { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policyDocuments'; import type { DirectorFramePreview, DirectorOverviewCard, DirectorRiskArea, } from '@/business/director-dashboard/types'; +import type { SafetyQuizCompletionSummary } from '@/business/safety-quiz/types'; export function calculateQuizCompletionRate( - quizResults: readonly SafetyQuizResultDto[], - staffCount: number, + quizSummary: SafetyQuizCompletionSummary, ): number { - if (staffCount <= 0) { - return 0; - } - - return Math.round((quizResults.length / staffCount) * 100); + return quizSummary.completionRate; } export function buildDirectorOverviewCards( attendanceRecords: readonly StaffAttendanceRecordViewModel[], - quizResults: readonly SafetyQuizResultDto[], + quizSummary: SafetyQuizCompletionSummary, frameEntries: readonly FrameEntryViewModel[], - staffCount: number, acknowledgmentSummary?: PolicyAcknowledgmentReportSummaryDto | null, ): readonly DirectorOverviewCard[] { const attendanceRate = staffAttendanceRate(attendanceRecords); - const quizCompletionRate = calculateQuizCompletionRate(quizResults, staffCount); + const quizCompletionRate = calculateQuizCompletionRate(quizSummary); const acknowledgmentRate = acknowledgmentSummary?.completionRate ?? 0; return [ @@ -53,7 +47,7 @@ export function buildDirectorOverviewCards( }, { label: 'De-escalation Completion', - value: `${quizResults.length}/${staffCount}`, + value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`, change: `${quizCompletionRate}%`, trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down', iconId: 'shield', @@ -71,7 +65,7 @@ export function buildDirectorOverviewCards( }, { label: 'Staff Members', - value: staffCount.toString(), + value: quizSummary.totalStaff.toString(), change: 'Active', trend: 'up', iconId: 'users', @@ -92,10 +86,9 @@ export function buildDirectorOverviewCards( export function buildDirectorRiskAreas( attendanceRecords: readonly StaffAttendanceRecordViewModel[], - quizResults: readonly SafetyQuizResultDto[], - staffCount: number, + quizSummary: SafetyQuizCompletionSummary, ): readonly DirectorRiskArea[] { - const incompleteStaffCount = Math.max(staffCount - quizResults.length, 0); + const incompleteStaffCount = quizSummary.pendingCount; const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent'); return [ diff --git a/frontend/src/business/director-dashboard/types.ts b/frontend/src/business/director-dashboard/types.ts index 9702e30..1d2185f 100644 --- a/frontend/src/business/director-dashboard/types.ts +++ b/frontend/src/business/director-dashboard/types.ts @@ -3,7 +3,7 @@ import type { DirectorQuickActionConfig, } from '@/shared/constants/directorDashboard'; import type { ModuleId } from '@/shared/types/app'; -import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types'; export type DirectorDashboardTrend = 'up' | 'down'; export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low'; @@ -48,7 +48,7 @@ export interface DirectorDashboardPage { readonly riskAreas: readonly DirectorRiskArea[]; readonly framePreviews: readonly DirectorFramePreview[]; readonly quickActions: readonly DirectorQuickActionConfig[]; - readonly quizResults: readonly SafetyQuizResultDto[]; + readonly quizResults: readonly SafetyQuizComplianceRow[]; readonly isLoading: boolean; readonly error: unknown; readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void; diff --git a/frontend/src/business/safety-quiz/hooks.ts b/frontend/src/business/safety-quiz/hooks.ts index 7cdb8a4..e1f820a 100644 --- a/frontend/src/business/safety-quiz/hooks.ts +++ b/frontend/src/business/safety-quiz/hooks.ts @@ -2,9 +2,13 @@ import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createSafetyQuizResult, + getMySafetyQuizStatus, + getSafetyQuizCompletionReport, listSafetyQuizResults, } from '@/shared/api/safetyQuizResults'; import { + createManagedContentCatalog, + deleteManagedContentCatalog, getManagedContentCatalog, updateManagedContentCatalog, } from '@/shared/api/contentCatalog'; @@ -18,7 +22,7 @@ import { CONTENT_CATALOG_TYPES, } from '@/shared/constants/contentCatalog'; import { - toSafetyQuizComplianceRow, + toSafetyQuizCompletionRow, toSafetyQuizResultCreateDto, } from '@/business/safety-quiz/mappers'; import { @@ -30,12 +34,11 @@ import { calculateSafetyQuizCompletionSummary, calculateSafetyQuizScore, getCurrentSafetyQuizWeek, - parseSafetyQuizPayload, - serializeSafetyQuizPayload, + validateSafetyQuizPayload, } from '@/business/safety-quiz/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; -import type { SafetyQuiz } from '@/shared/types/app'; -import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows'; +import type { QuizQuestion, SafetyQuiz } from '@/shared/types/app'; +import { getApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { usePermissions } from '@/shared/app/usePermissions'; @@ -48,27 +51,60 @@ export function useSafetyQuizResults(weekOf?: string) { }); } +export function useMySafetyQuizStatus(weekOf?: string, enabled = true) { + return useQuery({ + queryKey: weekOf + ? [...SAFETY_QUIZ_QUERY_KEYS.personalStatus, weekOf] + : SAFETY_QUIZ_QUERY_KEYS.personalStatus, + enabled, + queryFn: () => getMySafetyQuizStatus(weekOf), + }); +} + export function useSafetyQuizCompliance(weekOf: string, enabled: boolean) { return useQuery({ - queryKey: [...SAFETY_QUIZ_QUERY_KEYS.results, SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, weekOf], + queryKey: [...SAFETY_QUIZ_QUERY_KEYS.completion, SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, weekOf], enabled, - queryFn: () => mapApiListRows(listSafetyQuizResults(weekOf), toSafetyQuizComplianceRow), + queryFn: async () => { + const report = await getSafetyQuizCompletionReport(weekOf); + return { + rows: report.rows.map(toSafetyQuizCompletionRow), + summary: report.summary, + }; + }, }); } export function useSaveSafetyQuizResult() { + const queryClient = useQueryClient(); + return useInvalidatingMutation({ mutationFn: (submission: SafetyQuizSubmission) => createSafetyQuizResult( toSafetyQuizResultCreateDto(submission), ), invalidateQueryKey: SAFETY_QUIZ_QUERY_KEYS.results, + onSuccess: async (_data, submission) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: SAFETY_QUIZ_QUERY_KEYS.results }), + queryClient.invalidateQueries({ queryKey: SAFETY_QUIZ_QUERY_KEYS.personalStatus }), + queryClient.invalidateQueries({ queryKey: SAFETY_QUIZ_QUERY_KEYS.completion }), + queryClient.invalidateQueries({ + queryKey: [...SAFETY_QUIZ_QUERY_KEYS.personalStatus, submission.weekOf], + }), + queryClient.invalidateQueries({ + queryKey: [...SAFETY_QUIZ_QUERY_KEYS.completion, SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, submission.weekOf], + }), + ]); + }, }); } export function useSafetyQuizPage(): SafetyQuizPage { const permissions = usePermissions(); const { ownTenant, selectedTenant } = useScopeContext(); - const canPersistResult = canPersistPersonalScopeResults(ownTenant, selectedTenant); + const activeTenant = selectedTenant ?? ownTenant; + const canPersistResult = canPersistPersonalScopeResults(ownTenant, selectedTenant) + && activeTenant?.level !== 'organization'; const quizQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.safetyQbsQuiz, null, @@ -83,10 +119,13 @@ export function useSafetyQuizPage(): SafetyQuizPage { const quiz = quizQuery.payload; const weekOf = getCurrentSafetyQuizWeek(new Date()); const canViewCompliance = permissions.has('READ_SAFETY_QUIZ_REPORTS'); - const canManageQuizContent = permissions.has('MANAGE_CONTENT_CATALOG'); + const canManageQuizContent = permissions.has('MANAGE_CONTENT_CATALOG') + && ownTenant?.level === 'organization' + && activeTenant?.level === 'organization'; const complianceQuery = useSafetyQuizCompliance(weekOf, canViewCompliance); const saveResultMutation = useSaveSafetyQuizResult(); - const complianceRows = complianceQuery.data ?? []; + const complianceRows = complianceQuery.data?.rows ?? []; + const completionSummary = complianceQuery.data?.summary ?? calculateSafetyQuizCompletionSummary(complianceRows); function startQuiz() { setQuizStarted(true); @@ -158,7 +197,7 @@ export function useSafetyQuizPage(): SafetyQuizPage { score, answers, complianceRows, - completionSummary: calculateSafetyQuizCompletionSummary(complianceRows), + completionSummary, canViewCompliance, canManageQuizContent, canPersistResult, @@ -177,7 +216,8 @@ export function useSafetyQuizPage(): SafetyQuizPage { export function useSafetyQuizContentEditor(): SafetyQuizContentEditor { const queryClient = useQueryClient(); - const [draftOverride, setDraftOverride] = useState(null); + const [draftOverride, setDraftOverride] = useState(null); + const [hasDraftOverride, setHasDraftOverride] = useState(false); const [validationError, setValidationError] = useState(null); const [savedMessage, setSavedMessage] = useState(null); const contentType = CONTENT_CATALOG_TYPES.safetyQbsQuiz; @@ -186,10 +226,14 @@ export function useSafetyQuizContentEditor(): SafetyQuizContentEditor { queryFn: () => getManagedContentCatalog(contentType), }); const saveMutation = useMutation({ - mutationFn: (payload: SafetyQuiz) => updateManagedContentCatalog(contentType, { payload }), + mutationFn: (payload: SafetyQuiz) => ( + managedQuery.data + ? updateManagedContentCatalog(contentType, { payload }) + : createManagedContentCatalog({ content_type: contentType, payload }) + ), onSuccess: async (response) => { - const serializedPayload = serializeSafetyQuizPayload(response.payload); - setDraftOverride(serializedPayload); + setDraftOverride(response.payload); + setHasDraftOverride(true); setValidationError(null); setSavedMessage('Safety quiz content saved.'); await Promise.all([ @@ -198,48 +242,188 @@ export function useSafetyQuizContentEditor(): SafetyQuizContentEditor { ]); }, }); + const deleteMutation = useMutation({ + mutationFn: () => deleteManagedContentCatalog(contentType), + onSuccess: async () => { + setDraftOverride(null); + setHasDraftOverride(true); + setValidationError(null); + setSavedMessage('Safety quiz content deleted.'); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType] }), + queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType] }), + ]); + }, + }); const serverDraft = useMemo( - () => (managedQuery.data?.payload ? serializeSafetyQuizPayload(managedQuery.data.payload) : ''), + () => managedQuery.data?.payload ?? null, [managedQuery.data], ); - const draft = draftOverride ?? serverDraft; + const draft = hasDraftOverride ? draftOverride : serverDraft; const canSave = useMemo( - () => Boolean(draft.trim()) && !managedQuery.isLoading && !saveMutation.isPending, - [draft, managedQuery.isLoading, saveMutation.isPending], + () => Boolean(draft) && !managedQuery.isLoading && !saveMutation.isPending && !deleteMutation.isPending, + [draft, managedQuery.isLoading, saveMutation.isPending, deleteMutation.isPending], ); + function updateDraft(updater: (current: SafetyQuiz) => SafetyQuiz): void { + setDraftOverride((currentOverride) => { + const current = hasDraftOverride ? currentOverride : serverDraft; + return current ? updater(current) : current; + }); + setHasDraftOverride(true); + setValidationError(null); + setSavedMessage(null); + } + return { draft, isLoading: managedQuery.isLoading, isSaving: saveMutation.isPending, + isDeleting: deleteMutation.isPending, errorMessage: getOptionalErrorMessage( - managedQuery.error || saveMutation.error, + managedQuery.error || saveMutation.error || deleteMutation.error, 'Safety quiz content could not be loaded or saved.', ), validationError, savedMessage, canSave, - setDraft: (nextDraft) => { - setDraftOverride(nextDraft); + updateQuiz: (patch) => updateDraft((current) => ({ ...current, ...patch })), + updateWeeklyFocus: (patch) => updateDraft((current) => ({ + ...current, + weeklyFocus: { ...current.weeklyFocus, ...patch }, + })), + updateReminder: (index, value) => updateDraft((current) => ({ + ...current, + keyReminders: current.keyReminders.map((reminder, reminderIndex) => + reminderIndex === index ? value : reminder, + ), + })), + addReminder: () => updateDraft((current) => ({ + ...current, + keyReminders: [...current.keyReminders, ''], + })), + removeReminder: (index) => updateDraft((current) => ({ + ...current, + keyReminders: current.keyReminders.filter((_reminder, reminderIndex) => reminderIndex !== index), + })), + updateQuestion: (index, patch) => updateDraft((current) => ({ + ...current, + questions: current.questions.map((question, questionIndex) => + questionIndex === index ? normalizeQuestion({ ...question, ...patch }) : question, + ), + })), + updateQuestionOption: (questionIndex, optionIndex, value) => updateDraft((current) => ({ + ...current, + questions: current.questions.map((question, currentQuestionIndex) => { + if (currentQuestionIndex !== questionIndex) { + return question; + } + return normalizeQuestion({ + ...question, + options: question.options.map((option, currentOptionIndex) => + currentOptionIndex === optionIndex ? value : option, + ), + }); + }), + })), + addQuestion: () => updateDraft((current) => ({ + ...current, + questions: [...current.questions, createDefaultQuestion(current.questions.length + 1)], + })), + removeQuestion: (index) => updateDraft((current) => ({ + ...current, + questions: current.questions.filter((_question, questionIndex) => questionIndex !== index), + })), + addQuestionOption: (questionIndex) => updateDraft((current) => ({ + ...current, + questions: current.questions.map((question, currentQuestionIndex) => + currentQuestionIndex === questionIndex + ? normalizeQuestion({ ...question, options: [...question.options, ''] }) + : question, + ), + })), + removeQuestionOption: (questionIndex, optionIndex) => updateDraft((current) => ({ + ...current, + questions: current.questions.map((question, currentQuestionIndex) => + currentQuestionIndex === questionIndex + ? normalizeQuestion({ + ...question, + options: question.options.filter((_option, currentOptionIndex) => currentOptionIndex !== optionIndex), + }) + : question, + ), + })), + addDefaultQuiz: () => { + setDraftOverride(createDefaultSafetyQuiz()); + setHasDraftOverride(true); setValidationError(null); setSavedMessage(null); }, reset: () => { setDraftOverride(null); + setHasDraftOverride(false); setValidationError(null); setSavedMessage(null); }, save: async () => { - const result = parseSafetyQuizPayload(draft); - - if (typeof result === 'string') { - setValidationError(result); + if (!draft) { + setValidationError('Add a quiz before saving.'); setSavedMessage(null); return; } - await saveMutation.mutateAsync(result); + const error = validateSafetyQuizPayload(draft); + if (error) { + setValidationError(error); + setSavedMessage(null); + return; + } + + await saveMutation.mutateAsync(draft); + }, + deleteQuiz: async () => { + await deleteMutation.mutateAsync(); }, }; } + +function normalizeQuestion(question: QuizQuestion): QuizQuestion { + const correctIndex = question.options.length === 0 + ? 0 + : Math.min(question.correctIndex, question.options.length - 1); + + return { ...question, correctIndex }; +} + +function createDefaultQuestion(sequence: number): QuizQuestion { + return { + id: `question-${Date.now()}-${sequence}`, + question: '', + options: ['Correct answer', 'Incorrect answer'], + correctIndex: 0, + explanation: '', + }; +} + +function createDefaultSafetyQuiz(): SafetyQuiz { + return { + id: `qbs-${Date.now()}`, + title: 'QBS Safety Quiz', + focus: 'de-escalation', + weeklyFocus: { + title: 'Weekly Safety Focus', + description: 'Add the weekly safety focus for staff.', + }, + keyReminders: ['Add a key reminder.'], + questions: [ + { + id: 'question-1', + question: 'Add the first QBS safety question.', + options: ['Correct answer', 'Incorrect answer'], + correctIndex: 0, + explanation: 'Explain why the correct answer is safest.', + }, + ], + }; +} diff --git a/frontend/src/business/safety-quiz/mappers.test.ts b/frontend/src/business/safety-quiz/mappers.test.ts index 77daae1..d96d0c2 100644 --- a/frontend/src/business/safety-quiz/mappers.test.ts +++ b/frontend/src/business/safety-quiz/mappers.test.ts @@ -37,8 +37,9 @@ function createResult(overrides: Partial = {}): SafetyQuizR describe('safety quiz mappers', () => { it('maps result DTOs to compliance rows with user-facing labels', () => { expect(toSafetyQuizComplianceRow(createResult())).toEqual({ + userId: 'user-1', name: 'Ava Lee', - role: 'Para', + role: 'Support Staff', status: 'complete', score: '4/5', date: 'Jun 8', @@ -110,12 +111,13 @@ describe('safety quiz selectors', () => { it('summarizes compliance rows', () => { expect( calculateSafetyQuizCompletionSummary([ - { name: 'Ava', role: 'Teacher', status: 'complete', score: '5/5', date: 'Jun 8' }, - { name: 'Ben', role: 'Para', status: 'complete', score: '4/5', date: 'Jun 8' }, + { userId: 'user-1', name: 'Ava', role: 'Teacher', status: 'complete', score: '5/5', date: 'Jun 8' }, + { userId: 'user-2', name: 'Ben', role: 'Para', status: 'complete', score: '4/5', date: 'Jun 8' }, ]), ).toEqual({ completedCount: 2, totalStaff: 2, + pendingCount: 0, completionRate: 100, }); }); diff --git a/frontend/src/business/safety-quiz/mappers.ts b/frontend/src/business/safety-quiz/mappers.ts index 18bbb67..b4a8f79 100644 --- a/frontend/src/business/safety-quiz/mappers.ts +++ b/frontend/src/business/safety-quiz/mappers.ts @@ -1,9 +1,25 @@ -import { SafetyQuizResultCreateDto, SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import { + SafetyQuizCompletionRowDto, + SafetyQuizResultCreateDto, + SafetyQuizResultDto, +} from '@/shared/types/safetyQuiz'; import { SafetyQuizComplianceRow, SafetyQuizSubmission } from '@/business/safety-quiz/types'; function toRoleLabel(role: string): string { + if (role === 'owner') { + return 'Owner'; + } + + if (role === 'principal') { + return 'Principal'; + } + + if (role === 'registrar') { + return 'Registrar'; + } + if (role === 'support_staff') { - return 'Para'; + return 'Support Staff'; } if (role === 'director') { @@ -23,6 +39,7 @@ function toRoleLabel(role: string): string { export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizComplianceRow { return { + userId: dto.userId, name: dto.user_name, role: toRoleLabel(dto.user_role), status: 'complete', @@ -34,6 +51,22 @@ export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizC }; } +export function toSafetyQuizCompletionRow(dto: SafetyQuizCompletionRowDto): SafetyQuizComplianceRow { + return { + userId: dto.userId, + name: dto.name, + role: dto.role ? toRoleLabel(dto.role) : 'Staff', + status: dto.status, + score: dto.result ? `${dto.result.score}/${dto.result.total_questions}` : 'Pending', + date: dto.result + ? new Date(dto.result.completed_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + : 'Not completed', + }; +} + export function toSafetyQuizResultCreateDto(submission: SafetyQuizSubmission): SafetyQuizResultCreateDto { return { quiz_id: submission.quizId, diff --git a/frontend/src/business/safety-quiz/selectors.ts b/frontend/src/business/safety-quiz/selectors.ts index bfef809..dcd5a6e 100644 --- a/frontend/src/business/safety-quiz/selectors.ts +++ b/frontend/src/business/safety-quiz/selectors.ts @@ -53,6 +53,7 @@ export function calculateSafetyQuizCompletionSummary( return { completedCount, totalStaff, + pendingCount: Math.max(totalStaff - completedCount, 0), completionRate: totalStaff > 0 ? Math.round((completedCount / totalStaff) * 100) : 0, }; } @@ -64,13 +65,18 @@ export function serializeSafetyQuizPayload(payload: SafetyQuiz): string { export function parseSafetyQuizPayload(draft: string): SafetyQuiz | string { try { const parsed: unknown = JSON.parse(draft); - return validateSafetyQuizPayload(parsed); + return getSafetyQuizPayloadValidationResult(parsed); } catch (error) { return error instanceof Error && error.message ? error.message : 'Safety quiz JSON is invalid.'; } } -function validateSafetyQuizPayload(value: unknown): SafetyQuiz | string { +export function validateSafetyQuizPayload(payload: SafetyQuiz): string | null { + const result = getSafetyQuizPayloadValidationResult(payload); + return typeof result === 'string' ? result : null; +} + +function getSafetyQuizPayloadValidationResult(value: unknown): SafetyQuiz | string { if (!isRecord(value)) { return 'Safety quiz payload must be a JSON object.'; } diff --git a/frontend/src/business/safety-quiz/types.ts b/frontend/src/business/safety-quiz/types.ts index feaf439..3499180 100644 --- a/frontend/src/business/safety-quiz/types.ts +++ b/frontend/src/business/safety-quiz/types.ts @@ -1,7 +1,8 @@ export interface SafetyQuizComplianceRow { + readonly userId: string; readonly name: string; readonly role: string; - readonly status: 'complete'; + readonly status: 'complete' | 'pending'; readonly score: string; readonly date: string; } @@ -18,6 +19,7 @@ export interface SafetyQuizSubmission { export interface SafetyQuizCompletionSummary { readonly completedCount: number; readonly totalStaff: number; + readonly pendingCount: number; readonly completionRate: number; } @@ -50,14 +52,30 @@ export interface SafetyQuizPage { } export interface SafetyQuizContentEditor { - readonly draft: string; + readonly draft: import('@/shared/types/app').SafetyQuiz | null; readonly isLoading: boolean; readonly isSaving: boolean; + readonly isDeleting: boolean; readonly errorMessage: string | null; readonly validationError: string | null; readonly savedMessage: string | null; readonly canSave: boolean; - readonly setDraft: (draft: string) => void; + readonly updateQuiz: (patch: Partial) => void; + readonly updateWeeklyFocus: (patch: Partial) => void; + readonly updateReminder: (index: number, value: string) => void; + readonly addReminder: () => void; + readonly removeReminder: (index: number) => void; + readonly updateQuestion: ( + index: number, + patch: Partial, + ) => void; + readonly updateQuestionOption: (questionIndex: number, optionIndex: number, value: string) => void; + readonly addQuestion: () => void; + readonly removeQuestion: (index: number) => void; + readonly addQuestionOption: (questionIndex: number) => void; + readonly removeQuestionOption: (questionIndex: number, optionIndex: number) => void; + readonly addDefaultQuiz: () => void; readonly reset: () => void; readonly save: () => Promise; + readonly deleteQuiz: () => Promise; } diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index 5f1363f..d7b4498 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -18,6 +18,7 @@ import { import { getScopedModules } from '@/business/app-shell/selectors'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks'; +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'; @@ -36,6 +37,7 @@ import type { import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; import { useScopeContext } from '@/shared/app/scope-context'; +import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; const EMPTY_STRATEGIES: readonly Strategy[] = []; const EMPTY_SIGNS: readonly SignItem[] = []; @@ -74,6 +76,18 @@ export function useTopBarPage({ zoneCheckIn.isLoading, zoneCheckIn.isCheckedInToday, ); + const canReceiveSafetyQuizNotification = canPersistPersonalResults + && hasPermission(user, 'TAKE_QUIZ') + && accessibleModuleIds.has('qbs') + && (effectiveTier === 'school' || effectiveTier === 'campus' || effectiveTier === 'class'); + const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); + const safetyQuizStatus = useMySafetyQuizStatus( + safetyQuizWeek, + canReceiveSafetyQuizNotification, + ); + const needsSafetyQuiz = canReceiveSafetyQuizNotification + && !safetyQuizStatus.isLoading + && safetyQuizStatus.data?.completed !== true; const communicationEvents = useCommunicationEvents(); const acknowledgedCommunicationEventIds = useMemo(() => new Set(), []); const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY'); @@ -86,6 +100,7 @@ export function useTopBarPage({ const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols); const notifications = buildTopBarNotifications({ needsZoneCheckIn, + needsSafetyQuiz, 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 edb0f9b..7b10884 100644 --- a/frontend/src/business/top-bar/selectors.test.ts +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -59,6 +59,26 @@ describe('top bar selectors', () => { expect(countUnreadTopBarNotifications(withNudge)).toBe(1); }); + it('surfaces an unread QBS quiz reminder when the weekly quiz is incomplete', () => { + expect(buildTopBarNotifications({ + needsZoneCheckIn: false, + needsSafetyQuiz: false, + })).toEqual([]); + + const withReminder = buildTopBarNotifications({ + needsZoneCheckIn: false, + needsSafetyQuiz: true, + }); + + expect(withReminder).toEqual([{ + id: 'safety-quiz-weekly', + text: "You haven't completed this week's QBS safety quiz", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.qbs, + }]); + }); + 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 87aee88..04c4166 100644 --- a/frontend/src/business/top-bar/selectors.ts +++ b/frontend/src/business/top-bar/selectors.ts @@ -38,6 +38,7 @@ export function countUnreadTopBarNotifications( } const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; +const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly'; /** * Builds the top-bar notification list from derived app state (there is no @@ -46,6 +47,7 @@ const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; */ export function buildTopBarNotifications(input: { readonly needsZoneCheckIn: boolean; + readonly needsSafetyQuiz?: boolean; readonly communicationEvents?: readonly CommunicationEventDto[]; readonly acknowledgedCommunicationEventIds?: ReadonlySet; readonly handbookPolicies?: readonly PolicyViewModel[]; @@ -64,6 +66,16 @@ export function buildTopBarNotifications(input: { }); } + if (input.needsSafetyQuiz) { + notifications.push({ + id: SAFETY_QUIZ_NOTIFICATION_ID, + text: "You haven't completed this week's QBS safety quiz", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.qbs, + }); + } + 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 1153619..c5a8896 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 { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types'; interface DirectorQuizResultsPanelProps { - readonly results: readonly SafetyQuizResultDto[]; + readonly results: readonly SafetyQuizComplianceRow[]; } export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) { @@ -34,16 +34,20 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr {results.map((result) => ( - - {result.user_name} - {result.user_role} + + {result.name} + {result.role} - - {result.score}/{result.total_questions} + + {result.score} - {new Date(result.completed_at).toLocaleDateString()} + {result.date} ))} @@ -51,7 +55,7 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr ) : ( - No quiz results yet. Staff will appear here after completing quizzes. + No staff are in this completion scope yet. )} diff --git a/frontend/src/components/safety-quiz/SafetyQuizCompliancePanel.tsx b/frontend/src/components/safety-quiz/SafetyQuizCompliancePanel.tsx index bcb14a0..e75f22f 100644 --- a/frontend/src/components/safety-quiz/SafetyQuizCompliancePanel.tsx +++ b/frontend/src/components/safety-quiz/SafetyQuizCompliancePanel.tsx @@ -40,12 +40,16 @@ export function SafetyQuizCompliancePanel({ {rows.length > 0 && (
{rows.map((staff) => ( -
+

{staff.name}

-

{staff.role}

+

{staff.role} · {staff.date}

- + {staff.score}
diff --git a/frontend/src/components/safety-quiz/SafetyQuizContentEditorPanel.tsx b/frontend/src/components/safety-quiz/SafetyQuizContentEditorPanel.tsx index 2a999cd..5393dec 100644 --- a/frontend/src/components/safety-quiz/SafetyQuizContentEditorPanel.tsx +++ b/frontend/src/components/safety-quiz/SafetyQuizContentEditorPanel.tsx @@ -1,69 +1,381 @@ -import { Settings } from 'lucide-react'; +import { ChevronDown, Plus, Settings, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import type { ReactNode } from 'react'; import { useSafetyQuizContentEditor } from '@/business/safety-quiz/hooks'; import { Button } from '@/components/ui/button'; +import { ConfirmationDialog } from '@/components/common/ConfirmationDialog'; +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 type { SafetyQuiz } from '@/shared/types/app'; + +const focusOptions: readonly { value: SafetyQuiz['focus']; label: string }[] = [ + { value: 'de-escalation', label: 'De-escalation' }, + { value: 'physical-management', label: 'Physical management' }, + { value: 'safety-reminders', label: 'Safety reminders' }, +]; + +const inputClassName = 'border-slate-700/60 bg-slate-950/70 text-slate-100 placeholder:text-slate-500'; +const labelClassName = 'text-xs font-semibold uppercase tracking-wide text-slate-400'; +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'; export function SafetyQuizContentEditorPanel() { const editor = useSafetyQuizContentEditor(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [open, setOpen] = useState(false); + const disabled = editor.isLoading || editor.isSaving || editor.isDeleting; + const quiz = editor.draft; return ( -
-
- -

Quiz Content Editor

+ +
+ + + +
+ + +
- {editor.errorMessage && ( - - {editor.errorMessage} - - )} + + {editor.errorMessage && ( + + {editor.errorMessage} + + )} - {editor.validationError && ( - - {editor.validationError} - - )} + {editor.validationError && ( + + {editor.validationError} + + )} - {editor.savedMessage && ( - - {editor.savedMessage} - - )} + {editor.savedMessage && ( + + {editor.savedMessage} + + )} -