added behaviour management CRUD

This commit is contained in:
Dmitri 2026-06-18 10:09:11 +02:00
parent 40e60165d4
commit d79a618d4f
40 changed files with 1376 additions and 187 deletions

View File

@ -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`.

View File

@ -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: <SafetyQuizInput> }`.
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`.

View File

@ -9,6 +9,22 @@ export async function list(req: Request, res: Response): Promise<void> {
res.status(200).send(payload);
}
export async function me(req: Request, res: Response): Promise<void> {
const payload = await SafetyQuizResultsService.me(
req.query,
req.currentUser,
);
res.status(200).send(payload);
}
export async function completion(req: Request, res: Response): Promise<void> {
const payload = await SafetyQuizResultsService.completion(
req.query,
req.currentUser,
);
res.status(200).send(payload);
}
export async function create(req: Request, res: Response): Promise<void> {
const payload = await SafetyQuizResultsService.create(
req.body.data,

View File

@ -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.
},
};

View File

@ -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.
},
};

View File

@ -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<Record<RoleName, readonly string[]>> = {
// 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<Record<RoleName, readonly strin
'READ_SAFETY_QUIZ_REPORTS',
'READ_PERSONALITY_REPORTS',
'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
'TAKE_QUIZ',
'READ_AUDIO_FILES',
],
[ROLE_NAMES.OFFICE_MANAGER]: [

View File

@ -24,8 +24,9 @@ import { CONTENT_CATALOG_SEED_PAYLOADS } from './content-catalog-data/content-ca
/**
* Owning-tenant ids for a seeded content type, derived from its scope class so
* scoped users actually see the demo content: per-tenant types are seeded at
* org, school, and campus levels; school-scoped at schools; org-scoped at orgs;
* the rest (truly global) carry no tenant.
* org, school, and campus levels; school-scoped at schools; org-scoped at orgs
* (including the organization-owned QBS safety quiz); the rest (truly global)
* carry no tenant.
*/
function seedTenants(contentType: string): Array<{
organizationId: string | null;

View File

@ -29,7 +29,24 @@ const router = express.Router();
* responses:
* 200: { description: Submitted. }
* 403: { $ref: '#/components/responses/ForbiddenError' }
* /api/safety_quiz_results/me:
* get:
* tags: [Quizzes]
* summary: Get current user's weekly safety quiz completion status
* responses:
* 200: { description: Personal completion status. }
* 401: { $ref: '#/components/responses/UnauthorizedError' }
* /api/safety_quiz_results/completion:
* get:
* tags: [Quizzes]
* summary: Staff safety quiz completion report
* description: Requires the `READ_SAFETY_QUIZ_REPORTS` permission.
* responses:
* 200: { description: Completion rows and summary. }
* 403: { $ref: '#/components/responses/ForbiddenError' }
*/
router.get('/me', wrapAsync(safety_quiz_results.me));
router.get('/completion', wrapAsync(safety_quiz_results.completion));
router.get('/', wrapAsync(safety_quiz_results.list));
router.post(
'/',

View File

@ -172,4 +172,28 @@ describe('ContentCatalogService tenant scoping', () => {
{ 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' },
);
});
});

View File

@ -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();

View File

@ -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<Record<string, unknown>> = [];
mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne);
mock.method(db.content_catalog, 'create', (async (payload: Record<string, unknown>) => {
createdRows.push(payload);
return payload;
}) as typeof db.content_catalog.create);
await seedDefaultContentForTenant({
level: 'organization',
organizationId: 'org-1',
});
await seedDefaultContentForTenant({
level: 'school',
organizationId: 'org-1',
schoolId: 'school-1',
});
await seedDefaultContentForTenant({
level: 'campus',
organizationId: 'org-1',
schoolId: 'school-1',
campusId: 'campus-1',
});
const 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 },
],
);
});
});

View File

@ -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,

View File

@ -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');
});
});

View File

@ -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<string, SafetyQuizResults> {
const byUser = new Map<string, SafetyQuizResults>();
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;

View File

@ -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<string> = 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<string> = new Set([
/** **Org-scoped** content types (one per organization; preset at org creation). */
export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
SAFETY_QUIZ_CONTENT_TYPE,
CLASSROOM_SUPPORT_CONTENT_TYPE,
'regulation-zones',
'zones-of-regulation-page-content',

View File

@ -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.

View File

@ -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.

View File

@ -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);
});

View File

@ -34,7 +34,7 @@ const MODULE_SCOPE_TIERS: Partial<Record<ModuleId, readonly ScopeTier[]>> = {
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).

View File

@ -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<DirectorDashboardTimeRange>('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,

View File

@ -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<StaffAttendanceRecordViewModel> = {},
@ -24,23 +24,12 @@ function createAttendanceRecord(
};
}
function createQuizResult(overrides: Partial<SafetyQuizResultDto> = {}): SafetyQuizResultDto {
function createQuizSummary(overrides: Partial<SafetyQuizCompletionSummary> = {}): 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<FrameEntryViewModel> = {}): 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([

View File

@ -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 [

View File

@ -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;

View File

@ -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<SafetyQuiz | null>(
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<string | null>(null);
const [draftOverride, setDraftOverride] = useState<SafetyQuiz | null>(null);
const [hasDraftOverride, setHasDraftOverride] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [savedMessage, setSavedMessage] = useState<string | null>(null);
const contentType = CONTENT_CATALOG_TYPES.safetyQbsQuiz;
@ -186,10 +226,14 @@ export function useSafetyQuizContentEditor(): SafetyQuizContentEditor {
queryFn: () => getManagedContentCatalog<SafetyQuiz>(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.',
},
],
};
}

View File

@ -37,8 +37,9 @@ function createResult(overrides: Partial<SafetyQuizResultDto> = {}): 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,
});
});

View File

@ -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,

View File

@ -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.';
}

View File

@ -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<import('@/shared/types/app').SafetyQuiz>) => void;
readonly updateWeeklyFocus: (patch: Partial<import('@/shared/types/app').SafetyQuiz['weeklyFocus']>) => void;
readonly updateReminder: (index: number, value: string) => void;
readonly addReminder: () => void;
readonly removeReminder: (index: number) => void;
readonly updateQuestion: (
index: number,
patch: Partial<import('@/shared/types/app').QuizQuestion>,
) => 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<void>;
readonly deleteQuiz: () => Promise<void>;
}

View File

@ -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<string>(), []);
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 ?? [],

View File

@ -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');

View File

@ -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<string>;
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;

View File

@ -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
</TableHeader>
<TableBody>
{results.map((result) => (
<TableRow key={result.id} className="border-t border-gray-50">
<TableCell className="p-3 font-medium text-gray-700">{result.user_name}</TableCell>
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.user_role}</TableCell>
<TableRow key={result.userId} className="border-t border-gray-50">
<TableCell className="p-3 font-medium text-gray-700">{result.name}</TableCell>
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.role}</TableCell>
<TableCell className="p-3 text-center">
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${result.score === result.total_questions ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
{result.score}/{result.total_questions}
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${
result.status === 'complete'
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700'
}`}>
{result.score}
</span>
</TableCell>
<TableCell className="p-3 text-center text-xs text-gray-400">
{new Date(result.completed_at).toLocaleDateString()}
{result.date}
</TableCell>
</TableRow>
))}
@ -51,7 +55,7 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr
</Table>
) : (
<StatePanel className="border-0 bg-transparent" alignment="center">
No quiz results yet. Staff will appear here after completing quizzes.
No staff are in this completion scope yet.
</StatePanel>
)}
</div>

