Added Awards Earned & Reviews, Certifications & Degrees, Autism Support Steps pages with CRUD and permissions

This commit is contained in:
Dmitri 2026-06-27 08:47:17 +02:00
parent 1b9ea3fdd6
commit dd5e9c6ad4
50 changed files with 3448 additions and 9 deletions

View File

@ -28,6 +28,8 @@ 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.
- `esa-funding-content` management requires `MANAGE_ESA_FUNDING_CONTENT`, campus effective scope, and `organizations.esaEnabled = true`. Parent users manage campus ESA content by drilling into a campus. Editing ESA content also stores the updated section name, bumps the linked `policy_documents` ESA acknowledgment document version, and makes staff re-acknowledge.
- `autism-support-steps` management requires `MANAGE_AUTISM_SUPPORT_STEPS` and campus effective scope. The payload stores editable step cards and visual lanyard card entries; uploaded visual card images are stored through the file subsystem and referenced by private URL.
- `para-certifications` management requires `MANAGE_PARA_CERTIFICATIONS` and campus effective scope. The payload stores the hero image, highlight cards, and editable certification/degree resource cards.
- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library.
- `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz.
- `emotional-intelligence-assessment-questions` and `emotional-intelligence-personality-quiz` are organization-scoped catalogs. Organization users with `MANAGE_CONTENT_CATALOG` manage the active quiz content once for the organization; descendant scopes read and complete that organization-owned content.
@ -38,7 +40,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.
- Campus-scoped types read the caller's resolved campus row; class-scoped and external users with a campus read the campus row.
- Campus-scoped types read the caller's resolved campus row; class-scoped and external users with a campus read the campus row. Current campus-scoped content types are `esa-funding-content`, `autism-support-steps`, and `para-certifications`.
- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows.
- `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload.
- Emotional Intelligence self-assessment and Personality Type quiz payloads are org-scoped for the same reason: each organization owns its active quiz versions and descendant scopes read the organization payloads.
@ -63,11 +65,17 @@ Management list excludes tenant-scoped content because those records are edited
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
### Seeded content types
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-personality-quiz`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`.
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-personality-quiz`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`, `autism-support-steps`, `para-certifications`.
Staff-support defaults:
- `autism-support-steps` contains six editable staff step cards and eight printable visual/lanyard cards copied from the legacy template payload.
- `para-certifications` contains the hero image/text, three highlight cards, and eight editable certification/degree resource cards copied from the legacy template payload.
- Both content types are campus-scoped. New campus creation seeds one row for each type; existing campuses are backfilled by `20260626110000-staff-support-content.ts`.
The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. The migration `20260618131000-backfill-emotional-intelligence-quiz-content.ts` backfills missing Emotional Intelligence and Personality Type quiz content for existing organizations.
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, the Personality Type quiz, and Zones of Regulation content; school creation presets school-scoped content; campus creation presets per-tenant campus content plus the campus-scoped ESA funding content and its policy-acknowledgment document. This keeps shared libraries and editable organization-wide content owned at the organization level while ESA stays campus-owned.
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, the Personality Type quiz, and Zones of Regulation content; school creation presets school-scoped content; campus creation presets per-tenant campus content plus campus-scoped ESA funding, Autism Support Steps, and Para Certifications content. This keeps shared libraries and editable organization-wide content owned at the organization level while campus-specific staff guidance stays campus-owned.
### Content authoring rules
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
@ -76,7 +84,7 @@ New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`
- `dashboard-sign-of-week` stores the selected sign card id, current Sunday-start `weekOf`, and fallback word, description, alt text, and image URL. Frontend dashboard and notification code resolve the id against `sign-language-items`; the fallback fields preserve compatibility with older seeded payloads.
## 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 organization-owned content, organization-only sign library management, organization-only seeding for preset organization-owned content, seeded Sign of the Week selector alignment with the Help sign card, and Zones of Regulation content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking.
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, organization-only sign library management, campus-only management for staff-support content, campus-only seeding for staff-support content, organization-only seeding for preset organization-owned content, seeded Sign of the Week selector alignment with the Help sign card, and Zones of Regulation content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking.
## Related
- Frontend: `frontend/docs/content-catalog-integration.md`.

View File

@ -28,6 +28,7 @@ Also: every tenant-owned table carries `organizationId` (+ optional `campusId`)
| Weekly F.R.A.M.E. entry | **`frame_entries`** | `week_of` is the canonical Sunday-start ISO (`shared/constants/week.ts`). |
| Per-user progress / daily self-state | **`user_progress`** | `progress_type` + `item_id` + `value`. Backs sign-learned, Classroom Support favorites, and the daily zone check-in (`item_id` = campus-local date). |
| Backend-owned editable content | **`content_catalog`** | scoped/editable JSONB payloads by `content_type`; truly global static catalogs stay in frontend constants. |
| Staff award/review records with attachments and leader-scoped access | **`staff_awards_reviews`** | per staff user + campus records for Awards Earned & Reviews. This intentionally does **not** use `user_progress` because leaders can create/read records for scoped staff users. |
| File upload/download | the **file subsystem** + `file` table | see `file.md`; downloads enforce per-file ownership. |
## Organization-level feature flags

View File

@ -6,7 +6,7 @@
## Overview
- **Engine:** PostgreSQL via **Sequelize 6** (models in `backend/src/db/models`, typed data access in `backend/src/db/api`).
- **Models:** 35 tables.
- **Models:** 36 tables.
- **Primary keys:** every table has a `uuid` `id` (default `UUIDV4`).
- **Soft delete:** all tables are `paranoid` — rows are flagged with `deletedAt` instead of being physically removed.
- **Timestamps:** `createdAt` / `updatedAt` are managed automatically.
@ -25,7 +25,7 @@ Types below are the SQL column types. A few Sequelize types are returned as JS `
- **Academics:** `academic_years`, `grades`, `subjects`, `classes`, `class_enrollments`, `class_subjects`, `timetables`, `timetable_periods`, `assessments`, `assessment_results`
- **Attendance:** `attendance_sessions`, `attendance_records`, `campus_attendance_config`, `campus_attendance_summaries`, `staff_attendance_records`
- **Communication:** `messages`, `message_recipients`, `communication_events`
- **Content & Product modules:** `content_catalog`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results`
- **Content & Product modules:** `content_catalog`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results`, `staff_awards_reviews`
- **Policy & Audio:** `policy_documents`, `policy_acknowledgments`, `audio_files`
- **System:** `file`, `auth_refresh_tokens`
@ -84,6 +84,10 @@ erDiagram
organizations ||--o{ staff_attendance_records : "organization"
campuses ||--o{ staff_attendance_records : "campus"
users ||--o{ staff_attendance_records : "user"
organizations ||--o{ staff_awards_reviews : "organization"
schools ||--o{ staff_awards_reviews : "school"
campuses ||--o{ staff_awards_reviews : "campus"
users ||--o{ staff_awards_reviews : "staffUser"
organizations ||--o{ subjects : "organization"
organizations ||--o{ timetable_periods : "organization"
timetables ||--o{ timetable_periods : "timetable"
@ -678,6 +682,37 @@ _Relations:_
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `users` as `user` (FK `userId`)
#### `staff_awards_reviews`
Staff awards and Director review records with optional uploaded attachment metadata.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `title` | text | no | — | |
| `record_type` | enum | no | — | `award` \| `review` |
| `year_label` | text | no | — | display school year or review year |
| `notes` | text | yes | — | |
| `file_name` | text | yes | — | original uploaded file name |
| `file_url` | varchar | yes | — | private file subsystem URL |
| `file_is_image` | boolean | no | false | controls inline image preview |
| `organizationId` | uuid | no | — | FK |
| `schoolId` | uuid | yes | — | FK |
| `campusId` | uuid | no | — | FK |
| `staffUserId` | uuid | no | — | FK to the staff user |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
_Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `schools` as `school` (FK `schoolId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `users` as `staffUser` (FK `staffUserId`)
### Communication
#### `messages`

View File

@ -45,6 +45,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
- [`policy-documents.md`](policy-documents.md): unified Safety Protocols + Handbook & Policies store and per-version acknowledgments.
- [`safety-quiz-results.md`](safety-quiz-results.md)
- [`staff-attendance.md`](staff-attendance.md)
- [`staff-awards-reviews.md`](staff-awards-reviews.md)
- [`user-progress.md`](user-progress.md)
- [`walkthrough-checkins.md`](walkthrough-checkins.md)
- [`zone-checkin.md`](zone-checkin.md): daily staff self-regulation check-in (campus-timezone "today" + nudge).

View File

@ -0,0 +1,73 @@
# Staff Awards Reviews Backend
## Purpose
`staff_awards_reviews` stores Awards Earned & Reviews page records for campus staff. It is a dedicated table instead of `user_progress` because the workflow is not self-only: staff can maintain their own records, and users with `MANAGE_AWARDS_REVIEWS` can list scoped staff and create, update, or delete records for those staff users.
## Slice Files
- Route: `src/routes/staff_awards_reviews.ts` (`GET /staff`, `GET /`, `POST /`, `PUT /:id`, `DELETE /:id`).
- Controller: `src/api/controllers/staff_awards_reviews.controller.ts`.
- Service: `src/services/staff_awards_reviews.ts`.
- Model: `src/db/models/staff_awards_reviews.ts`.
- Migration: `src/db/migrations/20260626110000-staff-support-content.ts`.
- Seeder: `src/db/seeders/20260613060000-staff-awards-reviews.ts`.
## API
Mounted at `/api/staff_awards_reviews` behind JWT authentication and active-scope resolution.
- `GET /staff` lists staff visible in the current scope. Requires `READ_AWARDS_REVIEWS` and `MANAGE_AWARDS_REVIEWS`.
- `GET /?staffUserId=<id>` lists records for the selected staff user when the caller can manage, otherwise it lists the current user's records.
- `POST /` creates a record. Staff users create for themselves; managers may pass `staffUserId`.
- `PUT /:id` updates a record in scope.
- `DELETE /:id` soft-deletes a record in scope.
## Access Rules
- Page read requires `READ_AWARDS_REVIEWS`.
- Staff list and cross-staff mutation require `MANAGE_AWARDS_REVIEWS`.
- Scope is derived from the current effective role scope, including drill-down active scope:
- organization scope sees organization staff;
- school scope sees school and descendant campus/class staff;
- campus and class scopes see the resolved campus staff;
- non-managers are restricted to their own `staffUserId`.
- Staff classification for the staff picker is based on first-class role names (`director`, `office_manager`, `teacher`, `support_staff`) as data classification, not as a feature-access fallback.
Seeded role permissions:
- Read: `system_admin`, `owner`, `superintendent`, `principal`, `director`, `office_manager`, `teacher`, and `support_staff`.
- Manage: `system_admin`, `owner`, `superintendent`, `principal`, `director`, and `office_manager`.
- `registrar` is intentionally excluded from this staff-support surface even though it has other school-level audit reads.
## Data Contract
Columns:
- `title`, `record_type` (`award` or `review`), `year_label`, `notes`;
- optional attachment metadata: `file_name`, `file_url`, `file_is_image`;
- tenant/scope columns: `organizationId`, `schoolId`, `campusId`, `staffUserId`;
- audit columns: `createdById`, `updatedById`, timestamps, `deletedAt`.
Attachments are uploaded through `POST /api/file/upload/staff-awards-reviews/attachments`; the awards/reviews row stores the returned private URL.
## Seeded Defaults
The demo and rival seed tenants each receive Awards Earned & Reviews rows for their seeded teacher and support staff users. Each seeded staff user receives:
- `Teacher of the Year` (`award`, `2025-2026`);
- `Annual Director Review` (`review`, `2025-2026`);
- `Perfect Attendance Award` (`award`, `2024-2025`).
The seeder uses stable UUIDs and skips rows that already exist, so reseeding is idempotent and does not overwrite user-created records.
## Tests
- `src/services/staff_awards_reviews.test.ts` covers self-service reads, management-only staff lists, school/campus scope filtering, and manager-created record tenant/campus ownership.
- `src/db/seeders/user-roles.test.ts` covers the seeded read/manage permission matrix, including registrar exclusion.
- Frontend API route construction is covered by `frontend/src/shared/api/staffAwardsReviews.test.ts`.
## Related
- Content pages backed by `content_catalog`: `autism-support-steps`, `para-certifications`.
- Frontend: `frontend/docs/staff-support-pages.md`.

View File

@ -0,0 +1,32 @@
import type { Request, Response } from 'express';
import { paramStr } from '@/api/http/request';
import StaffAwardsReviewsService from '@/services/staff_awards_reviews';
export async function listStaff(req: Request, res: Response): Promise<void> {
const payload = await StaffAwardsReviewsService.listStaff(req.query, req.currentUser);
res.status(200).send(payload);
}
export async function list(req: Request, res: Response): Promise<void> {
const payload = await StaffAwardsReviewsService.list(req.query, req.currentUser);
res.status(200).send(payload);
}
export async function create(req: Request, res: Response): Promise<void> {
const payload = await StaffAwardsReviewsService.create(req.body.data, req.currentUser);
res.status(201).send(payload);
}
export async function update(req: Request, res: Response): Promise<void> {
const payload = await StaffAwardsReviewsService.update(
paramStr(req.params.id),
req.body.data,
req.currentUser,
);
res.status(200).send(payload);
}
export async function remove(req: Request, res: Response): Promise<void> {
const payload = await StaffAwardsReviewsService.remove(paramStr(req.params.id), req.currentUser);
res.status(200).send(payload);
}

View File

@ -0,0 +1,225 @@
import { DataTypes, QueryTypes, type QueryInterface } from 'sequelize';
import { randomUUID } from 'node:crypto';
import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
async function tableExists(
queryInterface: QueryInterface,
table: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = '${table}'
`);
return (results as unknown[]).length > 0;
}
async function indexExists(
queryInterface: QueryInterface,
indexName: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public' AND indexname = '${indexName}'
`);
return (results as unknown[]).length > 0;
}
async function permissionIdByName(
queryInterface: QueryInterface,
name: string,
timestamp: Date,
): Promise<string> {
const rows = await queryInterface.sequelize.query<{ id: string }>(
`SELECT id FROM permissions WHERE name = :name LIMIT 1`,
{ replacements: { name }, type: QueryTypes.SELECT },
);
if (rows[0]) {
return rows[0].id;
}
const created = await queryInterface.sequelize.query<{ id: string }>(
`INSERT INTO permissions (id, name, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), :name, :createdAt, :updatedAt)
RETURNING id`,
{
replacements: { name, createdAt: timestamp, updatedAt: timestamp },
type: QueryTypes.SELECT,
},
);
return created[0].id;
}
async function grantPermissionToRoles(
queryInterface: QueryInterface,
permissionName: string,
roleNames: readonly string[],
timestamp: Date,
): Promise<void> {
const permissionId = await permissionIdByName(queryInterface, permissionName, timestamp);
const roles = await queryInterface.sequelize.query<{ id: string }>(
`SELECT id FROM roles WHERE name IN (:roleNames)`,
{ replacements: { roleNames }, type: QueryTypes.SELECT },
);
for (const role of roles) {
await queryInterface.sequelize.query(
`INSERT INTO "rolesPermissionsPermissions"
("roles_permissionsId", "permissionId", "createdAt", "updatedAt")
SELECT :roleId, :permissionId, :createdAt, :updatedAt
WHERE NOT EXISTS (
SELECT 1 FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId
)`,
{
replacements: {
roleId: role.id,
permissionId,
createdAt: timestamp,
updatedAt: timestamp,
},
},
);
}
}
async function seedCampusContent(
queryInterface: QueryInterface,
contentType: string,
payload: unknown,
timestamp: Date,
): Promise<void> {
const campuses = await queryInterface.sequelize.query<{
id: string;
organizationId: string;
}>(
`SELECT id, "organizationId" FROM campuses WHERE "deletedAt" IS NULL`,
{ type: QueryTypes.SELECT },
);
for (const campus of campuses) {
const existing = await queryInterface.sequelize.query<{ id: string }>(
`SELECT id FROM content_catalog
WHERE content_type = :contentType
AND "organizationId" = :organizationId
AND "schoolId" IS NULL
AND "campusId" = :campusId
AND "classId" IS NULL
LIMIT 1`,
{
replacements: {
contentType,
organizationId: campus.organizationId,
campusId: campus.id,
},
type: QueryTypes.SELECT,
},
);
if (existing[0]) {
continue;
}
await queryInterface.bulkInsert('content_catalog', [
{
id: randomUUID(),
content_type: contentType,
payload: JSON.stringify(payload),
active: true,
importHash: `content-catalog-${contentType}-${campus.id}`,
organizationId: campus.organizationId,
schoolId: null,
campusId: campus.id,
classId: null,
createdAt: timestamp,
updatedAt: timestamp,
},
]);
}
}
export default {
up: async (queryInterface: QueryInterface) => {
const timestamp = new Date();
await queryInterface.sequelize.query(`
DO $$
BEGIN
CREATE TYPE "public"."enum_staff_awards_reviews_record_type" AS ENUM ('award', 'review');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
if (!(await tableExists(queryInterface, 'staff_awards_reviews'))) {
await queryInterface.createTable('staff_awards_reviews', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: { type: DataTypes.TEXT, allowNull: false },
record_type: {
type: DataTypes.ENUM('award', 'review'),
allowNull: false,
},
year_label: { type: DataTypes.TEXT, allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
file_name: { type: DataTypes.TEXT, allowNull: true },
file_url: { type: DataTypes.STRING(2083), allowNull: true },
file_is_image: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
organizationId: { type: DataTypes.UUID, allowNull: false },
schoolId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: false },
staffUserId: { type: DataTypes.UUID, allowNull: false },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false },
updatedAt: { type: DataTypes.DATE, allowNull: false },
deletedAt: { type: DataTypes.DATE, allowNull: true },
});
}
if (!(await indexExists(queryInterface, 'staff_awards_reviews_staff_scope_idx'))) {
await queryInterface.addIndex('staff_awards_reviews', {
fields: ['organizationId', 'campusId', 'staffUserId'],
name: 'staff_awards_reviews_staff_scope_idx',
});
}
await seedCampusContent(
queryInterface,
'autism-support-steps',
CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps,
timestamp,
);
await seedCampusContent(
queryInterface,
'para-certifications',
CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications,
timestamp,
);
const readRoles = ['system_admin', 'owner', 'superintendent', 'principal', 'director', 'office_manager', 'teacher', 'support_staff'];
const manageRoles = ['system_admin', 'owner', 'superintendent', 'principal', 'director', 'office_manager'];
await grantPermissionToRoles(queryInterface, 'READ_AUTISM_SUPPORT_STEPS', readRoles, timestamp);
await grantPermissionToRoles(queryInterface, 'READ_PARA_CERTIFICATIONS', readRoles, timestamp);
await grantPermissionToRoles(queryInterface, 'READ_AWARDS_REVIEWS', readRoles, timestamp);
await grantPermissionToRoles(queryInterface, 'MANAGE_AUTISM_SUPPORT_STEPS', manageRoles, timestamp);
await grantPermissionToRoles(queryInterface, 'MANAGE_PARA_CERTIFICATIONS', manageRoles, timestamp);
await grantPermissionToRoles(queryInterface, 'MANAGE_AWARDS_REVIEWS', manageRoles, timestamp);
},
down: async (queryInterface: QueryInterface) => {
if (await tableExists(queryInterface, 'staff_awards_reviews')) {
await queryInterface.dropTable('staff_awards_reviews');
}
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "public"."enum_staff_awards_reviews_record_type";');
await queryInterface.sequelize.query(
`DELETE FROM content_catalog
WHERE "importHash" LIKE 'content-catalog-autism-support-steps-%'
OR "importHash" LIKE 'content-catalog-para-certifications-%'`,
);
},
};

View File

@ -0,0 +1,64 @@
import { QueryTypes, type QueryInterface } from 'sequelize';
const REGISTRAR_ROLE = 'registrar';
const STAFF_SUPPORT_READ_PERMISSIONS = [
'READ_AUTISM_SUPPORT_STEPS',
'READ_PARA_CERTIFICATIONS',
'READ_AWARDS_REVIEWS',
] as const;
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.query(
`DELETE FROM "rolesPermissionsPermissions" role_permissions
USING roles, permissions
WHERE role_permissions."roles_permissionsId" = roles.id
AND role_permissions."permissionId" = permissions.id
AND roles.name = :roleName
AND permissions.name IN (:permissionNames)`,
{
replacements: {
roleName: REGISTRAR_ROLE,
permissionNames: [...STAFF_SUPPORT_READ_PERMISSIONS],
},
},
);
},
down: async (queryInterface: QueryInterface) => {
const registrarRoles = await queryInterface.sequelize.query<{ id: string }>(
`SELECT id FROM roles WHERE name = :roleName`,
{ replacements: { roleName: REGISTRAR_ROLE }, type: QueryTypes.SELECT },
);
const permissions = await queryInterface.sequelize.query<{ id: string }>(
`SELECT id FROM permissions WHERE name IN (:permissionNames)`,
{
replacements: { permissionNames: [...STAFF_SUPPORT_READ_PERMISSIONS] },
type: QueryTypes.SELECT,
},
);
const timestamp = new Date();
for (const role of registrarRoles) {
for (const permission of permissions) {
await queryInterface.sequelize.query(
`INSERT INTO "rolesPermissionsPermissions"
("roles_permissionsId", "permissionId", "createdAt", "updatedAt")
SELECT :roleId, :permissionId, :createdAt, :updatedAt
WHERE NOT EXISTS (
SELECT 1 FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId
)`,
{
replacements: {
roleId: role.id,
permissionId: permission.id,
createdAt: timestamp,
updatedAt: timestamp,
},
},
);
}
}
},
};

View File

@ -40,6 +40,7 @@ import roles from './roles';
import safety_quiz_results from './safety_quiz_results';
import schools from './schools';
import staff_attendance_records from './staff_attendance_records';
import staff_awards_reviews from './staff_awards_reviews';
import subjects from './subjects';
import timetable_periods from './timetable_periods';
import timetables from './timetables';
@ -141,6 +142,7 @@ const models = {
safety_quiz_results: safety_quiz_results(sequelize),
schools: schools(sequelize),
staff_attendance_records: staff_attendance_records(sequelize),
staff_awards_reviews: staff_awards_reviews(sequelize),
subjects: subjects(sequelize),
timetable_periods: timetable_periods(sequelize),
timetables: timetables(sequelize),

View File

@ -0,0 +1,140 @@
import {
DataTypes,
Model,
type CreationOptional,
type InferAttributes,
type InferCreationAttributes,
type NonAttribute,
type Sequelize,
} from 'sequelize';
import type {
BelongsToGetAssociationMixin,
BelongsToSetAssociationMixin,
} from 'sequelize';
import type { Db } from '@/db/types';
import type { Campuses } from './campuses';
import type { Organizations } from './organizations';
import type { Schools } from './schools';
import type { Users } from './users';
export type StaffAwardReviewType = 'award' | 'review';
export class StaffAwardsReviews extends Model<
InferAttributes<StaffAwardsReviews>,
InferCreationAttributes<StaffAwardsReviews>
> {
declare id: CreationOptional<string>;
declare title: string;
declare record_type: StaffAwardReviewType;
declare year_label: string;
declare notes: CreationOptional<string | null>;
declare file_name: CreationOptional<string | null>;
declare file_url: CreationOptional<string | null>;
declare file_is_image: CreationOptional<boolean>;
declare organizationId: string;
declare schoolId: CreationOptional<string | null>;
declare campusId: string;
declare staffUserId: string;
declare createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>;
declare deletedAt: CreationOptional<Date | null>;
declare organization?: NonAttribute<Organizations>;
declare school?: NonAttribute<Schools>;
declare campus?: NonAttribute<Campuses>;
declare staffUser?: NonAttribute<Users>;
declare createdBy?: NonAttribute<Users>;
declare updatedBy?: NonAttribute<Users>;
declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
declare getSchool: BelongsToGetAssociationMixin<Schools>;
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
declare getCampus: BelongsToGetAssociationMixin<Campuses>;
declare setCampus: BelongsToSetAssociationMixin<Campuses, string>;
declare getStaffUser: BelongsToGetAssociationMixin<Users>;
declare setStaffUser: BelongsToSetAssociationMixin<Users, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
declare setUpdatedBy: BelongsToSetAssociationMixin<Users, string>;
static associate(db: Db): void {
db.staff_awards_reviews.belongsTo(db.organizations, {
as: 'organization',
foreignKey: { name: 'organizationId' },
constraints: false,
});
db.staff_awards_reviews.belongsTo(db.schools, {
as: 'school',
foreignKey: { name: 'schoolId' },
constraints: false,
});
db.staff_awards_reviews.belongsTo(db.campuses, {
as: 'campus',
foreignKey: { name: 'campusId' },
constraints: false,
});
db.staff_awards_reviews.belongsTo(db.users, {
as: 'staffUser',
foreignKey: { name: 'staffUserId' },
constraints: false,
});
db.staff_awards_reviews.belongsTo(db.users, {
as: 'createdBy',
foreignKey: { name: 'createdById' },
constraints: false,
});
db.staff_awards_reviews.belongsTo(db.users, {
as: 'updatedBy',
foreignKey: { name: 'updatedById' },
constraints: false,
});
}
}
export default function (sequelize: Sequelize): typeof StaffAwardsReviews {
StaffAwardsReviews.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: { type: DataTypes.TEXT, allowNull: false },
record_type: {
type: DataTypes.ENUM('award', 'review'),
allowNull: false,
},
year_label: { type: DataTypes.TEXT, allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
file_name: { type: DataTypes.TEXT, allowNull: true },
file_url: { type: DataTypes.STRING(2083), allowNull: true },
file_is_image: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
organizationId: { type: DataTypes.UUID, allowNull: false },
schoolId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: false },
staffUserId: { type: DataTypes.UUID, allowNull: false },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE },
},
{
sequelize,
modelName: 'staff_awards_reviews',
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
return StaffAwardsReviews;
}

View File

@ -8,6 +8,7 @@ import {
import { SEED_ALL_USERS } from '@/shared/constants/seed-fixtures';
import {
MODULE_READ_ALL_STAFF,
MODULE_READ_STAFF_SUPPORT,
MODULE_READ_INSTRUCTIONAL,
MODULE_READ_PARENT_COMM,
MODULE_READ_EXTERNAL,
@ -86,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, and
// complete required staff safety training, but no operational write actions.
// Registrar: read audit-oriented school surfaces and complete required staff
// safety training, but no operational write actions or campus staff-support pages.
[ROLE_NAMES.REGISTRAR]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_INSTRUCTIONAL,
@ -104,14 +105,19 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
],
[ROLE_NAMES.OFFICE_MANAGER]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_STAFF_SUPPORT,
...MODULE_READ_PARENT_COMM,
...MODULE_READ_EXTERNAL,
...MODULE_ACTIONS,
'MANAGE_ESA_FUNDING_CONTENT',
'MANAGE_AUTISM_SUPPORT_STEPS',
'MANAGE_PARA_CERTIFICATIONS',
'MANAGE_AWARDS_REVIEWS',
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
],
[ROLE_NAMES.TEACHER]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_STAFF_SUPPORT,
...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_PARENT_COMM,
...MODULE_READ_EXTERNAL,
@ -122,6 +128,7 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
],
[ROLE_NAMES.SUPPORT_STAFF]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_STAFF_SUPPORT,
...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_EXTERNAL,
'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',

View File

@ -112,6 +112,8 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
{ content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent },
{ content_type: 'autism-support-steps', payload: CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps },
{ content_type: 'para-certifications', payload: CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications },
// `safety-protocols` is no longer served from the content catalog — safety
// protocols are now seeded into `policy_documents` (see the policy-documents
// seeder), which the Safety Protocols page reads.

View File

@ -0,0 +1,155 @@
import {
Op,
QueryTypes,
type QueryInterface,
} from 'sequelize';
import {
SEED_CAMPUS_ID,
SEED_FIXTURE_USERS,
SEED_ORGANIZATION_ID,
SEED_ORGANIZATION_2_ID,
SEED_SCHOOL_ID,
SEED_SECONDARY_CAMPUS_ID,
SEED_SECONDARY_SCHOOL_ID,
SEED_SECONDARY_USERS,
} from '@/shared/constants/seed-fixtures';
import { ROLE_NAMES } from '@/shared/constants/roles';
interface StaffAwardsReviewSeedRow {
readonly id: string;
readonly title: string;
readonly record_type: 'award' | 'review';
readonly year_label: string;
readonly notes: string;
readonly file_name: null;
readonly file_url: null;
readonly file_is_image: false;
readonly organizationId: string;
readonly schoolId: string;
readonly campusId: string;
readonly staffUserId: string;
readonly createdById: string | null;
readonly updatedById: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
const primaryTeacher = SEED_FIXTURE_USERS.find((user) => user.role === ROLE_NAMES.TEACHER);
const primarySupportStaff = SEED_FIXTURE_USERS.find((user) => user.role === ROLE_NAMES.SUPPORT_STAFF);
const primaryDirector = SEED_FIXTURE_USERS.find((user) => user.role === ROLE_NAMES.DIRECTOR);
const secondaryTeacher = SEED_SECONDARY_USERS.find((user) => user.role === ROLE_NAMES.TEACHER);
const secondarySupportStaff = SEED_SECONDARY_USERS.find((user) => user.role === ROLE_NAMES.SUPPORT_STAFF);
const secondaryDirector = SEED_SECONDARY_USERS.find((user) => user.role === ROLE_NAMES.DIRECTOR);
const STAFF_SEEDS = [
{
idPrefix: 'b1a7c0de-1000-4000-8000-00000000001',
userId: primaryTeacher?.id,
directorId: primaryDirector?.id,
organizationId: SEED_ORGANIZATION_ID,
schoolId: SEED_SCHOOL_ID,
campusId: SEED_CAMPUS_ID,
},
{
idPrefix: 'b1a7c0de-1000-4000-8000-00000000002',
userId: primarySupportStaff?.id,
directorId: primaryDirector?.id,
organizationId: SEED_ORGANIZATION_ID,
schoolId: SEED_SCHOOL_ID,
campusId: SEED_CAMPUS_ID,
},
{
idPrefix: 'b1a7c0de-1000-4000-8000-00000000003',
userId: secondaryTeacher?.id,
directorId: secondaryDirector?.id,
organizationId: SEED_ORGANIZATION_2_ID,
schoolId: SEED_SECONDARY_SCHOOL_ID,
campusId: SEED_SECONDARY_CAMPUS_ID,
},
{
idPrefix: 'b1a7c0de-1000-4000-8000-00000000004',
userId: secondarySupportStaff?.id,
directorId: secondaryDirector?.id,
organizationId: SEED_ORGANIZATION_2_ID,
schoolId: SEED_SECONDARY_SCHOOL_ID,
campusId: SEED_SECONDARY_CAMPUS_ID,
},
] as const;
const TEMPLATE_RECORDS = [
{
suffix: '1',
title: 'Teacher of the Year',
record_type: 'award' as const,
year_label: '2025-2026',
notes: 'Recognized for outstanding classroom management and parent partnership.',
},
{
suffix: '2',
title: 'Annual Director Review',
record_type: 'review' as const,
year_label: '2025-2026',
notes: 'Exceeds expectations in de-escalation and team leadership. Goal: mentor 2 new staff.',
},
{
suffix: '3',
title: 'Perfect Attendance Award',
record_type: 'award' as const,
year_label: '2024-2025',
notes: 'No unexcused absences across the school year.',
},
] as const;
function seedRows(now: Date): StaffAwardsReviewSeedRow[] {
return STAFF_SEEDS.flatMap((staff) => {
const userId = staff.userId;
if (!userId) {
return [];
}
return TEMPLATE_RECORDS.map((record) => ({
id: `${staff.idPrefix}${record.suffix}`,
title: record.title,
record_type: record.record_type,
year_label: record.year_label,
notes: record.notes,
file_name: null,
file_url: null,
file_is_image: false,
organizationId: staff.organizationId,
schoolId: staff.schoolId,
campusId: staff.campusId,
staffUserId: userId,
createdById: staff.directorId ?? null,
updatedById: staff.directorId ?? null,
createdAt: now,
updatedAt: now,
}));
});
}
export default {
up: async (queryInterface: QueryInterface) => {
const rows = seedRows(new Date());
const existing = await queryInterface.sequelize.query<{ id: string }>(
`SELECT id FROM staff_awards_reviews WHERE id IN (:ids)`,
{
replacements: { ids: rows.map((row) => row.id) },
type: QueryTypes.SELECT,
},
);
const existingIds = new Set(existing.map((row) => row.id));
const missingRows = rows.filter((row) => !existingIds.has(row.id));
if (missingRows.length > 0) {
await queryInterface.bulkInsert('staff_awards_reviews', missingRows);
}
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.bulkDelete(
'staff_awards_reviews',
{ id: { [Op.in]: seedRows(new Date()).map((row) => row.id) } },
{},
);
},
};

View File

@ -1011,6 +1011,112 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
],
},
],
autismSupportSteps: {
steps: [
{
id: 'frequent-breaks',
iconId: 'coffee',
title: 'Give Frequent Breaks',
subtitle: 'Short, scheduled breaks prevent overwhelm and reduce escalation.',
spans: [
'Offer a break BEFORE the student reaches the Yellow Zone',
'Use a "break" card or the Break sign',
'Keep breaks short (2-5 min) and predictable',
'Have a calm-down corner ready',
],
},
{
id: 'work-chunks',
iconId: 'layers',
title: 'Break Work into Chunks',
subtitle: 'Large tasks feel impossible. Small steps build success and confidence.',
spans: [
'Show only one step at a time',
'Use a "first / then" board',
'Check off each chunk as it is finished',
'Celebrate completion of every chunk',
],
},
{
id: 'break-timer',
iconId: 'timer',
title: 'Set a Timer for Breaks',
subtitle: 'A visual timer makes "how long" concrete and eases transitions.',
spans: [
'Set a visual or sand timer when a break begins',
'Give a 1-minute warning before time is up',
'Pair with the Wait sign',
'Use the Classroom Timer module on the projector',
],
},
{
id: 'reward-system',
iconId: 'award',
title: 'Use a Reward System',
subtitle: 'A token economy gives students something positive to work toward.',
spans: [
'Pick a clear, motivating reward',
'Use a token board: earn X tokens, then reward',
'Reinforce immediately and consistently',
'Keep it simple and visual',
],
},
{
id: 'visual-supports',
iconId: 'eye',
title: 'Provide Visual Supports',
subtitle: 'Visuals reduce reliance on spoken language and lower anxiety.',
spans: [
'Post a picture schedule at eye level',
'Use choice boards for autonomy',
'Add visual boundary markers',
'Reference visuals throughout the day',
],
},
{
id: 'sensory-demands',
iconId: 'volume',
title: 'Reduce Sensory Demands',
subtitle: 'Lower the noise, light, and demand level during high-stress moments.',
spans: [
'Offer noise-canceling headphones',
'Dim lights or move to a quiet space',
'Reduce verbal language during escalation',
'Allow movement and fidgets',
],
},
],
visualCards: [
{ id: 'break', label: 'Break', imageUrl: null, iconText: '☕' },
{ id: 'first-then', label: 'First / Then', imageUrl: null, iconText: '➡️' },
{ id: 'timer', label: 'Timer', imageUrl: null, iconText: '⏲️' },
{ id: 'reward', label: 'Reward', imageUrl: null, iconText: '⭐' },
{ id: 'quiet', label: 'Quiet', imageUrl: null, iconText: '🤫' },
{ id: 'help', label: 'Help', imageUrl: null, iconText: '🙋' },
{ id: 'wait', label: 'Wait', imageUrl: null, iconText: '✋' },
{ id: 'all-done', label: 'All Done', imageUrl: null, iconText: '✅' },
],
},
paraCertifications: {
heroImage: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1780710919817_963ae6a1.jpg',
heroTitle: 'Already doing the job? Get certified!',
heroSubtitle: 'This is stuff you already know. Enroll back in school, earn your credential, and grow your career.',
highlights: [
{ id: 'flexible-learning', iconId: 'book', title: 'Flexible Learning', subtitle: 'Online and evening options' },
{ id: 'financial-aid', iconId: 'dollar', title: 'Financial Aid', subtitle: 'Grants and TEACH funding' },
{ id: 'work-learn', iconId: 'clock', title: 'Work While You Learn', subtitle: 'Keep your current role' },
],
programs: [
{ id: 'parapro', name: 'Paraprofessional Certification (ParaPro)', organization: 'ETS ParaPro Assessment', description: 'The standard assessment many states require for paraprofessional certification. Tests reading, math, and writing skills.', url: 'https://www.ets.org/parapro.html', tag: 'Certification' },
{ id: 'aa-education', name: 'Associate of Arts in Education', organization: 'Community Colleges', description: 'A 2-year degree that counts toward a teaching credential. Many credits transfer to 4-year programs.', url: 'https://www.communitycollegereview.com/', tag: 'Degree' },
{ id: 'cda', name: 'CDA - Child Development Associate', organization: 'Council for Professional Recognition', description: 'A nationally recognized early childhood credential - a great first step for support staff.', url: 'https://www.cdacouncil.org/', tag: 'Credential' },
{ id: 'bachelor-education', name: 'Bachelor of Education', organization: 'State Universities (online options)', description: 'Complete your teaching degree online while you keep working. Financial aid and grants available.', url: 'https://studentaid.gov/', tag: 'Degree' },
{ id: 'special-education-endorsement', name: 'Special Education Endorsement', organization: 'State Dept. of Education', description: 'Add a special education endorsement to your credential - high demand in autism-focused schools.', url: 'https://www.teachercertificationdegrees.com/certification/special-education/', tag: 'Endorsement' },
{ id: 'fafsa', name: 'Federal Financial Aid (FAFSA)', organization: 'U.S. Department of Education', description: 'Apply for grants and low-interest loans. Many paraprofessionals qualify for significant aid.', url: 'https://studentaid.gov/h/apply-for-aid/fafsa', tag: 'Funding' },
{ id: 'teach-grant', name: 'TEACH Grant', organization: 'U.S. Department of Education', description: 'Up to $4,000/year for students who agree to teach in a high-need field after graduating.', url: 'https://studentaid.gov/understand-aid/types/grants/teach', tag: 'Funding' },
{ id: 'autism-certificates', name: 'Autism Certificate Programs', organization: 'Universities and Online', description: 'Specialized certificates in Autism Spectrum Disorders - directly relevant to the work you already do.', url: 'https://www.autismspeaks.org/', tag: 'Certificate' },
],
},
emotionalIntelligenceWeeklyFocus: {
title: 'Stress Regulation',
description: 'Emotional regulation during escalations - pause, breathe, respond instead of react. Notice your own zone before intervening with a student.',
@ -1046,5 +1152,7 @@ export const CONTENT_CATALOG_DEFAULT_ROWS: ReadonlyArray<{
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
{ content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent },
{ content_type: 'autism-support-steps', payload: CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps },
{ content_type: 'para-certifications', payload: CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications },
{ content_type: 'emotional-intelligence-weekly-focus', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyFocus },
]);

