diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index cdb26ef..b818d6c 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -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`. diff --git a/backend/docs/data-model-guide.md b/backend/docs/data-model-guide.md index b7a8d85..c973ec4 100644 --- a/backend/docs/data-model-guide.md +++ b/backend/docs/data-model-guide.md @@ -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 diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index 0c9dc2a..d9351f2 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -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` diff --git a/backend/docs/index.md b/backend/docs/index.md index 88b44c0..428c8d5 100644 --- a/backend/docs/index.md +++ b/backend/docs/index.md @@ -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). diff --git a/backend/docs/staff-awards-reviews.md b/backend/docs/staff-awards-reviews.md new file mode 100644 index 0000000..cf6929d --- /dev/null +++ b/backend/docs/staff-awards-reviews.md @@ -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=` 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`. diff --git a/backend/src/api/controllers/staff_awards_reviews.controller.ts b/backend/src/api/controllers/staff_awards_reviews.controller.ts new file mode 100644 index 0000000..2f39cc5 --- /dev/null +++ b/backend/src/api/controllers/staff_awards_reviews.controller.ts @@ -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 { + const payload = await StaffAwardsReviewsService.listStaff(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function list(req: Request, res: Response): Promise { + const payload = await StaffAwardsReviewsService.list(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function create(req: Request, res: Response): Promise { + const payload = await StaffAwardsReviewsService.create(req.body.data, req.currentUser); + res.status(201).send(payload); +} + +export async function update(req: Request, res: Response): Promise { + 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 { + const payload = await StaffAwardsReviewsService.remove(paramStr(req.params.id), req.currentUser); + res.status(200).send(payload); +} diff --git a/backend/src/db/migrations/20260626110000-staff-support-content.ts b/backend/src/db/migrations/20260626110000-staff-support-content.ts new file mode 100644 index 0000000..1374035 --- /dev/null +++ b/backend/src/db/migrations/20260626110000-staff-support-content.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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-%'`, + ); + }, +}; diff --git a/backend/src/db/migrations/20260626114000-remove-all-registrar-staff-support-page-grants.ts b/backend/src/db/migrations/20260626114000-remove-all-registrar-staff-support-page-grants.ts new file mode 100644 index 0000000..56b3dd7 --- /dev/null +++ b/backend/src/db/migrations/20260626114000-remove-all-registrar-staff-support-page-grants.ts @@ -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, + }, + }, + ); + } + } + }, +}; diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts index 8adf9a7..fd74968 100644 --- a/backend/src/db/models/index.ts +++ b/backend/src/db/models/index.ts @@ -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), diff --git a/backend/src/db/models/staff_awards_reviews.ts b/backend/src/db/models/staff_awards_reviews.ts new file mode 100644 index 0000000..8422ac3 --- /dev/null +++ b/backend/src/db/models/staff_awards_reviews.ts @@ -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, + InferCreationAttributes +> { + declare id: CreationOptional; + declare title: string; + declare record_type: StaffAwardReviewType; + declare year_label: string; + declare notes: CreationOptional; + declare file_name: CreationOptional; + declare file_url: CreationOptional; + declare file_is_image: CreationOptional; + declare organizationId: string; + declare schoolId: CreationOptional; + declare campusId: string; + declare staffUserId: string; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + declare organization?: NonAttribute; + declare school?: NonAttribute; + declare campus?: NonAttribute; + declare staffUser?: NonAttribute; + declare createdBy?: NonAttribute; + declare updatedBy?: NonAttribute; + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getSchool: BelongsToGetAssociationMixin; + declare setSchool: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getStaffUser: BelongsToGetAssociationMixin; + declare setStaffUser: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + 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; +} diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index 6c67165..5461b40 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -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> = { - // 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 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) } }, + {}, + ); + }, +}; diff --git a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts index be6d4c6..ec18870 100644 --- a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts +++ b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts @@ -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 }, ]); diff --git a/backend/src/db/seeders/user-roles.test.ts b/backend/src/db/seeders/user-roles.test.ts index cb9a1c0..64be978 100644 --- a/backend/src/db/seeders/user-roles.test.ts +++ b/backend/src/db/seeders/user-roles.test.ts @@ -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); diff --git a/backend/src/index.ts b/backend/src/index.ts index 9845387..73df4bb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/staff_awards_reviews.ts b/backend/src/routes/staff_awards_reviews.ts new file mode 100644 index 0000000..e098b4a --- /dev/null +++ b/backend/src/routes/staff_awards_reviews.ts @@ -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; diff --git a/backend/src/services/content_catalog.test.ts b/backend/src/services/content_catalog.test.ts index c5a6766..e5c44cc 100644 --- a/backend/src/services/content_catalog.test.ts +++ b/backend/src/services/content_catalog.test.ts @@ -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 | null = null; + mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record }) => { + 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( diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts index 32af439..ef25fc8 100644 --- a/backend/src/services/content_catalog.ts +++ b/backend/src/services/content_catalog.ts @@ -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(); diff --git a/backend/src/services/content_catalog_seed.test.ts b/backend/src/services/content_catalog_seed.test.ts index 4577147..9c2c9ea 100644 --- a/backend/src/services/content_catalog_seed.test.ts +++ b/backend/src/services/content_catalog_seed.test.ts @@ -306,4 +306,73 @@ describe('seedDefaultContentForTenant', () => { classId: null, }); }); + + test('seeds staff support page content only at campus scope', async () => { + const createdRows: Array> = []; + + mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne); + mock.method(db.content_catalog, 'create', (async (payload: Record) => { + createdRows.push(payload); + return payload; + }) as typeof db.content_catalog.create); + + await seedDefaultContentForTenant({ + level: 'organization', + organizationId: 'org-1', + }); + await seedDefaultContentForTenant({ + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + }); + await seedDefaultContentForTenant({ + level: 'campus', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: 'campus-1', + }); + + const 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, + ); + }); }); diff --git a/backend/src/services/staff_awards_reviews.test.ts b/backend/src/services/staff_awards_reviews.test.ts new file mode 100644 index 0000000..47c5ba5 --- /dev/null +++ b/backend/src/services/staff_awards_reviews.test.ts @@ -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 { + return typeof value === 'object' && value !== null; +} + +function staffUser(overrides: Record = {}) { + 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 = {}) { + 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' }, + ); + }); +}); diff --git a/backend/src/services/staff_awards_reviews.ts b/backend/src/services/staff_awards_reviews.ts new file mode 100644 index 0000000..6daec99 --- /dev/null +++ b/backend/src/services/staff_awards_reviews.ts @@ -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 { + 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; diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index 9f53041..143ba5f 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -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 = new Set([ /** **Campus-scoped** content types (class users read the campus row). */ export const CAMPUS_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ ESA_CONTENT_TYPE, + AUTISM_SUPPORT_STEPS_CONTENT_TYPE, + PARA_CERTIFICATIONS_CONTENT_TYPE, ]); /** **School-scoped** content types (one per school). */ diff --git a/backend/src/shared/constants/product-permissions.ts b/backend/src/shared/constants/product-permissions.ts index b9e5bd1..940a9e9 100644 --- a/backend/src/shared/constants/product-permissions.ts +++ b/backend/src/shared/constants/product-permissions.ts @@ -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', diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md index 97be4ad..7aaade9 100644 --- a/frontend/docs/frontend-architecture.md +++ b/frontend/docs/frontend-architecture.md @@ -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. diff --git a/frontend/docs/index.md b/frontend/docs/index.md index c7e18ea..d1358ef 100644 --- a/frontend/docs/index.md +++ b/frontend/docs/index.md @@ -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) diff --git a/frontend/docs/staff-support-pages.md b/frontend/docs/staff-support-pages.md new file mode 100644 index 0000000..de0d600 --- /dev/null +++ b/frontend/docs/staff-support-pages.md @@ -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`. diff --git a/frontend/src/app/appRoutes.tsx b/frontend/src/app/appRoutes.tsx index a90eac5..ba753fe 100644 --- a/frontend/src/app/appRoutes.tsx +++ b/frontend/src/app/appRoutes.tsx @@ -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 = { community: , vocational: , esa: , + 'autism-support': , + 'para-certifications': , + 'awards-reviews': , walkthrough: , director: , }; diff --git a/frontend/src/app/lazyModulePages.ts b/frontend/src/app/lazyModulePages.ts index 05b880e..802a7e8 100644 --- a/frontend/src/app/lazyModulePages.ts +++ b/frontend/src/app/lazyModulePages.ts @@ -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')); diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts index 5c79f84..6fec3a6 100644 --- a/frontend/src/business/app-shell/selectors.test.ts +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -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), diff --git a/frontend/src/business/app-shell/selectors.ts b/frontend/src/business/app-shell/selectors.ts index e9bcf38..3689aea 100644 --- a/frontend/src/business/app-shell/selectors.ts +++ b/frontend/src/business/app-shell/selectors.ts @@ -47,6 +47,9 @@ const MODULE_SCOPE_TIERS: Partial> = { 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'], diff --git a/frontend/src/business/staff-support/hooks.ts b/frontend/src/business/staff-support/hooks.ts new file mode 100644 index 0000000..c059072 --- /dev/null +++ b/frontend/src/business/staff-support/hooks.ts @@ -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( + CONTENT_CATALOG_TYPES.autismSupportSteps, + EMPTY_AUTISM_SUPPORT_CONTENT, + ); + const managedQuery = useManagedContentCatalog( + 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( + CONTENT_CATALOG_TYPES.paraCertifications, + EMPTY_PARA_CERTIFICATIONS_CONTENT, + ); + const managedQuery = useManagedContentCatalog( + 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(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), + }; +} diff --git a/frontend/src/business/staff-support/selectors.test.ts b/frontend/src/business/staff-support/selectors.test.ts new file mode 100644 index 0000000..6e04f5a --- /dev/null +++ b/frontend/src/business/staff-support/selectors.test.ts @@ -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: [], + }); + }); +}); diff --git a/frontend/src/business/staff-support/selectors.ts b/frontend/src/business/staff-support/selectors.ts new file mode 100644 index 0000000..546e839 --- /dev/null +++ b/frontend/src/business/staff-support/selectors.ts @@ -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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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), + }; + }) + : [], + }; +} diff --git a/frontend/src/components/frameworks/AutismSupportSteps.tsx b/frontend/src/components/frameworks/AutismSupportSteps.tsx new file mode 100644 index 0000000..9d7c2e4 --- /dev/null +++ b/frontend/src/components/frameworks/AutismSupportSteps.tsx @@ -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) => ( + `

Step ${index + 1}: ${escapePrintHtml(step.title)}

${escapePrintHtml(step.subtitle)}

    ${step.spans.map((span) => `
  • ${escapePrintHtml(span)}
  • `).join('')}
` + )).join(''); + return `

In-Class Support Steps for Students with Autism

A quick reference for teachers and support staff.

${cards}`; +} + +function printableVisualCards(content: AutismSupportStepsContent): string { + const cards = content.visualCards.map((card) => { + const icon = card.imageUrl + ? `` + : escapePrintHtml(card.iconText); + return `
${icon}
${escapePrintHtml(card.label)}
`; + }).join(''); + return `

Visual Support Cards

Cut out and attach to lanyards. Students and staff can refer back to these and to the FRAMEworks app.

${cards}
`; +} + +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 ; + } + if (page.error) { + return Autism support content is unavailable.; + } + + const content = page.content; + + function openEditor() { + setDraftState({ source: page.content, value: page.content }); + setIsEditorOpen(true); + } + + function updateStep(index: number, updates: Partial) { + 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) { + 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 ( +
+
+ +
+ + + {page.canManage && !isEditorOpen && ( + + )} +
+
+ + {page.canManage && isEditorOpen && ( +
+
+

Edit Autism Support Content

+ +
+
+ {draft.steps.map((step, index) => ( +
+
+ + 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`} + /> +
+