View File

@ -40,12 +40,16 @@ export function SafetyQuizCompliancePanel({
{rows.length > 0 && (
<div className="space-y-2">
{rows.map((staff) => (
<div key={`${staff.name}-${staff.date}`} className="flex items-center justify-between p-2 rounded-lg bg-slate-700/30 border border-slate-700/30">
<div key={staff.userId} className="flex items-center justify-between p-2 rounded-lg bg-slate-700/30 border border-slate-700/30">
<div>
<p className="text-xs font-medium text-slate-200">{staff.name}</p>
<p className="text-[10px] text-slate-500">{staff.role}</p>
<p className="text-[10px] text-slate-500">{staff.role} · {staff.date}</p>
</div>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-lg border bg-emerald-500/15 text-emerald-400 border-emerald-500/20">
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-lg border ${
staff.status === 'complete'
? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'
: 'bg-amber-500/15 text-amber-300 border-amber-500/20'
}`}>
{staff.score}
</span>
</div>

View File

@ -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 (
<div className="rounded-2xl border border-slate-700/40 bg-slate-800/40 p-4 space-y-3">
<div className="flex items-center gap-2">
<Settings size={18} className="text-blue-400" />
<h3 className="text-sm font-semibold text-white">Quiz Content Editor</h3>
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-2xl border border-slate-700/40 bg-slate-800/40"
>
<div className="flex flex-col gap-3 border-b border-slate-700/40 p-4 sm:flex-row sm:items-center sm:justify-between">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
aria-label={open ? 'Collapse quiz editor' : 'Expand quiz editor'}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-500/15 text-blue-300">
<Settings size={18} />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-white">Quiz Content Editor</span>
<span className="block text-xs text-slate-400">Edit the weekly QBS quiz and key reminders.</span>
</span>
<ChevronDown
size={18}
className={`ml-auto text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
</CollapsibleTrigger>
<div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
leadingIcon={<Plus size={16} />}
onClick={() => {
editor.addDefaultQuiz();
setOpen(true);
}}
disabled={disabled}
className="border border-emerald-500/30 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20"
>
{quiz ? 'Replace with Default' : 'Add Quiz'}
</Button>
<Button
type="button"
variant="ghost"
leadingIcon={<Trash2 size={16} />}
onClick={() => {
setDeleteOpen(true);
setOpen(true);
}}
disabled={disabled || !quiz}
className="border border-red-500/30 bg-red-500/10 text-red-200 hover:bg-red-500/20"
>
Delete Quiz
</Button>
</div>
</div>
{editor.errorMessage && (
<StatePanel tone="red" role="alert" size="inline">
{editor.errorMessage}
</StatePanel>
)}
<CollapsibleContent className="space-y-4 p-4">
{editor.errorMessage && (
<StatePanel tone="red" role="alert" size="inline">
{editor.errorMessage}
</StatePanel>
)}
{editor.validationError && (
<StatePanel tone="red" role="alert" size="inline">
{editor.validationError}
</StatePanel>
)}
{editor.validationError && (
<StatePanel tone="red" role="alert" size="inline">
{editor.validationError}
</StatePanel>
)}
{editor.savedMessage && (
<StatePanel tone="cyan" size="inline">
{editor.savedMessage}
</StatePanel>
)}
{editor.savedMessage && (
<StatePanel tone="cyan" size="inline">
{editor.savedMessage}
</StatePanel>
)}
<Textarea
value={editor.draft}
onChange={(event) => editor.setDraft(event.target.value)}
disabled={editor.isLoading || editor.isSaving}
aria-label="Safety quiz content JSON"
className="min-h-80 border-slate-700/60 bg-slate-950/70 font-mono text-xs text-slate-200"
placeholder="Loading safety quiz content..."
{!quiz ? (
<div className="rounded-xl border border-dashed border-slate-700/70 bg-slate-950/30 px-4 py-8 text-center">
<p className="text-sm font-semibold text-slate-100">No QBS quiz is configured.</p>
<p className="mt-1 text-sm text-slate-400">Add a quiz to publish questions and reminders for this scope.</p>
</div>
) : (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void editor.save();
}}
>
<div className={sectionClassName}>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Quiz title">
<Input
value={quiz.title}
onChange={(event) => editor.updateQuiz({ title: event.target.value })}
disabled={disabled}
className={inputClassName}
placeholder="QBS Safety Quiz"
/>
</Field>
<Field label="Focus">
<NativeSelect
value={quiz.focus}
onChange={(event) => {
const focus = focusOptions.find((option) => option.value === event.target.value)?.value;
if (focus) {
editor.updateQuiz({ focus });
}
}}
disabled={disabled}
className={inputClassName}
>
{focusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</NativeSelect>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Weekly focus title">
<Input
value={quiz.weeklyFocus.title}
onChange={(event) => editor.updateWeeklyFocus({ title: event.target.value })}
disabled={disabled}
className={inputClassName}
placeholder="This week's focus"
/>
</Field>
<Field label="Weekly focus description">
<Textarea
value={quiz.weeklyFocus.description}
onChange={(event) => editor.updateWeeklyFocus({ description: event.target.value })}
disabled={disabled}
className={`${inputClassName} min-h-24`}
placeholder="Describe what staff should focus on this week."
/>
</Field>
</div>
</div>
<div className={sectionClassName}>
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold text-white">Key Reminders</h4>
<Button
type="button"
variant="ghost"
size="sm"
leadingIcon={<Plus size={14} />}
onClick={editor.addReminder}
disabled={disabled}
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
>
Add Reminder
</Button>
</div>
<div className="space-y-2">
{quiz.keyReminders.map((reminder, index) => (
<div key={`${index}-${reminder}`} className="flex items-center gap-2">
<Input
value={reminder}
onChange={(event) => editor.updateReminder(index, event.target.value)}
disabled={disabled}
className={inputClassName}
aria-label={`Key reminder ${index + 1}`}
placeholder="Add a key reminder."
/>
<Button
type="button"
variant="ghost"
aria-label={`Remove reminder ${index + 1}`}
onClick={() => editor.removeReminder(index)}
disabled={disabled || quiz.keyReminders.length <= 1}
className={iconButtonClassName}
>
<Trash2 size={15} />
</Button>
</div>
))}
</div>
</div>
<div className={sectionClassName}>
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold text-white">Questions</h4>
<Button
type="button"
variant="ghost"
size="sm"
leadingIcon={<Plus size={14} />}
onClick={editor.addQuestion}
disabled={disabled}
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
>
Add Question
</Button>
</div>
<div className="space-y-4">
{quiz.questions.map((question, questionIndex) => (
<div
key={question.id}
className="rounded-xl border border-slate-700/50 bg-slate-900/50 p-4 space-y-4"
>
<div className="flex items-center justify-between gap-3">
<h5 className="text-sm font-semibold text-slate-100">Question {questionIndex + 1}</h5>
<Button
type="button"
variant="ghost"
aria-label={`Remove question ${questionIndex + 1}`}
onClick={() => editor.removeQuestion(questionIndex)}
disabled={disabled || quiz.questions.length <= 1}
className={iconButtonClassName}
>
<Trash2 size={15} />
</Button>
</div>
<Field label="Question text">
<Textarea
value={question.question}
onChange={(event) => editor.updateQuestion(questionIndex, { question: event.target.value })}
disabled={disabled}
className={`${inputClassName} min-h-20`}
placeholder="Write the question staff should answer."
/>
</Field>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className={labelClassName}>Answers</span>
<Button
type="button"
variant="ghost"
size="sm"
leadingIcon={<Plus size={14} />}
onClick={() => editor.addQuestionOption(questionIndex)}
disabled={disabled}
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
>
Add Answer
</Button>
</div>
{question.options.map((option, optionIndex) => (
<div key={`${question.id}-${optionIndex}`} className="grid gap-2 md:grid-cols-[1fr_160px_40px]">
<Input
value={option}
onChange={(event) =>
editor.updateQuestionOption(questionIndex, optionIndex, event.target.value)
}
disabled={disabled}
className={inputClassName}
aria-label={`Question ${questionIndex + 1} answer ${optionIndex + 1}`}
placeholder={`Answer ${optionIndex + 1}`}
/>
<Button
type="button"
variant={question.correctIndex === optionIndex ? 'default' : 'ghost'}
onClick={() => editor.updateQuestion(questionIndex, { correctIndex: optionIndex })}
disabled={disabled}
className={question.correctIndex === optionIndex
? 'bg-emerald-500/20 text-emerald-200 border border-emerald-500/30 hover:bg-emerald-500/30'
: 'border border-slate-700/60 text-slate-300 hover:bg-slate-700/40'}
>
Correct
</Button>
<Button
type="button"
variant="ghost"
aria-label={`Remove answer ${optionIndex + 1}`}
onClick={() => editor.removeQuestionOption(questionIndex, optionIndex)}
disabled={disabled || question.options.length <= 2}
className={iconButtonClassName}
>
<Trash2 size={15} />
</Button>
</div>
))}
</div>
<Field label="Explanation">
<Textarea
value={question.explanation}
onChange={(event) => editor.updateQuestion(questionIndex, { explanation: event.target.value })}
disabled={disabled}
className={`${inputClassName} min-h-20`}
placeholder="Explain why the correct answer is safest."
/>
</Field>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
loading={editor.isSaving}
disabled={!editor.canSave}
className="bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/30"
>
Save Quiz
</Button>
<Button
type="button"
variant="ghost"
onClick={editor.reset}
disabled={disabled}
className="border border-slate-700/60 text-slate-300 hover:bg-slate-700/40"
>
Reset
</Button>
</div>
</form>
)}
</CollapsibleContent>
<ConfirmationDialog
open={deleteOpen}
title="Delete QBS safety quiz?"
description="This will remove the current quiz and key reminders from this tenant's Behavior Management page."
confirmLabel="Delete quiz"
loading={editor.isDeleting}
tone="danger"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
void editor.deleteQuiz().then(() => setDeleteOpen(false));
}}
/>
<div className="flex flex-col sm:flex-row gap-2">
<Button
type="button"
onClick={() => {
void editor.save();
}}
loading={editor.isSaving}
disabled={!editor.canSave}
className="bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/30"
>
Save Content
</Button>
<Button
type="button"
variant="ghost"
onClick={editor.reset}
disabled={editor.isLoading || editor.isSaving}
className="border border-slate-700/60 text-slate-300 hover:bg-slate-700/40"
>
Reset
</Button>
</div>
</div>
</Collapsible>
);
}
function Field({
label,
children,
}: {
readonly label: string;
readonly children: ReactNode;
}) {
return (
<label className="space-y-2">
<span className={labelClassName}>{label}</span>
{children}
</label>
);
}

View File

@ -38,6 +38,7 @@ export function SafetyQuizView({ page }: SafetyQuizViewProps) {
{!quizConfigurationError && page.quiz ? (
<SafetyQuizFocusPanel weeklyFocus={page.quiz.weeklyFocus} />
) : null}
{page.canManageQuizContent && <SafetyQuizContentEditorPanel />}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<SafetyQuizMainPanel page={page} />
@ -52,7 +53,6 @@ export function SafetyQuizView({ page }: SafetyQuizViewProps) {
error={page.complianceError}
/>
)}
{page.canManageQuizContent && <SafetyQuizContentEditorPanel />}
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
import type { FormEvent } from 'react';
import { useMemo, useState } from 'react';
import { KeyRound, Loader2, UserCircle } from 'lucide-react';
import { KeyRound, Loader2, ShieldCheck, UserCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -15,6 +15,7 @@ import { changePassword, updateOwnProfile, updateOrganization } from '@/business
import { getErrorMessage } from '@/shared/errors/errorMessages';
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
import { ImageUpload } from '@/components/common/ImageUpload';
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
interface StatusMessage {
readonly type: 'success' | 'error';
@ -57,6 +58,7 @@ function ReadOnlyField({ label, value }: { label: string; value: string }) {
export default function ProfilePage() {
const { user, profile, refreshUser } = useAuth();
const capabilitiesQuery = useIamCapabilities();
const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user));
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
@ -342,6 +344,39 @@ export default function ProfilePage() {
)}
</div>
<Card className={profileCardClassName}>
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
<ShieldCheck size={16} />
QBS safety quiz
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<div className={`${formPanelClassName} mt-6`}>
{safetyQuizStatus.isLoading ? (
<p className="text-sm text-slate-300">Loading quiz status...</p>
) : safetyQuizStatus.data?.result ? (
<div className="grid gap-3 md:grid-cols-3">
<ReadOnlyField
label="Latest quiz"
value={safetyQuizStatus.data.result.quiz_title}
/>
<ReadOnlyField
label="Score"
value={`${safetyQuizStatus.data.result.score}/${safetyQuizStatus.data.result.total_questions}`}
/>
<ReadOnlyField
label="Completed"
value={new Date(safetyQuizStatus.data.result.completed_at).toLocaleDateString()}
/>
</div>
) : (
<p className="text-sm text-slate-300">No QBS safety quiz result is saved yet.</p>
)}
</div>
</CardContent>
</Card>
<Card className={profileCardClassName}>
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">

View File

@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createSafetyQuizResult,
getMySafetyQuizStatus,
getSafetyQuizCompletionReport,
listSafetyQuizResults,
} from '@/shared/api/safetyQuizResults';
import { apiRequest } from '@/shared/api/httpClient';
@ -29,6 +31,18 @@ describe('safety quiz results API', () => {
expect(apiRequestMock).toHaveBeenCalledWith('/safety_quiz_results?week_of=2026-06-08');
});
it('gets current user quiz status filtered by week', () => {
void getMySafetyQuizStatus('2026-06-08');
expect(apiRequestMock).toHaveBeenCalledWith('/safety_quiz_results/me?week_of=2026-06-08');
});
it('gets quiz completion report filtered by week', () => {
void getSafetyQuizCompletionReport('2026-06-08');
expect(apiRequestMock).toHaveBeenCalledWith('/safety_quiz_results/completion?week_of=2026-06-08');
});
it('creates safety quiz results with POST body wrapped in data', () => {
const request: SafetyQuizResultCreateDto = {
quiz_id: 'quiz-1',

View File

@ -1,6 +1,8 @@
import { apiRequest } from '@/shared/api/httpClient';
import { ApiListResponse } from '@/shared/types/api';
import {
SafetyQuizCompletionReportDto,
SafetyQuizPersonalStatusDto,
SafetyQuizResultCreateDto,
SafetyQuizResultDto,
} from '@/shared/types/safetyQuiz';
@ -19,6 +21,16 @@ export function listSafetyQuizResults(weekOf?: string): Promise<ApiListResponse<
return apiRequest<ApiListResponse<SafetyQuizResultDto>>(createSafetyQuizResultsPath(weekOf));
}
export function getMySafetyQuizStatus(weekOf?: string): Promise<SafetyQuizPersonalStatusDto> {
const suffix = weekOf ? `?${new URLSearchParams({ week_of: weekOf }).toString()}` : '';
return apiRequest<SafetyQuizPersonalStatusDto>(`${SAFETY_QUIZ_RESULTS_PATH}/me${suffix}`);
}
export function getSafetyQuizCompletionReport(weekOf?: string): Promise<SafetyQuizCompletionReportDto> {
const suffix = weekOf ? `?${new URLSearchParams({ week_of: weekOf }).toString()}` : '';
return apiRequest<SafetyQuizCompletionReportDto>(`${SAFETY_QUIZ_RESULTS_PATH}/completion${suffix}`);
}
export function createSafetyQuizResult(request: SafetyQuizResultCreateDto): Promise<SafetyQuizResultDto | null> {
return apiRequest<SafetyQuizResultDto | null>(SAFETY_QUIZ_RESULTS_PATH, {
method: 'POST',

View File

@ -1,5 +1,7 @@
export const SAFETY_QUIZ_QUERY_KEYS = {
results: ['safetyQuizResults'],
personalStatus: ['safetyQuizPersonalStatus'],
completion: ['safetyQuizCompletion'],
} as const;
export const SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT = 'compliance';

View File

@ -26,3 +26,29 @@ export interface SafetyQuizResultCreateDto {
readonly total_questions: number;
readonly answers: readonly number[];
}
export interface SafetyQuizPersonalStatusDto {
readonly completed: boolean;
readonly result: SafetyQuizResultDto | null;
}
export interface SafetyQuizCompletionRowDto {
readonly userId: string;
readonly name: string;
readonly email: string;
readonly role: UserRole | null;
readonly status: 'complete' | 'pending';
readonly result: SafetyQuizResultDto | null;
}
export interface SafetyQuizCompletionSummaryDto {
readonly totalStaff: number;
readonly completedCount: number;
readonly pendingCount: number;
readonly completionRate: number;
}
export interface SafetyQuizCompletionReportDto {
readonly summary: SafetyQuizCompletionSummaryDto;
readonly rows: readonly SafetyQuizCompletionRowDto[];
}