View File

@ -176,6 +176,50 @@ describe('user-role seed permission contract', () => {
]);
});
test('staff support page reads are seeded for campus staff and leaders', () => {
for (const permission of [
FEATURE_PERMISSIONS.READ_AUTISM_SUPPORT_STEPS,
FEATURE_PERMISSIONS.READ_PARA_CERTIFICATIONS,
FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS,
]) {
const readers = Object.values(ROLE_NAMES).filter((role) =>
granted(role).includes(permission),
);
assert.deepEqual(readers, [
ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR,
ROLE_NAMES.OFFICE_MANAGER,
ROLE_NAMES.TEACHER,
ROLE_NAMES.SUPPORT_STAFF,
]);
}
});
test('staff support page management grants match leaders and office managers', () => {
for (const permission of [
FEATURE_PERMISSIONS.MANAGE_AUTISM_SUPPORT_STEPS,
FEATURE_PERMISSIONS.MANAGE_PARA_CERTIFICATIONS,
FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS,
]) {
const managers = Object.values(ROLE_NAMES).filter((role) =>
granted(role).includes(permission),
);
assert.deepEqual(managers, [
ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR,
ROLE_NAMES.OFFICE_MANAGER,
]);
}
});
test('seeded permission grants are unique per role', () => {
for (const role of Object.values(ROLE_NAMES)) {
const permissions = granted(role);

View File

@ -70,6 +70,7 @@ import personalityQuizResultsRoutes from '@/routes/personality_quiz_results';
import campusAttendanceRoutes from '@/routes/campus_attendance';
import classAttendanceRoutes from '@/routes/class_attendance';
import staffAttendanceRoutes from '@/routes/staff_attendance';
import staffAwardsReviewsRoutes from '@/routes/staff_awards_reviews';
import policyDocumentsRoutes from '@/routes/policy_documents';
import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments';
import audioFilesRoutes from '@/routes/audio_files';
@ -279,6 +280,7 @@ app.use(
app.use('/api/campus_attendance', authenticated, campusAttendanceRoutes);
app.use('/api/class_attendance', authenticated, classAttendanceRoutes);
app.use('/api/staff_attendance', authenticated, staffAttendanceRoutes);
app.use('/api/staff_awards_reviews', authenticated, staffAwardsReviewsRoutes);
app.use('/api/content-catalog', authenticated, contentCatalogRoutes);
app.use('/api/policy_documents', authenticated, policyDocumentsRoutes);
app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes);

View File

@ -0,0 +1,34 @@
import express from 'express';
import { wrapAsync } from '@/api/http/request';
import * as staff_awards_reviews from '@/api/controllers/staff_awards_reviews.controller';
const router = express.Router();
/**
* @openapi
* /api/staff_awards_reviews:
* get:
* tags: [Staff Awards Reviews]
* summary: List awards/reviews for the current or selected staff user.
* post:
* tags: [Staff Awards Reviews]
* summary: Create an award or review.
* /api/staff_awards_reviews/staff:
* get:
* tags: [Staff Awards Reviews]
* summary: List staff visible to a manager in the current scope.
* /api/staff_awards_reviews/{id}:
* put:
* tags: [Staff Awards Reviews]
* summary: Update an award or review.
* delete:
* tags: [Staff Awards Reviews]
* summary: Delete an award or review.
*/
router.get('/staff', wrapAsync(staff_awards_reviews.listStaff));
router.get('/', wrapAsync(staff_awards_reviews.list));
router.post('/', wrapAsync(staff_awards_reviews.create));
router.put('/:id', wrapAsync(staff_awards_reviews.update));
router.delete('/:id', wrapAsync(staff_awards_reviews.remove));
export default router;

View File

@ -163,6 +163,64 @@ describe('ContentCatalogService tenant scoping', () => {
});
});
test('allows staff support content management from a leader drilled to campus scope', async () => {
let capturedWhere: Record<string, unknown> | null = null;
mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record<string, unknown> }) => {
capturedWhere = options.where ?? null;
return catalogRecord([]);
}) as typeof db.content_catalog.findOne);
await ContentCatalogService.findManagedByType(
'autism-support-steps',
createGlobalAccessUser({
app_role: {
name: ROLE_NAMES.OWNER,
scope: ROLE_SCOPES.ORGANIZATION,
globalAccess: false,
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_AUTISM_SUPPORT_STEPS }],
},
organizationId: 'org-1',
organizations: { id: 'org-1' },
activeScope: {
level: ROLE_SCOPES.CAMPUS,
organizationId: 'org-1',
schoolId: 'school-1',
campusId: 'campus-1',
classId: null,
},
}),
);
assert.deepEqual(capturedWhere, {
content_type: 'autism-support-steps',
active: true,
organizationId: 'org-1',
schoolId: null,
campusId: 'campus-1',
classId: null,
});
});
test('rejects staff support content management outside campus scope', async () => {
await assert.rejects(
() => ContentCatalogService.findManagedByType(
'para-certifications',
createGlobalAccessUser({
app_role: {
name: ROLE_NAMES.PRINCIPAL,
scope: ROLE_SCOPES.SCHOOL,
globalAccess: false,
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_PARA_CERTIFICATIONS }],
},
organizationId: 'org-1',
organizations: { id: 'org-1' },
schoolId: 'school-1',
}),
),
{ name: 'ForbiddenError' },
);
});
test('rejects classroom strategy management outside organization scope', async () => {
await assert.rejects(
() => ContentCatalogService.findManagedByType(

View File

@ -18,6 +18,8 @@ import {
import {
CLASSROOM_SUPPORT_CONTENT_TYPE,
ESA_CONTENT_TYPE,
AUTISM_SUPPORT_STEPS_CONTENT_TYPE,
PARA_CERTIFICATIONS_CONTENT_TYPE,
EI_ASSESSMENT_CONTENT_TYPE,
PERSONALITY_QUIZ_CONTENT_TYPE,
SIGN_LANGUAGE_ITEMS_CONTENT_TYPE,
@ -163,12 +165,30 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo
FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
)
)
&& !(
contentType === AUTISM_SUPPORT_STEPS_CONTENT_TYPE
&& hasFeaturePermission(
currentUser,
FEATURE_PERMISSIONS.MANAGE_AUTISM_SUPPORT_STEPS,
)
)
&& !(
contentType === PARA_CERTIFICATIONS_CONTENT_TYPE
&& hasFeaturePermission(
currentUser,
FEATURE_PERMISSIONS.MANAGE_PARA_CERTIFICATIONS,
)
)
) {
throw new ForbiddenError();
}
if (
contentType === ESA_CONTENT_TYPE
(
contentType === ESA_CONTENT_TYPE
|| contentType === AUTISM_SUPPORT_STEPS_CONTENT_TYPE
|| contentType === PARA_CERTIFICATIONS_CONTENT_TYPE
)
&& getRoleScope(currentUser) !== ROLE_SCOPES.CAMPUS
) {
throw new ForbiddenError();

View File

@ -306,4 +306,73 @@ describe('seedDefaultContentForTenant', () => {
classId: null,
});
});
test('seeds staff support page content only at campus 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 staffSupportRows = createdRows.filter((row) =>
row.content_type === 'autism-support-steps' ||
row.content_type === 'para-certifications',
);
assert.equal(staffSupportRows.length, 2);
assert.deepEqual(
staffSupportRows.map((row) => ({
content_type: row.content_type,
organizationId: row.organizationId,
schoolId: row.schoolId,
campusId: row.campusId,
classId: row.classId,
active: row.active,
})).sort((left, right) => String(left.content_type).localeCompare(String(right.content_type))),
[
{
content_type: 'autism-support-steps',
organizationId: 'org-1',
schoolId: null,
campusId: 'campus-1',
classId: null,
active: true,
},
{
content_type: 'para-certifications',
organizationId: 'org-1',
schoolId: null,
campusId: 'campus-1',
classId: null,
active: true,
},
],
);
assert.deepEqual(
staffSupportRows.find((row) => row.content_type === 'autism-support-steps')?.payload,
CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps,
);
assert.deepEqual(
staffSupportRows.find((row) => row.content_type === 'para-certifications')?.payload,
CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications,
);
});
});

View File

@ -0,0 +1,225 @@
import { afterEach, describe, mock, test } from 'node:test';
import assert from 'node:assert/strict';
import { Op } from 'sequelize';
import db from '@/db/models';
import StaffAwardsReviewsService from '@/services/staff_awards_reviews';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import { createTestUser } from '@/test-utils';
function permission(name: string) {
return { name };
}
function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
return typeof value === 'object' && value !== null;
}
function staffUser(overrides: Record<string, unknown> = {}) {
const row = {
id: 'staff-1',
email: 'teacher@flatlogic.com',
firstName: 'Emily',
lastName: 'Johnson',
organizationId: 'org-1',
schoolId: 'school-1',
campusId: 'campus-1',
app_role: { name: ROLE_NAMES.TEACHER },
campus: { name: 'Tigers Campus', schoolId: 'school-1' },
school: { name: 'Demo School' },
...overrides,
};
return {
...row,
get: () => row,
};
}
function awardRecord(overrides: Record<string, unknown> = {}) {
const row = {
id: 'record-1',
title: 'Teacher of the Year',
record_type: 'award',
year_label: '2025-2026',
notes: 'Outstanding classroom management.',
file_name: null,
file_url: null,
file_is_image: false,
staffUserId: 'staff-1',
staffUser: staffUser(),
createdAt: new Date('2026-06-26T12:00:00Z'),
updatedAt: new Date('2026-06-26T12:00:00Z'),
...overrides,
};
return {
...row,
get: () => row,
};
}
afterEach(() => {
mock.restoreAll();
});
describe('StaffAwardsReviewsService', () => {
test('non-managers list only their own awards and reviews', async () => {
const actor = createTestUser({
id: 'teacher-1',
organizationId: 'org-1',
organizations: { id: 'org-1' },
campusId: 'campus-1',
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission(FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS)],
},
});
let capturedWhere: unknown = null;
mock.method(db.staff_awards_reviews, 'findAndCountAll', async (options: unknown) => {
if (isRecord(options)) {
capturedWhere = options.where;
}
return { rows: [awardRecord({ staffUserId: 'teacher-1' })], count: 1 };
});
const result = await StaffAwardsReviewsService.list({ staffUserId: 'other-staff' }, actor);
assert.equal(result.canManage, false);
assert.equal(result.selectedStaffUserId, 'teacher-1');
assert.equal(isRecord(capturedWhere), true);
if (isRecord(capturedWhere)) {
assert.equal(capturedWhere.staffUserId, 'teacher-1');
assert.equal(capturedWhere.campusId, 'campus-1');
}
});
test('managers list staff using school descendant scope', async () => {
const actor = createTestUser({
id: 'principal-1',
organizationId: 'org-1',
organizations: { id: 'org-1' },
schoolId: 'school-1',
campusId: null,
app_role: {
name: ROLE_NAMES.PRINCIPAL,
scope: ROLE_SCOPES.SCHOOL,
globalAccess: false,
permissions: [
permission(FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS),
permission(FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS),
],
},
});
let capturedWhere: unknown = null;
mock.method(db.users, 'findAndCountAll', async (options: unknown) => {
if (isRecord(options)) {
capturedWhere = options.where;
}
return { rows: [staffUser()], count: 1 };
});
const result = await StaffAwardsReviewsService.listStaff({}, actor);
assert.equal(result.count, 1);
assert.equal(result.rows[0]?.name, 'Emily Johnson');
assert.equal(isRecord(capturedWhere), true);
if (isRecord(capturedWhere)) {
assert.equal(Object.getOwnPropertySymbols(capturedWhere).includes(Op.or), true);
}
});
test('manager create stores selected staff tenant and campus fields', async () => {
const actor = 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_AWARDS_REVIEWS),
permission(FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS),
],
},
});
let staffWhere: unknown = null;
let createdPayload: unknown = null;
mock.method(db.users, 'findOne', async (options: unknown) => {
if (isRecord(options)) {
staffWhere = options.where;
}
return staffUser({
id: 'teacher-1',
organizationId: 'org-1',
schoolId: null,
campusId: 'campus-1',
campus: { schoolId: 'school-1', name: 'Tigers Campus' },
});
});
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(db.staff_awards_reviews, 'create', async (payload: unknown) => {
createdPayload = payload;
return awardRecord(isRecord(payload) ? payload : {});
});
const created = await StaffAwardsReviewsService.create(
{
staffUserId: 'teacher-1',
title: ' Teacher of the Year ',
type: 'award',
year: '2025-2026',
notes: ' Great progress. ',
fileName: 'award.pdf',
fileUrl: '/private/files/award.pdf',
fileIsImage: false,
},
actor,
);
assert.equal(created.title, 'Teacher of the Year');
assert.equal(isRecord(staffWhere), true);
if (isRecord(staffWhere)) {
assert.equal(staffWhere.id, 'teacher-1');
assert.equal(Object.getOwnPropertySymbols(staffWhere).includes(Op.or), true);
}
assert.equal(isRecord(createdPayload), true);
if (isRecord(createdPayload)) {
assert.equal(createdPayload.organizationId, 'org-1');
assert.equal(createdPayload.schoolId, 'school-1');
assert.equal(createdPayload.campusId, 'campus-1');
assert.equal(createdPayload.staffUserId, 'teacher-1');
assert.equal(createdPayload.createdById, 'director-1');
assert.equal(createdPayload.updatedById, 'director-1');
}
});
test('staff list requires awards/reviews management permission', async () => {
const actor = createTestUser({
id: 'teacher-1',
organizationId: 'org-1',
organizations: { id: 'org-1' },
campusId: 'campus-1',
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission(FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS)],
},
});
await assert.rejects(
() => StaffAwardsReviewsService.listStaff({}, actor),
{ name: 'ForbiddenError' },
);
});
});

View File

@ -0,0 +1,420 @@
import { Op, literal, type WhereAttributeHash } from 'sequelize';
import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction';
import {
assertAuthenticatedTenantUser,
getCampusId,
getOrganizationId,
getOrganizationIdOrGlobal,
getRoleScope,
getSchoolId,
hasFeaturePermission,
hasGlobalAccess,
requireUserId,
} from '@/services/shared/access';
import { resolvePagination } from '@/shared/constants/pagination';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import ForbiddenError from '@/shared/errors/forbidden';
import NotFoundError from '@/shared/errors/not-found';
import ValidationError from '@/shared/errors/validation';
import type { StaffAwardsReviews, StaffAwardReviewType } from '@/db/models/staff_awards_reviews';
import type { Users } from '@/db/models/users';
import type { CurrentUser } from '@/db/api/types';
interface StaffAwardReviewInput {
staffUserId?: unknown;
title?: unknown;
type?: unknown;
year?: unknown;
notes?: unknown;
fileName?: unknown;
fileUrl?: unknown;
fileIsImage?: unknown;
}
interface ListFilter {
staffUserId?: string;
limit?: number | string;
page?: number | string;
}
const STAFF_ROLE_NAMES = [
ROLE_NAMES.DIRECTOR,
ROLE_NAMES.OFFICE_MANAGER,
ROLE_NAMES.TEACHER,
ROLE_NAMES.SUPPORT_STAFF,
] as const;
function assertCanRead(currentUser?: CurrentUser): void {
if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS)) {
throw new ForbiddenError();
}
}
function canManage(currentUser?: CurrentUser): boolean {
return hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS);
}
function assertCanManage(currentUser?: CurrentUser): void {
if (!canManage(currentUser)) {
throw new ForbiddenError();
}
}
function requireString(value: unknown, maxLength: number): string {
if (typeof value !== 'string') {
throw new ValidationError();
}
const trimmed = value.trim();
if (!trimmed) {
throw new ValidationError();
}
return trimmed.slice(0, maxLength);
}
function optionalString(value: unknown, maxLength: number): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, maxLength) : null;
}
function recordType(value: unknown): StaffAwardReviewType {
if (value === 'award' || value === 'review') {
return value;
}
throw new ValidationError();
}
function uuidOrNull(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function userScopeWhere(currentUser?: CurrentUser): WhereAttributeHash {
if (hasGlobalAccess(currentUser)) {
return {};
}
const scope = getRoleScope(currentUser);
const organizationId = getOrganizationId(currentUser);
if (scope === ROLE_SCOPES.ORGANIZATION && organizationId) {
return { organizationId };
}
if (scope === ROLE_SCOPES.SCHOOL) {
const schoolId = getSchoolId(currentUser);
if (!schoolId) return {};
return {
[Op.or]: [
{ schoolId },
{
campusId: {
[Op.in]: literal(
`(SELECT "id" FROM "campuses" WHERE "schoolId" = ${db.sequelize.escape(schoolId)} AND "deletedAt" IS NULL)`,
),
},
},
{
classId: {
[Op.in]: literal(
`(SELECT "c"."id" FROM "classes" "c" JOIN "campuses" "cm" ON "cm"."id" = "c"."campusId" WHERE "cm"."schoolId" = ${db.sequelize.escape(schoolId)} AND "c"."deletedAt" IS NULL AND "cm"."deletedAt" IS NULL)`,
),
},
},
],
};
}
if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) {
const campusId = getCampusId(currentUser);
if (!campusId) return {};
return {
[Op.or]: [
{ campusId },
{
classId: {
[Op.in]: literal(
`(SELECT "id" FROM "classes" WHERE "campusId" = ${db.sequelize.escape(campusId)} AND "deletedAt" IS NULL)`,
),
},
},
],
};
}
return organizationId ? { organizationId } : {};
}
function recordScopeWhere(currentUser?: CurrentUser): WhereAttributeHash {
if (hasGlobalAccess(currentUser)) {
return {};
}
const scope = getRoleScope(currentUser);
const organizationId = getOrganizationId(currentUser);
if (scope === ROLE_SCOPES.ORGANIZATION && organizationId) {
return { organizationId };
}
if (scope === ROLE_SCOPES.SCHOOL) {
const schoolId = getSchoolId(currentUser);
return schoolId ? { schoolId } : {};
}
if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) {
const campusId = getCampusId(currentUser);
return campusId ? { campusId } : {};
}
return organizationId ? { organizationId } : {};
}
function displayName(user: Users): string {
const firstName = user.firstName ?? '';
const lastName = user.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim();
return fullName || user.email;
}
function userDto(user: Users) {
const plain = user.get({ plain: true }) as Users & {
app_role?: { name?: string | null } | null;
campus?: { name?: string | null } | null;
school?: { name?: string | null } | null;
};
return {
id: plain.id,
name: displayName(user),
email: plain.email,
role: plain.app_role?.name ?? null,
schoolName: plain.school?.name ?? null,
campusName: plain.campus?.name ?? null,
};
}
function recordDto(record: StaffAwardsReviews) {
const plain = record.get({ plain: true }) as StaffAwardsReviews & {
staffUser?: Users | null;
};
return {
id: plain.id,
title: plain.title,
type: plain.record_type,
year: plain.year_label,
notes: plain.notes ?? '',
fileName: plain.file_name ?? null,
fileUrl: plain.file_url ?? null,
fileIsImage: plain.file_is_image,
staffUserId: plain.staffUserId,
staffName: plain.staffUser ? displayName(plain.staffUser) : null,
createdAt: plain.createdAt,
updatedAt: plain.updatedAt,
};
}
function staffCampusId(user: Users): string | null {
return user.campusId ?? null;
}
function staffSchoolId(user: Users): string | null {
const plain = user.get({ plain: true }) as Users & {
campus?: { schoolId?: string | null } | null;
};
return user.schoolId ?? plain.campus?.schoolId ?? null;
}
async function findStaffUserOrThrow(
staffUserId: string,
currentUser?: CurrentUser,
): Promise<Users> {
const staff = await db.users.findOne({
where: {
id: staffUserId,
...userScopeWhere(currentUser),
},
include: [
{
model: db.roles,
as: 'app_role',
where: { name: { [Op.in]: [...STAFF_ROLE_NAMES] } },
attributes: ['id', 'name', 'scope'],
},
{ model: db.schools, as: 'school', attributes: ['id', 'name'], required: false },
{ model: db.campuses, as: 'campus', attributes: ['id', 'name', 'schoolId'], required: false },
],
});
if (!staff) {
throw new NotFoundError();
}
if (!staffCampusId(staff)) {
throw new ValidationError();
}
return staff;
}
class StaffAwardsReviewsService {
static async listStaff(filter: ListFilter, currentUser?: CurrentUser) {
assertAuthenticatedTenantUser(currentUser);
assertCanRead(currentUser);
assertCanManage(currentUser);
const { limit, offset } = resolvePagination(filter.limit, filter.page);
const result = await db.users.findAndCountAll({
where: userScopeWhere(currentUser),
include: [
{
model: db.roles,
as: 'app_role',
where: { name: { [Op.in]: [...STAFF_ROLE_NAMES] } },
attributes: ['id', 'name', 'scope'],
},
{ model: db.schools, as: 'school', attributes: ['id', 'name'], required: false },
{ model: db.campuses, as: 'campus', attributes: ['id', 'name', 'schoolId'], required: false },
],
order: [['firstName', 'asc'], ['lastName', 'asc'], ['email', 'asc']],
limit,
offset,
});
return {
rows: result.rows.map(userDto),
count: result.count,
};
}
static async list(filter: ListFilter, currentUser?: CurrentUser) {
assertAuthenticatedTenantUser(currentUser);
assertCanRead(currentUser);
const { limit, offset } = resolvePagination(filter.limit, filter.page);
const targetUserId = canManage(currentUser)
? (filter.staffUserId || requireUserId(currentUser))
: requireUserId(currentUser);
if (targetUserId !== requireUserId(currentUser) || canManage(currentUser)) {
await findStaffUserOrThrow(targetUserId, currentUser);
}
const result = await db.staff_awards_reviews.findAndCountAll({
where: {
...recordScopeWhere(currentUser),
staffUserId: targetUserId,
},
include: [{ model: db.users, as: 'staffUser' }],
order: [['createdAt', 'desc']],
limit,
offset,
});
return {
rows: result.rows.map(recordDto),
count: result.count,
canManage: canManage(currentUser),
currentUserId: requireUserId(currentUser),
selectedStaffUserId: targetUserId,
};
}
static async create(data: StaffAwardReviewInput, currentUser?: CurrentUser) {
assertAuthenticatedTenantUser(currentUser);
assertCanRead(currentUser);
const staffUserId = canManage(currentUser)
? uuidOrNull(data?.staffUserId) ?? requireUserId(currentUser)
: requireUserId(currentUser);
const staff = await findStaffUserOrThrow(staffUserId, currentUser);
const organizationId = getOrganizationIdOrGlobal(currentUser) ?? staff.organizationId;
const campusId = staffCampusId(staff);
if (!organizationId || !campusId) {
throw new ForbiddenError();
}
return withTransaction(async (transaction) => {
const created = await db.staff_awards_reviews.create(
{
title: requireString(data?.title, 240),
record_type: recordType(data?.type),
year_label: requireString(data?.year, 80),
notes: optionalString(data?.notes, 4000),
file_name: optionalString(data?.fileName, 512),
file_url: optionalString(data?.fileUrl, 2083),
file_is_image: data?.fileIsImage === true,
organizationId,
schoolId: staffSchoolId(staff),
campusId,
staffUserId,
createdById: requireUserId(currentUser),
updatedById: requireUserId(currentUser),
},
{ transaction },
);
return recordDto(created);
});
}
static async update(id: string, data: StaffAwardReviewInput, currentUser?: CurrentUser) {
assertAuthenticatedTenantUser(currentUser);
assertCanRead(currentUser);
const record = await db.staff_awards_reviews.findOne({
where: {
id,
...recordScopeWhere(currentUser),
...(canManage(currentUser) ? {} : { staffUserId: requireUserId(currentUser) }),
},
});
if (!record) {
throw new NotFoundError();
}
const nextStaffUserId = canManage(currentUser)
? uuidOrNull(data?.staffUserId) ?? record.staffUserId
: record.staffUserId;
const staff = await findStaffUserOrThrow(nextStaffUserId, currentUser);
const organizationId = staff.organizationId;
const campusId = staffCampusId(staff);
if (!organizationId || !campusId) {
throw new ForbiddenError();
}
await record.update({
title: requireString(data?.title, 240),
record_type: recordType(data?.type),
year_label: requireString(data?.year, 80),
notes: optionalString(data?.notes, 4000),
file_name: optionalString(data?.fileName, 512),
file_url: optionalString(data?.fileUrl, 2083),
file_is_image: data?.fileIsImage === true,
organizationId,
staffUserId: nextStaffUserId,
schoolId: staffSchoolId(staff),
campusId,
updatedById: requireUserId(currentUser),
});
return recordDto(record);
}
static async remove(id: string, currentUser?: CurrentUser) {
assertAuthenticatedTenantUser(currentUser);
assertCanRead(currentUser);
const record = await db.staff_awards_reviews.findOne({
where: {
id,
...recordScopeWhere(currentUser),
...(canManage(currentUser) ? {} : { staffUserId: requireUserId(currentUser) }),
},
});
if (!record) {
throw new NotFoundError();
}
await record.destroy();
return { deletedCount: 1 };
}
}
export default StaffAwardsReviewsService;

View File

@ -16,6 +16,12 @@ export const SIGN_LANGUAGE_ITEMS_CONTENT_TYPE = 'sign-language-items';
/** ESA funding content — campus-scoped so each campus owns its local guidance. */
export const ESA_CONTENT_TYPE = 'esa-funding-content';
/** Autism Support Steps — campus-scoped so each campus owns local staff guidance. */
export const AUTISM_SUPPORT_STEPS_CONTENT_TYPE = 'autism-support-steps';
/** Paraprofessional certifications — campus-scoped staff development resources. */
export const PARA_CERTIFICATIONS_CONTENT_TYPE = 'para-certifications';
/**
* **Per-tenant** content types (read/written at the user's own tenant level via
* `getOwnTenant`; preset at organization + school + campus levels). Dashboard
@ -31,6 +37,8 @@ export const PER_TENANT_CONTENT_TYPES: ReadonlySet<string> = new Set([
/** **Campus-scoped** content types (class users read the campus row). */
export const CAMPUS_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
ESA_CONTENT_TYPE,
AUTISM_SUPPORT_STEPS_CONTENT_TYPE,
PARA_CERTIFICATIONS_CONTENT_TYPE,
]);
/** **School-scoped** content types (one per school). */

View File

@ -20,6 +20,13 @@ export const MODULE_READ_ALL_STAFF = [
'READ_HANDBOOK',
] as const;
/** Staff-support pages requested for campus staff and leaders. */
export const MODULE_READ_STAFF_SUPPORT = [
'READ_AUTISM_SUPPORT_STEPS',
'READ_PARA_CERTIFICATIONS',
'READ_AWARDS_REVIEWS',
] as const;
/** Platform dashboard page. Global users pass this through global access. */
export const MODULE_READ_PLATFORM = ['READ_PLATFORM_DASHBOARD'] as const;
@ -64,6 +71,9 @@ export const MODULE_MANAGEMENT_PERMISSIONS = [
'MANAGE_INTERNAL_COMM',
'MANAGE_CONTENT_CATALOG',
'MANAGE_ESA_FUNDING_CONTENT',
'MANAGE_AUTISM_SUPPORT_STEPS',
'MANAGE_PARA_CERTIFICATIONS',
'MANAGE_AWARDS_REVIEWS',
'READ_STAFF_ATTENDANCE_REPORTS',
'READ_SAFETY_QUIZ_REPORTS',
'READ_PERSONALITY_REPORTS',
@ -78,6 +88,7 @@ export const AUDIO_PERMISSIONS = ['READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES'] as c
export const MODULE_PERMISSIONS: readonly string[] = Object.freeze([
...MODULE_READ_PLATFORM,
...MODULE_READ_ALL_STAFF,
...MODULE_READ_STAFF_SUPPORT,
...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_PARENT_COMM,
...MODULE_READ_EXTERNAL,
@ -98,6 +109,9 @@ export const FEATURE_PERMISSIONS = Object.freeze({
READ_INTERNAL_COMM: 'READ_INTERNAL_COMM',
READ_PARENT_COMM: 'READ_PARENT_COMM',
READ_SAFETY: 'READ_SAFETY',
READ_AUTISM_SUPPORT_STEPS: 'READ_AUTISM_SUPPORT_STEPS',
READ_PARA_CERTIFICATIONS: 'READ_PARA_CERTIFICATIONS',
READ_AWARDS_REVIEWS: 'READ_AWARDS_REVIEWS',
READ_WALKTHROUGH: 'READ_WALKTHROUGH',
FILL_ATTENDANCE: 'FILL_ATTENDANCE',
TAKE_QUIZ: 'TAKE_QUIZ',
@ -109,6 +123,9 @@ export const FEATURE_PERMISSIONS = Object.freeze({
MANAGE_INTERNAL_COMM: 'MANAGE_INTERNAL_COMM',
MANAGE_CONTENT_CATALOG: 'MANAGE_CONTENT_CATALOG',
MANAGE_ESA_FUNDING_CONTENT: 'MANAGE_ESA_FUNDING_CONTENT',
MANAGE_AUTISM_SUPPORT_STEPS: 'MANAGE_AUTISM_SUPPORT_STEPS',
MANAGE_PARA_CERTIFICATIONS: 'MANAGE_PARA_CERTIFICATIONS',
MANAGE_AWARDS_REVIEWS: 'MANAGE_AWARDS_REVIEWS',
READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS',
READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS',
READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS',

View File

@ -207,6 +207,7 @@ The active frontend already has:
- Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`.
- Walk-through check-ins under `frontend/src/business/walkthrough/`, with typed API calls in `frontend/src/shared/api/walkthrough.ts`, shared constants in `frontend/src/shared/constants/walkthrough.ts`, and summary calculations in typed selectors.
- Communications under `frontend/src/business/communications/`, with typed API calls in `frontend/src/shared/api/communications.ts` for parent messages, internal alerts, and dashboard upcoming events.
- Staff support pages under `frontend/src/business/staff-support/`, with Autism Support Steps and Certifications & Degrees backed by campus-scoped `content_catalog` payloads and Awards Earned & Reviews backed by `frontend/src/shared/api/staffAwardsReviews.ts`.
- My Class under `frontend/src/business/my-class/` and `frontend/src/pages/modules/MyClassPage.tsx`, with class roster queries plus student-only create/edit payload selectors that reuse the shared Users API while backend service guards enforce own-class scope.
- EI/personality results under `frontend/src/business/personality/`, with typed API calls in `frontend/src/shared/api/personality.ts`, DTO mappers, distribution selectors, workflow-specific hook files, and explicit loading/error states in the view.
- Campus attendance config and staff-only attendance rollups under `frontend/src/business/campus-attendance/`, with typed API calls through `frontend/src/shared/api/campusAttendance.ts` for config and `frontend/src/shared/api/staffAttendance.ts` for daily staff records, DTO mappers, staff rollup selectors, and explicit loading/error states in the view.

View File

@ -49,6 +49,7 @@ Read the repository rules first, then use the frontend architecture document as
- [`safety-quiz-integration.md`](safety-quiz-integration.md)
- [`sign-language-integration.md`](sign-language-integration.md)
- [`staff-attendance-integration.md`](staff-attendance-integration.md)
- [`staff-support-pages.md`](staff-support-pages.md)
- [`user-progress-integration.md`](user-progress-integration.md)
- [`vocational-opportunities.md`](vocational-opportunities.md)
- [`walkthrough-integration.md`](walkthrough-integration.md)

View File

@ -0,0 +1,85 @@
# Staff Support Pages
## Purpose
The Autism Support Steps, Certifications & Degrees, and Awards Earned & Reviews pages were adapted from the legacy `education-support-management` template into the current three-layer frontend.
## Frontend Layers
View:
- `frontend/src/pages/modules/AutismSupportStepsPage.tsx`
- `frontend/src/pages/modules/ParaCertificationsPage.tsx`
- `frontend/src/pages/modules/AwardsReviewsPage.tsx`
- `frontend/src/components/frameworks/AutismSupportSteps.tsx`
- `frontend/src/components/frameworks/ParaCertifications.tsx`
- `frontend/src/components/frameworks/AwardsReviews.tsx`
Business:
- `frontend/src/business/staff-support/hooks.ts`
- `frontend/src/business/staff-support/selectors.ts`
API/data:
- `frontend/src/shared/api/contentCatalog.ts`
- `frontend/src/shared/api/staffAwardsReviews.ts`
- `frontend/src/shared/api/files.ts`
- `frontend/src/shared/types/staffSupport.ts`
## Backend Contracts
Autism Support Steps:
- Reads `GET /api/content-catalog/read/autism-support-steps`.
- Managers update `PUT /api/content-catalog/autism-support-steps`.
- Visual card images upload through `POST /api/file/upload/autism-support/visual-cards`.
- Payload shape is `steps` plus `visualCards`. Step cards store `id`, `iconId`, `title`, `subtitle`, and `spans`. Visual cards store `id`, `label`, optional `imageUrl`, and fallback `iconText`.
- The page renders normal read-only cards by default. Users with management access get a collapsed `Edit Content` panel for step cards and printable lanyard cards.
- `Print Steps` prints the staff step cards; `Print Lanyards` prints the visual cards using the same payload.
Certifications & Degrees:
- Reads `GET /api/content-catalog/read/para-certifications`.
- Managers update `PUT /api/content-catalog/para-certifications`.
- Hero image uploads through `POST /api/file/upload/para-certifications/hero`.
- Payload shape is `heroImage`, `heroTitle`, `heroSubtitle`, `highlights`, and `programs`.
- Highlight cards store `id`, `iconId`, `title`, and `subtitle`. Program cards store `id`, `name`, `organization`, `description`, `url`, and `tag`.
- The page renders normal read-only cards by default. Users with management access get a collapsed `Edit Content` panel for the hero, highlights, and certification cards.
Awards Earned & Reviews:
- Reads records through `GET /api/staff_awards_reviews`.
- Managers list visible staff through `GET /api/staff_awards_reviews/staff`.
- Creates, updates, and deletes records through `POST /`, `PUT /:id`, and `DELETE /:id`.
- Attachments upload through `POST /api/file/upload/staff-awards-reviews/attachments`.
- Staff users see only their own records and a collapsed add/edit form.
- Leaders and office managers with management permission see a staff list first; selecting a staff member loads that staff member's records and enables cross-staff create/update/delete.
## Access
- Route visibility is driven by `READ_AUTISM_SUPPORT_STEPS`, `READ_PARA_CERTIFICATIONS`, and `READ_AWARDS_REVIEWS`.
- Content management is driven by `MANAGE_AUTISM_SUPPORT_STEPS` and `MANAGE_PARA_CERTIFICATIONS`, and is enabled only at campus effective scope.
- Awards/reviews self-service is available to readers for their own records. Cross-staff staff list and mutation require `MANAGE_AWARDS_REVIEWS`; backend scope checks decide which staff are visible.
- Campus staff readers are `director`, `office_manager`, `teacher`, and `support_staff` through seeded preset permissions. Leaders (`owner`, `superintendent`, `principal`) access the pages through scope drilling and the same permission names.
- `registrar` is intentionally not granted these pages.
## Data Ownership
- Autism Support Steps and Certifications & Degrees content belongs in backend `content_catalog` seed payloads and campus-scoped content rows.
- Awards/reviews records belong in `staff_awards_reviews`, not `user_progress`, because leaders can maintain records for scoped staff users.
- Uploaded images/files are stored in the shared file subsystem; payloads and rows store only the returned private URLs.
## Seeded Defaults
- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts` contains the default Autism Support Steps and Certifications & Degrees payloads.
- `backend/src/db/seeders/20260608103000-content-catalog.ts` includes those payloads for standard seeding.
- `ContentCatalogSeedService.seedDefaultContentForTenant` creates campus-scoped rows for every new campus so new organizations see the legacy template content immediately.
- `backend/src/db/seeders/20260613060000-staff-awards-reviews.ts` seeds demo and rival teacher/support-staff award/review records, so seeded staff pages are not empty by default.
## Tests
- `frontend/src/business/staff-support/selectors.test.ts` covers payload normalization for Autism Support Steps and Certifications & Degrees.
- `frontend/src/shared/api/staffAwardsReviews.test.ts` covers Awards Earned & Reviews API route construction and mutation payloads.
- `frontend/src/business/app-shell/selectors.test.ts` covers sidebar visibility for the three staff-support modules across organization, school, campus, and class effective scopes.
- Backend behavior is covered by `backend/src/services/content_catalog.test.ts`, `backend/src/services/content_catalog_seed.test.ts`, `backend/src/services/staff_awards_reviews.test.ts`, and `backend/src/db/seeders/user-roles.test.ts`.

View File

@ -7,6 +7,8 @@ import type { ModuleId } from '@/shared/types/app';
import {
CampusAttendancePage,
CampusAttendanceDetailsPage,
AutismSupportStepsPage,
AwardsReviewsPage,
ClassroomSupportPage,
ClassroomTimerPage,
CommunityPartnershipsPage,
@ -20,6 +22,7 @@ import {
MessagesPage,
MyClassPage,
OrganizationManagementPage,
ParaCertificationsPage,
PlatformDashboardPage,
ProfilePage,
QbsSafetyPage,
@ -60,6 +63,9 @@ const MODULE_ELEMENTS: Record<ModuleId, ReactNode> = {
community: <CommunityPartnershipsPage />,
vocational: <VocationalOpportunitiesPage />,
esa: <EsaFundingPage />,
'autism-support': <AutismSupportStepsPage />,
'para-certifications': <ParaCertificationsPage />,
'awards-reviews': <AwardsReviewsPage />,
walkthrough: <WalkthroughPage />,
director: <DirectorDashboardPage />,
};

View File

@ -16,6 +16,9 @@ export const HandbookPoliciesPage = lazy(() => import('@/pages/modules/HandbookP
export const CommunityPartnershipsPage = lazy(() => import('@/pages/modules/CommunityPartnershipsPage'));
export const VocationalOpportunitiesPage = lazy(() => import('@/pages/modules/VocationalOpportunitiesPage'));
export const EsaFundingPage = lazy(() => import('@/pages/modules/EsaFundingPage'));
export const AutismSupportStepsPage = lazy(() => import('@/pages/modules/AutismSupportStepsPage'));
export const ParaCertificationsPage = lazy(() => import('@/pages/modules/ParaCertificationsPage'));
export const AwardsReviewsPage = lazy(() => import('@/pages/modules/AwardsReviewsPage'));
export const WalkthroughPage = lazy(() => import('@/pages/modules/WalkthroughPage'));
export const DirectorDashboardPage = lazy(() => import('@/pages/modules/DirectorDashboardPage'));
export const PlatformDashboardPage = lazy(() => import('@/pages/modules/PlatformDashboardPage'));

View File

@ -59,6 +59,9 @@ const scopedModules: readonly Module[] = [
{ id: 'qbs', name: 'Behavior Management', icon: 'shield', permissions: ['READ_QBS'], color: '', routePath: '/qbs-safety' },
{ id: 'zones', name: 'Zones', icon: 'layers', permissions: ['READ_ZONES'], color: '', routePath: '/zones-of-regulation' },
{ id: 'esa', name: 'ESA Funding Info', icon: 'wallet', permissions: ['READ_ESA'], color: '', routePath: '/esa-funding' },
{ id: 'autism-support', name: 'Autism Support Steps', icon: 'puzzle', permissions: ['READ_AUTISM_SUPPORT_STEPS'], color: '', routePath: '/autism-support-steps' },
{ id: 'para-certifications', name: 'Certifications & Degrees', icon: 'graduation', permissions: ['READ_PARA_CERTIFICATIONS'], color: '', routePath: '/para-certifications' },
{ id: 'awards-reviews', name: 'Awards & Reviews', icon: 'award', permissions: ['READ_AWARDS_REVIEWS'], color: '', routePath: '/awards-reviews' },
{ id: 'director', name: 'Director', icon: 'chart', permissions: ['READ_DIRECTOR_DASHBOARD'], color: '', routePath: '/director-dashboard' },
];
@ -130,6 +133,20 @@ describe('app-shell selectors', () => {
expect(canAccessScopedModuleRoute(scopedModules, '/esa-funding', disabledEsaUser, 'campus', false)).toBe(false);
});
it('shows staff support pages from organization through class scopes', () => {
const staffSupportUser = user([
'READ_AUTISM_SUPPORT_STEPS',
'READ_PARA_CERTIFICATIONS',
'READ_AWARDS_REVIEWS',
]);
const expected = ['autism-support', 'para-certifications', 'awards-reviews'];
expect(getScopedModules(scopedModules, staffSupportUser, 'organization', false).map((m) => m.id)).toEqual(expected);
expect(getScopedModules(scopedModules, staffSupportUser, 'school', false).map((m) => m.id)).toEqual(expected);
expect(getScopedModules(scopedModules, staffSupportUser, 'campus', false).map((m) => m.id)).toEqual(expected);
expect(getScopedModules(scopedModules, staffSupportUser, 'class', false).map((m) => m.id)).toEqual(expected);
});
it('never shows the Director Dashboard via drill-down', () => {
expect(
getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id),

View File

@ -47,6 +47,9 @@ const MODULE_SCOPE_TIERS: Partial<Record<ModuleId, readonly ScopeTier[]>> = {
director: ['organization', 'school', 'campus'],
'parent-comm': ['school', 'campus', 'class', 'external'],
esa: ['school', 'campus', 'class', 'external'],
'autism-support': ['organization', 'school', 'campus', 'class'],
'para-certifications': ['organization', 'school', 'campus', 'class'],
'awards-reviews': ['organization', 'school', 'campus', 'class'],
walkthrough: ['organization', 'school', 'campus'],
'internal-comm': ['global', ...ALL_STAFF_TIERS],
community: [...ALL_STAFF_TIERS, 'external'],

View File

@ -0,0 +1,208 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
useContentCatalogPayload,
useManagedContentCatalog,
} from '@/business/content-catalog/hooks';
import {
createManagedContentCatalog,
updateManagedContentCatalog,
} from '@/shared/api/contentCatalog';
import {
createStaffAwardReview,
deleteStaffAwardReview,
listAwardsReviewStaff,
listStaffAwardsReviews,
updateStaffAwardReview,
} from '@/shared/api/staffAwardsReviews';
import { uploadFile } from '@/shared/api/files';
import {
CONTENT_CATALOG_QUERY_KEYS,
CONTENT_CATALOG_TYPES,
} from '@/shared/constants/contentCatalog';
import { useScopeContext } from '@/shared/app/scope-context';
import { usePermissions } from '@/shared/app/usePermissions';
import type {
AutismSupportStepsContent,
ParaCertificationsContent,
StaffAwardReviewMutationDto,
} from '@/shared/types/staffSupport';
import {
EMPTY_AUTISM_SUPPORT_CONTENT,
EMPTY_PARA_CERTIFICATIONS_CONTENT,
normalizeAutismSupportContent,
normalizeParaCertificationsContent,
} from '@/business/staff-support/selectors';
const STAFF_AWARDS_REVIEWS_QUERY_KEY = ['staffAwardsReviews'] as const;
export function useAutismSupportStepsPage() {
const { effectiveTenant } = useScopeContext();
const permissions = usePermissions();
const queryClient = useQueryClient();
const canManage = effectiveTenant?.level === 'campus' && permissions.has('MANAGE_AUTISM_SUPPORT_STEPS');
const contentQuery = useContentCatalogPayload<AutismSupportStepsContent>(
CONTENT_CATALOG_TYPES.autismSupportSteps,
EMPTY_AUTISM_SUPPORT_CONTENT,
);
const managedQuery = useManagedContentCatalog<AutismSupportStepsContent>(
CONTENT_CATALOG_TYPES.autismSupportSteps,
{ enabled: canManage },
);
const content = useMemo(
() => normalizeAutismSupportContent(contentQuery.payload),
[contentQuery.payload],
);
const saveMutation = useMutation({
mutationFn: async (nextContent: AutismSupportStepsContent) => (
managedQuery.data
? updateManagedContentCatalog(CONTENT_CATALOG_TYPES.autismSupportSteps, {
payload: nextContent,
changeSummary: 'Autism support steps',
})
: createManagedContentCatalog({
content_type: CONTENT_CATALOG_TYPES.autismSupportSteps,
payload: nextContent,
changeSummary: 'Autism support steps',
})
),
onSuccess: async (response) => {
const normalized = normalizeAutismSupportContent(response.payload);
queryClient.setQueryData(
[...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.autismSupportSteps],
normalized,
);
await Promise.all([
queryClient.invalidateQueries({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.autismSupportSteps],
}),
queryClient.invalidateQueries({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.autismSupportSteps],
}),
]);
},
});
return {
content,
canManage,
isLoading: contentQuery.isLoading,
isSaving: saveMutation.isPending || managedQuery.isLoading,
error: contentQuery.error ?? managedQuery.error ?? saveMutation.error,
saveContent: (nextContent: AutismSupportStepsContent) => saveMutation.mutateAsync(nextContent),
uploadVisualCardImage: (file: File) => uploadFile('autism-support', 'visual-cards', file),
};
}
export function useParaCertificationsPage() {
const { effectiveTenant } = useScopeContext();
const permissions = usePermissions();
const queryClient = useQueryClient();
const canManage = effectiveTenant?.level === 'campus' && permissions.has('MANAGE_PARA_CERTIFICATIONS');
const contentQuery = useContentCatalogPayload<ParaCertificationsContent>(
CONTENT_CATALOG_TYPES.paraCertifications,
EMPTY_PARA_CERTIFICATIONS_CONTENT,
);
const managedQuery = useManagedContentCatalog<ParaCertificationsContent>(
CONTENT_CATALOG_TYPES.paraCertifications,
{ enabled: canManage },
);
const content = useMemo(
() => normalizeParaCertificationsContent(contentQuery.payload),
[contentQuery.payload],
);
const saveMutation = useMutation({
mutationFn: async (nextContent: ParaCertificationsContent) => (
managedQuery.data
? updateManagedContentCatalog(CONTENT_CATALOG_TYPES.paraCertifications, {
payload: nextContent,
changeSummary: 'Certifications and degrees',
})
: createManagedContentCatalog({
content_type: CONTENT_CATALOG_TYPES.paraCertifications,
payload: nextContent,
changeSummary: 'Certifications and degrees',
})
),
onSuccess: async (response) => {
const normalized = normalizeParaCertificationsContent(response.payload);
queryClient.setQueryData(
[...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.paraCertifications],
normalized,
);
await Promise.all([
queryClient.invalidateQueries({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.paraCertifications],
}),
queryClient.invalidateQueries({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.paraCertifications],
}),
]);
},
});
return {
content,
canManage,
isLoading: contentQuery.isLoading,
isSaving: saveMutation.isPending || managedQuery.isLoading,
error: contentQuery.error ?? managedQuery.error ?? saveMutation.error,
saveContent: (nextContent: ParaCertificationsContent) => saveMutation.mutateAsync(nextContent),
uploadHeroImage: (file: File) => uploadFile('para-certifications', 'hero', file),
};
}
export function useAwardsReviewsPage() {
const permissions = usePermissions();
const queryClient = useQueryClient();
const canManage = permissions.has('MANAGE_AWARDS_REVIEWS');
const [selectedStaffUserId, setSelectedStaffUserId] = useState<string | null>(null);
const staffQuery = useQuery({
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'staff'],
queryFn: listAwardsReviewStaff,
enabled: canManage,
});
const recordsQuery = useQuery({
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'records', selectedStaffUserId],
queryFn: () => listStaffAwardsReviews(selectedStaffUserId ?? undefined),
});
const saveMutation = useMutation({
mutationFn: (input: { readonly id?: string; readonly data: StaffAwardReviewMutationDto }) => (
input.id
? updateStaffAwardReview(input.id, input.data)
: createStaffAwardReview(input.data)
),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'records', selectedStaffUserId],
});
},
});
const deleteMutation = useMutation({
mutationFn: deleteStaffAwardReview,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'records', selectedStaffUserId],
});
},
});
return {
canManage,
staff: staffQuery.data?.rows ?? [],
selectedStaffUserId,
setSelectedStaffUserId,
records: recordsQuery.data?.rows ?? [],
selectedStaffName: recordsQuery.data?.rows[0]?.staffName
?? staffQuery.data?.rows.find((staff) => staff.id === selectedStaffUserId)?.name
?? null,
currentUserId: recordsQuery.data?.currentUserId ?? null,
isLoading: recordsQuery.isLoading || staffQuery.isLoading,
isSaving: saveMutation.isPending || deleteMutation.isPending,
error: recordsQuery.error ?? staffQuery.error ?? saveMutation.error ?? deleteMutation.error,
saveRecord: (id: string | undefined, data: StaffAwardReviewMutationDto) =>
saveMutation.mutateAsync({ id, data }),
deleteRecord: (id: string) => deleteMutation.mutateAsync(id),
uploadAttachment: (file: File) => uploadFile('staff-awards-reviews', 'attachments', file),
};
}

View File

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import {
normalizeAutismSupportContent,
normalizeParaCertificationsContent,
} from '@/business/staff-support/selectors';
describe('staff support selectors', () => {
it('normalizes Autism Support Steps cards and visual cards', () => {
const content = normalizeAutismSupportContent({
steps: [
{
id: 'breaks',
iconId: '',
title: 'Give Frequent Breaks',
subtitle: 'Short scheduled breaks reduce escalation.',
spans: ['Offer a break early', 42, 'Keep breaks predictable'],
},
],
visualCards: [
{
id: 'first-then',
label: 'First / Then',
imageUrl: ' /private/files/first-then.png ',
iconText: '1/2',
},
{
label: 'Wait',
imageUrl: '',
iconText: '...',
},
],
});
expect(content.steps).toEqual([
{
id: 'breaks',
iconId: 'puzzle',
title: 'Give Frequent Breaks',
subtitle: 'Short scheduled breaks reduce escalation.',
spans: ['Offer a break early', 'Keep breaks predictable'],
},
]);
expect(content.visualCards).toEqual([
{
id: 'first-then',
label: 'First / Then',
imageUrl: ' /private/files/first-then.png ',
iconText: '1/2',
},
{
id: expect.any(String),
label: 'Wait',
imageUrl: null,
iconText: '...',
},
]);
});
it('returns empty Autism Support content for malformed payloads', () => {
expect(normalizeAutismSupportContent(null)).toEqual({
steps: [],
visualCards: [],
});
expect(normalizeAutismSupportContent({ steps: 'bad', visualCards: {} })).toEqual({
steps: [],
visualCards: [],
});
});
it('normalizes Certifications & Degrees hero, highlights, and programs', () => {
const content = normalizeParaCertificationsContent({
heroImage: '/private/files/hero.jpg',
heroTitle: 'Grow Your Career',
heroSubtitle: 'Earn credentials while working.',
highlights: [
{ id: 'flexible', iconId: '', title: 'Flexible Learning', subtitle: 'Online and evenings' },
],
programs: [
{
id: 'parapro',
name: 'Paraprofessional Certification',
organization: 'ETS',
description: 'Assessment-based credential.',
url: 'https://www.ets.org/parapro.html',
tag: 'Certification',
},
{
name: 'Associate Degree',
organization: 'Community Colleges',
description: 'Two-year degree.',
url: 123,
tag: 'Degree',
},
],
});
expect(content.heroTitle).toBe('Grow Your Career');
expect(content.highlights).toEqual([
{
id: 'flexible',
iconId: 'book',
title: 'Flexible Learning',
subtitle: 'Online and evenings',
},
]);
expect(content.programs).toEqual([
{
id: 'parapro',
name: 'Paraprofessional Certification',
organization: 'ETS',
description: 'Assessment-based credential.',
url: 'https://www.ets.org/parapro.html',
tag: 'Certification',
},
{
id: expect.any(String),
name: 'Associate Degree',
organization: 'Community Colleges',
description: 'Two-year degree.',
url: '',
tag: 'Degree',
},
]);
});
it('returns empty Certifications & Degrees content for malformed payloads', () => {
expect(normalizeParaCertificationsContent([])).toEqual({
heroImage: '',
heroTitle: '',
heroSubtitle: '',
highlights: [],
programs: [],
});
});
});

View File

@ -0,0 +1,101 @@
import type {
AutismSupportStep,
AutismSupportStepsContent,
AutismVisualCard,
ParaCertificationHighlight,
ParaCertificationProgram,
ParaCertificationsContent,
} from '@/shared/types/staffSupport';
export const EMPTY_AUTISM_SUPPORT_CONTENT: AutismSupportStepsContent = {
steps: [],
visualCards: [],
};
export const EMPTY_PARA_CERTIFICATIONS_CONTENT: ParaCertificationsContent = {
heroImage: '',
heroTitle: '',
heroSubtitle: '',
highlights: [],
programs: [],
};
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function str(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function nullableStr(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null;
}
function stringArray(value: unknown): readonly string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
}
export function normalizeAutismSupportContent(value: unknown): AutismSupportStepsContent {
const source = record(value);
return {
steps: Array.isArray(source.steps)
? source.steps.map((item): AutismSupportStep => {
const row = record(item);
return {
id: str(row.id) || crypto.randomUUID(),
iconId: str(row.iconId) || 'puzzle',
title: str(row.title),
subtitle: str(row.subtitle),
spans: stringArray(row.spans),
};
})
: [],
visualCards: Array.isArray(source.visualCards)
? source.visualCards.map((item): AutismVisualCard => {
const row = record(item);
return {
id: str(row.id) || crypto.randomUUID(),
label: str(row.label),
imageUrl: nullableStr(row.imageUrl),
iconText: str(row.iconText),
};
})
: [],
};
}
export function normalizeParaCertificationsContent(value: unknown): ParaCertificationsContent {
const source = record(value);
return {
heroImage: str(source.heroImage),
heroTitle: str(source.heroTitle),
heroSubtitle: str(source.heroSubtitle),
highlights: Array.isArray(source.highlights)
? source.highlights.map((item): ParaCertificationHighlight => {
const row = record(item);
return {
id: str(row.id) || crypto.randomUUID(),
iconId: str(row.iconId) || 'book',
title: str(row.title),
subtitle: str(row.subtitle),
};
})
: [],
programs: Array.isArray(source.programs)
? source.programs.map((item): ParaCertificationProgram => {
const row = record(item);
return {
id: str(row.id) || crypto.randomUUID(),
name: str(row.name),
organization: str(row.organization),
description: str(row.description),
url: str(row.url),
tag: str(row.tag),
};
})
: [],
};
}

View File

@ -0,0 +1,352 @@
import { useState } from 'react';
import {
Award,
Coffee,
Eye,
Hand,
Layers,
ListChecks,
Pencil,
Plus,
Printer,
Puzzle,
Save,
Timer,
Trash2,
Upload,
Volume2,
X,
} from 'lucide-react';
import { useAutismSupportStepsPage } from '@/business/staff-support/hooks';
import { fileAssetUrl } from '@/business/files/api';
import { Button } from '@/components/ui/button';
import { ModuleHeader } from '@/components/ui/module-header';
import { StatePanel } from '@/components/ui/state-panel';
import { escapePrintHtml, printHtml } from '@/shared/utils/printHtml';
import type {
AutismSupportStep,
AutismSupportStepsContent,
AutismVisualCard,
} from '@/shared/types/staffSupport';
const iconMap = {
coffee: Coffee,
layers: Layers,
timer: Timer,
award: Award,
eye: Eye,
volume: Volume2,
puzzle: Puzzle,
} as const;
const iconOptions = Object.keys(iconMap);
function newStep(): AutismSupportStep {
return {
id: crypto.randomUUID(),
iconId: 'puzzle',
title: '',
subtitle: '',
spans: [''],
};
}
function newVisualCard(): AutismVisualCard {
return {
id: crypto.randomUUID(),
label: '',
imageUrl: null,
iconText: '*',
};
}
function printableSteps(content: AutismSupportStepsContent): string {
const cards = content.steps.map((step, index) => (
`<div class="card"><h3>Step ${index + 1}: ${escapePrintHtml(step.title)}</h3><p>${escapePrintHtml(step.subtitle)}</p><ul>${step.spans.map((span) => `<li>${escapePrintHtml(span)}</li>`).join('')}</ul></div>`
)).join('');
return `<h1>In-Class Support Steps for Students with Autism</h1><p class="muted">A quick reference for teachers and support staff.</p>${cards}`;
}
function printableVisualCards(content: AutismSupportStepsContent): string {
const cards = content.visualCards.map((card) => {
const icon = card.imageUrl
? `<img src="${escapePrintHtml(fileAssetUrl(card.imageUrl))}" alt="">`
: escapePrintHtml(card.iconText);
return `<div class="vcard"><div class="ic">${icon}</div><div class="lbl">${escapePrintHtml(card.label)}</div></div>`;
}).join('');
return `<h1>Visual Support Cards</h1><p class="muted">Cut out and attach to lanyards. Students and staff can refer back to these and to the FRAMEworks app.</p><div class="grid">${cards}</div>`;
}
const AutismSupportSteps = () => {
const page = useAutismSupportStepsPage();
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [draftState, setDraftState] = useState<{
readonly source: AutismSupportStepsContent;
readonly value: AutismSupportStepsContent;
}>({ source: page.content, value: page.content });
const draft = draftState.source === page.content ? draftState.value : page.content;
const setDraft = (
updater: AutismSupportStepsContent | ((current: AutismSupportStepsContent) => AutismSupportStepsContent),
) => {
setDraftState({
source: page.content,
value: typeof updater === 'function' ? updater(draft) : updater,
});
};
if (page.isLoading) {
return <StatePanel loading title="Loading autism support steps" />;
}
if (page.error) {
return <StatePanel tone="red" role="alert">Autism support content is unavailable.</StatePanel>;
}
const content = page.content;
function openEditor() {
setDraftState({ source: page.content, value: page.content });
setIsEditorOpen(true);
}
function updateStep(index: number, updates: Partial<AutismSupportStep>) {
setDraft((current) => ({
...current,
steps: current.steps.map((step, stepIndex) => stepIndex === index ? { ...step, ...updates } : step),
}));
}
function updateStepSpan(stepIndex: number, spanIndex: number, value: string) {
setDraft((current) => ({
...current,
steps: current.steps.map((step, index) => index === stepIndex
? { ...step, spans: step.spans.map((span, itemIndex) => itemIndex === spanIndex ? value : span) }
: step),
}));
}
function updateCard(index: number, updates: Partial<AutismVisualCard>) {
setDraft((current) => ({
...current,
visualCards: current.visualCards.map((card, cardIndex) => cardIndex === index ? { ...card, ...updates } : card),
}));
}
async function uploadCardImage(index: number, file: File | undefined) {
if (!file) return;
const imageUrl = await page.uploadVisualCardImage(file);
updateCard(index, { imageUrl, iconText: '' });
}
async function saveDraft() {
await page.saveContent(draft);
setIsEditorOpen(false);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<ModuleHeader
title="Autism Support Steps (In-Class)"
description="Practical steps staff can use inside the classroom to support students with autism."
icon={Puzzle}
iconClassName="bg-teal-600"
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
className="bg-teal-600 text-white hover:bg-teal-700"
onClick={() => printHtml('In-Class Autism Support Steps', printableSteps(content))}
leadingIcon={<Printer size={16} />}
>
Print Steps
</Button>
<Button
type="button"
variant="secondary"
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
onClick={() => printHtml('Visual Lanyard Cards', printableVisualCards(content))}
leadingIcon={<Printer size={16} />}
>
Print Lanyards
</Button>
{page.canManage && !isEditorOpen && (
<Button
type="button"
variant="secondary"
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
onClick={openEditor}
leadingIcon={<Pencil size={16} />}
>
Edit Content
</Button>
)}
</div>
</div>
{page.canManage && isEditorOpen && (
<section className="rounded-2xl border border-slate-700/70 bg-slate-900/80 p-5 shadow-lg shadow-black/10">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-lg font-bold text-white">Edit Autism Support Content</h3>
<Button type="button" variant="ghost" size="icon" onClick={() => setIsEditorOpen(false)} aria-label="Close autism support editor">
<X size={18} />
</Button>
</div>
<div className="space-y-4">
{draft.steps.map((step, index) => (
<div key={step.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-[160px_1fr]">
<select
value={step.iconId}
onChange={(event) => updateStep(index, { iconId: event.target.value })}
className="h-11 rounded-lg border border-slate-700 bg-slate-950 px-3 text-sm text-white"
aria-label={`Step ${index + 1} icon`}
>
{iconOptions.map((iconId) => <option key={iconId} value={iconId}>{iconId}</option>)}
</select>
<input
value={step.title}
onChange={(event) => updateStep(index, { title: event.target.value })}
className="h-11 min-w-0 rounded-lg border border-slate-700 bg-slate-950 px-3 text-sm font-semibold text-white"
aria-label={`Step ${index + 1} title`}
/>
</div>
<textarea
value={step.subtitle}
onChange={(event) => updateStep(index, { subtitle: event.target.value })}
rows={2}
className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
aria-label={`Step ${index + 1} subtitle`}
/>
<div className="mt-3 space-y-2">
{step.spans.map((span, spanIndex) => (
<div key={`${step.id}-${spanIndex}`} className="flex gap-2">
<input
value={span}
onChange={(event) => updateStepSpan(index, spanIndex, event.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm text-white"
aria-label={`Step ${index + 1} span ${spanIndex + 1}`}
/>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => updateStep(index, { spans: step.spans.filter((_, itemIndex) => itemIndex !== spanIndex) })}
aria-label={`Remove span ${spanIndex + 1}`}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => updateStep(index, { spans: [...step.spans, ''] })} leadingIcon={<Plus size={14} />}>
Add Span
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setDraft((current) => ({ ...current, steps: current.steps.filter((item) => item.id !== step.id) }))}
leadingIcon={<Trash2 size={14} />}
>
Delete Step
</Button>
</div>
</div>
))}
<Button type="button" variant="secondary" onClick={() => setDraft((current) => ({ ...current, steps: [...current.steps, newStep()] }))} leadingIcon={<Plus size={16} />}>
Add Step
</Button>
</div>
<div className="mt-6 border-t border-slate-700 pt-5">
<h4 className="mb-3 font-semibold text-white">Lanyard Visual Cards</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{draft.visualCards.map((card, index) => (
<div key={card.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-3">
<div className="mb-3 flex h-24 items-center justify-center rounded-lg border border-slate-700 bg-white text-slate-900">
{card.imageUrl ? (
<img src={fileAssetUrl(card.imageUrl)} alt="" className="h-20 w-20 object-contain" />
) : (
<span className="text-3xl">{card.iconText || '*'}</span>
)}
</div>
<input value={card.label} onChange={(event) => updateCard(index, { label: event.target.value })} className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-bold text-white" aria-label="Visual card label" />
<input value={card.iconText} onChange={(event) => updateCard(index, { iconText: event.target.value, imageUrl: null })} className="mt-2 w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm text-white" aria-label="Visual card icon text" />
<div className="mt-2 flex flex-wrap gap-2">
<label className="inline-flex cursor-pointer items-center justify-center gap-1 rounded border border-slate-700 px-2 py-1 text-xs text-slate-200">
<Upload size={13} /> Image
<input type="file" accept="image/*" className="hidden" onChange={(event) => void uploadCardImage(index, event.target.files?.[0])} />
</label>
<Button type="button" size="sm" variant="destructive" onClick={() => setDraft((current) => ({ ...current, visualCards: current.visualCards.filter((item) => item.id !== card.id) }))}>
Delete
</Button>
</div>
</div>
))}
</div>
<Button type="button" className="mt-4" variant="secondary" onClick={() => setDraft((current) => ({ ...current, visualCards: [...current.visualCards, newVisualCard()] }))} leadingIcon={<Plus size={16} />}>
Add Card
</Button>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<Button type="button" loading={page.isSaving} onClick={() => void saveDraft()} leadingIcon={<Save size={16} />}>
Save Changes
</Button>
<Button type="button" variant="secondary" disabled={page.isSaving} onClick={() => setIsEditorOpen(false)}>
Cancel
</Button>
</div>
</section>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{content.steps.map((step, index) => {
const Icon = iconMap[step.iconId as keyof typeof iconMap] ?? Puzzle;
return (
<section key={step.id} className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5">
<div className="mb-2 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-teal-500/15 text-teal-300">
<Icon size={22} />
</div>
<h3 className="font-bold text-white">Step {index + 1}: {step.title}</h3>
</div>
<p className="mb-3 text-sm text-slate-400">{step.subtitle}</p>
<ul className="space-y-2">
{step.spans.map((span, spanIndex) => (
<li key={`${step.id}-${spanIndex}`} className="flex items-start gap-2 text-sm text-slate-300">
<ListChecks size={15} className="mt-0.5 shrink-0 text-teal-300" />
{span}
</li>
))}
</ul>
</section>
);
})}
</div>
<section className="rounded-2xl border border-teal-500/20 bg-teal-500/10 p-5">
<div className="mb-3 flex items-center gap-2">
<Hand className="text-teal-300" size={20} />
<h3 className="font-bold text-white">Printable Visual Cards for Lanyards</h3>
</div>
<p className="mb-4 text-sm text-slate-400">Print these visual cards for student lanyards and classroom reminders. Students and staff can also refer back to the app at any time.</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{content.visualCards.map((card) => (
<div key={card.id} className="rounded-xl border-2 border-teal-400 bg-white p-4 text-center text-slate-900">
{card.imageUrl ? (
<img src={fileAssetUrl(card.imageUrl)} alt="" className="mx-auto h-14 w-14 object-contain" />
) : (
<div className="text-3xl">{card.iconText}</div>
)}
<p className="mt-1 text-sm font-bold">{card.label}</p>
</div>
))}
</div>
</section>
</div>
);
};
export default AutismSupportSteps;

View File

@ -0,0 +1,259 @@
import { useState } from 'react';
import {
Award,
FileText,
Image as ImageIcon,
Pencil,
Plus,
Printer,
Save,
Star,
Trash2,
Trophy,
Upload,
X,
} from 'lucide-react';
import { useAwardsReviewsPage } from '@/business/staff-support/hooks';
import { fileAssetUrl } from '@/business/files/api';
import { AWARDS_REVIEWS_IMAGE } from '@/shared/constants/appData';
import { Button } from '@/components/ui/button';
import { ModuleHeader } from '@/components/ui/module-header';
import { StatePanel } from '@/components/ui/state-panel';
import { escapePrintHtml, printHtml } from '@/shared/utils/printHtml';
import type {
StaffAwardReviewDto,
StaffAwardReviewMutationDto,
} from '@/shared/types/staffSupport';
interface Draft {
readonly id?: string;
readonly title: string;
readonly type: 'award' | 'review';
readonly year: string;
readonly notes: string;
readonly fileName: string | null;
readonly fileUrl: string | null;
readonly fileIsImage: boolean;
}
const emptyDraft: Draft = {
title: '',
type: 'award',
year: '2025-2026',
notes: '',
fileName: null,
fileUrl: null,
fileIsImage: false,
};
function draftFromRecord(record: StaffAwardReviewDto): Draft {
return {
id: record.id,
title: record.title,
type: record.type,
year: record.year,
notes: record.notes,
fileName: record.fileName,
fileUrl: record.fileUrl,
fileIsImage: record.fileIsImage,
};
}
function printableRecords(records: readonly StaffAwardReviewDto[], staffName: string | null): string {
const rows = records.map((record) => (
`<div class="card"><span class="badge">${record.type === 'award' ? 'Award' : 'Review'}</span> &nbsp;<strong>${escapePrintHtml(record.title)}</strong> <span class="muted">(${escapePrintHtml(record.year)})</span><p>${escapePrintHtml(record.notes)}</p>${record.fileName ? `<p class="muted">Attachment: ${escapePrintHtml(record.fileName)}</p>` : ''}</div>`
)).join('');
return `<h1>Awards Earned &amp; Director Reviews</h1><p class="muted">${escapePrintHtml(staffName ?? 'Staff Member')} - Annual Record</p>${rows}`;
}
const AwardsReviews = () => {
const page = useAwardsReviewsPage();
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [isFormOpen, setIsFormOpen] = useState(false);
if (page.isLoading) {
return <StatePanel loading title="Loading awards and reviews" />;
}
if (page.error) {
return <StatePanel tone="red" role="alert">Awards and reviews are unavailable.</StatePanel>;
}
const activeStaffUserId = page.selectedStaffUserId ?? page.currentUserId;
function openNewForm() {
setDraft(emptyDraft);
setIsFormOpen(true);
}
function openEditForm(record: StaffAwardReviewDto) {
setDraft(draftFromRecord(record));
setIsFormOpen(true);
}
function closeForm() {
setDraft(emptyDraft);
setIsFormOpen(false);
}
async function uploadAttachment(file: File | undefined) {
if (!file) return;
const fileUrl = await page.uploadAttachment(file);
setDraft((current) => ({
...current,
fileName: file.name,
fileUrl,
fileIsImage: file.type.startsWith('image/'),
}));
}
async function saveDraft() {
if (!draft.title.trim()) return;
const data: StaffAwardReviewMutationDto = {
staffUserId: page.selectedStaffUserId ?? undefined,
title: draft.title,
type: draft.type,
year: draft.year,
notes: draft.notes,
fileName: draft.fileName,
fileUrl: draft.fileUrl,
fileIsImage: draft.fileIsImage,
};
await page.saveRecord(draft.id, data);
closeForm();
}
const formTitle = draft.id ? 'Edit Award or Review' : 'Upload an Award or Review';
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<ModuleHeader
title="Awards Earned & Reviews"
description="Upload awards and Director reviews for the year, then print them out."
icon={Award}
iconClassName="bg-yellow-600"
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
className="bg-yellow-600 text-white hover:bg-yellow-700"
onClick={() => printHtml('Awards & Reviews', printableRecords(page.records, page.selectedStaffName))}
leadingIcon={<Printer size={16} />}
>
Print All
</Button>
{!isFormOpen && (
<Button
type="button"
variant="secondary"
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
onClick={openNewForm}
leadingIcon={<Plus size={16} />}
>
Add Record
</Button>
)}
</div>
</div>
<section className="relative overflow-hidden rounded-2xl border border-slate-700/50">
<img src={AWARDS_REVIEWS_IMAGE} alt="Teacher holding an award" className="h-56 w-full object-cover md:h-72" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/40 to-transparent" />
<div className="absolute bottom-0 left-0 p-6">
<h2 className="flex items-center gap-2 text-2xl font-bold text-white"><Trophy className="text-yellow-300" /> Celebrate Your Achievements</h2>
<p className="mt-1 max-w-xl text-sm text-slate-200">Keep a record of every award and review - proof of the incredible work you do every day.</p>
</div>
</section>
<div className={page.canManage ? 'grid grid-cols-1 gap-4 lg:grid-cols-[280px_minmax(0,1fr)]' : 'space-y-4'}>
{page.canManage && (
<aside className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-4">
<h3 className="mb-3 text-sm font-semibold text-white">Staff</h3>
<div className="space-y-2">
{page.staff.map((staff) => (
<button
key={staff.id}
type="button"
onClick={() => {
closeForm();
page.setSelectedStaffUserId(staff.id);
}}
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors ${activeStaffUserId === staff.id ? 'border-yellow-400 bg-yellow-500/10 text-white' : 'border-slate-700 bg-slate-950/60 text-slate-300 hover:border-slate-500'}`}
>
<span className="block font-semibold">{staff.name}</span>
<span className="block text-xs text-slate-500">{staff.role ?? 'staff'} - {staff.campusName ?? 'Campus'}</span>
</button>
))}
</div>
</aside>
)}
<div className="min-w-0 space-y-4">
{isFormOpen && (
<section className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="flex items-center gap-2 font-bold text-white"><Upload size={18} className="text-yellow-300" /> {formTitle}</h3>
<Button type="button" variant="ghost" size="icon" onClick={closeForm} aria-label="Close awards form">
<X size={18} />
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px_150px]">
<input value={draft.title} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} placeholder="Title (e.g. Teacher of the Year)" className="min-w-0 rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white placeholder:text-slate-500" />
<select value={draft.type} onChange={(event) => setDraft((current) => ({ ...current, type: event.target.value as Draft['type'] }))} className="rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white">
<option value="award">Award</option>
<option value="review">Director Review</option>
</select>
<input value={draft.year} onChange={(event) => setDraft((current) => ({ ...current, year: event.target.value }))} placeholder="Year" className="rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white" />
</div>
<textarea value={draft.notes} onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))} placeholder="Notes / summary..." rows={3} className="mt-3 w-full rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white placeholder:text-slate-500" />
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
<label className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-xl border border-dashed border-slate-600 bg-slate-950 px-3 py-2.5 text-sm text-slate-400 hover:border-yellow-400">
<Upload size={16} className="shrink-0" />
<span className="truncate">{draft.fileName ?? 'Choose file (PDF or image)'}</span>
<input type="file" accept="image/*,.pdf" onChange={(event) => void uploadAttachment(event.target.files?.[0])} className="hidden" />
</label>
<div className="flex flex-wrap gap-2">
<Button type="button" loading={page.isSaving} onClick={() => void saveDraft()} leadingIcon={<Save size={16} />}>
{draft.id ? 'Save Record' : 'Add Record'}
</Button>
<Button type="button" variant="secondary" disabled={page.isSaving} onClick={closeForm}>
Cancel
</Button>
</div>
</div>
</section>
)}
{page.records.length === 0 ? (
<StatePanel>No awards or reviews have been added yet.</StatePanel>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{page.records.map((record) => (
<article key={record.id} className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5">
<div className="flex items-start justify-between gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${record.type === 'award' ? 'bg-yellow-500/15 text-yellow-300' : 'bg-violet-500/15 text-violet-300'}`}>
{record.type === 'award' ? 'Award' : 'Review'}
</span>
<div className="flex gap-2">
<button type="button" onClick={() => openEditForm(record)} className="inline-flex items-center gap-1 text-xs font-semibold text-slate-400 hover:text-white"><Pencil size={12} /> Edit</button>
<button type="button" onClick={() => void page.deleteRecord(record.id)} className="text-slate-500 hover:text-rose-300" aria-label={`Delete ${record.title}`}><Trash2 size={15} /></button>
</div>
</div>
<h3 className="mt-2 flex items-center gap-2 font-bold text-white">
{record.type === 'award' ? <Star size={16} className="text-yellow-300" /> : <FileText size={16} className="text-violet-300" />} {record.title}
</h3>
<p className="mt-0.5 text-xs text-slate-500">{record.year}</p>
{record.notes && <p className="mt-2 text-sm text-slate-400">{record.notes}</p>}
{record.fileUrl && record.fileIsImage && <img src={fileAssetUrl(record.fileUrl)} alt={record.title} className="mt-3 h-40 w-full rounded-lg border border-slate-700 object-cover" />}
{record.fileName && !record.fileIsImage && <p className="mt-3 flex items-center gap-2 text-xs text-slate-400"><ImageIcon size={14} /> {record.fileName}</p>}
</article>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default AwardsReviews;

View File

@ -0,0 +1,251 @@
import { useState } from 'react';
import {
Award,
BookOpen,
Clock,
DollarSign,
ExternalLink,
GraduationCap,
Pencil,
Plus,
Printer,
Save,
Trash2,
Upload,
X,
} from 'lucide-react';
import { useParaCertificationsPage } from '@/business/staff-support/hooks';
import { fileAssetUrl } from '@/business/files/api';
import { Button } from '@/components/ui/button';
import { ModuleHeader } from '@/components/ui/module-header';
import { StatePanel } from '@/components/ui/state-panel';
import { escapePrintHtml, printHtml } from '@/shared/utils/printHtml';
import type {
ParaCertificationHighlight,
ParaCertificationProgram,
ParaCertificationsContent,
} from '@/shared/types/staffSupport';
const tagColor: Record<string, string> = {
Certification: 'text-violet-300 bg-violet-500/15',
Degree: 'text-blue-300 bg-blue-500/15',
Credential: 'text-emerald-300 bg-emerald-500/15',
Endorsement: 'text-amber-300 bg-amber-500/15',
Funding: 'text-rose-300 bg-rose-500/15',
Certificate: 'text-teal-300 bg-teal-500/15',
};
function newProgram(): ParaCertificationProgram {
return {
id: crypto.randomUUID(),
name: '',
organization: '',
description: '',
url: '',
tag: 'Certification',
};
}
function printPrograms(content: ParaCertificationsContent): string {
const rows = content.programs.map((program) => (
`<div class="card"><span class="badge">${escapePrintHtml(program.tag)}</span> &nbsp;<strong>${escapePrintHtml(program.name)}</strong><p class="muted">${escapePrintHtml(program.organization)}</p><p>${escapePrintHtml(program.description)}</p><p class="muted">Link: ${escapePrintHtml(program.url)}</p></div>`
)).join('');
return `<h1>Certifications &amp; Degrees</h1><p>You're already doing the job - now get certified. These programs and links can help support staff advance their careers.</p>${rows}`;
}
const highlightIcons = [BookOpen, DollarSign, Clock];
const highlightIconColors = ['text-sky-300', 'text-emerald-300', 'text-amber-300'] as const;
const ParaCertifications = () => {
const page = useParaCertificationsPage();
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [draftState, setDraftState] = useState<{
readonly source: ParaCertificationsContent;
readonly value: ParaCertificationsContent;
}>({ source: page.content, value: page.content });
const draft = draftState.source === page.content ? draftState.value : page.content;
const setDraft = (
updater: ParaCertificationsContent | ((current: ParaCertificationsContent) => ParaCertificationsContent),
) => {
setDraftState({
source: page.content,
value: typeof updater === 'function' ? updater(draft) : updater,
});
};
if (page.isLoading) {
return <StatePanel loading title="Loading certifications" />;
}
if (page.error) {
return <StatePanel tone="red" role="alert">Certification content is unavailable.</StatePanel>;
}
const content = page.content;
function openEditor() {
setDraftState({ source: page.content, value: page.content });
setIsEditorOpen(true);
}
function updateProgram(index: number, updates: Partial<ParaCertificationProgram>) {
setDraft((current) => ({
...current,
programs: current.programs.map((program, programIndex) => programIndex === index ? { ...program, ...updates } : program),
}));
}
function updateHighlight(index: number, updates: Partial<ParaCertificationHighlight>) {
setDraft((current) => ({
...current,
highlights: current.highlights.map((highlight, highlightIndex) => highlightIndex === index ? { ...highlight, ...updates } : highlight),
}));
}
async function uploadHero(file: File | undefined) {
if (!file) return;
const heroImage = await page.uploadHeroImage(file);
setDraft((current) => ({ ...current, heroImage }));
}
async function saveDraft() {
await page.saveContent(draft);
setIsEditorOpen(false);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<ModuleHeader
title="Certifications & Degrees"
description="For paraprofessionals and support staff - you're already doing the job, now get certified."
icon={GraduationCap}
iconClassName="bg-sky-600"
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
onClick={() => printHtml('Certifications & Degrees for Paraprofessionals', printPrograms(content))}
leadingIcon={<Printer size={16} />}
>
Print List
</Button>
{page.canManage && !isEditorOpen && (
<Button
type="button"
variant="secondary"
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
onClick={openEditor}
leadingIcon={<Pencil size={16} />}
>
Edit Content
</Button>
)}
</div>
</div>
{page.canManage && isEditorOpen && (
<section className="rounded-2xl border border-slate-700/70 bg-slate-900/80 p-5 shadow-lg shadow-black/10">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-lg font-bold text-white">Edit Certifications & Degrees</h3>
<Button type="button" variant="ghost" size="icon" onClick={() => setIsEditorOpen(false)} aria-label="Close certifications editor">
<X size={18} />
</Button>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_auto]">
<div className="space-y-3">
<input value={draft.heroTitle} onChange={(event) => setDraft((current) => ({ ...current, heroTitle: event.target.value }))} className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-bold text-white" aria-label="Hero title" />
<textarea value={draft.heroSubtitle} onChange={(event) => setDraft((current) => ({ ...current, heroSubtitle: event.target.value }))} rows={2} className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100" aria-label="Hero subtitle" />
</div>
<label className="inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-100 px-3 text-sm font-semibold text-slate-900">
<Upload size={15} /> Upload Hero
<input type="file" accept="image/*" className="hidden" onChange={(event) => void uploadHero(event.target.files?.[0])} />
</label>
</div>
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-3">
{draft.highlights.map((highlight, index) => (
<div key={highlight.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-3">
<input value={highlight.title} onChange={(event) => updateHighlight(index, { title: event.target.value })} className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-semibold text-white" aria-label={`Highlight ${index + 1} title`} />
<input value={highlight.subtitle} onChange={(event) => updateHighlight(index, { subtitle: event.target.value })} className="mt-2 w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm text-white" aria-label={`Highlight ${index + 1} subtitle`} />
</div>
))}
</div>
<div className="mt-5 space-y-3">
{draft.programs.map((program, index) => (
<div key={program.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<input value={program.name} onChange={(event) => updateProgram(index, { name: event.target.value })} placeholder="Program name" className="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
<input value={program.tag} onChange={(event) => updateProgram(index, { tag: event.target.value })} placeholder="Tag" className="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
</div>
<input value={program.organization} onChange={(event) => updateProgram(index, { organization: event.target.value })} placeholder="Organization" className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
<input value={program.url} onChange={(event) => updateProgram(index, { url: event.target.value })} placeholder="URL" className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
<textarea value={program.description} onChange={(event) => updateProgram(index, { description: event.target.value })} rows={3} placeholder="Description" className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
<Button type="button" className="mt-3" variant="destructive" size="sm" onClick={() => setDraft((current) => ({ ...current, programs: current.programs.filter((item) => item.id !== program.id) }))} leadingIcon={<Trash2 size={14} />}>
Delete
</Button>
</div>
))}
<Button type="button" variant="secondary" onClick={() => setDraft((current) => ({ ...current, programs: [...current.programs, newProgram()] }))} leadingIcon={<Plus size={16} />}>
Add Certification
</Button>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<Button type="button" loading={page.isSaving} onClick={() => void saveDraft()} leadingIcon={<Save size={16} />}>
Save Changes
</Button>
<Button type="button" variant="secondary" disabled={page.isSaving} onClick={() => setIsEditorOpen(false)}>
Cancel
</Button>
</div>
</section>
)}
<section className="relative overflow-hidden rounded-2xl border border-slate-700/50">
{content.heroImage && (
<img src={fileAssetUrl(content.heroImage)} alt="Support staff with certificate" className="h-56 w-full object-cover md:h-72" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/50 to-transparent" />
<div className="absolute bottom-0 left-0 p-6">
<h2 className="text-2xl font-bold text-white">{content.heroTitle}</h2>
<p className="mt-1 max-w-xl text-sm text-slate-200">{content.heroSubtitle}</p>
</div>
</section>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{content.highlights.map((highlight, index) => {
const Icon = highlightIcons[index] ?? BookOpen;
const iconColor = highlightIconColors[index] ?? 'text-sky-300';
return (
<div key={highlight.id} className="flex items-center gap-3 rounded-2xl border border-slate-700/50 bg-slate-800/50 p-4">
<Icon className={iconColor} />
<div>
<p className="text-sm font-semibold text-slate-200">{highlight.title}</p>
<p className="text-xs text-slate-500">{highlight.subtitle}</p>
</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{content.programs.map((program) => (
<a key={program.id} href={program.url} target="_blank" rel="noopener noreferrer" className="group block rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5 transition-colors hover:border-sky-500/40">
<div className="flex items-start justify-between gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${tagColor[program.tag] ?? 'bg-slate-500/15 text-slate-300'}`}>{program.tag}</span>
<ExternalLink size={16} className="text-slate-500 group-hover:text-sky-300" />
</div>
<h3 className="mt-2 flex items-center gap-2 font-bold text-white"><Award size={16} className="text-sky-300" /> {program.name}</h3>
<p className="mt-0.5 text-xs text-sky-300/80">{program.organization}</p>
<p className="mt-2 text-sm text-slate-400">{program.description}</p>
</a>
))}
</div>
</div>
);
};
export default ParaCertifications;

View File

@ -17,7 +17,9 @@ import {
Layers,
LayoutGrid,
MessageSquare,
Puzzle,
Shield,
Trophy,
Timer,
Users,
Wallet,
@ -47,6 +49,8 @@ const sidebarIcons: Record<string, ReactNode> = {
briefcase: <Briefcase size={SIDEBAR_ICON_SIZE} />,
wallet: <Wallet size={SIDEBAR_ICON_SIZE} />,
clipboard: <ClipboardCheck size={SIDEBAR_ICON_SIZE} />,
puzzle: <Puzzle size={SIDEBAR_ICON_SIZE} />,
award: <Trophy size={SIDEBAR_ICON_SIZE} />,
};
export function getSidebarIcon(icon: string): ReactNode {

View File

@ -0,0 +1,5 @@
import AutismSupportSteps from '@/components/frameworks/AutismSupportSteps';
export default function AutismSupportStepsPage() {
return <AutismSupportSteps />;
}

View File

@ -0,0 +1,5 @@
import AwardsReviews from '@/components/frameworks/AwardsReviews';
export default function AwardsReviewsPage() {
return <AwardsReviews />;
}

View File

@ -0,0 +1,5 @@
import ParaCertifications from '@/components/frameworks/ParaCertifications';
export default function ParaCertificationsPage() {
return <ParaCertifications />;
}

View File

@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createStaffAwardReview,
deleteStaffAwardReview,
listAwardsReviewStaff,
listStaffAwardsReviews,
updateStaffAwardReview,
} from '@/shared/api/staffAwardsReviews';
import { apiRequest } from '@/shared/api/httpClient';
vi.mock('@/shared/api/httpClient', () => ({
apiRequest: vi.fn(),
}));
const apiRequestMock = vi.mocked(apiRequest);
describe('staff awards reviews API', () => {
beforeEach(() => {
apiRequestMock.mockReset();
});
it('loads visible staff and selected staff records', () => {
void listAwardsReviewStaff();
void listStaffAwardsReviews('staff-1');
void listStaffAwardsReviews();
expect(apiRequestMock).toHaveBeenNthCalledWith(1, '/staff_awards_reviews/staff');
expect(apiRequestMock).toHaveBeenNthCalledWith(2, '/staff_awards_reviews?staffUserId=staff-1');
expect(apiRequestMock).toHaveBeenNthCalledWith(3, '/staff_awards_reviews');
});
it('creates, updates, and deletes award/review records', () => {
const payload = {
staffUserId: 'staff-1',
title: 'Teacher of the Year',
type: 'award' as const,
year: '2025-2026',
notes: 'Recognized for classroom leadership.',
fileName: null,
fileUrl: null,
fileIsImage: false,
};
void createStaffAwardReview(payload);
void updateStaffAwardReview('record-1', payload);
void deleteStaffAwardReview('record-1');
expect(apiRequestMock).toHaveBeenNthCalledWith(1, '/staff_awards_reviews', {
method: 'POST',
body: { data: payload },
});
expect(apiRequestMock).toHaveBeenNthCalledWith(2, '/staff_awards_reviews/record-1', {
method: 'PUT',
body: { data: payload },
});
expect(apiRequestMock).toHaveBeenNthCalledWith(3, '/staff_awards_reviews/record-1', {
method: 'DELETE',
});
});
});

View File

@ -0,0 +1,57 @@
import { apiRequest } from '@/shared/api/httpClient';
import type { ApiListResponse } from '@/shared/types/api';
import type {
StaffAwardReviewDto,
StaffAwardReviewMutationDto,
StaffAwardsReviewsStaffDto,
} from '@/shared/types/staffSupport';
const STAFF_AWARDS_REVIEWS_PATH = '/staff_awards_reviews';
export interface StaffAwardsReviewsListResponse extends ApiListResponse<StaffAwardReviewDto> {
readonly canManage: boolean;
readonly currentUserId: string;
readonly selectedStaffUserId: string;
}
export function listAwardsReviewStaff(): Promise<ApiListResponse<StaffAwardsReviewsStaffDto>> {
return apiRequest<ApiListResponse<StaffAwardsReviewsStaffDto>>(`${STAFF_AWARDS_REVIEWS_PATH}/staff`);
}
export function listStaffAwardsReviews(
staffUserId?: string,
): Promise<StaffAwardsReviewsListResponse> {
const search = new URLSearchParams();
if (staffUserId) {
search.set('staffUserId', staffUserId);
}
const qs = search.toString();
return apiRequest<StaffAwardsReviewsListResponse>(
`${STAFF_AWARDS_REVIEWS_PATH}${qs ? `?${qs}` : ''}`,
);
}
export function createStaffAwardReview(
data: StaffAwardReviewMutationDto,
): Promise<StaffAwardReviewDto> {
return apiRequest<StaffAwardReviewDto>(STAFF_AWARDS_REVIEWS_PATH, {
method: 'POST',
body: { data },
});
}
export function updateStaffAwardReview(
id: string,
data: StaffAwardReviewMutationDto,
): Promise<StaffAwardReviewDto> {
return apiRequest<StaffAwardReviewDto>(`${STAFF_AWARDS_REVIEWS_PATH}/${id}`, {
method: 'PUT',
body: { data },
});
}
export function deleteStaffAwardReview(id: string): Promise<{ readonly deletedCount: number }> {
return apiRequest<{ readonly deletedCount: number }>(`${STAFF_AWARDS_REVIEWS_PATH}/${id}`, {
method: 'DELETE',
});
}

View File

@ -29,9 +29,11 @@ export const MODULE_PERMISSIONS = [
'READ_EI', 'READ_ZONES', 'READ_SIGNS', 'READ_ATTENDANCE', 'READ_PARENT_COMM',
'READ_INTERNAL_COMM', 'READ_SAFETY', 'READ_HANDBOOK', 'READ_COMMUNITY',
'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD',
'READ_AUTISM_SUPPORT_STEPS', 'READ_PARA_CERTIFICATIONS', 'READ_AWARDS_REVIEWS',
'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM',
'MANAGE_CONTENT_CATALOG', 'MANAGE_ESA_FUNDING_CONTENT',
'MANAGE_AUTISM_SUPPORT_STEPS', 'MANAGE_PARA_CERTIFICATIONS', 'MANAGE_AWARDS_REVIEWS',
'READ_STAFF_ATTENDANCE_REPORTS',
'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_REPORTS',
'READ_ZONE_CHECKIN_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS',

View File

@ -20,6 +20,9 @@ export const MODULES: Module[] = [
{ id: 'community', name: 'Community & Partnerships', icon: 'globe', permissions: ['READ_COMMUNITY'], color: 'bg-green-600', routePath: APP_ROUTE_PATHS.community },
{ id: 'vocational', name: 'Vocational Opportunities', icon: 'briefcase', permissions: ['READ_VOCATIONAL'], color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.vocational },
{ id: 'esa', name: 'ESA Funding Info', icon: 'wallet', permissions: ['READ_ESA'], color: 'bg-emerald-600', routePath: APP_ROUTE_PATHS.esa },
{ id: 'autism-support', name: 'Autism Support Steps', icon: 'puzzle', permissions: ['READ_AUTISM_SUPPORT_STEPS'], color: 'bg-teal-600', routePath: APP_ROUTE_PATHS.autismSupport },
{ id: 'para-certifications', name: 'Certifications & Degrees', icon: 'graduation', permissions: ['READ_PARA_CERTIFICATIONS'], color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.paraCertifications },
{ id: 'awards-reviews', name: 'Awards & Reviews', icon: 'award', permissions: ['READ_AWARDS_REVIEWS'], color: 'bg-yellow-600', routePath: APP_ROUTE_PATHS.awardsReviews },
{ id: 'walkthrough', name: 'Walk-Through Check-In', icon: 'clipboard', permissions: ['MANAGE_WALKTHROUGH'], color: 'bg-indigo-600', routePath: APP_ROUTE_PATHS.walkthrough },
{ id: 'director', name: 'Director Dashboard', icon: 'chart', permissions: ['READ_DIRECTOR_DASHBOARD'], color: 'bg-purple-600', routePath: APP_ROUTE_PATHS.director },
{ id: 'organization-management', name: 'Organizations & Locations', icon: 'building', permissions: ['READ_ORGANIZATIONS', 'CREATE_ORGANIZATIONS', 'UPDATE_ORGANIZATIONS', 'DELETE_ORGANIZATIONS', 'READ_SCHOOLS', 'CREATE_SCHOOLS', 'UPDATE_SCHOOLS', 'DELETE_SCHOOLS', 'READ_CAMPUSES', 'CREATE_CAMPUSES', 'UPDATE_CAMPUSES', 'DELETE_CAMPUSES', 'READ_CLASSES', 'CREATE_CLASSES', 'UPDATE_CLASSES', 'DELETE_CLASSES'], color: 'bg-fuchsia-600', routePath: APP_ROUTE_PATHS.organizations },
@ -28,3 +31,4 @@ export const MODULES: Module[] = [
export const HERO_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658813159_517b0df6.jpg';
export const HANDBOOK_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659087111_5a2d27c8.jpg';
export const AWARDS_REVIEWS_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1780710903198_8b1aff85.jpg';

View File

@ -21,6 +21,8 @@ export const CONTENT_CATALOG_TYPES = {
emotionalIntelligenceGrowthTips: 'emotional-intelligence-growth-tips',
emotionalIntelligenceTeamWellnessMetrics: 'emotional-intelligence-team-wellness-metrics',
esaFundingContent: 'esa-funding-content',
autismSupportSteps: 'autism-support-steps',
paraCertifications: 'para-certifications',
safetyProtocols: 'safety-protocols',
emotionalIntelligenceWeeklyFocus: 'emotional-intelligence-weekly-focus',
} as const;

View File

@ -17,6 +17,9 @@ export const APP_ROUTE_PATHS = {
community: '/community-partnerships',
vocational: '/vocational-opportunities',
esa: '/esa-funding',
autismSupport: '/autism-support-steps',
paraCertifications: '/para-certifications',
awardsReviews: '/awards-reviews',
walkthrough: '/walkthrough',
director: '/director-dashboard',
platformDashboard: '/platform-dashboard',

View File

@ -177,6 +177,9 @@ export type ModuleId =
| 'community'
| 'vocational'
| 'esa'
| 'autism-support'
| 'para-certifications'
| 'awards-reviews'
| 'walkthrough';
export interface WalkthroughCheckin {

View File

@ -0,0 +1,78 @@
export interface AutismSupportStep {
readonly id: string;
readonly iconId: string;
readonly title: string;
readonly subtitle: string;
readonly spans: readonly string[];
}
export interface AutismVisualCard {
readonly id: string;
readonly label: string;
readonly imageUrl: string | null;
readonly iconText: string;
}
export interface AutismSupportStepsContent {
readonly steps: readonly AutismSupportStep[];
readonly visualCards: readonly AutismVisualCard[];
}
export interface ParaCertificationHighlight {
readonly id: string;
readonly iconId: string;
readonly title: string;
readonly subtitle: string;
}
export interface ParaCertificationProgram {
readonly id: string;
readonly name: string;
readonly organization: string;
readonly description: string;
readonly url: string;
readonly tag: string;
}
export interface ParaCertificationsContent {
readonly heroImage: string;
readonly heroTitle: string;
readonly heroSubtitle: string;
readonly highlights: readonly ParaCertificationHighlight[];
readonly programs: readonly ParaCertificationProgram[];
}
export interface StaffAwardsReviewsStaffDto {
readonly id: string;
readonly name: string;
readonly email: string;
readonly role: string | null;
readonly schoolName: string | null;
readonly campusName: string | null;
}
export interface StaffAwardReviewDto {
readonly id: string;
readonly title: string;
readonly type: 'award' | 'review';
readonly year: string;
readonly notes: string;
readonly fileName: string | null;
readonly fileUrl: string | null;
readonly fileIsImage: boolean;
readonly staffUserId: string;
readonly staffName: string | null;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface StaffAwardReviewMutationDto {
readonly staffUserId?: string;
readonly title: string;
readonly type: 'award' | 'review';
readonly year: string;
readonly notes: string;
readonly fileName?: string | null;
readonly fileUrl?: string | null;
readonly fileIsImage?: boolean;
}

View File

@ -0,0 +1,38 @@
export function escapePrintHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function printHtml(title: string, bodyHtml: string): void {
const win = window.open('', '_blank', 'width=900,height=700');
if (!win) return;
win.document.write(`<!doctype html>
<html>
<head>
<title>${escapePrintHtml(title)}</title>
<style>
body { font-family: Inter, Arial, sans-serif; margin: 24px; color: #0f172a; }
h1 { font-size: 24px; margin: 0 0 8px; }
.muted { color: #64748b; }
.card { border: 1px solid #cbd5e1; border-radius: 10px; padding: 14px; margin: 12px 0; page-break-inside: avoid; }
.badge { display: inline-block; border-radius: 999px; padding: 2px 8px; background: #e2e8f0; font-size: 12px; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.vcard { border: 2px solid #14b8a6; border-radius: 12px; min-height: 112px; padding: 14px; text-align: center; page-break-inside: avoid; }
.ic { font-size: 34px; min-height: 42px; display: flex; align-items: center; justify-content: center; }
.ic img { max-width: 72px; max-height: 72px; object-fit: contain; }
.lbl { font-weight: 800; margin-top: 8px; }
@media print { .no-print { display: none; } body { margin: 14px; } }
</style>
</head>
<body>${bodyHtml}
<script>window.onload = function(){ setTimeout(function(){ window.print(); }, 350); }</script>
</body>
</html>`);
win.document.close();
win.focus();
}